From 70af2922f5a4783606ed66dc024cb0079733be04 Mon Sep 17 00:00:00 2001 From: austin Date: Sun, 26 Jul 2026 16:07:01 -0500 Subject: [PATCH] The site renders itself: Thymeleaf pages, and a catering page among them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public site was a React SPA. It is now server-rendered Thymeleaf, and the goodie box and catering tables added in the previous commit have a page of their own. The look is unchanged: the templates carry the same Tailwind classes the components did, and every one of the 241 classes the five pages use resolves in the compiled stylesheet. WHAT WENT AWAY. PageMetaController — 148 lines whose only job was to splice per-page and OG tags into one shell with regular expressions, with a test that read the real index.html so that reformatting it failed the build instead of silently breaking the rewriting. A page rendered on the server writes its own head. Also react-router (no client-side routes left), motion, vite-plugin-svgr, and the SPA fallback (platform.web.spa.enabled=false): with the site server-rendered, forwarding a mistyped URL to /index.html would answer with a blank admin shell and a 200 instead of the site's own 404 page. WHAT GOT BETTER ON THE WAY, none of it visible. The category filter is a ?category= link, so every filtered view is a URL you can send someone and a crawler can reach all forty items instead of the twelve the default filter showed. The contact form is a form post: the enquiry is recorded before delivery is attempted, and a refused relay re-renders the page with what the visitor typed still in the boxes. The mobile menu is a <details> — the React version needed four effects to close on navigation, close on Escape, stop the page behind it scrolling, and unmount (a panel parked off-screen still extends the scrollable area, which is how you used to be able to scroll sideways and find the menu); a new document cannot inherit an open menu. THE PUBLIC SITE SHIPS 5 KB OF JAVASCRIPT, and works without it. The product cards are scroll-snap strips, so the photos swipe on a phone and scroll with a trackpad unaided; gallery.js adds the arrows and the dots, and creates them itself rather than having the template render controls that would sit there dead. Tailwind still needs its compiler, so npm remains a BUILD tool: the CLI compiles the templates into static/css/site.css at process-classes (so `spring-boot:run` gets it too), and frontend/ now builds only that stylesheet and the admin. The brand tokens are one file both stylesheets import — the alternative was the shop front and the screen that edits it drifting a shade apart. The stylesheet URL carries ?v=<sha>, because one hand-written CSS file has no content hash and a deploy has to be able to tell a browser that what it cached is stale. The admin is still React and is untouched, apart from losing the router it no longer needs. It is an editor, not content. PlatformContractTest stopped inheriting platform-starter-test's contract and restates it. The shared version asserts that an unknown path forwards to the SPA shell, which is no longer true here, and its test methods are package-private so it cannot be overridden. The platform should decide that assertion from platform.web.spa.enabled — noted in the file. 9 new tests (46 total): every page's real title and og:url, the catalogue and the catering tables in the HTML rather than fetched afterwards, server-side filtering, the 404, and that a crafted ?about= link cannot put words of its own choosing in front of a customer. --- Dockerfile | 3 + README.md | 69 +- frontend/index.html | 21 +- frontend/package-lock.json | 1520 +++++------------ frontend/package.json | 9 +- frontend/site.css | 22 + frontend/src/App.tsx | 70 +- frontend/src/components/Footer.tsx | 52 - frontend/src/components/Header.tsx | 118 -- frontend/src/components/Logo.tsx | 64 - frontend/src/components/ProductGallery.tsx | 143 -- frontend/src/index.css | 96 +- frontend/src/lib/assets.ts | 17 - frontend/src/main.tsx | 6 +- frontend/src/pages/Contact.tsx | 127 -- frontend/src/pages/History.tsx | 59 - frontend/src/pages/Home.tsx | 133 -- frontend/src/pages/NotFound.tsx | 30 - frontend/src/pages/Products.tsx | 94 - frontend/src/tokens.css | 140 ++ frontend/tsconfig.json | 2 +- frontend/vite.config.ts | 17 +- pom.xml | 27 +- .../com/itsthevine/web/ContactController.java | 32 +- .../java/com/itsthevine/web/Enquiries.java | 50 + .../itsthevine/web/PageMetaController.java | 148 -- .../com/itsthevine/web/ProductCatalog.java | 28 +- .../com/itsthevine/web/SiteController.java | 157 ++ .../java/com/itsthevine/web/SiteModel.java | 69 + .../java/com/itsthevine/web/SitePhotos.java | 51 + src/main/resources/application.yaml | 10 +- .../resources/static}/fonts/AdBhashitha.woff | Bin .../static}/fonts/LeJour-Script.woff | Bin .../static}/fonts/raleway-latin.woff2 | Bin .../main/resources/static/images}/logo_L.svg | 0 .../main/resources/static/images}/logo_R.svg | 0 .../static}/images/resources/logo_L.png | Bin .../static}/images/resources/logo_dark.png | Bin src/main/resources/static/js/gallery.js | 141 ++ src/main/resources/templates/catering.html | 121 ++ src/main/resources/templates/contact.html | 66 + src/main/resources/templates/error.html | 29 + src/main/resources/templates/error/404.html | 39 + .../resources/templates/fragments/footer.html | 39 + .../resources/templates/fragments/head.html | 39 + .../resources/templates/fragments/header.html | 58 + .../resources/templates/fragments/lockup.html | 35 + .../resources/templates/fragments/page.html | 28 + src/main/resources/templates/history.html | 54 + src/main/resources/templates/home.html | 112 ++ src/main/resources/templates/products.html | 71 + .../web/PageMetaControllerTest.java | 83 - .../itsthevine/web/PlatformContractTest.java | 84 +- .../itsthevine/web/SiteControllerTest.java | 142 ++ 54 files changed, 2127 insertions(+), 2398 deletions(-) create mode 100644 frontend/site.css delete mode 100644 frontend/src/components/Footer.tsx delete mode 100644 frontend/src/components/Header.tsx delete mode 100644 frontend/src/components/Logo.tsx delete mode 100644 frontend/src/components/ProductGallery.tsx delete mode 100644 frontend/src/lib/assets.ts delete mode 100644 frontend/src/pages/Contact.tsx delete mode 100644 frontend/src/pages/History.tsx delete mode 100644 frontend/src/pages/Home.tsx delete mode 100644 frontend/src/pages/NotFound.tsx delete mode 100644 frontend/src/pages/Products.tsx create mode 100644 frontend/src/tokens.css create mode 100644 src/main/java/com/itsthevine/web/Enquiries.java delete mode 100644 src/main/java/com/itsthevine/web/PageMetaController.java create mode 100644 src/main/java/com/itsthevine/web/SiteController.java create mode 100644 src/main/java/com/itsthevine/web/SiteModel.java create mode 100644 src/main/java/com/itsthevine/web/SitePhotos.java rename {frontend/src => src/main/resources/static}/fonts/AdBhashitha.woff (100%) rename {frontend/src => src/main/resources/static}/fonts/LeJour-Script.woff (100%) rename {frontend/src => src/main/resources/static}/fonts/raleway-latin.woff2 (100%) rename {frontend/src/assets => src/main/resources/static/images}/logo_L.svg (100%) rename {frontend/src/assets => src/main/resources/static/images}/logo_R.svg (100%) rename {frontend/public => src/main/resources/static}/images/resources/logo_L.png (100%) rename {frontend/public => src/main/resources/static}/images/resources/logo_dark.png (100%) create mode 100644 src/main/resources/static/js/gallery.js create mode 100644 src/main/resources/templates/catering.html create mode 100644 src/main/resources/templates/contact.html create mode 100644 src/main/resources/templates/error.html create mode 100644 src/main/resources/templates/error/404.html create mode 100644 src/main/resources/templates/fragments/footer.html create mode 100644 src/main/resources/templates/fragments/head.html create mode 100644 src/main/resources/templates/fragments/header.html create mode 100644 src/main/resources/templates/fragments/lockup.html create mode 100644 src/main/resources/templates/fragments/page.html create mode 100644 src/main/resources/templates/history.html create mode 100644 src/main/resources/templates/home.html create mode 100644 src/main/resources/templates/products.html delete mode 100644 src/test/java/com/itsthevine/web/PageMetaControllerTest.java create mode 100644 src/test/java/com/itsthevine/web/SiteControllerTest.java diff --git a/Dockerfile b/Dockerfile index c9a05ff..b967ab8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,5 +36,8 @@ ARG GIT_SHA=unknown LABEL org.opencontainers.image.title="itsthevine" \ org.opencontainers.image.source="https://git.thebennett.net/thevine/itsthevine" \ org.opencontainers.image.revision="${GIT_SHA}" +# Also the cache-buster on the stylesheet URL: one hand-written CSS file has no content hash in its +# name, so a deploy has to tell the browser that what it cached is stale (see site.build). +ENV GIT_SHA=${GIT_SHA} ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"] diff --git a/README.md b/README.md index a41d880..b5831dc 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,74 @@ # The Vine Coffeehouse + Bakery — itsthevine.com -Site for The Vine, 215 E Main Street, Princeville, Illinois. Spring Boot serving a Vite/React SPA, -on [the Bennett platform](https://git.thebennett.net/austin/platform). +Site for The Vine, 215 E Main Street, Princeville, Illinois. Spring Boot rendering its own pages with +Thymeleaf, on [the Bennett platform](https://git.thebennett.net/austin/platform). -Previously a Next.js app on Cloudflare, then self-hosted; the look is unchanged. +Previously a Next.js app on Cloudflare, then a React SPA on Spring, now server-rendered. The look has +not changed through any of it. ## Shape | | | |---|---| | Backend | Spring Boot 4 / Java 25, `com.itsthevine.web` | -| Frontend | Vite + React 19 + TypeScript + Tailwind v4, served from the jar | +| Pages | Thymeleaf, `src/main/resources/templates` — **no JavaScript** except one 100-line file for the product-card arrows | +| Styling | Tailwind v4, compiled from the templates by the Tailwind CLI into `static/css/site.css` | +| Admin | the one React screen that is left, served at `/admin` only | | Database | Postgres (`itsthevine` on the shared `app-db` cluster), Flyway | | Photos | public MinIO bucket `itsthevine` — **not** in the repo or the image | | Deploy | Gitea CI → image → Watchtower → Caddy | +### Why server-rendered + +The pages are content: a menu, a story, opening hours, a price list. Rendering them in the browser meant +shipping a router and a component tree to show them, and it meant `PageMetaController` — a class whose +only job was to splice per-page `<title>` and OG tags into one shell with regular expressions, because a +crawler or a link-preview scraper got nothing useful otherwise. A page that is rendered on the server +writes its own head, so that whole mechanism is deleted rather than ported. The category filter is a +`?category=` link instead of a click handler, which also makes every filtered view a URL you can send +someone, and the contact form is a form post. + +`platform.web.spa.enabled=false` follows from that: the platform's fallback forwards extension-less paths +to `/index.html`, which now holds nothing but the admin. `SiteController` maps `/admin` to it explicitly. + ## What the server owns -The SPA renders; it doesn't decide anything. +Everything. The pages arrive complete. - **`/api/products`**, **`/api/categories`** — the catalogue, its curated order, the category filter and the absolute image URLs. This was a TypeScript array shipped to every visitor; it's now a table (`V2__products.sql`) read through `ProductCatalog`. -- **`/api/catering`** — the goodie box and catering price tables (Office, Parties, Weddings): the +- **`/catering`** — the goodie box and catering page. Each table is rendered twice from the same model + and CSS shows one: a real `<table>` on a wide screen, because that is what a price list is and a screen + reader then announces the size and the item together; stacked cards on a phone, because a four-column + price table there is either illegible or a sideways scroll, and this page is mostly read on phones. +- **`/api/catering`** — the same tables as JSON: the columns, the prices already written the way they should be read, the entries under each column, and the small print. These came from the bakery as a spreadsheet and are stored as one (`V4__catering.sql`, read through `CateringMenu`) rather than as markup, because the prices move and the last line of that spreadsheet says the tables are "mostly just an idea for people". `Money` is the only thing that decides what a typed price means or how it prints. A table with no columns or no lines is left off the public response — adding a table and filling it in are two separate acts in the admin, and the gap - between them shouldn't put a bare heading on the live page. *(No public page renders this yet.)* + between them shouldn't put a bare heading on the live page. +- **`/contact`** — the form posts here and gets a page back. It renders rather than redirects on failure, + so a refused relay comes back with what the visitor typed still in the boxes: they wrote it once, and + the failure is ours. `/api/contact` still exists and answers JSON; both go through `Enquiries`, so + there is one order of operations for taking an enquiry. - **`/api/contact`** — validates, **records the enquiry**, emails it, then fans out to the n8n hub. Recorded before sending on purpose: a relay outage costs a notification, not the enquiry. Undelivered ones are `enquiry.delivered = false`. Validation and delivery come from `platform-starter-contact`, shared with the other sites. -- **Per-page metadata** — `PageMetaController` rewrites `<title>`/`<meta>`/OG tags per route. Next used - to server-render these; a plain SPA would hand crawlers and link-preview scrapers one generic shell. +- **Per-page metadata** — each route states its own title and description in `SiteController`, next to + the handler that serves it, and `fragments/head.html` lays them out. `SiteControllerTest` asserts the + real `<title>` of every page. ## /admin +**The last React in the repo.** The public pages are server-rendered; this screen is a Vite/React app +because it is not content — it is an editor, and the instant-feedback editing (reorder that applies +before the network answers, a whole price table arranged on screen and saved in one go) is the point of +it. Everything under `frontend/` builds only this, plus the site's stylesheet. + The catalogue is editable from the site: add an item with a photo and a name, reorder it, rename or reorder the category filters. Nothing there needs a deploy or a migration — which is the point, since the person adding a cake is the person who baked it. @@ -72,14 +102,22 @@ history. EXIF (including GPS from phone photos) is stripped by the re-encode. ## Local development ```bash -# backend (needs Postgres on :5432 with an itsthevine database) -mvn spring-boot:run +# the whole site (needs Postgres on :5432 with an itsthevine database) +mvn spring-boot:run # http://localhost:8080 -# frontend, proxies /api to :8080 -cd frontend && npm install && npm run dev # http://localhost:2024 +# just the stylesheet, while editing templates — watches and recompiles +cd frontend && npm install && npx tailwindcss -i site.css -o ../target/classes/static/css/site.css --watch + +# the admin screen, proxying /api to :8080 +cd frontend && npm run dev # http://localhost:2024/admin ``` -Build without the SPA for quick backend loops: `mvn -DskipFrontend=true package`. +`mvn spring-boot:run` compiles the stylesheet on the way (the Tailwind step is bound to +`process-classes` for exactly that reason). `-DskipFrontend=true` skips both frontend steps for a fast +backend loop — the pages then render **unstyled** until you build the CSS once. + +Templates are cached by default, so a template edit needs a restart; add +`spring.thymeleaf.cache=false` to a local run if you are editing markup. Tests need Docker (Testcontainers): @@ -96,7 +134,8 @@ mvn verify | `CONTACT_TO` / `CONTACT_FROM` | enquiry recipient and envelope sender | | `CONTACT_HUB_URL` | optional n8n webhook; best-effort, never blocks a submission | | `SITE_BASE_URL` | absolute base for `og:url` | -| `VITE_ASSET_BASE` / `site.assets.base-url` | photo bucket | +| `site.assets.base-url` | photo bucket. Server-side only now — the browser is handed finished URLs | +| `GIT_SHA` | passed by the image build; becomes `?v=` on the stylesheet so a deploy invalidates the cached CSS | | `SECURITY_MODE` | `OIDC` turns on Authentik login **and brings `/admin` into existence**. Unset = brochure site, no admin | | `STORAGE_ENDPOINT` / `STORAGE_ACCESS_KEY` / `STORAGE_SECRET_KEY` / `STORAGE_BUCKET` | MinIO, for admin photo uploads. Blank endpoint leaves storage switched off | diff --git a/frontend/index.html b/frontend/index.html index 56edbf0..a494fc0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,22 +5,11 @@ <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="icon" media="(prefers-color-scheme: light)" href="/images/resources/logo_L.png"> <link rel="icon" media="(prefers-color-scheme: dark)" href="/images/resources/logo_dark.png"> - <!-- Preconnect to the photo bucket so product images start loading a round-trip sooner. --> - <link rel="preconnect" href="https://s3.thebennett.net" crossorigin> - <!-- The wordmark is set in these; without preloading they arrive late and the logo visibly reflows. --> - <link rel="preload" as="font" type="font/woff2" href="/src/fonts/raleway-latin.woff2" crossorigin> - <link rel="preload" as="image" href="https://s3.thebennett.net/itsthevine/images/gallery/Outside.webp" fetchpriority="high"> - <!-- PageMetaController rewrites the title and the four meta tags below per route, so crawlers and - link-preview scrapers get real per-page metadata instead of one generic shell. Keep the - attribute order and quoting as-is — it matches on them. --> - <title>The Vine Coffeehouse + Bakery - - - - - - - + + + The Vine — admin
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5e0a68d..bd90f7d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,12 +8,11 @@ "name": "itsthevine-frontend", "version": "0.1.0", "dependencies": { - "motion": "12.42.2", "react": "^19.2.7", - "react-dom": "^19.2.7", - "react-router-dom": "7.18.1" + "react-dom": "^19.2.7" }, "devDependencies": { + "@tailwindcss/cli": "4.3.3", "@tailwindcss/vite": "4.3.3", "@types/node": "^26.1.0", "@types/react": "^19.2.17", @@ -21,248 +20,7 @@ "@vitejs/plugin-react": "^6.0.3", "tailwindcss": "4.3.3", "typescript": "^7.0.0", - "vite": "^8.1.3", - "vite-plugin-svgr": "^5.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" + "vite": "^8.1.3" } }, "node_modules/@emnapi/core": { @@ -378,6 +136,328 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", @@ -642,252 +722,23 @@ "dev": true, "license": "MIT" }, - "node_modules/@rollup/pluginutils": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", - "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "node_modules/@tailwindcss/cli": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.3.3.tgz", + "integrity": "sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" + "@parcel/watcher": "2.5.1", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "enhanced-resolve": "^5.24.1", + "mri": "^1.2.0", + "picocolors": "^1.1.1", + "tailwindcss": "4.3.3" }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@svgr/babel-plugin-add-jsx-attribute": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", - "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", - "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", - "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", - "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-svg-dynamic-title": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", - "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-svg-em-dimensions": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", - "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-transform-react-native-svg": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", - "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-plugin-transform-svg-component": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", - "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/babel-preset": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", - "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", - "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", - "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", - "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", - "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", - "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", - "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", - "@svgr/babel-plugin-transform-svg-component": "8.0.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@svgr/core": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", - "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.21.3", - "@svgr/babel-preset": "8.1.0", - "camelcase": "^6.2.0", - "cosmiconfig": "^8.1.3", - "snake-case": "^3.0.4" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/hast-util-to-babel-ast": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", - "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.21.3", - "entities": "^4.4.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - } - }, - "node_modules/@svgr/plugin-jsx": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", - "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.21.3", - "@svgr/babel-preset": "8.1.0", - "@svgr/hast-util-to-babel-ast": "8.0.0", - "svg-parser": "^2.0.4" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/gregberge" - }, - "peerDependencies": { - "@svgr/core": "*" + "bin": { + "tailwindcss": "dist/index.mjs" } }, "node_modules/@tailwindcss/node": { @@ -1173,13 +1024,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/node": { "version": "26.1.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", @@ -1576,149 +1420,17 @@ } } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", - "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/browserslist": { - "version": "4.28.7", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", - "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.10.44", - "caniuse-lite": "^1.0.30001806", - "electron-to-chromium": "^1.5.393", - "node-releases": "^2.0.51", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001806", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", - "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/cosmiconfig": { - "version": "8.3.6", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", - "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "license": "MIT", "dependencies": { - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0", - "path-type": "^4.0.0" + "fill-range": "^7.1.1" }, "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "node": ">=8" } }, "node_modules/csstype": { @@ -1728,24 +1440,6 @@ "dev": true, "license": "MIT" }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1756,24 +1450,6 @@ "node": ">=8" } }, - "node_modules/dot-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", - "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.395", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz", - "integrity": "sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==", - "dev": true, - "license": "ISC" - }, "node_modules/enhanced-resolve": { "version": "5.24.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", @@ -1788,46 +1464,6 @@ "node": ">=10.13.0" } }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1846,31 +1482,17 @@ } } }, - "node_modules/framer-motion": { - "version": "12.42.2", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz", - "integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==", + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "license": "MIT", "dependencies": { - "motion-dom": "^12.42.2", - "motion-utils": "^12.39.0", - "tslib": "^2.4.0" + "to-regex-range": "^5.0.1" }, - "peerDependencies": { - "@emotion/is-prop-valid": "*", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/is-prop-valid": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } + "engines": { + "node": ">=8" } }, "node_modules/fsevents": { @@ -1888,16 +1510,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1905,29 +1517,38 @@ "dev": true, "license": "ISC" }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" + "is-extglob": "^2.1.1" }, "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10.0" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } }, "node_modules/jiti": { "version": "2.7.0", @@ -1939,69 +1560,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2263,33 +1821,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2300,53 +1831,42 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/motion": { - "version": "12.42.2", - "resolved": "https://registry.npmjs.org/motion/-/motion-12.42.2.tgz", - "integrity": "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==", - "license": "MIT", - "dependencies": { - "framer-motion": "^12.42.2", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "@emotion/is-prop-valid": "*", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/is-prop-valid": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, - "node_modules/motion-dom": { - "version": "12.42.2", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz", - "integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==", - "license": "MIT", - "dependencies": { - "motion-utils": "^12.39.0" - } - }, - "node_modules/motion-utils": { - "version": "12.39.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", - "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } }, "node_modules/nanoid": { "version": "3.3.16", @@ -2367,68 +1887,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/no-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "dev": true, - "license": "MIT", - "dependencies": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" - } - }, - "node_modules/node-releases": { - "version": "2.0.51", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", - "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } + "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", @@ -2500,54 +1964,6 @@ "react": "^19.2.8" } }, - "node_modules/react-router": { - "version": "7.18.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", - "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", - "license": "MIT", - "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, - "node_modules/react-router-dom": { - "version": "7.18.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", - "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", - "license": "MIT", - "dependencies": { - "react-router": "7.18.1" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -2588,33 +2004,6 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT" - }, - "node_modules/snake-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", - "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", - "dev": true, - "license": "MIT", - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2625,13 +2014,6 @@ "node": ">=0.10.0" } }, - "node_modules/svg-parser": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", - "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", - "dev": true, - "license": "MIT" - }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -2670,11 +2052,26 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "dev": true, + "license": "0BSD", + "optional": true }, "node_modules/typescript": { "version": "7.0.2", @@ -2718,37 +2115,6 @@ "dev": true, "license": "MIT" }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, "node_modules/vite": { "version": "8.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", @@ -2826,28 +2192,6 @@ "optional": true } } - }, - "node_modules/vite-plugin-svgr": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-5.2.0.tgz", - "integrity": "sha512-qj2eAKF8C6PZWemVTvQA0xgQIcP1hHU6Buh7fl6BhvayWwnuxE+z417miKxeDvRWbDrupQ1oK99hfxElopJ3sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.3.0", - "@svgr/core": "^8.1.0", - "@svgr/plugin-jsx": "^8.1.0" - }, - "peerDependencies": { - "vite": ">=3.0.0" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" } } } diff --git a/frontend/package.json b/frontend/package.json index fb9bde5..29174e2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,15 +6,15 @@ "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", + "build:css": "tailwindcss -i site.css -o ../target/classes/static/css/site.css --minify", "preview": "vite preview" }, "dependencies": { - "motion": "12.42.2", "react": "^19.2.7", - "react-dom": "^19.2.7", - "react-router-dom": "7.18.1" + "react-dom": "^19.2.7" }, "devDependencies": { + "@tailwindcss/cli": "4.3.3", "@tailwindcss/vite": "4.3.3", "@types/node": "^26.1.0", "@types/react": "^19.2.17", @@ -22,7 +22,6 @@ "@vitejs/plugin-react": "^6.0.3", "tailwindcss": "4.3.3", "typescript": "^7.0.0", - "vite": "^8.1.3", - "vite-plugin-svgr": "^5.0.0" + "vite": "^8.1.3" } } diff --git a/frontend/site.css b/frontend/site.css new file mode 100644 index 0000000..e44b5d6 --- /dev/null +++ b/frontend/site.css @@ -0,0 +1,22 @@ +/* + * The stylesheet for the server-rendered site. + * + * Compiled by the Tailwind CLI (`npm run build:css`) straight into target/classes/static/css: it is a + * source file, not a resource, and the generated stylesheet belongs in the build output rather than in + * src/main/resources next to it. + * + * It lives in this directory, beside the admin's stylesheet, because Tailwind resolves `@import + * "tailwindcss"` by walking up from the CSS file looking for node_modules — and node_modules is here. + * So this directory is the whole asset pipeline: one Tailwind, two stylesheets, one of them for pages + * that contain no JavaScript at all. + * + * @source points Tailwind at the templates, and at gallery.js — the product-card arrows are created in + * script, so their classes are only written down there. A utility exists in the output only if Tailwind + * saw it in one of these files, which is why a class name must never be assembled from pieces at + * runtime. + */ +@import "tailwindcss"; +@import "./src/tokens.css"; + +@source "../src/main/resources/templates"; +@source "../src/main/resources/static/js"; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0e84f3d..fd1caa7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,66 +1,20 @@ -import { useEffect } from 'react'; -import { Outlet, Route, Routes, useLocation } from 'react-router-dom'; -import Header from '@/components/Header'; -import Footer from '@/components/Footer'; -import HomePage from '@/pages/Home'; -import ProductsPage from '@/pages/Products'; -import HistoryPage from '@/pages/History'; -import ContactPage from '@/pages/Contact'; -import NotFoundPage from '@/pages/NotFound'; import AdminPage from '@/pages/Admin'; /** - * Client-side navigation keeps the previous scroll position, which lands you halfway down a page you - * just opened. Anchors like /#visit still need to work, so only reset when there isn't one. + * What's left of the React app: the admin screen, and nothing else. + * + * The shop front is server-rendered Thymeleaf now, so there are no client-side routes to route + * between — react-router went with the pages it used to switch. Spring serves this shell at /admin and + * only at /admin; every other URL is a page in src/main/resources/templates. + * + * The admin sits outside the public chrome deliberately: the nav would offer a signed-in editor links + * away from unsaved work, and the opening hours in the footer are noise on a screen whose whole job is + * the catalogue. */ -const ScrollToTop = () => { - const { pathname, hash } = useLocation(); - useEffect(() => { - // 'instant' overrides the page's scroll-behavior:smooth, which is meant for the #visit - // anchor, not for landing on a new page. - if (!hash) window.scrollTo({ top: 0, behavior: 'instant' }); - }, [pathname, hash]); - return null; -}; - -/** The shop front: the nav, the footer, and the pages a customer sees. */ -const PublicLayout = () => ( -
-
-
- -
-
-
-); - -/** - * 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 = () => ( -
- -
-); - const App = () => ( - <> - - - }> - } /> - } /> - } /> - } /> - } /> - - }> - } /> - - - +
+ +
); export default App; diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx deleted file mode 100644 index 2b42224..0000000 --- a/frontend/src/components/Footer.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Link } from 'react-router-dom'; -import Logo from './Logo'; - -const Footer = () => { - return ( -
-
-
- {/* Logo */} -
- -
- - {/* Navigation Links */} -
-

Navigation

-
    -
  • - Our Products -
  • -
  • - Our Story -
  • -
  • - Contact -
  • -
-
- - {/* Contact Info */} -
-

Visit

-
-

215 E Main Street
Princeville, IL 61559

-

(309) 701-0660

-

- contact@itsthevine.com -

-
-
-
- - {/* Bottom Bar */} -
-

© {new Date().getFullYear()} The Vine Coffeehouse + Bakery

-
-
-
- ); -}; - -export default Footer; diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx deleted file mode 100644 index bd3ab89..0000000 --- a/frontend/src/components/Header.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { useEffect, useState } from 'react'; -import { motion, AnimatePresence } from 'motion/react'; -import { Link, useLocation } from 'react-router-dom'; -import Logo from './Logo'; - -const navItems = [ - { label: 'Our Products', href: '/products' }, - { label: 'Our Story', href: '/history' }, - { label: 'Contact', href: '/contact' }, -]; - -const Header = () => { - const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); - const { pathname } = useLocation(); - - // Close on navigation — without this the panel stays up over the page you just opened. - useEffect(() => setIsMobileMenuOpen(false), [pathname]); - - // Escape closes it, and the page behind it doesn't scroll while it's up. - useEffect(() => { - if (!isMobileMenuOpen) return; - const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setIsMobileMenuOpen(false); }; - const previousOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; - window.addEventListener('keydown', onKey); - return () => { - document.body.style.overflow = previousOverflow; - window.removeEventListener('keydown', onKey); - }; - }, [isMobileMenuOpen]); - - return ( - <> -
-
-
- {/* Logo */} - - {/* Desktop Navigation */} - - - {/* Mobile menu button — the same control opens and closes, so the bar never - disappears out from under your thumb. */} - -
-
-
- - {/* Mobile navigation. Three things here are load-bearing: - - It lives OUTSIDE
. The header carries `backdrop-blur`, and a backdrop-filter - makes an element a containing block for fixed-position descendants — so a `fixed` panel - nested inside it resolves against the 80px header box, not the viewport, and gets - clipped to a sliver. - - It starts BELOW the bar (`top-20`) instead of covering it, so the logo and the toggle - stay put and the panel needs no second copy of either. One logo, one position, every - breakpoint. - - It must UNMOUNT when closed: a panel parked off-screen still extends the scrollable - area, which is what used to let you scroll sideways and find the menu. */} - - {isMobileMenuOpen && ( - - - - )} - - - ); -}; - -export default Header; diff --git a/frontend/src/components/Logo.tsx b/frontend/src/components/Logo.tsx deleted file mode 100644 index 6c8a7ce..0000000 --- a/frontend/src/components/Logo.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { Link } from 'react-router-dom'; -import Logo_R from '@/assets/logo_R.svg?react'; -import Logo_L from '@/assets/logo_L.svg?react'; - -interface LogoProps { - /** Tailwind text-* class. The SVG marks fill with currentColor, so this colors the whole lockup. */ - className?: string; - size?: 'sm' | 'lg'; - /** The hero sits on the homepage, where a link back to "/" is pointless. */ - linked?: boolean; -} - -// The lockup: branch · "The Vine" over "Coffeehouse + Bakery" · branch. -// Branch heights track the two-line wordmark so the marks read as part of it. -const SIZES = { - sm: { - branch: 'w-12 h-12 sm:w-14 sm:h-14', - name: 'text-xl sm:text-2xl md:text-3xl', - tag: 'text-[0.6rem] sm:text-xs tracking-[0.18em]', - gap: 'gap-1.5 sm:gap-2', - }, - lg: { - branch: 'w-20 h-20 sm:w-28 sm:h-28', - name: 'text-4xl sm:text-5xl md:text-6xl', - tag: 'text-xs sm:text-base tracking-[0.2em]', - gap: 'gap-2 sm:gap-4', - }, -}; - -const Logo: React.FC = ({ className = 'text-bakery-700', size = 'sm', linked = true }) => { - const s = SIZES[size]; - const inner = ( - <> - - - - The Vine - - - Coffeehouse + Bakery - - - - - ); - - const classes = `flex items-center ${s.gap} min-w-0 shrink ${className}`; - - if (!linked) { - return ( -
- {inner} -
- ); - } - - return ( - - {inner} - - ); -}; - -export default Logo; diff --git a/frontend/src/components/ProductGallery.tsx b/frontend/src/components/ProductGallery.tsx deleted file mode 100644 index 95016ad..0000000 --- a/frontend/src/components/ProductGallery.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { useRef, useState } from 'react'; - -interface ProductGalleryProps { - images: string[]; - alt: string; -} - -const Chevron = ({ direction }: { direction: 'left' | 'right' }) => ( - -); - -/** - * The square photo on a product card, with arrows and dots when there's more than one shot. - * - * Replaces react-awesome-slider, which hasn't been published since 2020 and pins peer deps to - * React 16 — the same job in a fraction of the code, and one less unmaintained dependency in a - * build we gate on CVEs. Behaviour is what the old cards did: one image at a time, square crop, - * arrows only when they'd do something. - */ -/** Past this many pixels a horizontal drag counts as a swipe rather than a tap or a page scroll. */ -const SWIPE_THRESHOLD = 40; - -const ProductGallery: React.FC = ({ images, alt }) => { - const [index, setIndex] = useState(0); - - // Filtering swaps the product under a reused component instance, so a stale index can point - // past the new list — every frame then renders at opacity-0 and the card goes blank. Reset - // during render (the React-sanctioned way to derive state from props) rather than in an effect, - // so the correct frame paints on the first pass instead of flashing an empty square. - const [renderedFor, setRenderedFor] = useState(images); - if (renderedFor !== images) { - setRenderedFor(images); - setIndex(0); - } - - const many = images.length > 1; - const active = index < images.length ? index : 0; - - const step = (delta: number) => setIndex((i) => (i + delta + images.length) % images.length); - - // Swipe on touch devices and arrow keys — the react-awesome-slider this replaced had swipe, and - // the products page is browsed mostly on phones. Vertical drags are left alone so the page still - // scrolls through the card. - const touchStart = useRef<{ x: number; y: number } | null>(null); - const onTouchStart = (e: React.TouchEvent) => { - const t = e.touches[0]; - touchStart.current = { x: t.clientX, y: t.clientY }; - }; - const onTouchEnd = (e: React.TouchEvent) => { - const start = touchStart.current; - touchStart.current = null; - if (!start || !many) return; - const t = e.changedTouches[0]; - const dx = t.clientX - start.x; - const dy = t.clientY - start.y; - if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dx) <= Math.abs(dy)) return; - step(dx < 0 ? 1 : -1); - }; - - const arrowClass = - 'absolute top-1/2 -translate-y-1/2 grid place-items-center h-10 w-10 rounded-full bg-bakery-900/40 text-white backdrop-blur-sm transition hover:bg-bakery-900/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-white'; - - return ( -
{ - if (e.key === 'ArrowLeft') { e.preventDefault(); step(-1); } - if (e.key === 'ArrowRight') { e.preventDefault(); step(1); } - } : undefined} - tabIndex={many ? 0 : undefined} - role={many ? 'group' : undefined} - aria-roledescription={many ? 'carousel' : undefined} - aria-label={many ? `${alt} — ${images.length} photos` : undefined} - > - {images.map((src, i) => ( - {i - ))} - - {many && ( - <> - - - - {/* Dots: how many photos there are, and which one you're on. */} -
- {images.map((src, i) => ( -
- - )} -
- ); -}; - -export default ProductGallery; diff --git a/frontend/src/index.css b/frontend/src/index.css index 8994160..40693f5 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,85 +1,13 @@ +/* + * The admin screen's stylesheet. + * + * The brand itself lives in ./tokens.css, shared with the server-rendered + * site's stylesheet — one palette, one set of fonts, and no drift between the shop front and the + * screen that edits it. Everything else the admin needs comes from utility classes in the React + * source, which Tailwind finds by scanning it. + * + * The fonts are served by Spring from /fonts rather than bundled here, so both stylesheets can name + * the same URL. + */ @import "tailwindcss"; - -/* Brand fonts ship with the app rather than coming from Google — same look, no third-party request - on every page load. Raleway is the variable latin subset. */ -@font-face { - font-family: 'Raleway'; - src: url('./fonts/raleway-latin.woff2') format('woff2'); - font-weight: 100 900; - font-style: normal; - font-display: swap; -} -@font-face { - font-family: 'AdBhashitha'; - src: url('./fonts/AdBhashitha.woff') format('woff'); - font-display: swap; -} -@font-face { - font-family: 'LeJour Script'; - src: url('./fonts/LeJour-Script.woff') format('woff'); - font-display: swap; -} - -@theme { - /* Sage & Cream. 500 is the signature sage; 600+ are the darker tones that white text can actually - sit on (500 on white is only 3.6:1 — too low). */ - --color-bakery-50: #faf7f0; - --color-bakery-100: #f0efe3; - --color-bakery-200: #dde0cc; - --color-bakery-300: #c3cbae; - --color-bakery-400: #a2ae8b; - --color-bakery-500: #7c8b6b; - --color-bakery-600: #5f6f52; - --color-bakery-700: #4a5740; - --color-bakery-800: #37412f; - --color-bakery-900: #232b1e; - - --font-sans: 'Raleway', ui-sans-serif, system-ui, sans-serif; - --font-adbhashitha: 'AdBhashitha', ui-serif, Georgia, serif; - --font-lejour: 'LeJour Script', ui-serif, Georgia, cursive; -} - -html { - scroll-behavior: smooth; - /* Nothing on this site is meant to scroll sideways. */ - overflow-x: hidden; -} - -body { - background-color: var(--color-bakery-50); - color: var(--color-bakery-900); - font-family: var(--font-sans); - overflow-x: hidden; - opacity: 0; - animation: fadeIn 0.5s ease-in forwards; -} - -@media (prefers-reduced-motion: reduce) { - html { scroll-behavior: auto; } - body { animation: none; opacity: 1; } -} - -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -/* In @layer components, matching the old site. That ordering matters: a utility like `px-4` — which - the markup applies alongside `container` on nearly every section — has to win over this, or every - gutter on the site silently widens. */ -@layer components { - .container { - @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8; - } -} - -::selection { - background-color: var(--color-bakery-300); - color: var(--color-bakery-900); -} +@import "./tokens.css"; diff --git a/frontend/src/lib/assets.ts b/frontend/src/lib/assets.ts deleted file mode 100644 index 6e6ba6f..0000000 --- a/frontend/src/lib/assets.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Photos live in the public MinIO bucket, not in the app image — 50 MB of JPEGs has no business - * inside a container we redeploy on every commit, and the bucket serves them with a year-long - * cache. Small brand assets (logo marks, favicons) stay local so first paint needs nothing external. - */ -const BASE = (import.meta.env.VITE_ASSET_BASE ?? 'https://s3.thebennett.net/itsthevine').replace(/\/$/, ''); - -/** - * `photo('products/scones.webp')` -> absolute bucket URL. - * - * Segments are encoded individually: some gallery files have spaces in their names ("Cinnamon - * Rolls.webp") and a raw space in a URL doesn't fetch. - */ -export function photo(key: string): string { - const path = key.replace(/^\//, '').split('/').map(encodeURIComponent).join('/'); - return `${BASE}/images/${path}`; -} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index e2d123a..a74ce78 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,13 +1,11 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; -import { BrowserRouter } from 'react-router-dom'; import App from './App'; import './index.css'; +// No BrowserRouter: this shell is served at /admin and nowhere else, so there is nothing to route. createRoot(document.getElementById('root')!).render( - - - + , ); diff --git a/frontend/src/pages/Contact.tsx b/frontend/src/pages/Contact.tsx deleted file mode 100644 index 0a4f66e..0000000 --- a/frontend/src/pages/Contact.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { useState } from 'react'; -import { csrfHeader } from '@/lib/api'; - -type Status = 'idle' | 'sending' | 'sent' | 'error'; - -const ContactPage = () => { - const [formData, setFormData] = useState({ - name: '', - email: '', - message: '' - }); - const [status, setStatus] = useState('idle'); - const [error, setError] = useState(''); - - const handleChange = (e: React.ChangeEvent) => { - setFormData({ - ...formData, - [e.target.name]: e.target.value - }); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setStatus('sending'); - setError(''); - try { - // csrfHeader() is empty unless security is switched on, so this posts the same as it always - // has on a deployment with no identity provider — and keeps working once one is configured, - // where an unaccompanied POST would otherwise be rejected. - const res = await fetch('/api/contact', { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...csrfHeader() }, - body: JSON.stringify(formData), - }); - const data = await res.json(); - if (!res.ok) throw new Error(data.error || 'Could not send the message.'); - setStatus('sent'); - setFormData({ name: '', email: '', message: '' }); - } catch (err) { - // Never claim success we did not get. Tell them, and give them the phone number. - setStatus('error'); - setError(err instanceof Error ? err.message : 'Could not send the message.'); - } - }; - - return ( -
- {/* Page header */} -
-

- Contact us -

-
- - {/* Contact Form Section */} -
-
-

Get in touch

-

- Or call us: (309) 701-0660 -

-
-
- - -
-
- - -
-
- - +
+
+ + + + +
+
+ +

+ Thanks. Your message is on its way, and we will get back to you. +

+
+
+
+ + diff --git a/src/main/resources/templates/error.html b/src/main/resources/templates/error.html new file mode 100644 index 0000000..330e014 --- /dev/null +++ b/src/main/resources/templates/error.html @@ -0,0 +1,29 @@ + + + + +
+
+

Something went wrong

+

+ That is our fault, not yours. Try again in a moment, or call us on + (309) 701-0660. +

+ + Back home + +
+
+ + diff --git a/src/main/resources/templates/error/404.html b/src/main/resources/templates/error/404.html new file mode 100644 index 0000000..367c48d --- /dev/null +++ b/src/main/resources/templates/error/404.html @@ -0,0 +1,39 @@ + + + + +
+
+

+ We could not find that page +

+

+ It may have moved. The menu, our story, and how to reach us are all still here. +

+ +
+
+ + diff --git a/src/main/resources/templates/fragments/footer.html b/src/main/resources/templates/fragments/footer.html new file mode 100644 index 0000000..35254ce --- /dev/null +++ b/src/main/resources/templates/fragments/footer.html @@ -0,0 +1,39 @@ + + + + + + diff --git a/src/main/resources/templates/fragments/head.html b/src/main/resources/templates/fragments/head.html new file mode 100644 index 0000000..d541fce --- /dev/null +++ b/src/main/resources/templates/fragments/head.html @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + The Vine Coffeehouse + Bakery + + + + + + + + + + + + + diff --git a/src/main/resources/templates/fragments/header.html b/src/main/resources/templates/fragments/header.html new file mode 100644 index 0000000..f32717b --- /dev/null +++ b/src/main/resources/templates/fragments/header.html @@ -0,0 +1,58 @@ + + + + +
+
+
+
+ + + +
+ + + + +
+ +
+
+
+
+
+ + + + Our Products + Catering + Our Story + Contact + + + diff --git a/src/main/resources/templates/fragments/lockup.html b/src/main/resources/templates/fragments/lockup.html new file mode 100644 index 0000000..0c04717 --- /dev/null +++ b/src/main/resources/templates/fragments/lockup.html @@ -0,0 +1,35 @@ + + + + + + + + + The Vine + Coffeehouse + Bakery + + + + + + + + + + +
+ +
+ + diff --git a/src/main/resources/templates/fragments/page.html b/src/main/resources/templates/fragments/page.html new file mode 100644 index 0000000..f6351ba --- /dev/null +++ b/src/main/resources/templates/fragments/page.html @@ -0,0 +1,28 @@ + + + + + +
+
+
+
+
+
+
+ + + + diff --git a/src/main/resources/templates/history.html b/src/main/resources/templates/history.html new file mode 100644 index 0000000..56c4220 --- /dev/null +++ b/src/main/resources/templates/history.html @@ -0,0 +1,54 @@ + + + +
+ +
+

Our story

+
+ +
+
+
+

How it started

+

+ Morissa Bennett opened The Vine in 2024, at 215 E Main Street, in the middle of downtown + Princeville. The plan was not complicated. Bake it ourselves, sell it ourselves, and keep + enough tables that nobody feels rushed out the door. +

+
+ +
+

What we make

+

+ We opened with coffee and pastries. The menu kept growing. Now there are cinnamon rolls + and caramel rolls, scones, cookie bars, macarons, brownies, and pies, plus sandwiches and + paninis once the lunch crowd shows up. +

+
+ +
+

The cakes are the fun part

+

+ Cakes and decorated cookies are made to order, which means we mostly bake whatever + Princeville is celebrating that week. We have done a tractor, a cow, a 76th birthday, a + retirement, a wedding, and a cake for the class of 1964. We have iced sugar cookies for + the cross country team and for a bridal party. If you can describe it, we will have a go + at it. +

+
+ +
+

Around town

+

+ We turn out for Christmas in the Village every year and for other civic events, and the + Princeville Civic Association counts us among the town's small businesses. Enjoy + Illinois and Discover Peoria have both pointed travelers our way. If you are one of them, + we open at 7:00am, Tuesday through Saturday. +

+
+
+
+
+ + diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html new file mode 100644 index 0000000..e6f057f --- /dev/null +++ b/src/main/resources/templates/home.html @@ -0,0 +1,112 @@ + + + +
+ + +
+ + + +
+
+ + +
+
+
+

+ A coffeehouse and bakery in downtown Princeville, Illinois. +

+ +
+
+
+ + +
+
+

What people come in for

+
+
+
+ + +
+

Cinnamon Rolls

+
+
+
+
+ + +
+
+
+

Our story

+

+ 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. +

+ + Read our story + +
+
+
+ + +
+
+

Visit us

+
+
+

Our hours

+
    +
  • + Tuesday – Friday + 7:00am – 2:00pm +
  • +
  • + Saturday + 7:00am – 12:00pm +
  • +
  • + Sunday – Monday + Closed +
  • +
+
+
+

Find us

+
+

215 E Main Street
Princeville, IL 61559

+

+ (309) 701-0660 +

+

+ contact@itsthevine.com +

+
+
+
+
+
+ +
+ + diff --git a/src/main/resources/templates/products.html b/src/main/resources/templates/products.html new file mode 100644 index 0000000..90ce582 --- /dev/null +++ b/src/main/resources/templates/products.html @@ -0,0 +1,71 @@ + + + +
+ +
+

Our products

+
+ +
+ +
+ All +
+ + +
+
+
+
+
+
+

Name

+ Category +
+
+
+ +

+ Nothing in that category just now. + See everything. +

+
+
+ + +
+ +
+ + diff --git a/src/test/java/com/itsthevine/web/PageMetaControllerTest.java b/src/test/java/com/itsthevine/web/PageMetaControllerTest.java deleted file mode 100644 index 89e1be1..0000000 --- a/src/test/java/com/itsthevine/web/PageMetaControllerTest.java +++ /dev/null @@ -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("Our products · The Vine Coffeehouse + Bakery"); - assertThat(html).contains(""); - } - - @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("Our story · "); - assertThat(get("/contact")).contains("<title>Contact us · "); - assertThat(get("/")).contains("<title>The Vine Coffeehouse + Bakery"); - } - - @Test - void theHomepageOgUrlHasNoTrailingSlash() { - assertThat(get("/")).contains(""); - } - - @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("The Vine Coffeehouse + Bakery"); - } - - @Test - void theAppShellIsStillIntact() { - // Rewriting the head must not disturb what actually boots the SPA. - String html = get("/products"); - assertThat(html).contains("
"); - assertThat(html).contains("/src/main.tsx"); - } -} diff --git a/src/test/java/com/itsthevine/web/PlatformContractTest.java b/src/test/java/com/itsthevine/web/PlatformContractTest.java index e3e1f6b..a167769 100644 --- a/src/test/java/com/itsthevine/web/PlatformContractTest.java +++ b/src/test/java/com/itsthevine/web/PlatformContractTest.java @@ -1,15 +1,42 @@ package com.itsthevine.web; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.utility.DockerImageName; -import net.thebennett.platform.test.PlatformWebContract; - -/** Everything in {@link PlatformWebContract} — what this app must do because it is on the platform. */ +/** + * What this app must do because it is on the platform. + * + *

It used to extend {@code PlatformWebContract} from platform-starter-test and inherit these five + * assertions verbatim. It can't any more, and the reason is worth stating: the shared contract asserts + * that an unknown extension-less path forwards to {@code /index.html}, because it was written when every + * app on the platform was a React SPA. This one is server-rendered now — {@code /index.html} holds + * nothing but the admin shell — so forwarding a mistyped URL there would answer with a blank page and a + * 200 instead of the site's own 404. The contract's own test methods are package-private, so the + * assertion cannot be overridden from here. + * + *

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). + * + *

Platform follow-up: {@code PlatformWebContract} should decide which of the two routing + * behaviours to assert by reading {@code platform.web.spa.enabled} — then one contract would cover both + * kinds of app and this file could go back to inheriting it. + */ @SpringBootTest(properties = { // The storage starter activates on its default endpoint, so an S3 client is built even in // tests and fails on blank keys. @@ -19,10 +46,59 @@ import net.thebennett.platform.test.PlatformWebContract; "platform.contact.from=noreply@example.com" }) @Testcontainers -class PlatformContractTest extends PlatformWebContract { +class PlatformContractTest { @Container @ServiceConnection static PostgreSQLContainer postgres = new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine")); + + @Autowired + WebApplicationContext context; + + MockMvc mvc; + + @BeforeEach + void setUp() { + mvc = MockMvcBuilders.webAppContextSetup(context).build(); + } + + @Test + @DisplayName("an /api path that matches no controller returns 404, not a page") + void unknownApiPathIsNotFound() throws Exception { + mvc.perform(get("/api/a-path-no-controller-serves")).andExpect(status().isNotFound()); + } + + @Test + @DisplayName("a nested unknown /api path returns 404 too") + void unknownNestedApiPathIsNotFound() throws Exception { + mvc.perform(get("/api/deeper/still/not/real")).andExpect(status().isNotFound()); + } + + @Test + @DisplayName("health reports UP") + void healthIsUp() throws Exception { + mvc.perform(get("/actuator/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("UP")); + } + + @Test + @DisplayName("liveness and readiness probes are exposed") + void probesAreExposed() throws Exception { + // Docker's HEALTHCHECK and any future orchestrator depend on these existing. + mvc.perform(get("/actuator/health/liveness")).andExpect(status().isOk()); + mvc.perform(get("/actuator/health/readiness")).andExpect(status().isOk()); + } + + @Test + @DisplayName("an unknown route is a 404, and only /admin serves the SPA shell") + void routingIsServerSideExceptForTheAdmin() throws Exception { + mvc.perform(get("/some/client/side/route")).andExpect(status().isNotFound()); + // Assert the forward TARGET, not the body: MockMvc records a forward rather than executing it, + // so the body is empty here by design — which also means this passes before the admin is built. + mvc.perform(get("/admin")) + .andExpect(status().isOk()) + .andExpect(forwardedUrl("/index.html")); + } } diff --git a/src/test/java/com/itsthevine/web/SiteControllerTest.java b/src/test/java/com/itsthevine/web/SiteControllerTest.java new file mode 100644 index 0000000..78d33b2 --- /dev/null +++ b/src/test/java/com/itsthevine/web/SiteControllerTest.java @@ -0,0 +1,142 @@ +package com.itsthevine.web; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +/** + * The pages, rendered. + * + *

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 }. 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", + "platform.contact.to=test@example.com", + "platform.contact.from=noreply@example.com", + "site.base-url=https://itsthevine.test", + "site.assets.base-url=https://s3.example.test/itsthevine" +}) +@Testcontainers +class SiteControllerTest { + + @Container + @ServiceConnection + static PostgreSQLContainer<?> postgres = + new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine")); + + @Autowired + WebApplicationContext context; + + MockMvc mvc; + + @BeforeEach + void setUp() { + mvc = MockMvcBuilders.webAppContextSetup(context).build(); + } + + @Test + void everyPageStatesItsOwnTitleAndDescription() throws Exception { + // One generic shell for every page was the SPA's problem, and the reason a controller used to + // rewrite the head with regular expressions. + mvc.perform(get("/")).andExpect(status().isOk()) + .andExpect(content().string(containsString("<title>The Vine Coffeehouse + Bakery"))) + .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("Our products · The Vine Coffeehouse + Bakery"))) + .andExpect(content().string(containsString("og:url\" content=\"https://itsthevine.test/products\""))); + mvc.perform(get("/catering")) + .andExpect(content().string(containsString("Goodie boxes & catering · The Vine Coffeehouse + Bakery"))); + mvc.perform(get("/history")) + .andExpect(content().string(containsString("Our story · The Vine Coffeehouse + Bakery"))); + mvc.perform(get("/contact")) + .andExpect(content().string(containsString("Contact us · The Vine Coffeehouse + Bakery"))); + } + + @Test + void theCatalogueIsInTheHtmlRatherThanFetchedAfterwards() throws Exception { + mvc.perform(get("/products")).andExpect(status().isOk()) + // A real product, its category, and a photo URL built from the bucket config. + .andExpect(content().string(containsString("76th Birthday Cake"))) + .andExpect(content().string(containsString("https://s3.example.test/itsthevine/images/"))) + // The filter buttons are links now, so every filtered view is a URL a crawler can follow. + .andExpect(content().string(containsString("href=\"/products?category=Cakes\""))); + } + + @Test + void theCategoryFilterIsAppliedByTheServer() throws Exception { + mvc.perform(get("/products").param("category", "Pie")).andExpect(status().isOk()) + .andExpect(content().string(containsString("Blueberry Cream Pie"))) + .andExpect(content().string(not(containsString("76th Birthday Cake")))) + // The chosen one is marked for a screen reader, not just coloured in. + .andExpect(content().string(containsString("aria-current=\"page\""))); + } + + @Test + void theCateringTablesAreRenderedFromTheDatabase() throws Exception { + mvc.perform(get("/catering")).andExpect(status().isOk()) + .andExpect(content().string(containsString("Office"))) + .andExpect(content().string(containsString("Weddings"))) + // Prices as the server writes them — the page never formats money. + .andExpect(content().string(containsString("$24"))) + .andExpect(content().string(containsString("$236"))) + // A cell, and a note. + .andExpect(content().string(containsString("6+6+6 or 12+6"))) + .andExpect(content().string(containsString("Minimum of 6 items per baked good"))) + // Both renderings of the same table are present; CSS decides which one is visible. + .andExpect(content().string(containsString("alert(1)")) + .andExpect(status().isOk()) + .andExpect(content().string(not(containsString("")))) + .andExpect(content().string(not(containsString("like to ask about")))); + } + + @Test + void anUnknownPageIsNotFound() throws Exception { + // Status only: MockMvc does not run the servlet container's error dispatch, so the body of the + // rendered error/404.html page can't be asserted here. It is checked against a running container + // instead — the page itself is a template like any other, and the layout it uses is covered above. + mvc.perform(get("/no-such-page")).andExpect(status().isNotFound()); + } +}