Rewrite on the Bennett platform: Spring Boot + Vite/React SPA

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
This commit is contained in:
2026-07-22 22:09:51 -05:00
parent 207415dbbe
commit f471462d05
164 changed files with 4223 additions and 14003 deletions
+26
View File
@@ -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>
+2466
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+40
View File
@@ -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;
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 31 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 31 KiB

+52
View File
@@ -0,0 +1,52 @@
import { Link } from 'react-router-dom';
import Logo from './Logo';
const Footer = () => {
return (
<footer className="bg-bakery-900 text-bakery-100">
<div className="container mx-auto px-4 py-14">
<div className="grid grid-cols-1 md:grid-cols-4 gap-10">
{/* Logo */}
<div className="col-span-1 md:col-span-2 flex items-start">
<Logo className="text-bakery-50" />
</div>
{/* Navigation Links */}
<div>
<h3 className="font-adbhashitha text-sm uppercase tracking-[0.18em] text-bakery-300 mb-4">Navigation</h3>
<ul className="space-y-2">
<li>
<Link to="/products" className="hover:text-white transition">Our Products</Link>
</li>
<li>
<Link to="/history" className="hover:text-white transition">Our Story</Link>
</li>
<li>
<Link to="/contact" className="hover:text-white transition">Contact</Link>
</li>
</ul>
</div>
{/* Contact Info */}
<div>
<h3 className="font-adbhashitha text-sm uppercase tracking-[0.18em] text-bakery-300 mb-4">Visit</h3>
<address className="not-italic space-y-2">
<p>215 E Main Street<br />Princeville, IL 61559</p>
<p><a href="tel:+13097010660" className="hover:text-white transition">(309) 701-0660</a></p>
<p className="break-words">
<a href="mailto:[email protected]" className="hover:text-white transition">contact@itsthevine.com</a>
</p>
</address>
</div>
</div>
{/* Bottom Bar */}
<div className="mt-12 text-center text-sm text-bakery-300">
<p>© {new Date().getFullYear()} The Vine Coffeehouse + Bakery</p>
</div>
</div>
</footer>
);
};
export default Footer;
+106
View File
@@ -0,0 +1,106 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Link } from 'react-router-dom';
import Logo from './Logo';
const Header = () => {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const navItems = [
{ label: 'Our Products', href: '/products' },
{ label: 'Our Story', href: '/history' },
{ label: 'Contact', href: '/contact' },
];
return (
<header className="sticky top-0 z-40 bg-bakery-50/90 backdrop-blur border-b border-bakery-200">
<div className="container mx-auto px-4">
<div className="flex items-center justify-between gap-2 h-20 md:h-24">
{/* Logo */}
<Logo className="text-bakery-700" />
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center gap-8">
{navItems.map((item) => (
<Link
key={item.href}
to={item.href}
className="text-sm uppercase tracking-[0.15em] text-bakery-700 hover:text-bakery-900 transition"
>
{item.label}
</Link>
))}
</nav>
{/* Mobile menu button */}
<button
className="md:hidden p-2 shrink-0"
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
aria-label="Toggle menu"
aria-expanded={isMobileMenuOpen}
>
<svg
className="h-6 w-6 text-bakery-700"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
</div>
{/* Mobile Navigation — must UNMOUNT when closed. A panel parked off-screen
at translate-x-full still extends the scrollable area, which is what let
you scroll sideways and find the menu. */}
<AnimatePresence>
{isMobileMenuOpen && (
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'tween', duration: 0.25, ease: 'easeOut' }}
className="md:hidden fixed inset-0 bg-bakery-50 z-50"
>
<div className="p-4">
<div className="flex justify-between items-center gap-2 mb-8 h-16">
<Logo className="text-bakery-800" />
<button
onClick={() => setIsMobileMenuOpen(false)}
className="p-2 shrink-0"
aria-label="Close menu"
>
<svg
className="h-6 w-6 text-bakery-700"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<nav className="flex flex-col">
{navItems.map((item) => (
<Link
key={item.href}
to={item.href}
className="text-bakery-800 hover:text-bakery-600 transition py-4 text-lg"
onClick={() => setIsMobileMenuOpen(false)}
>
{item.label}
</Link>
))}
</nav>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</header>
);
};
export default Header;
+64
View File
@@ -0,0 +1,64 @@
import { Link } from 'react-router-dom';
import Logo_R from '@/assets/logo_R.svg?react';
import Logo_L from '@/assets/logo_L.svg?react';
interface LogoProps {
/** Tailwind text-* class. The SVG marks fill with currentColor, so this colors the whole lockup. */
className?: string;
size?: 'sm' | 'lg';
/** The hero sits on the homepage, where a link back to "/" is pointless. */
linked?: boolean;
}
// The lockup: branch · "The Vine" over "Coffeehouse + Bakery" · branch.
// Branch heights track the two-line wordmark so the marks read as part of it.
const SIZES = {
sm: {
branch: 'w-12 h-12 sm:w-14 sm:h-14',
name: 'text-xl sm:text-2xl md:text-3xl',
tag: 'text-[0.6rem] sm:text-xs tracking-[0.18em]',
gap: 'gap-1.5 sm:gap-2',
},
lg: {
branch: 'w-20 h-20 sm:w-28 sm:h-28',
name: 'text-4xl sm:text-5xl md:text-6xl',
tag: 'text-xs sm:text-base tracking-[0.2em]',
gap: 'gap-2 sm:gap-4',
},
};
const Logo: React.FC<LogoProps> = ({ className = 'text-bakery-700', size = 'sm', linked = true }) => {
const s = SIZES[size];
const inner = (
<>
<Logo_R className={`${s.branch} shrink-0`} />
<span className="flex flex-col items-center leading-none min-w-0">
<span className={`font-lejour ${s.name}`} style={{ letterSpacing: '0.01em' }}>
The Vine
</span>
<span className={`font-adbhashitha ${s.tag} uppercase mt-1.5 whitespace-nowrap`}>
Coffeehouse + Bakery
</span>
</span>
<Logo_L className={`${s.branch} shrink-0`} />
</>
);
const classes = `flex items-center ${s.gap} min-w-0 shrink ${className}`;
if (!linked) {
return (
<div className={`${classes} justify-center`} role="img" aria-label="The Vine Coffeehouse + Bakery">
{inner}
</div>
);
}
return (
<Link to="/" className={classes} aria-label="The Vine Coffeehouse + Bakery, home">
{inner}
</Link>
);
};
export default Logo;
@@ -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;
Binary file not shown.
Binary file not shown.
Binary file not shown.
+90
View File
@@ -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);
}
+23
View File
@@ -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');
+17
View File
@@ -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}`;
}
+13
View File
@@ -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>,
);
+123
View File
@@ -0,0 +1,123 @@
import { useState } from 'react';
type Status = 'idle' | 'sending' | 'sent' | 'error';
const ContactPage = () => {
const [formData, setFormData] = useState({
name: '',
email: '',
message: ''
});
const [status, setStatus] = useState<Status>('idle');
const [error, setError] = useState('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setStatus('sending');
setError('');
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Could not send the message.');
setStatus('sent');
setFormData({ name: '', email: '', message: '' });
} catch (err) {
// Never claim success we did not get. Tell them, and give them the phone number.
setStatus('error');
setError(err instanceof Error ? err.message : 'Could not send the message.');
}
};
return (
<div className="min-h-screen bg-bakery-50">
{/* Page header */}
<header className="container mx-auto px-4 pt-14 pb-10 md:pt-20 md:pb-12 text-center">
<h1 className="font-adbhashitha text-4xl md:text-5xl text-bakery-900">
Contact us
</h1>
</header>
{/* Contact Form Section */}
<div className="container mx-auto px-4 pb-16 md:pb-24">
<div className="max-w-2xl mx-auto bg-white p-8 md:p-10 rounded-3xl shadow-sm">
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-2">Get in touch</h2>
<p className="text-bakery-700 mb-8">
Or call us: <a href="tel:+13097010660" className="underline underline-offset-4 hover:text-bakery-600">(309) 701-0660</a>
</p>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="name">Name</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className="w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500 focus:border-bakery-500"
required
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="email">Email</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
className="w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500 focus:border-bakery-500"
required
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-bakery-800 mb-2" htmlFor="message">Message</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
className="w-full px-4 py-2.5 bg-bakery-50 border border-bakery-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-bakery-500 focus:border-bakery-500"
rows={5}
required
/>
</div>
<div className="flex flex-col items-center gap-4">
<button
type="submit"
disabled={status === 'sending'}
className="px-8 py-3 bg-bakery-600 text-white rounded-full tracking-wide hover:bg-bakery-700 transition-colors disabled:opacity-60"
>
{status === 'sending' ? 'Sending...' : 'Send message'}
</button>
{status === 'sent' && (
<p role="status" className="text-bakery-700">
Thanks. Your message is on its way, and we will get back to you.
</p>
)}
{status === 'error' && (
<p role="alert" className="text-center text-red-700">
{error} Please call us on{' '}
<a href="tel:+13097010660" className="underline underline-offset-4">(309) 701-0660</a>.
</p>
)}
</div>
</form>
</div>
</div>
</div>
);
};
export default ContactPage;
+59
View File
@@ -0,0 +1,59 @@
const HistoryPage = () => {
return (
<div className="bg-bakery-50">
{/* Page header */}
<header className="container mx-auto px-4 pt-14 pb-10 md:pt-20 md:pb-12 text-center">
<h1 className="font-adbhashitha text-4xl md:text-5xl text-bakery-900">
Our story
</h1>
</header>
{/* Story */}
<div className="container mx-auto px-4 pb-16 md:pb-24">
<div className="max-w-2xl mx-auto space-y-12">
<section>
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">How it started</h2>
<p className="text-bakery-800 leading-relaxed">
Morissa Bennett opened The Vine in 2024, at 215 E Main Street, in the middle of downtown
Princeville. The plan was not complicated. Bake it ourselves, sell it ourselves, and keep
enough tables that nobody feels rushed out the door.
</p>
</section>
<section>
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">What we make</h2>
<p className="text-bakery-800 leading-relaxed">
We opened with coffee and pastries. The menu kept growing. Now there are cinnamon rolls
and caramel rolls, scones, cookie bars, macarons, brownies, and pies, plus sandwiches and
paninis once the lunch crowd shows up.
</p>
</section>
<section>
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">The cakes are the fun part</h2>
<p className="text-bakery-800 leading-relaxed">
Cakes and decorated cookies are made to order, which means we mostly bake whatever
Princeville is celebrating that week. We have done a tractor, a cow, a 76th birthday, a
retirement, a wedding, and a cake for the class of 1964. We have iced sugar cookies for
the cross country team and for a bridal party. If you can describe it, we will have a go
at it.
</p>
</section>
<section>
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">Around town</h2>
<p className="text-bakery-800 leading-relaxed">
We turn out for Christmas in the Village every year and for other civic events, and the
Princeville Civic Association counts us among the town&apos;s small businesses. Enjoy
Illinois and Discover Peoria have both pointed travelers our way. If you are one of them,
we open at 7:00am, Tuesday through Saturday.
</p>
</section>
</div>
</div>
</div>
);
};
export default HistoryPage;
+133
View File
@@ -0,0 +1,133 @@
import { Link } from 'react-router-dom';
import Logo from '@/components/Logo';
import { photo } from '@/lib/assets';
const HomePage = () => {
return (
<div>
{/* Hero — centred lockup on a sage wash. */}
<section className="relative flex items-center min-h-[78svh] py-20 md:py-28 bg-bakery-900 text-white overflow-hidden">
{/* Softened on purpose: the storefront's own painted sign sits right behind
the logo, so a sharp photo makes you read the name twice. scale-110 hides
the blur's feathered edges. */}
<img
src={photo('gallery/Outside.webp')}
alt=""
fetchPriority="high"
className="absolute inset-0 w-full h-full object-cover object-bottom blur-[3px] scale-110"
/>
{/* Sage wash rather than a neutral black scrim — the tint is the identity. */}
<div className="absolute inset-0 bg-bakery-900/80" />
<div className="absolute inset-0 bg-gradient-to-t from-bakery-900 via-bakery-800/60 to-bakery-900/80" />
{/* w-full/min-w-0 keep this flex item from sizing to its max-content
width and blowing out the page on narrow screens. */}
<div className="relative container mx-auto px-4 w-full min-w-0">
<div className="max-w-3xl mx-auto text-center">
<Logo size="lg" linked={false} className="text-bakery-50 mb-10 justify-center" />
<p className="text-lg sm:text-xl text-bakery-100 mb-10 text-balance leading-relaxed">
A coffeehouse and bakery in downtown Princeville, Illinois.
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link
to="/products"
className="bg-bakery-50 hover:bg-white text-bakery-900 px-8 py-3.5 rounded-full font-medium tracking-wide inline-block transition shadow-lg"
>
See the menu
</Link>
<a
href="#visit"
className="border border-bakery-200/60 hover:bg-bakery-50/10 text-bakery-50 px-8 py-3.5 rounded-full font-medium tracking-wide inline-block transition"
>
Visit us
</a>
</div>
</div>
</div>
</section>
{/* Our Specialties */}
<section className="py-16 md:py-24 bg-bakery-50">
<div className="container mx-auto px-4">
<h2 className="font-adbhashitha text-3xl md:text-4xl text-center text-bakery-900 mb-12">What people come in for</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 md:gap-8">
{['Cinnamon Rolls', 'Sugar Cookies', 'Cakes'].map((item) => (
<div
key={item}
className="group text-center bg-white rounded-3xl overflow-hidden shadow-sm hover:shadow-lg hover:-translate-y-1 transition duration-300"
>
<div className="overflow-hidden">
<img
src={photo(`gallery/${item}.webp`)}
alt={item}
width={600}
height={400}
loading="lazy"
className="w-full h-56 object-cover transition-transform duration-500 group-hover:scale-105"
/>
</div>
<h3 className="font-adbhashitha text-xl md:text-2xl text-bakery-800 py-6 tracking-wide">{item}</h3>
</div>
))}
</div>
</div>
</section>
{/* About Us */}
<section className="py-16 md:py-24 bg-bakery-800 text-white">
<div className="container mx-auto px-4">
<div className="max-w-3xl mx-auto text-center">
<h2 className="font-adbhashitha text-3xl md:text-4xl mb-8" style={{ letterSpacing: '0.01em' }}>Our story</h2>
<p className="text-base md:text-lg text-bakery-100 mb-8 leading-relaxed">
Morissa Bennett opened The Vine in 2024. We bake in our own kitchen on Main Street:
cinnamon rolls, cookies, custom cakes, sandwiches, paninis, and coffee.
</p>
<Link to="/history" className="text-white hover:text-bakery-200 font-semibold underline underline-offset-4">
Read our story
</Link>
</div>
</div>
</section>
{/* Hours & Contact */}
<section id="visit" className="py-16 md:py-24 bg-bakery-50 scroll-mt-24">
<div className="container mx-auto px-4">
<h2 className="font-adbhashitha text-3xl md:text-4xl text-center text-bakery-900 mb-12">Visit us</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12 max-w-4xl mx-auto">
<div className="bg-white rounded-3xl p-6 md:p-8">
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-6">Our hours</h2>
<ul className="space-y-3 text-bakery-800">
<li className="flex justify-between gap-4">
<span>Tuesday Friday</span>
<span className="font-medium whitespace-nowrap">7:00am 2:00pm</span>
</li>
<li className="flex justify-between gap-4">
<span>Saturday</span>
<span className="font-medium whitespace-nowrap">7:00am 12:00pm</span>
</li>
<li className="flex justify-between gap-4">
<span>Sunday Monday</span>
<span className="font-medium">Closed</span>
</li>
</ul>
</div>
<div className="bg-white rounded-3xl p-6 md:p-8">
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-6">Find us</h2>
<address className="not-italic space-y-3 text-bakery-800">
<p>215 E Main Street<br />Princeville, IL 61559</p>
<p>
<a href="tel:+13097010660" className="hover:text-bakery-600 underline underline-offset-4">(309) 701-0660</a>
</p>
<p className="break-words">
<a href="mailto:[email protected]" className="hover:text-bakery-600 underline underline-offset-4">contact@itsthevine.com</a>
</p>
</address>
</div>
</div>
</div>
</section>
</div>
);
};
export default HomePage;
+30
View File
@@ -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;
+102
View File
@@ -0,0 +1,102 @@
import { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { fetchCategories, fetchProducts, type Product } from '@/lib/api';
import ProductGallery from '@/components/ProductGallery';
const ProductsPage = () => {
const [categories, setCategories] = useState<string[]>(['All']);
const [selectedCategory, setSelectedCategory] = useState('All');
const [products, setProducts] = useState<Product[]>([]);
const [failed, setFailed] = useState(false);
useEffect(() => {
fetchCategories().then(setCategories).catch(() => setFailed(true));
}, []);
// The filter is applied by the API, not in the browser — one source of truth for what's in a
// category. `ignore` drops a slow response that lost the race to a newer click.
useEffect(() => {
let ignore = false;
setFailed(false);
fetchProducts(selectedCategory)
.then((p) => { if (!ignore) setProducts(p); })
.catch(() => { if (!ignore) setFailed(true); });
return () => { ignore = true; };
}, [selectedCategory]);
return (
<div className="min-h-screen bg-bakery-50">
{/* Page header */}
<header className="container mx-auto px-4 pt-14 pb-10 md:pt-20 md:pb-12 text-center">
<h1 className="font-adbhashitha text-4xl md:text-5xl text-bakery-900">
Our products
</h1>
</header>
{/* Products Section */}
<div className="container mx-auto px-4 pb-16">
{/* Categories */}
<div className="flex flex-wrap justify-center gap-4 mb-12">
{categories.map((category) => (
<motion.button
key={category}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => setSelectedCategory(category)}
className={`px-6 py-2 rounded-full border text-sm uppercase tracking-[0.12em] transition-colors ${
selectedCategory === category
? 'bg-bakery-600 text-white border-bakery-600'
: 'bg-white border-bakery-300 text-bakery-700 hover:bg-bakery-100'
}`}
>
{category}
</motion.button>
))}
</div>
{failed && (
<p role="alert" className="text-center text-bakery-800">
We could not load the menu just now. Please refresh, or call us on{' '}
<a href="tel:+13097010660" className="underline underline-offset-4">(309) 701-0660</a>.
</p>
)}
{/* Products Grid */}
<motion.div
layout
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
>
<AnimatePresence>
{products.map((product) => (
<motion.div
key={product.id}
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="group bg-white rounded-3xl overflow-hidden shadow-sm hover:shadow-lg hover:-translate-y-1 transition duration-300"
>
{/* Image Container */}
<div className="relative w-full overflow-hidden">
<ProductGallery images={product.images} alt={product.name} />
</div>
{/* Content */}
<div className="p-6 text-center">
<h3 className="font-adbhashitha text-xl text-bakery-900 mb-2 tracking-wide">
{product.name}
</h3>
<span className="text-xs uppercase tracking-[0.15em] text-bakery-600">
{product.category}
</span>
</div>
</motion.div>
))}
</AnimatePresence>
</motion.div>
</div>
</div>
);
};
export default ProductsPage;
+18
View File
@@ -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"]
}
+26
View File
@@ -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,
},
});