Rewrite on the Bennett platform: Spring Boot + Vite/React SPA
build-and-publish / build (push) Successful in 1m9s
Replaces the Next.js app. Same site, same look; the parts that were decisions rather than markup now live in Java. - catalogue, curated order, category filter and image URLs move from a TypeScript array into Postgres behind /api/products and /api/categories - contact form uses the shared platform-starter-contact: validate, RECORD, send, then fan out to n8n. Recording first means a relay outage costs a notification, not an enquiry - PageMetaController rewrites title/description/OG per route, replacing what Next's SSR gave crawlers and link-preview scrapers - 50MB of photos leave the repo for the MinIO bucket, re-encoded to webp (14MB) with EXIF (including phone GPS) stripped - fixes a catalogue typo: 'Strawberry Pie' was category 'Pies', which no filter matched, so it was unreachable unless browsing All Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01XXKjx7FNyRVAjU8dgB5KhN
@@ -1,10 +1,7 @@
|
|||||||
node_modules
|
target/
|
||||||
.next
|
frontend/node_modules/
|
||||||
.git
|
frontend/dist/
|
||||||
npm-debug.log
|
.git/
|
||||||
.env
|
.idea/
|
||||||
.env.local
|
*.iml
|
||||||
Dockerfile
|
.vscode/
|
||||||
.dockerignore
|
|
||||||
README.md
|
|
||||||
default.nix
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
# SMTP credentials for the contact form. Copy to .env and fill in.
|
|
||||||
# .env is gitignored — never commit real values.
|
|
||||||
SMTP_SERVER=
|
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_USERNAME=
|
|
||||||
SMTP_TOKEN=
|
|
||||||
|
|
||||||
# Where contact form submissions are delivered.
|
|
||||||
CONTACT_TO=[email protected]
|
|
||||||
# Envelope sender. Many SMTP providers require this to match SMTP_USERNAME or a verified domain.
|
|
||||||
CONTACT_FROM=
|
|
||||||
|
|
||||||
# Optional: automation hub (n8n) webhook. When set, each enquiry is also POSTed here
|
|
||||||
# (fire-and-forget) so the hub can send the customer auto-reply and create CRM/task
|
|
||||||
# records. The direct email above stays the reliable path; leave blank to disable.
|
|
||||||
CONTACT_HUB_URL=
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 http://maven.apache.org/xsd/settings-1.0.0.xsd">
|
||||||
|
<servers>
|
||||||
|
<server>
|
||||||
|
<id>gitea</id>
|
||||||
|
<username>${env.MAVEN_USER}</username>
|
||||||
|
<password>${env.MAVEN_TOKEN}</password>
|
||||||
|
</server>
|
||||||
|
</servers>
|
||||||
|
<profiles>
|
||||||
|
<profile>
|
||||||
|
<id>gitea</id>
|
||||||
|
<repositories>
|
||||||
|
<repository>
|
||||||
|
<id>gitea</id>
|
||||||
|
<url>https://git.thebennett.net/api/packages/austin/maven</url>
|
||||||
|
<releases><enabled>true</enabled></releases>
|
||||||
|
<snapshots><enabled>true</enabled></snapshots>
|
||||||
|
</repository>
|
||||||
|
</repositories>
|
||||||
|
</profile>
|
||||||
|
</profiles>
|
||||||
|
<activeProfiles><activeProfile>gitea</activeProfile></activeProfiles>
|
||||||
|
</settings>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
name: build-and-publish
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
# Files that can't change the image. Skipping them avoids a pointless rebuild that Watchtower
|
||||||
|
# would then redeploy — a few seconds of downtime on a live site for a docs-only commit.
|
||||||
|
paths-ignore: ["renovate.json", "**.md"]
|
||||||
|
# Lets `rebuild-all-apps.sh` force a rebuild (e.g. to roll out an urgent platform fix immediately
|
||||||
|
# instead of waiting for a Renovate bump PR).
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to the Gitea container registry
|
||||||
|
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.thebennett.net -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
|
||||||
|
# The platform is now referenced by an immutable RELEASE version, so a cached maven layer can't
|
||||||
|
# silently hold an old build — layer caching is safe again (and much faster).
|
||||||
|
- name: Build image
|
||||||
|
env:
|
||||||
|
DOCKER_BUILDKIT: "1"
|
||||||
|
MAVEN_USER: ${{ secrets.REGISTRY_USER }}
|
||||||
|
MAVEN_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
--secret id=maven_user,env=MAVEN_USER \
|
||||||
|
--secret id=maven_token,env=MAVEN_TOKEN \
|
||||||
|
--build-arg GIT_SHA=${{ github.sha }} \
|
||||||
|
-t git.thebennett.net/thevine/itsthevine:latest \
|
||||||
|
-t git.thebennett.net/thevine/itsthevine:${{ github.sha }} .
|
||||||
|
|
||||||
|
- name: Scan image (Trivy)
|
||||||
|
run: |
|
||||||
|
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
aquasec/trivy:latest image --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed --no-progress \
|
||||||
|
git.thebennett.net/thevine/itsthevine:latest || true
|
||||||
|
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
|
||||||
|
aquasec/trivy:latest image --scanners vuln --severity HIGH,CRITICAL --ignore-unfixed \
|
||||||
|
--pkg-types library --exit-code 1 --no-progress \
|
||||||
|
git.thebennett.net/thevine/itsthevine:latest
|
||||||
|
|
||||||
|
- name: Push image
|
||||||
|
run: |
|
||||||
|
docker push git.thebennett.net/thevine/itsthevine:latest
|
||||||
|
docker push git.thebennett.net/thevine/itsthevine:${{ github.sha }}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
name: build-and-publish
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master, main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Log in to the Gitea container registry
|
|
||||||
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.thebennett.net -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
|
||||||
|
|
||||||
- name: Build image
|
|
||||||
run: |
|
|
||||||
docker build \
|
|
||||||
-t git.thebennett.net/thevine/itsthevine:latest \
|
|
||||||
-t git.thebennett.net/thevine/itsthevine:${{ github.sha }} .
|
|
||||||
|
|
||||||
- name: Push image
|
|
||||||
run: |
|
|
||||||
docker push git.thebennett.net/thevine/itsthevine:latest
|
|
||||||
docker push git.thebennett.net/thevine/itsthevine:${{ github.sha }}
|
|
||||||
@@ -1,41 +1,7 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
target/
|
||||||
|
frontend/node_modules/
|
||||||
# dependencies
|
frontend/dist/
|
||||||
/node_modules
|
.idea/
|
||||||
/.pnp
|
*.iml
|
||||||
.pnp.*
|
.vscode/
|
||||||
.yarn/*
|
|
||||||
!.yarn/patches
|
|
||||||
!.yarn/plugins
|
|
||||||
!.yarn/releases
|
|
||||||
!.yarn/versions
|
|
||||||
|
|
||||||
# testing
|
|
||||||
/coverage
|
|
||||||
|
|
||||||
# next.js
|
|
||||||
/.next/
|
|
||||||
/out/
|
|
||||||
|
|
||||||
# production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
|
||||||
|
|
||||||
# debug
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
|
|
||||||
# env files (can opt-in for commiting if needed)
|
|
||||||
.env*
|
|
||||||
|
|
||||||
# vercel
|
|
||||||
.vercel
|
|
||||||
|
|
||||||
# typescript
|
|
||||||
*.tsbuildinfo
|
|
||||||
next-env.d.ts
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,37 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
# Multi-stage build for the Next.js (standalone) itsthevine.com site.
|
|
||||||
|
|
||||||
FROM node:22-alpine AS deps
|
# ---------- build ----------
|
||||||
WORKDIR /app
|
FROM maven:3.9-eclipse-temurin-25 AS build
|
||||||
COPY package.json package-lock.json ./
|
WORKDIR /src
|
||||||
# framer-motion@11 declares a React 18 peer; project is on React 19 (as built on Vercel)
|
|
||||||
RUN npm ci --legacy-peer-deps
|
|
||||||
|
|
||||||
FROM node:22-alpine AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY . .
|
COPY . .
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
# Resolve the platform from the Gitea Maven registry (creds via BuildKit secrets); the Maven build also
|
||||||
RUN npm run build
|
# runs the Vite/React SPA build (frontend-maven-plugin) and folds it into the jar. Tests are skipped here
|
||||||
|
# because Testcontainers needs a Docker daemon — they run via `mvn verify`, not in the image build.
|
||||||
|
RUN --mount=type=secret,id=maven_user --mount=type=secret,id=maven_token \
|
||||||
|
MAVEN_USER="$(cat /run/secrets/maven_user)" MAVEN_TOKEN="$(cat /run/secrets/maven_token)" \
|
||||||
|
mvn -B -ntp -s .gitea/ci-settings.xml -DskipTests package \
|
||||||
|
&& cp "$(ls target/itsthevine-*.jar | grep -v original | head -1)" app.jar \
|
||||||
|
&& java -Djarmode=tools -jar app.jar extract --layers --destination extracted
|
||||||
|
|
||||||
FROM node:22-alpine AS runner
|
# ---------- runtime ----------
|
||||||
|
FROM eclipse-temurin:25-jre-alpine AS runtime
|
||||||
|
RUN apk -U upgrade --no-cache && apk add --no-cache curl
|
||||||
|
RUN addgroup -S spring && adduser -S -D -H -h /app -s /sbin/nologin -G spring spring
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production \
|
|
||||||
NEXT_TELEMETRY_DISABLED=1 \
|
COPY --from=build --chown=spring:spring /src/extracted/dependencies/ ./
|
||||||
PORT=3000 \
|
COPY --from=build --chown=spring:spring /src/extracted/snapshot-dependencies/ ./
|
||||||
HOSTNAME=0.0.0.0
|
COPY --from=build --chown=spring:spring /src/extracted/application/ ./
|
||||||
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
|
|
||||||
# standalone output already contains a minimal node_modules + server.js
|
USER spring
|
||||||
COPY --from=builder /app/public ./public
|
EXPOSE 8080
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
|
||||||
USER nextjs
|
CMD curl -fsS http://localhost:8080/actuator/health || exit 1
|
||||||
EXPOSE 3000
|
|
||||||
CMD ["node", "server.js"]
|
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}"
|
||||||
|
|
||||||
|
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]
|
||||||
|
|||||||
@@ -1,11 +1,69 @@
|
|||||||
# [The Vine Coffeehouse & Bakery](http://itsthevine.com)
|
# The Vine Coffeehouse + Bakery — itsthevine.com
|
||||||
|
|
||||||
## Getting Started
|
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).
|
||||||
|
|
||||||
First, run the development server:
|
Previously a Next.js app on Cloudflare, then self-hosted; the look is unchanged.
|
||||||
|
|
||||||
|
## Shape
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Backend | Spring Boot 4 / Java 25, `com.itsthevine.web` |
|
||||||
|
| Frontend | Vite + React 19 + TypeScript + Tailwind v4, served from the jar |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
## What the server owns
|
||||||
|
|
||||||
|
The SPA renders; it doesn't decide anything.
|
||||||
|
|
||||||
|
- **`/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/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.
|
||||||
|
|
||||||
|
## Photos
|
||||||
|
|
||||||
|
Re-encoded to webp and uploaded to the bucket once (50 MB of originals → 14 MB), served with a
|
||||||
|
year-long cache. `site.assets.base-url` says where they live. The originals remain in this repo's
|
||||||
|
history. EXIF (including GPS from phone photos) is stripped by the re-encode.
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
# backend (needs Postgres on :5432 with an itsthevine database)
|
||||||
|
mvn spring-boot:run
|
||||||
|
|
||||||
|
# frontend, proxies /api to :8080
|
||||||
|
cd frontend && npm install && npm run dev # http://localhost:2024
|
||||||
```
|
```
|
||||||
|
|
||||||
Open http://localhost:3000 with your browser to see the result.
|
Build without the SPA for quick backend loops: `mvn -DskipFrontend=true package`.
|
||||||
|
|
||||||
|
Tests need Docker (Testcontainers):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mvn verify
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `DB_URL` / `DB_USER` / `DB_PASSWORD` | Postgres |
|
||||||
|
| `SMTP_SERVER` / `SMTP_PORT` / `SMTP_USERNAME` / `SMTP_TOKEN` | relay for the contact form |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
`platform.contact.to` unset disables the contact starter, and the app won't start with a
|
||||||
|
`ContactController` that has nothing to send with — so a missing `CONTACT_TO` fails loudly rather than
|
||||||
|
silently dropping enquiries.
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
let
|
|
||||||
pkgs = import <nixpkgs> {};
|
|
||||||
in
|
|
||||||
|
|
||||||
pkgs.mkShell {
|
|
||||||
buildInputs = [
|
|
||||||
pkgs.nodejs
|
|
||||||
];
|
|
||||||
|
|
||||||
shellHook = ''
|
|
||||||
if ! [ -d "node_modules" ]; then
|
|
||||||
npm install --legacy-peer-deps
|
|
||||||
fi
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" media="(prefers-color-scheme: light)" href="/images/resources/logo_L.png">
|
||||||
|
<link rel="icon" media="(prefers-color-scheme: dark)" href="/images/resources/logo_dark.png">
|
||||||
|
<!-- Preconnect to the photo bucket so product images start loading a round-trip sooner. -->
|
||||||
|
<link rel="preconnect" href="https://s3.thebennett.net" crossorigin>
|
||||||
|
<!-- PageMetaController rewrites the title and the four meta tags below per route, so crawlers and
|
||||||
|
link-preview scrapers get real per-page metadata instead of one generic shell. Keep the
|
||||||
|
attribute order and quoting as-is — it matches on them. -->
|
||||||
|
<title>The Vine Coffeehouse + Bakery</title>
|
||||||
|
<meta name="description" content="A locally owned coffeehouse and bakery in downtown Princeville, Illinois. We bake pastries, custom cakes, cookies, and cinnamon rolls, and serve sandwiches, paninis, and coffee.">
|
||||||
|
<meta property="og:title" content="The Vine Coffeehouse + Bakery">
|
||||||
|
<meta property="og:description" content="A locally owned coffeehouse and bakery in downtown Princeville, IL.">
|
||||||
|
<meta property="og:url" content="https://itsthevine.com">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:locale" content="en_US">
|
||||||
|
<meta name="keywords" content="bakery, coffeehouse, pastries, custom cakes, cinnamon rolls, paninis, Princeville IL">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "itsthevine-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"framer-motion": "12.34.4",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7",
|
||||||
|
"react-router-dom": "7.18.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "4.3.3",
|
||||||
|
"@types/node": "^26.1.0",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
"tailwindcss": "4.3.3",
|
||||||
|
"typescript": "^5.9.0",
|
||||||
|
"vite": "^8.1.3",
|
||||||
|
"vite-plugin-svgr": "^4.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { 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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side navigation keeps the previous scroll position, which lands you halfway down a page you
|
||||||
|
* just opened. Anchors like /#visit still need to work, so only reset when there isn't one.
|
||||||
|
*/
|
||||||
|
const ScrollToTop = () => {
|
||||||
|
const { pathname, hash } = useLocation();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hash) window.scrollTo(0, 0);
|
||||||
|
}, [pathname, hash]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const App = () => (
|
||||||
|
<div className="min-h-screen bg-bakery-50 flex flex-col">
|
||||||
|
<ScrollToTop />
|
||||||
|
<Header />
|
||||||
|
<main className="flex-grow">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/products" element={<ProductsPage />} />
|
||||||
|
<Route path="/history" element={<HistoryPage />} />
|
||||||
|
<Route path="/contact" element={<ContactPage />} />
|
||||||
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
@@ -1,4 +1,4 @@
|
|||||||
import Link from 'next/link';
|
import { Link } from 'react-router-dom';
|
||||||
import Logo from './Logo';
|
import Logo from './Logo';
|
||||||
|
|
||||||
const Footer = () => {
|
const Footer = () => {
|
||||||
@@ -16,13 +16,13 @@ const Footer = () => {
|
|||||||
<h3 className="font-adbhashitha text-sm uppercase tracking-[0.18em] text-bakery-300 mb-4">Navigation</h3>
|
<h3 className="font-adbhashitha text-sm uppercase tracking-[0.18em] text-bakery-300 mb-4">Navigation</h3>
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
<li>
|
<li>
|
||||||
<Link href="/products" className="hover:text-white transition">Our Products</Link>
|
<Link to="/products" className="hover:text-white transition">Our Products</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/history" className="hover:text-white transition">Our Story</Link>
|
<Link to="/history" className="hover:text-white transition">Our Story</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/contact" className="hover:text-white transition">Contact</Link>
|
<Link to="/contact" className="hover:text-white transition">Contact</Link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client'
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import Link from 'next/link';
|
import { Link } from 'react-router-dom';
|
||||||
import Logo from './Logo';
|
import Logo from './Logo';
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
@@ -24,7 +23,7 @@ const Header = () => {
|
|||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
to={item.href}
|
||||||
className="text-sm uppercase tracking-[0.15em] text-bakery-700 hover:text-bakery-900 transition"
|
className="text-sm uppercase tracking-[0.15em] text-bakery-700 hover:text-bakery-900 transition"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -87,7 +86,7 @@ const Header = () => {
|
|||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
to={item.href}
|
||||||
className="text-bakery-800 hover:text-bakery-600 transition py-4 text-lg"
|
className="text-bakery-800 hover:text-bakery-600 transition py-4 text-lg"
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
>
|
>
|
||||||
@@ -104,4 +103,4 @@ const Header = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Header;
|
export default Header;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Link from 'next/link';
|
import { Link } from 'react-router-dom';
|
||||||
import Logo_R from 'public/images/resources/logo_R.svg';
|
import Logo_R from '@/assets/logo_R.svg?react';
|
||||||
import Logo_L from 'public/images/resources/logo_L.svg';
|
import Logo_L from '@/assets/logo_L.svg?react';
|
||||||
|
|
||||||
interface LogoProps {
|
interface LogoProps {
|
||||||
/** Tailwind text-* class. The SVG marks fill with currentColor, so this colors the whole lockup. */
|
/** Tailwind text-* class. The SVG marks fill with currentColor, so this colors the whole lockup. */
|
||||||
@@ -55,7 +55,7 @@ const Logo: React.FC<LogoProps> = ({ className = 'text-bakery-700', size = 'sm',
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href="/" className={classes} aria-label="The Vine Coffeehouse + Bakery, home">
|
<Link to="/" className={classes} aria-label="The Vine Coffeehouse + Bakery, home">
|
||||||
{inner}
|
{inner}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface ProductGalleryProps {
|
||||||
|
images: string[];
|
||||||
|
alt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The square photo on a product card, with arrows 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.
|
||||||
|
*/
|
||||||
|
const ProductGallery: React.FC<ProductGalleryProps> = ({ images, alt }) => {
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const many = images.length > 1;
|
||||||
|
|
||||||
|
const step = (delta: number) => setIndex((i) => (i + delta + images.length) % images.length);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative aspect-square bg-bakery-100">
|
||||||
|
{images.map((src, i) => (
|
||||||
|
<img
|
||||||
|
key={src}
|
||||||
|
src={src}
|
||||||
|
// Only the visible frame gets described; the rest are decorative duplicates of the same item.
|
||||||
|
alt={i === 0 ? alt : ''}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${
|
||||||
|
i === index ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||||
|
}`}
|
||||||
|
aria-hidden={i === index ? undefined : true}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{many && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => step(-1)}
|
||||||
|
aria-label={`Previous photo of ${alt}`}
|
||||||
|
className="absolute left-2 top-1/2 -translate-y-1/2 grid place-items-center h-10 w-10 rounded-full bg-bakery-900/40 text-white text-2xl leading-none backdrop-blur-sm transition hover:bg-bakery-900/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">{'<'}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => step(1)}
|
||||||
|
aria-label={`Next photo of ${alt}`}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 grid place-items-center h-10 w-10 rounded-full bg-bakery-900/40 text-white text-2xl leading-none backdrop-blur-sm transition hover:bg-bakery-900/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">{'>'}</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductGallery;
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Deliberately unlayered so it overrides Tailwind's own `container` utility — the site's gutters are
|
||||||
|
part of the layout, not the breakpoint default. */
|
||||||
|
.container {
|
||||||
|
max-width: 80rem;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding-inline: 1rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 40rem) {
|
||||||
|
.container { padding-inline: 1.5rem; }
|
||||||
|
}
|
||||||
|
@media (min-width: 64rem) {
|
||||||
|
.container { padding-inline: 2rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background-color: var(--color-bakery-300);
|
||||||
|
color: var(--color-bakery-900);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* The catalogue, its ordering, its category filter and its image URLs are all decided by the
|
||||||
|
* backend — this file just fetches them. Same origin, so no base URL and no CORS.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Product {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
/** Absolute, ready to put in a src. Built server-side from the bucket config. */
|
||||||
|
images: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get<T>(path: string): Promise<T> {
|
||||||
|
const res = await fetch(path, { headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) throw new Error(`${path} responded ${res.status}`);
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchProducts = (category?: string) =>
|
||||||
|
get<Product[]>(category && category !== 'All' ? `/api/products?category=${encodeURIComponent(category)}` : '/api/products');
|
||||||
|
|
||||||
|
export const fetchCategories = () => get<string[]>('/api/categories');
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* 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}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
'use client';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
type Status = 'idle' | 'sending' | 'sent' | 'error';
|
type Status = 'idle' | 'sending' | 'sent' | 'error';
|
||||||
@@ -59,38 +58,38 @@ const ContactPage = () => {
|
|||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="name">Name</label>
|
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="name">Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500 focus:border-bakery-500"
|
className="w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500 focus:border-bakery-500"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="email">Email</label>
|
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="email">Email</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500 focus:border-bakery-500"
|
className="w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500 focus:border-bakery-500"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="message">Message</label>
|
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="message">Message</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="message"
|
id="message"
|
||||||
name="message"
|
name="message"
|
||||||
value={formData.message}
|
value={formData.message}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500 focus:border-bakery-500"
|
className="w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500 focus:border-bakery-500"
|
||||||
rows={5}
|
rows={5}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client'
|
import { Link } from 'react-router-dom';
|
||||||
import Link from 'next/link';
|
import Logo from '@/components/Logo';
|
||||||
import Image from 'next/image';
|
import { photo } from '@/lib/assets';
|
||||||
import Logo from './Logo';
|
|
||||||
|
|
||||||
const HomePage = () => {
|
const HomePage = () => {
|
||||||
return (
|
return (
|
||||||
@@ -11,13 +10,11 @@ const HomePage = () => {
|
|||||||
{/* Softened on purpose: the storefront's own painted sign sits right behind
|
{/* Softened on purpose: the storefront's own painted sign sits right behind
|
||||||
the logo, so a sharp photo makes you read the name twice. scale-110 hides
|
the logo, so a sharp photo makes you read the name twice. scale-110 hides
|
||||||
the blur's feathered edges. */}
|
the blur's feathered edges. */}
|
||||||
<Image
|
<img
|
||||||
src="/images/gallery/Outside.jpg"
|
src={photo('gallery/Outside.webp')}
|
||||||
alt=""
|
alt=""
|
||||||
fill
|
fetchPriority="high"
|
||||||
priority
|
className="absolute inset-0 w-full h-full object-cover object-bottom blur-[3px] scale-110"
|
||||||
quality={90}
|
|
||||||
className="object-cover object-bottom blur-[3px] scale-110"
|
|
||||||
/>
|
/>
|
||||||
{/* Sage wash rather than a neutral black scrim — the tint is the identity. */}
|
{/* Sage wash rather than a neutral black scrim — the tint is the identity. */}
|
||||||
<div className="absolute inset-0 bg-bakery-900/80" />
|
<div className="absolute inset-0 bg-bakery-900/80" />
|
||||||
@@ -33,7 +30,7 @@ const HomePage = () => {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
<Link
|
<Link
|
||||||
href="/products"
|
to="/products"
|
||||||
className="bg-bakery-50 hover:bg-white text-bakery-900 px-8 py-3.5 rounded-full font-medium tracking-wide inline-block transition shadow-lg"
|
className="bg-bakery-50 hover:bg-white text-bakery-900 px-8 py-3.5 rounded-full font-medium tracking-wide inline-block transition shadow-lg"
|
||||||
>
|
>
|
||||||
See the menu
|
See the menu
|
||||||
@@ -49,8 +46,6 @@ const HomePage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Our Specialties */}
|
{/* Our Specialties */}
|
||||||
<section className="py-16 md:py-24 bg-bakery-50">
|
<section className="py-16 md:py-24 bg-bakery-50">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
@@ -62,12 +57,12 @@ const HomePage = () => {
|
|||||||
className="group text-center bg-white rounded-3xl overflow-hidden shadow-sm hover:shadow-lg hover:-translate-y-1 transition duration-300"
|
className="group text-center bg-white rounded-3xl overflow-hidden shadow-sm hover:shadow-lg hover:-translate-y-1 transition duration-300"
|
||||||
>
|
>
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
<Image
|
<img
|
||||||
src={`/images/gallery/${item}.png`}
|
src={photo(`gallery/${item}.webp`)}
|
||||||
alt={item}
|
alt={item}
|
||||||
width={600}
|
width={600}
|
||||||
height={400}
|
height={400}
|
||||||
sizes="(max-width: 640px) 100vw, (max-width: 768px) 50vw, 33vw"
|
loading="lazy"
|
||||||
className="w-full h-56 object-cover transition-transform duration-500 group-hover:scale-105"
|
className="w-full h-56 object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,7 +82,7 @@ const HomePage = () => {
|
|||||||
Morissa Bennett opened The Vine in 2024. We bake in our own kitchen on Main Street:
|
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.
|
cinnamon rolls, cookies, custom cakes, sandwiches, paninis, and coffee.
|
||||||
</p>
|
</p>
|
||||||
<Link href="/history" className="text-white hover:text-bakery-200 font-semibold underline underline-offset-4">
|
<Link to="/history" className="text-white hover:text-bakery-200 font-semibold underline underline-offset-4">
|
||||||
Read our story
|
Read our story
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,4 +130,4 @@ const HomePage = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default HomePage;
|
export default HomePage;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
const NotFoundPage = () => (
|
||||||
|
<div className="bg-bakery-50">
|
||||||
|
<div className="container mx-auto px-4 py-24 md:py-32 text-center">
|
||||||
|
<h1 className="font-adbhashitha text-4xl md:text-5xl text-bakery-900 mb-6">
|
||||||
|
We could not find that page
|
||||||
|
</h1>
|
||||||
|
<p className="text-bakery-800 mb-10">
|
||||||
|
It may have moved. The menu, our story, and how to reach us are all still here.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="bg-bakery-600 hover:bg-bakery-700 text-white px-8 py-3.5 rounded-full font-medium tracking-wide inline-block transition"
|
||||||
|
>
|
||||||
|
Back home
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/products"
|
||||||
|
className="border border-bakery-300 hover:bg-bakery-100 text-bakery-700 px-8 py-3.5 rounded-full font-medium tracking-wide inline-block transition"
|
||||||
|
>
|
||||||
|
See the menu
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default NotFoundPage;
|
||||||
@@ -1,16 +1,28 @@
|
|||||||
'use client';
|
import { useEffect, useState } from 'react';
|
||||||
import { useState } from 'react';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { products, categories } from '@/data/products';
|
import { fetchCategories, fetchProducts, type Product } from '@/lib/api';
|
||||||
import 'react-awesome-slider/dist/styles.css';
|
import ProductGallery from '@/components/ProductGallery';
|
||||||
import AwesomeSlider from 'react-awesome-slider';
|
|
||||||
|
|
||||||
const ProductsPage = () => {
|
const ProductsPage = () => {
|
||||||
|
const [categories, setCategories] = useState<string[]>(['All']);
|
||||||
const [selectedCategory, setSelectedCategory] = useState('All');
|
const [selectedCategory, setSelectedCategory] = useState('All');
|
||||||
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
const filteredProducts = selectedCategory === 'All'
|
useEffect(() => {
|
||||||
? products
|
fetchCategories().then(setCategories).catch(() => setFailed(true));
|
||||||
: products.filter(product => product.category === selectedCategory);
|
}, []);
|
||||||
|
|
||||||
|
// The filter is applied by the API, not in the browser — one source of truth for what's in a
|
||||||
|
// category. `ignore` drops a slow response that lost the race to a newer click.
|
||||||
|
useEffect(() => {
|
||||||
|
let ignore = false;
|
||||||
|
setFailed(false);
|
||||||
|
fetchProducts(selectedCategory)
|
||||||
|
.then((p) => { if (!ignore) setProducts(p); })
|
||||||
|
.catch(() => { if (!ignore) setFailed(true); });
|
||||||
|
return () => { ignore = true; };
|
||||||
|
}, [selectedCategory]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-bakery-50">
|
<div className="min-h-screen bg-bakery-50">
|
||||||
@@ -42,13 +54,20 @@ const ProductsPage = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{failed && (
|
||||||
|
<p role="alert" className="text-center text-bakery-800">
|
||||||
|
We could not load the menu just now. Please refresh, or call us on{' '}
|
||||||
|
<a href="tel:+13097010660" className="underline underline-offset-4">(309) 701-0660</a>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Products Grid */}
|
{/* Products Grid */}
|
||||||
<motion.div
|
<motion.div
|
||||||
layout
|
layout
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||||
>
|
>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{filteredProducts.map((product) => (
|
{products.map((product) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={product.id}
|
key={product.id}
|
||||||
layout
|
layout
|
||||||
@@ -59,18 +78,7 @@ const ProductsPage = () => {
|
|||||||
>
|
>
|
||||||
{/* Image Container */}
|
{/* Image Container */}
|
||||||
<div className="relative w-full overflow-hidden">
|
<div className="relative w-full overflow-hidden">
|
||||||
<AwesomeSlider
|
<ProductGallery images={product.images} alt={product.name} />
|
||||||
bullets={false}
|
|
||||||
style={{ aspectRatio: '1 / 1' }}
|
|
||||||
organicArrows={product.images.length > 1}
|
|
||||||
customContent={true}
|
|
||||||
buttonContentLeft={product.images.length > 1 ? <span className="text-white text-2xl">{'<'}</span> : null}
|
|
||||||
buttonContentRight={product.images.length > 1 ? <span className="text-white text-2xl">{'>'}</span> : null}
|
|
||||||
>
|
|
||||||
{product.images.map((image, index) => (
|
|
||||||
<div key={index} data-src={image} />
|
|
||||||
))}
|
|
||||||
</AwesomeSlider>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
@@ -91,4 +99,4 @@ const ProductsPage = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProductsPage;
|
export default ProductsPage;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"types": ["vite/client", "vite-plugin-svgr/client", "node"],
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["./src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import svgr from 'vite-plugin-svgr';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
// svgr keeps the `import Logo from './x.svg?react'` style the old @svgr/webpack setup used, so the
|
||||||
|
// logo marks stay inline SVG and inherit currentColor.
|
||||||
|
plugins: [react(), tailwindcss(), svgr()],
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': path.resolve(__dirname, './src') },
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 2024,
|
||||||
|
// Backend on :8080 during development; the built SPA is served by Spring, same origin.
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8080',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
// The photos live in MinIO, so what's left is small — a warning here would mean a real regression.
|
||||||
|
chunkSizeWarningLimit: 600,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { NextConfig } from "next";
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
output: 'standalone', // self-contained server bundle for the Docker image
|
|
||||||
images: {
|
|
||||||
remotePatterns: [
|
|
||||||
{
|
|
||||||
protocol: 'https',
|
|
||||||
hostname: 'itsthevine.com',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
turbopack: {
|
|
||||||
rules: {
|
|
||||||
'*.svg': {
|
|
||||||
loaders: ['@svgr/webpack'],
|
|
||||||
as: '*.js',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "itsthevine.com",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "next dev -p 2024",
|
|
||||||
"build": "next build",
|
|
||||||
"start": "next start -p 2024",
|
|
||||||
"lint": "next lint"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@svgr/webpack": "^8.1.0",
|
|
||||||
"framer-motion": "^11.11.11",
|
|
||||||
"next": "^16.2.10",
|
|
||||||
"nodemailer": "^9.0.3",
|
|
||||||
"react": "^19.0.0",
|
|
||||||
"react-awesome-slider": "^4.1.0",
|
|
||||||
"react-dom": "^19.0.0",
|
|
||||||
"sass": "^1.83.4"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^20",
|
|
||||||
"@types/nodemailer": "^8.0.1",
|
|
||||||
"@types/react": "^18",
|
|
||||||
"@types/react-dom": "^18",
|
|
||||||
"eslint": "^9.39.2",
|
|
||||||
"eslint-config-next": "15.0.2",
|
|
||||||
"postcss": "^8",
|
|
||||||
"tailwindcss": "^3.4.1",
|
|
||||||
"typescript": "^5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>net.thebennett.platform</groupId>
|
||||||
|
<artifactId>platform-parent</artifactId>
|
||||||
|
<version>0.1.4</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.itsthevine</groupId>
|
||||||
|
<artifactId>itsthevine</artifactId>
|
||||||
|
<version>0.1.0</version>
|
||||||
|
<name>The Vine Coffeehouse + Bakery</name>
|
||||||
|
<description>Site for The Vine in Princeville, IL — menu, story, and contact form — on the Bennett platform.</description>
|
||||||
|
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>net.thebennett.platform</groupId>
|
||||||
|
<artifactId>platform-bom</artifactId>
|
||||||
|
<version>0.1.4</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
<!-- Spring Boot 4.1 no longer manages the raw org.testcontainers:* module versions -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>testcontainers-bom</artifactId>
|
||||||
|
<version>1.21.4</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
|
||||||
|
<!-- Platform releases live in the Gitea Maven registry (anonymous read). Declared here so Renovate
|
||||||
|
can discover new platform versions and open a bump PR. Maven still needs this repo in
|
||||||
|
settings.xml for PARENT resolution (see .gitea/ci-settings.xml). -->
|
||||||
|
<repositories>
|
||||||
|
<repository>
|
||||||
|
<id>gitea</id>
|
||||||
|
<url>https://git.thebennett.net/api/packages/austin/maven</url>
|
||||||
|
<releases><enabled>true</enabled></releases>
|
||||||
|
<snapshots><enabled>false</enabled></snapshots>
|
||||||
|
</repository>
|
||||||
|
</repositories>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>net.thebennett.platform</groupId>
|
||||||
|
<artifactId>platform-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>net.thebennett.platform</groupId>
|
||||||
|
<artifactId>platform-starter-data</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>net.thebennett.platform</groupId>
|
||||||
|
<artifactId>platform-starter-contact</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- No starter-security: this is a public brochure site with nothing to sign in to, and no
|
||||||
|
starter-storage: the photos are served straight from the public MinIO bucket. -->
|
||||||
|
|
||||||
|
<!-- test -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-testcontainers</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
<!-- SPA build inherited from platform-parent (node install + npm build + copy dist -> jar). -->
|
||||||
|
<plugin>
|
||||||
|
<groupId>com.github.eirslett</groupId>
|
||||||
|
<artifactId>frontend-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-resources-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/** @type {import('postcss-load-config').Config} */
|
|
||||||
const config = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 468 KiB |
|
Before Width: | Height: | Size: 473 KiB |
|
Before Width: | Height: | Size: 509 KiB |
|
Before Width: | Height: | Size: 917 KiB |
|
Before Width: | Height: | Size: 471 KiB |
|
Before Width: | Height: | Size: 454 KiB |
|
Before Width: | Height: | Size: 487 KiB |
|
Before Width: | Height: | Size: 628 KiB |
|
Before Width: | Height: | Size: 442 KiB |
|
Before Width: | Height: | Size: 643 KiB |
|
Before Width: | Height: | Size: 590 KiB |
|
Before Width: | Height: | Size: 680 KiB |
|
Before Width: | Height: | Size: 501 KiB |
|
Before Width: | Height: | Size: 740 KiB |
|
Before Width: | Height: | Size: 498 KiB |
|
Before Width: | Height: | Size: 645 KiB |
|
Before Width: | Height: | Size: 511 KiB |
|
Before Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 454 KiB |
|
Before Width: | Height: | Size: 490 KiB |
|
Before Width: | Height: | Size: 842 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 368 KiB |
|
Before Width: | Height: | Size: 392 KiB |
|
Before Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 630 KiB |
|
Before Width: | Height: | Size: 630 KiB |
|
Before Width: | Height: | Size: 608 KiB |
|
Before Width: | Height: | Size: 592 KiB |
|
Before Width: | Height: | Size: 667 KiB |
|
Before Width: | Height: | Size: 670 KiB |
|
Before Width: | Height: | Size: 419 KiB |
|
Before Width: | Height: | Size: 444 KiB |
|
Before Width: | Height: | Size: 355 KiB |
|
Before Width: | Height: | Size: 394 KiB |
|
Before Width: | Height: | Size: 496 KiB |
|
Before Width: | Height: | Size: 663 KiB |
|
Before Width: | Height: | Size: 392 KiB |
|
Before Width: | Height: | Size: 422 KiB |
|
Before Width: | Height: | Size: 422 KiB |
|
Before Width: | Height: | Size: 501 KiB |
|
Before Width: | Height: | Size: 350 KiB |
|
Before Width: | Height: | Size: 459 KiB |
|
Before Width: | Height: | Size: 424 KiB |
|
Before Width: | Height: | Size: 462 KiB |
|
Before Width: | Height: | Size: 449 KiB |
|
Before Width: | Height: | Size: 432 KiB |
|
Before Width: | Height: | Size: 415 KiB |
|
Before Width: | Height: | Size: 503 KiB |
|
Before Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 636 KiB |
|
Before Width: | Height: | Size: 696 KiB |
|
Before Width: | Height: | Size: 639 KiB |
|
Before Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 296 KiB |