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
This commit is contained in:
2026-07-22 22:09:51 -05:00
co-authored by Claude Opus 4.8
parent 3f9efa178a
commit 11ae5f378d
164 changed files with 4223 additions and 14003 deletions
-82
View File
@@ -1,82 +0,0 @@
import { NextResponse } from 'next/server';
import nodemailer from 'nodemailer';
// Server-side only, so the SMTP credentials never reach the browser.
const { SMTP_SERVER, SMTP_PORT, SMTP_USERNAME, SMTP_TOKEN, CONTACT_TO, CONTACT_FROM } = process.env;
// Optional automation hub (n8n). When set, we also POST the enquiry here so it can
// send the customer auto-reply and (later) create a CRM lead / task. Best-effort:
// the direct email above is the reliable path, so a slow or down hub never blocks
// — or fails — the contact form.
const CONTACT_HUB_URL = process.env.CONTACT_HUB_URL;
function notifyHub(payload: { name: string; email: string; message: string }) {
if (!CONTACT_HUB_URL) return;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 4000);
fetch(CONTACT_HUB_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal,
})
.catch((err) => console.error('contact: hub notify failed (non-fatal)', err))
.finally(() => clearTimeout(timeout));
}
export async function POST(request: Request) {
if (!SMTP_SERVER || !SMTP_PORT) {
console.error('contact: SMTP env vars missing');
return NextResponse.json({ error: 'Email is not configured.' }, { status: 500 });
}
let body: { name?: string; email?: string; message?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid request.' }, { status: 400 });
}
const name = body.name?.trim();
const email = body.email?.trim();
const message = body.message?.trim();
// Trust boundary: validate before handing anything to the mailer.
if (!name || !email || !message) {
return NextResponse.json({ error: 'Please fill in every field.' }, { status: 400 });
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return NextResponse.json({ error: 'That email address does not look right.' }, { status: 400 });
}
if (message.length > 5000) {
return NextResponse.json({ error: 'That message is too long.' }, { status: 400 });
}
const port = Number(SMTP_PORT);
const transporter = nodemailer.createTransport({
host: SMTP_SERVER,
port,
secure: port === 465, // 465 is implicit TLS; 587/other upgrade via STARTTLS
auth: SMTP_USERNAME && SMTP_TOKEN ? { user: SMTP_USERNAME, pass: SMTP_TOKEN } : undefined,
// local Proton Bridge / SMTP relay presents a self-signed cert (CN=127.0.0.1) — trust it
tls: { rejectUnauthorized: false },
});
try {
await transporter.sendMail({
from: CONTACT_FROM || SMTP_USERNAME,
to: CONTACT_TO || '[email protected]',
replyTo: `${name} <${email}>`, // so hitting reply answers the customer
subject: `Website enquiry from ${name}`,
text: `${message}\n\nFrom: ${name} <${email}>`,
});
} catch (err) {
console.error('contact: send failed', err);
return NextResponse.json({ error: 'Could not send the message.' }, { status: 502 });
}
// Email delivered — fan out to the automation hub (fire-and-forget).
notifyHub({ name, email, message });
return NextResponse.json({ ok: true });
}
-24
View File
@@ -1,24 +0,0 @@
import { Metadata } from 'next';
import ContactPage from '@/components/ContactPage';
export const metadata: Metadata = {
title: 'Contact | The Vine',
description: 'Découvrez notre sélection de Breads traditionnels, Pastries et Cakes artisanales. products frais préparés chaque jour avec passion.',
keywords: 'boulangerie, pâtisserie, pain artisanal, Pastries, croissant, chocolatine, pagnot, baguette tradition',
openGraph: {
title: 'Contact | The Vine',
description: 'Découvrez nos products artisanaux frais',
images: [
{
url: '/images/og/products.jpg',
width: 1200,
height: 630,
alt: 'Products of The Vine'
},
],
},
};
export default function Page() {
return <ContactPage />;
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-42
View File
@@ -1,42 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
scroll-behavior: smooth;
/* Nothing on this site is meant to scroll sideways. */
overflow-x: hidden;
}
body {
@apply bg-bakery-50 text-bakery-900;
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);
}
}
@layer components {
.container {
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
}
}
::selection {
@apply bg-bakery-300 text-bakery-900;
}
-24
View File
@@ -1,24 +0,0 @@
import { Metadata } from 'next';
import HistoryPage from '@/components/HistoryPage';
export const metadata: Metadata = {
title: 'History | The Vine',
description: 'Découvrez notre sélection de Breads traditionnels, Pastries et Cakes artisanales. products frais préparés chaque jour avec passion.',
keywords: 'boulangerie, pâtisserie, pain artisanal, Pastries, croissant, chocolatine, pagnot, baguette tradition',
openGraph: {
title: 'Contact | The Vine',
description: 'Découvrez nos products artisanaux frais',
images: [
{
url: '/images/og/products.jpg',
width: 1200,
height: 630,
alt: 'History of The Vine'
},
],
},
};
export default function Page() {
return <HistoryPage />;
}
-69
View File
@@ -1,69 +0,0 @@
import type { Metadata } from "next";
import { Raleway } from "next/font/google";
import localFont from 'next/font/local';
import "./globals.css";
import Header from '@/components/Header';
import Footer from '@/components/Footer';
const raleway = Raleway({
subsets: ["latin"],
variable: '--font-raleway',
display: 'swap',
});
const adbhashitha = localFont({
src: './fonts/AdBhashitha.woff',
variable: '--font-adbhashitha',
display: 'swap',
});
const lejour = localFont({
src: './fonts/LeJour-Script.woff',
variable: '--font-lejour',
display: 'swap',
});
export const metadata: Metadata = {
title: "The Vine Coffeehouse + Bakery",
description: "A locally owned coffeehouse and bakery in downtown Princeville, Illinois. We bake pastries, custom cakes, cookies, and cinnamon rolls, and serve sandwiches, paninis, and coffee.",
keywords: "bakery, coffeehouse, pastries, custom cakes, cinnamon rolls, paninis, Princeville IL",
metadataBase: new URL('https://itsthevine.com'),
openGraph: {
title: 'The Vine Coffeehouse + Bakery',
description: 'A locally owned coffeehouse and bakery in downtown Princeville, IL.',
locale: 'en_US',
type: 'website',
},
icons: {
icon: [
{
media: '(prefers-color-scheme: light)',
url: '/images/resources/logo_L.png',
href: '/images/resources/logo_L.png',
},
{
media: '(prefers-color-scheme: dark)',
url: '/images/resources/logo_dark.png',
href: '/images/resources/logo_dark.png',
},
],
}
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className={`${raleway.variable} ${adbhashitha.variable} ${lejour.variable}`}>
<body className="min-h-screen bg-bakery-50 flex flex-col">
<Header />
<main className="flex-grow">
{children}
</main>
<Footer />
</body>
</html>
);
}
-5
View File
@@ -1,5 +0,0 @@
import HomePage from '@/components/HomePage';
export default function Home() {
return <HomePage />;
}
-24
View File
@@ -1,24 +0,0 @@
import { Metadata } from 'next';
import ProductsPage from '@/components/ProductsPage';
export const metadata: Metadata = {
title: 'Products | The Vine',
description: 'Découvrez notre sélection de Breads traditionnels, Pastries et Cakes artisanales. products frais préparés chaque jour avec passion.',
keywords: 'boulangerie, pâtisserie, pain artisanal, Pastries, croissant, chocolatine, pagnot, baguette tradition',
openGraph: {
title: 'Products | The Vine',
description: 'Découvrez nos products artisanaux frais',
images: [
{
url: '/images/og/products.jpg',
width: 1200,
height: 630,
alt: 'Products of The Vine'
},
],
},
};
export default function Page() {
return <ProductsPage />;
}
-124
View File
@@ -1,124 +0,0 @@
'use client';
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;
-52
View File
@@ -1,52 +0,0 @@
import Link from 'next/link';
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 href="/products" className="hover:text-white transition">Our Products</Link>
</li>
<li>
<Link href="/history" className="hover:text-white transition">Our Story</Link>
</li>
<li>
<Link href="/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;
-107
View File
@@ -1,107 +0,0 @@
'use client'
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import Link from 'next/link';
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}
href={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}
href={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;
-59
View File
@@ -1,59 +0,0 @@
const HistoryPage = () => {
return (
<div className="bg-bakery-50">
{/* Page header */}
<header className="container mx-auto px-4 pt-14 pb-10 md:pt-20 md:pb-12 text-center">
<h1 className="font-adbhashitha text-4xl md:text-5xl text-bakery-900">
Our story
</h1>
</header>
{/* Story */}
<div className="container mx-auto px-4 pb-16 md:pb-24">
<div className="max-w-2xl mx-auto space-y-12">
<section>
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">How it started</h2>
<p className="text-bakery-800 leading-relaxed">
Morissa Bennett opened The Vine in 2024, at 215 E Main Street, in the middle of downtown
Princeville. The plan was not complicated. Bake it ourselves, sell it ourselves, and keep
enough tables that nobody feels rushed out the door.
</p>
</section>
<section>
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">What we make</h2>
<p className="text-bakery-800 leading-relaxed">
We opened with coffee and pastries. The menu kept growing. Now there are cinnamon rolls
and caramel rolls, scones, cookie bars, macarons, brownies, and pies, plus sandwiches and
paninis once the lunch crowd shows up.
</p>
</section>
<section>
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">The cakes are the fun part</h2>
<p className="text-bakery-800 leading-relaxed">
Cakes and decorated cookies are made to order, which means we mostly bake whatever
Princeville is celebrating that week. We have done a tractor, a cow, a 76th birthday, a
retirement, a wedding, and a cake for the class of 1964. We have iced sugar cookies for
the cross country team and for a bridal party. If you can describe it, we will have a go
at it.
</p>
</section>
<section>
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">Around town</h2>
<p className="text-bakery-800 leading-relaxed">
We turn out for Christmas in the Village every year and for other civic events, and the
Princeville Civic Association counts us among the town&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;
-138
View File
@@ -1,138 +0,0 @@
'use client'
import Link from 'next/link';
import Image from 'next/image';
import Logo from './Logo';
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. */}
<Image
src="/images/gallery/Outside.jpg"
alt=""
fill
priority
quality={90}
className="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
href="/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">
<Image
src={`/images/gallery/${item}.png`}
alt={item}
width={600}
height={400}
sizes="(max-width: 640px) 100vw, (max-width: 768px) 50vw, 33vw"
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 href="/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;
-64
View File
@@ -1,64 +0,0 @@
import Link from 'next/link';
import Logo_R from 'public/images/resources/logo_R.svg';
import Logo_L from 'public/images/resources/logo_L.svg';
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 href="/" className={classes} aria-label="The Vine Coffeehouse + Bakery, home">
{inner}
</Link>
);
};
export default Logo;
-94
View File
@@ -1,94 +0,0 @@
'use client';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { products, categories } from '@/data/products';
import 'react-awesome-slider/dist/styles.css';
import AwesomeSlider from 'react-awesome-slider';
const ProductsPage = () => {
const [selectedCategory, setSelectedCategory] = useState('All');
const filteredProducts = selectedCategory === 'All'
? products
: products.filter(product => product.category === 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>
{/* Products Grid */}
<motion.div
layout
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
>
<AnimatePresence>
{filteredProducts.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">
<AwesomeSlider
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>
{/* 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;
-330
View File
@@ -1,330 +0,0 @@
import { Product } from '@/types/product';
export const categories = ['All', 'Cookies', 'Cakes', 'Rolls', 'Pie', 'Brownies', 'Pastries'];
export const products: Product[] = [
{
id: 1,
name: "76th Birthday Cake",
category: "Cakes",
images: [
"/images/products/76th_birthday_cake.jpg",
"/images/products/76th_birthday_cake2.jpg",
"/images/products/76th_birthday_cake3.jpg"
],
},
{
id: 2,
name: "1964 Graduates Cake",
category: "Cakes",
images: [
"/images/products/1964_graduates_cake.jpg",
"/images/products/1964_graduates_cake2.jpg",
"/images/products/1964_graduates_cake3.jpg"
],
},
{
id: 3,
name: "Baby Shower Cake",
category: "Cakes",
images: ["/images/products/babyshower_cake.jpg"],
},
{
id: 4,
name: "Blueberry Cream Pie",
category: "Pie",
images: ["/images/products/blueberry_cream_pie.jpg"],
},
{
id: 5,
name: "Bridesmaids Sugar Cookies",
category: "Cookies",
images: [
"/images/products/bridesmaids_sugar_cookies.jpg",
"/images/products/bridesmaids_sugar_cookies2.jpg"
],
},
{
id: 6,
name: "Bundt Cake",
category: "Cakes",
images: ["/images/products/bundt_cake.jpg"],
},
{
id: 7,
name: "Caramel Rolls",
category: "Rolls",
images: ["/images/products/carmel_rolls.jpg"],
},
{
id: 8,
name: "Cat Birthday Cake",
category: "Cakes",
images: ["/images/products/cat_birthday_cake.jpg"],
},
{
id: 9,
name: "Chocolate Chip Scones",
category: "Pastries",
images: ["/images/products/ChocalateChip_Scones.jpg"],
},
{
id: 10,
name: "Christmas Sugar Cookies",
category: "Cookies",
images: [
"/images/products/christmas_sugar_cookies.jpg",
"/images/products/christmas_sugar_cookies2.jpg"
],
},
{
id: 11,
name: "Circus Birthday Cake",
category: "Cakes",
images: [
"/images/products/circus_birthday_cake.jpg",
"/images/products/circus_birthday_cake2.jpg"
],
},
{
id: 12,
name: "Cookie Bars",
category: "Cookies",
images: [
"/images/products/cookie_bars.jpg",
"/images/products/cookie_bars2.jpg"
],
},
{
id: 13,
name: "Cinnamon Rolls",
category: "Rolls",
images: ["/images/products/cinnamonrolls.jpg"],
},
{
id: 14,
name: "Cow Birthday Cake",
category: "Cakes",
images: [
"/images/products/cow_birthday_cake.jpg",
"/images/products/cow_birthday_cake2.jpg"
],
},
{
id: 15,
name: "Cow Cupcakes",
category: "Cakes",
images: ["/images/products/cow_cupcakes.jpg"],
},
{
id: 16,
name: "Doggy Sugar Cookies",
category: "Cookies",
images: [
"/images/products/doggy_sugar_cookies.jpg",
"/images/products/doggy_sugar_cookies2.jpg",
"/images/products/doggy_sugar_cookies3.jpg"
],
},
{
id: 17,
name: "Fall Sugar Cookies",
category: "Cookies",
images: [
"/images/products/fall_sugar_cookies.jpg",
"/images/products/fall_sugar_cookies2.jpg"
],
},
{
id: 18,
name: "Flower Cupcakes",
category: "Cakes",
images: ["/images/products/flower_cupcakes.jpg"],
},
{
id: 19,
name: "Heart Cakes",
category: "Cakes",
images: [
"/images/products/heart_cakes.jpg",
"/images/products/heart_cakes2.jpg",
"/images/products/heart_cakes3.jpg",
"/images/products/heart_cakes4.jpg",
"/images/products/heart_cakes5.jpg",
"/images/products/heart_cakes6.jpg"
],
},
{
id: 20,
name: "Lemon Berry Cake",
category: "Cakes",
images: [
"/images/products/lemon_berry_cake.jpg",
"/images/products/lemon_berry_cake2.jpg",
"/images/products/lemon_berry_cake3.jpg",
"/images/products/lemon_berry_cake4.jpg",
"/images/products/lemon_berry_cake5.jpg"
],
},
{
id: 21,
name: "Macarons",
category: "Pastries",
images: ["/images/products/macarons.jpg"],
},
{
id: 22,
name: "Moana Birthday Cake",
category: "Cakes",
images: [
"/images/products/moana_birthday_cake.jpg",
"/images/products/moana_birthday_cake2.jpg"
],
},
{
id: 23,
name: "Natalie Purple Birthday Cake",
category: "Cakes",
images: [
"/images/products/natalie_purple_birthday_cake.jpg",
"/images/products/natalie_purple_birthday_cake2.jpg",
"/images/products/natalie_purple_birthday_cake3.jpg"
],
},
{
id: 24,
name: "Oreo Brownies",
category: "Brownies",
images: ["/images/products/oreo_brownies.jpg"],
},
{
id: 25,
name: "Peanut Butter Cookie Cake",
category: "Cakes",
images: [
"/images/products/peanutbutter_cookie_cake.jpg",
"/images/products/peanutbutter_cookie_cake2.jpg"
],
},
{
id: 26,
name: "Pink Rose Birthday Cake",
category: "Cakes",
images: ["/images/products/pink_rose_birthday_cake.jpg"],
},
{
id: 27,
name: "Princeville Sugar Cookies",
category: "Cookies",
images: [
"/images/products/princeville_sugar_cookies.jpg",
"/images/products/princeville_sugar_cookies2.jpg",
"/images/products/princeville_sugar_cookies3.jpg",
"/images/products/princeville_sugar_cookies4.jpg",
"/images/products/princeville_sugar_cookies5.jpg"
],
},
{
id: 28,
name: "Princeville XC Sugar Cookies",
category: "Cookies",
images: [
"/images/products/princeville_xc_sugar_cookies.jpg",
"/images/products/princeville_xc_sugar_cookies2.jpg"
],
},
{
id: 29,
name: "Pumpkin Birthday Cake",
category: "Cakes",
images: [
"/images/products/pumpkin_birthday_cake.jpg",
"/images/products/pumpkin_birthday_cake2.jpg",
"/images/products/pumpkin_birthday_cake3.jpg"
],
},
{
id: 30,
name: "Purple Birthday Cake",
category: "Cakes",
images: ["/images/products/purple_birthday_cake.jpg"],
},
{
id: 31,
name: "Rainbow Sugar Cookies",
category: "Cookies",
images: [
"/images/products/rainbow_sugar_cookies.jpg",
"/images/products/rainbow_sugar_cookies2.jpg"
],
},
{
id: 32,
name: "Retirement Cake",
category: "Cakes",
images: ["/images/products/retirement_cake.jpg"],
},
{
id: 33,
name: "Scones",
category: "Pastries",
images: ["/images/products/scones.jpg"],
},
{
id: 34,
name: "Soccer Sugar Cookies",
category: "Cookies",
images: [
"/images/products/soccer_sugar_cookies.jpg",
"/images/products/soccer_sugar_cookies2.jpg"
],
},
{
id: 35,
name: "Speciality Cookies",
category: "Cookies",
images: ["/images/products/speciality_cookies.jpg"],
},
{
id: 36,
name: "Strawberry Pie",
category: "Pies",
images: [
"/images/products/strawberry_pie.jpg",
"/images/products/strawberry_pie2.jpg",
"/images/products/strawberry_pie3.jpg"
],
},
{
id: 37,
name: "Timecapsul Sugar Cookies",
category: "Cookies",
images: ["/images/products/timecapsul_sugar_cookies.jpg"],
},
{
id: 38,
name: "Tractor Birthday Cake",
category: "Cakes",
images: ["/images/products/tractor_birthday_cake.jpg"],
},
{
id: 39,
name: "Valentines Cookie Cakes",
category: "Cakes",
images: [
"/images/products/valentines_cookie_cakes.jpg",
"/images/products/valentines_cookie_cakes2.jpg",
"/images/products/valentines_cookie_cakes3.jpg"
],
},
{
id: 40,
name: "Yellow Wedding Cake",
category: "Cakes",
images: [
"/images/products/yellow_wedding_cake.jpg",
"/images/products/yellow_wedding_cake2.jpg",
"/images/products/yellow_wedding_cake3.jpg"
],
}
];
@@ -0,0 +1,63 @@
package com.itsthevine.web;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.itsthevine.web.domain.ContactEnquiry;
import com.itsthevine.web.domain.ContactEnquiryRepository;
import net.thebennett.platform.contact.ContactException;
import net.thebennett.platform.contact.ContactService;
import net.thebennett.platform.contact.Enquiry;
/**
* The contact form. Keeps the response shape the old Next route used ({@code {ok:true}} /
* {@code {error:"..."}}), because the error text is shown to the visitor as-is.
*/
@RestController
@RequestMapping("/api/contact")
public class ContactController {
private final ContactService contact;
private final ContactEnquiryRepository enquiries;
public ContactController(ContactService contact, ContactEnquiryRepository enquiries) {
this.contact = contact;
this.enquiries = enquiries;
}
public record Submission(String name, String email, String message) {}
@PostMapping
public ResponseEntity<Map<String, Object>> submit(@RequestBody Submission body) {
Enquiry enquiry = Enquiry.of(body.name(), body.email(), body.message());
// Validate first so junk never reaches the table, then record it BEFORE attempting delivery:
// if the relay is down we still have the enquiry, flagged undelivered.
contact.validate(enquiry);
ContactEnquiry recorded = enquiries.save(
new ContactEnquiry(enquiry.name(), enquiry.email(), enquiry.message()));
contact.submit(enquiry);
recorded.markDelivered();
enquiries.save(recorded);
return ResponseEntity.ok(Map.of("ok", true));
}
/**
* The visitor sees this text, so it must stay the wording the service chose — never a stack trace
* or a generic 500.
*/
@org.springframework.web.bind.annotation.ExceptionHandler(ContactException.class)
public ResponseEntity<Map<String, Object>> handle(ContactException ex) {
HttpStatus status = ex.isClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.BAD_GATEWAY;
return ResponseEntity.status(status).body(Map.of("error", ex.getMessage()));
}
}
@@ -0,0 +1,12 @@
package com.itsthevine.web;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ItsTheVineApplication {
public static void main(String[] args) {
SpringApplication.run(ItsTheVineApplication.class, args);
}
}
@@ -0,0 +1,148 @@
package com.itsthevine.web;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import jakarta.servlet.http.HttpServletRequest;
/**
* Serves {@code index.html} with per-page title/description/OG tags filled in.
*
* <p>The site used to be server-rendered by Next, so every page came with its own metadata. A plain
* SPA would hand crawlers and link-preview scrapers one generic shell for all four pages — a real
* loss for a shop that people find by searching. Rendering just the {@code <head>} on the server keeps
* that, without dragging SSR (and a Node runtime) into the one-jar model.
*
* <p>Only the four real routes are listed. Anything else falls through to the platform's SPA
* fallback, which is what we want for 404s — no invented metadata for URLs that don't exist.
*/
@Controller
public class PageMetaController {
private static final Logger log = LoggerFactory.getLogger(PageMetaController.class);
private static final String NAME = "The Vine Coffeehouse + Bakery";
private static final String HOME_DESCRIPTION =
"A locally owned coffeehouse and bakery in downtown Princeville, Illinois. We bake pastries, "
+ "custom cakes, cookies, and cinnamon rolls, and serve sandwiches, paninis, and coffee.";
private record PageMeta(String title, String description) {}
private static final Map<String, PageMeta> PAGES = new LinkedHashMap<>(Map.of(
"/", new PageMeta(NAME, HOME_DESCRIPTION),
"/products", new PageMeta("Our products · " + NAME,
"Cinnamon rolls, caramel rolls, scones, cookie bars, macarons, brownies, pies, and "
+ "made-to-order cakes and decorated cookies from The Vine in Princeville, Illinois."),
"/history", new PageMeta("Our story · " + NAME,
"Morissa Bennett opened The Vine in 2024 at 215 E Main Street in downtown Princeville, "
+ "Illinois. We bake in our own kitchen on Main Street."),
"/contact", new PageMeta("Contact us · " + NAME,
"Get in touch with The Vine Coffeehouse + Bakery, 215 E Main Street, Princeville, "
+ "Illinois. Call (309) 701-0660 or send us a message.")));
private static final Pattern TITLE = Pattern.compile("<title>.*?</title>", Pattern.DOTALL);
private final ResourceLoader resourceLoader;
private final String baseUrl;
/** Cached because the file never changes at runtime — it's baked into the jar. */
private volatile String template;
public PageMetaController(ResourceLoader resourceLoader,
@Value("${site.base-url:https://itsthevine.com}") String baseUrl) {
this.resourceLoader = resourceLoader;
this.baseUrl = baseUrl;
}
@GetMapping(value = {"/", "/products", "/history", "/contact"}, produces = MediaType.TEXT_HTML_VALUE)
@ResponseBody
public String page(HttpServletRequest request) {
String path = request.getRequestURI();
PageMeta meta = PAGES.getOrDefault(path, PAGES.get("/"));
String html = template();
if (html == null) {
// No built SPA (backend-only build). Nothing to decorate.
return "<!doctype html><title>" + escape(meta.title()) + "</title>";
}
return render(html, meta, path);
}
private String render(String html, PageMeta meta, String path) {
String out = TITLE.matcher(html).replaceFirst(
Matcher.quoteReplacement("<title>" + escape(meta.title()) + "</title>"));
out = setMeta(out, "name", "description", meta.description());
out = setMeta(out, "property", "og:title", meta.title());
out = setMeta(out, "property", "og:description", meta.description());
out = setMeta(out, "property", "og:url", baseUrl + ("/".equals(path) ? "" : path));
return out;
}
/**
* Rewrites the {@code content} of an existing meta tag. Deliberately does not add missing tags —
* index.html carries the full set, so a miss here means the template changed and should be fixed
* there rather than papered over with a duplicate tag.
*/
private static String setMeta(String html, String keyAttr, String key, String value) {
Pattern p = Pattern.compile(
"(<meta\\s+" + keyAttr + "=\"" + Pattern.quote(key) + "\"\\s+content=\")[^\"]*(\")");
Matcher m = p.matcher(html);
if (!m.find()) {
log.warn("index.html has no <meta {}=\"{}\"> to fill in", keyAttr, key);
return html;
}
// Splice by index rather than replaceFirst: replacement strings give $ and \ special meaning,
// and these values are prose.
return new StringBuilder(html)
.replace(m.start(), m.end(), m.group(1) + escape(value) + m.group(2))
.toString();
}
private String template() {
String cached = template;
if (cached == null) {
synchronized (this) {
if (template == null) {
template = load();
}
cached = template;
}
}
return cached.isEmpty() ? null : cached;
}
private String load() {
Resource resource = resourceLoader.getResource("classpath:/static/index.html");
if (!resource.exists()) {
log.warn("no classpath:/static/index.html — serving pages without metadata");
return "";
}
try (var in = resource.getInputStream()) {
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
log.error("could not read index.html", e);
return "";
}
}
/** Escapes for both element text and double-quoted attribute values. */
private static String escape(String s) {
return s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;");
}
}
@@ -0,0 +1,96 @@
package com.itsthevine.web;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import com.itsthevine.web.domain.Product;
import com.itsthevine.web.domain.ProductRepository;
/**
* The products page's brains: which categories to offer, in what order, what's in each, and where the
* photos are. All of this used to live in a TypeScript array shipped to the browser.
*/
@Service
public class ProductCatalog {
/** The filter shown first — every category at once. Not a stored category. */
public static final String ALL = "All";
/**
* Curated display order. The catalogue is sorted for browsing, not alphabetically, and the order
* predates the database, so it's stated here. Categories that exist in the data but aren't listed
* still show up (appended, alphabetically) rather than silently disappearing from the filter.
*/
private static final List<String> ORDER =
List.of("Cookies", "Cakes", "Rolls", "Pie", "Brownies", "Pastries");
private final ProductRepository products;
private final String assetBaseUrl;
public ProductCatalog(ProductRepository products,
@Value("${site.assets.base-url:https://s3.thebennett.net/itsthevine}") String assetBaseUrl) {
this.products = products;
// A trailing slash here would produce '//images/...' — harmless on most servers, but it shows
// up in every image URL on the page.
this.assetBaseUrl = assetBaseUrl.replaceAll("/+$", "");
}
public record ProductView(Long id, String name, String category, List<String> images) {}
/**
* @param category a stored category, or {@link #ALL}/blank for everything
*/
@Transactional(readOnly = true)
public List<ProductView> list(String category) {
List<Product> found = (!StringUtils.hasText(category) || ALL.equalsIgnoreCase(category))
? products.findAllByOrderByPositionAsc()
: products.findAllByCategoryOrderByPositionAsc(category);
return found.stream().map(this::toView).toList();
}
/** The filter buttons, in display order, starting with "All". */
@Transactional(readOnly = true)
public List<String> categories() {
Set<String> present = products.findAllByOrderByPositionAsc().stream()
.map(Product::getCategory)
.collect(Collectors.toCollection(LinkedHashSet::new));
List<String> ordered = new ArrayList<>();
ordered.add(ALL);
ORDER.stream().filter(present::contains).forEach(ordered::add);
present.stream()
.filter(c -> !ORDER.contains(c))
.sorted(Comparator.naturalOrder())
.forEach(ordered::add);
return ordered;
}
private ProductView toView(Product p) {
return new ProductView(p.getId(), p.getName(), p.getCategory(),
p.getImageKeys().stream().map(this::imageUrl).toList());
}
/**
* Some photo filenames contain spaces ("Cinnamon Rolls.webp"), and a raw space in a URL doesn't
* fetch — so each path segment is encoded. {@code URLEncoder} is form-encoding, which differs
* from path-encoding in exactly one way that matters here: it turns a space into '+'.
*/
private String imageUrl(String key) {
String encoded = Arrays.stream(key.replaceAll("^/+", "").split("/"))
.map(segment -> URLEncoder.encode(segment, StandardCharsets.UTF_8).replace("+", "%20"))
.collect(Collectors.joining("/"));
return assetBaseUrl + "/images/" + encoded;
}
}
@@ -0,0 +1,29 @@
package com.itsthevine.web;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ProductController {
private final ProductCatalog catalog;
public ProductController(ProductCatalog catalog) {
this.catalog = catalog;
}
/** @param category filter; omit (or pass "All") for the whole catalogue */
@GetMapping("/api/products")
public List<ProductCatalog.ProductView> products(
@RequestParam(required = false) String category) {
return catalog.list(category);
}
@GetMapping("/api/categories")
public List<String> categories() {
return catalog.categories();
}
}
@@ -0,0 +1,50 @@
package com.itsthevine.web.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import net.thebennett.platform.data.BaseEntity;
/**
* A contact-form submission, recorded before we try to deliver it.
*
* <p>Writing this row first means a relay outage costs a notification, not the enquiry itself —
* {@code delivered} shows which ones still need chasing up by hand.
*/
@Entity
@Table(name = "enquiry")
public class ContactEnquiry extends BaseEntity {
@Column(nullable = false, length = 200)
private String name;
@Column(nullable = false, length = 320)
private String email;
@Column(nullable = false, columnDefinition = "text")
private String message;
@Column(nullable = false)
private boolean delivered;
protected ContactEnquiry() {
// for JPA
}
public ContactEnquiry(String name, String email, String message) {
this.name = name;
this.email = email;
this.message = message;
this.delivered = false;
}
public void markDelivered() {
this.delivered = true;
}
public String getName() { return name; }
public String getEmail() { return email; }
public String getMessage() { return message; }
public boolean isDelivered() { return delivered; }
}
@@ -0,0 +1,6 @@
package com.itsthevine.web.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ContactEnquiryRepository extends JpaRepository<ContactEnquiry, Long> {
}
@@ -0,0 +1,50 @@
package com.itsthevine.web.domain;
import java.util.ArrayList;
import java.util.List;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OrderColumn;
import jakarta.persistence.Table;
import net.thebennett.platform.data.BaseEntity;
/** Something the bakery makes, with the photos that show it off. */
@Entity
@Table(name = "product")
public class Product extends BaseEntity {
@Column(nullable = false, length = 200)
private String name;
@Column(nullable = false, length = 60)
private String category;
/** Display order on the products page; the catalogue is curated, not alphabetical. */
@Column(name = "position", nullable = false)
private int position;
/**
* Object keys, not URLs — where the bucket lives is deployment configuration, so the absolute
* URL is built at the edge of the app ({@code ProductCatalog}) rather than baked into the data.
*/
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "product_image", joinColumns = @JoinColumn(name = "product_id"))
@OrderColumn(name = "position")
@Column(name = "image_key", nullable = false, length = 300)
private List<String> imageKeys = new ArrayList<>();
protected Product() {
// for JPA
}
public String getName() { return name; }
public String getCategory() { return category; }
public int getPosition() { return position; }
public List<String> getImageKeys() { return imageKeys; }
}
@@ -0,0 +1,12 @@
package com.itsthevine.web.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
List<Product> findAllByOrderByPositionAsc();
List<Product> findAllByCategoryOrderByPositionAsc(String category);
}
+63
View File
@@ -0,0 +1,63 @@
spring:
application:
name: itsthevine
datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/itsthevine}
username: ${DB_USER:itsthevine}
password: ${DB_PASSWORD:changeme}
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
flyway:
enabled: true
mail:
host: ${SMTP_SERVER:localhost}
port: ${SMTP_PORT:25}
username: ${SMTP_USERNAME:}
password: ${SMTP_TOKEN:}
properties:
mail:
smtp:
# Only authenticate when we were actually given credentials — the LAN relay takes mail
# from the docker network without them.
auth: ${SMTP_AUTH:false}
starttls:
enable: ${SMTP_STARTTLS:false}
# The local relay / Proton Bridge presents a self-signed cert (CN=127.0.0.1). This trusts
# only the configured host, not every server we might ever talk to.
ssl:
trust: ${SMTP_SERVER:localhost}
platform:
web:
spa:
enabled: true
data:
auditing:
enabled: true
contact:
to: ${CONTACT_TO:}
from: ${CONTACT_FROM:}
hub-url: ${CONTACT_HUB_URL:}
# Absolute URLs for og:url. Only matters to link-preview scrapers, which need a full URL.
site:
base-url: ${SITE_BASE_URL:https://itsthevine.com}
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
probes:
enabled: true
health:
mail:
# OFF deliberately. Boot's mail contributor opens an SMTP connection on every health check, so a
# relay outage would report the container unhealthy and get it restarted — taking a perfectly
# good website down over a side feature. Enquiries are persisted either way, and a failed send
# is already surfaced to the visitor and the log.
enabled: false
@@ -0,0 +1,17 @@
-- Contact-form submissions. Written before delivery is attempted, so an SMTP outage costs a
-- notification rather than the enquiry; `delivered` marks the ones that still need chasing.
create table enquiry (
id bigserial primary key,
name varchar(200) not null,
email varchar(320) not null,
message text not null,
delivered boolean not null default false,
created_at timestamptz not null,
updated_at timestamptz
);
-- The only query anyone actually runs: what came in, newest first.
create index enquiry_created_at_idx on enquiry (created_at desc);
-- Finding the ones the relay never took.
create index enquiry_undelivered_idx on enquiry (created_at desc) where not delivered;
@@ -0,0 +1,150 @@
-- The product catalogue. Lives in the database rather than a TypeScript array so the
-- catalogue, its ordering and its category filter are server-side concerns like any other
-- Spring app — the SPA just renders what /api/products returns.
create table product (
id bigserial primary key,
name varchar(200) not null,
category varchar(60) not null,
position integer not null,
created_at timestamptz not null,
updated_at timestamptz
);
create index product_category_idx on product (category, position);
-- Ordered photos for a product; the first is the one the card shows.
create table product_image (
product_id bigint not null references product (id) on delete cascade,
position integer not null,
image_key varchar(300) not null,
primary key (product_id, position)
);
-- Seeded from the catalogue the site shipped with; `position` preserves the original order.
insert into product (id, name, category, position, created_at) values (1, '76th Birthday Cake', 'Cakes', 1, now());
insert into product (id, name, category, position, created_at) values (2, '1964 Graduates Cake', 'Cakes', 2, now());
insert into product (id, name, category, position, created_at) values (3, 'Baby Shower Cake', 'Cakes', 3, now());
insert into product (id, name, category, position, created_at) values (4, 'Blueberry Cream Pie', 'Pie', 4, now());
insert into product (id, name, category, position, created_at) values (5, 'Bridesmaids Sugar Cookies', 'Cookies', 5, now());
insert into product (id, name, category, position, created_at) values (6, 'Bundt Cake', 'Cakes', 6, now());
insert into product (id, name, category, position, created_at) values (7, 'Caramel Rolls', 'Rolls', 7, now());
insert into product (id, name, category, position, created_at) values (8, 'Cat Birthday Cake', 'Cakes', 8, now());
insert into product (id, name, category, position, created_at) values (9, 'Chocolate Chip Scones', 'Pastries', 9, now());
insert into product (id, name, category, position, created_at) values (10, 'Christmas Sugar Cookies', 'Cookies', 10, now());
insert into product (id, name, category, position, created_at) values (11, 'Circus Birthday Cake', 'Cakes', 11, now());
insert into product (id, name, category, position, created_at) values (12, 'Cookie Bars', 'Cookies', 12, now());
insert into product (id, name, category, position, created_at) values (13, 'Cinnamon Rolls', 'Rolls', 13, now());
insert into product (id, name, category, position, created_at) values (14, 'Cow Birthday Cake', 'Cakes', 14, now());
insert into product (id, name, category, position, created_at) values (15, 'Cow Cupcakes', 'Cakes', 15, now());
insert into product (id, name, category, position, created_at) values (16, 'Doggy Sugar Cookies', 'Cookies', 16, now());
insert into product (id, name, category, position, created_at) values (17, 'Fall Sugar Cookies', 'Cookies', 17, now());
insert into product (id, name, category, position, created_at) values (18, 'Flower Cupcakes', 'Cakes', 18, now());
insert into product (id, name, category, position, created_at) values (19, 'Heart Cakes', 'Cakes', 19, now());
insert into product (id, name, category, position, created_at) values (20, 'Lemon Berry Cake', 'Cakes', 20, now());
insert into product (id, name, category, position, created_at) values (21, 'Macarons', 'Pastries', 21, now());
insert into product (id, name, category, position, created_at) values (22, 'Moana Birthday Cake', 'Cakes', 22, now());
insert into product (id, name, category, position, created_at) values (23, 'Natalie Purple Birthday Cake', 'Cakes', 23, now());
insert into product (id, name, category, position, created_at) values (24, 'Oreo Brownies', 'Brownies', 24, now());
insert into product (id, name, category, position, created_at) values (25, 'Peanut Butter Cookie Cake', 'Cakes', 25, now());
insert into product (id, name, category, position, created_at) values (26, 'Pink Rose Birthday Cake', 'Cakes', 26, now());
insert into product (id, name, category, position, created_at) values (27, 'Princeville Sugar Cookies', 'Cookies', 27, now());
insert into product (id, name, category, position, created_at) values (28, 'Princeville XC Sugar Cookies', 'Cookies', 28, now());
insert into product (id, name, category, position, created_at) values (29, 'Pumpkin Birthday Cake', 'Cakes', 29, now());
insert into product (id, name, category, position, created_at) values (30, 'Purple Birthday Cake', 'Cakes', 30, now());
insert into product (id, name, category, position, created_at) values (31, 'Rainbow Sugar Cookies', 'Cookies', 31, now());
insert into product (id, name, category, position, created_at) values (32, 'Retirement Cake', 'Cakes', 32, now());
insert into product (id, name, category, position, created_at) values (33, 'Scones', 'Pastries', 33, now());
insert into product (id, name, category, position, created_at) values (34, 'Soccer Sugar Cookies', 'Cookies', 34, now());
insert into product (id, name, category, position, created_at) values (35, 'Speciality Cookies', 'Cookies', 35, now());
-- 'Pies' in the original data, which no filter button matched — so this one was unreachable unless you
-- were browsing "All". Filed under 'Pie' with the other one.
insert into product (id, name, category, position, created_at) values (36, 'Strawberry Pie', 'Pie', 36, now());
insert into product (id, name, category, position, created_at) values (37, 'Timecapsul Sugar Cookies', 'Cookies', 37, now());
insert into product (id, name, category, position, created_at) values (38, 'Tractor Birthday Cake', 'Cakes', 38, now());
insert into product (id, name, category, position, created_at) values (39, 'Valentines Cookie Cakes', 'Cakes', 39, now());
insert into product (id, name, category, position, created_at) values (40, 'Yellow Wedding Cake', 'Cakes', 40, now());
insert into product_image (product_id, position, image_key) values (1, 0, 'products/76th_birthday_cake.webp');
insert into product_image (product_id, position, image_key) values (1, 1, 'products/76th_birthday_cake2.webp');
insert into product_image (product_id, position, image_key) values (1, 2, 'products/76th_birthday_cake3.webp');
insert into product_image (product_id, position, image_key) values (2, 0, 'products/1964_graduates_cake.webp');
insert into product_image (product_id, position, image_key) values (2, 1, 'products/1964_graduates_cake2.webp');
insert into product_image (product_id, position, image_key) values (2, 2, 'products/1964_graduates_cake3.webp');
insert into product_image (product_id, position, image_key) values (3, 0, 'products/babyshower_cake.webp');
insert into product_image (product_id, position, image_key) values (4, 0, 'products/blueberry_cream_pie.webp');
insert into product_image (product_id, position, image_key) values (5, 0, 'products/bridesmaids_sugar_cookies.webp');
insert into product_image (product_id, position, image_key) values (5, 1, 'products/bridesmaids_sugar_cookies2.webp');
insert into product_image (product_id, position, image_key) values (6, 0, 'products/bundt_cake.webp');
insert into product_image (product_id, position, image_key) values (7, 0, 'products/carmel_rolls.webp');
insert into product_image (product_id, position, image_key) values (8, 0, 'products/cat_birthday_cake.webp');
insert into product_image (product_id, position, image_key) values (9, 0, 'products/ChocalateChip_Scones.webp');
insert into product_image (product_id, position, image_key) values (10, 0, 'products/christmas_sugar_cookies.webp');
insert into product_image (product_id, position, image_key) values (10, 1, 'products/christmas_sugar_cookies2.webp');
insert into product_image (product_id, position, image_key) values (11, 0, 'products/circus_birthday_cake.webp');
insert into product_image (product_id, position, image_key) values (11, 1, 'products/circus_birthday_cake2.webp');
insert into product_image (product_id, position, image_key) values (12, 0, 'products/cookie_bars.webp');
insert into product_image (product_id, position, image_key) values (12, 1, 'products/cookie_bars2.webp');
insert into product_image (product_id, position, image_key) values (13, 0, 'products/cinnamonrolls.webp');
insert into product_image (product_id, position, image_key) values (14, 0, 'products/cow_birthday_cake.webp');
insert into product_image (product_id, position, image_key) values (14, 1, 'products/cow_birthday_cake2.webp');
insert into product_image (product_id, position, image_key) values (15, 0, 'products/cow_cupcakes.webp');
insert into product_image (product_id, position, image_key) values (16, 0, 'products/doggy_sugar_cookies.webp');
insert into product_image (product_id, position, image_key) values (16, 1, 'products/doggy_sugar_cookies2.webp');
insert into product_image (product_id, position, image_key) values (16, 2, 'products/doggy_sugar_cookies3.webp');
insert into product_image (product_id, position, image_key) values (17, 0, 'products/fall_sugar_cookies.webp');
insert into product_image (product_id, position, image_key) values (17, 1, 'products/fall_sugar_cookies2.webp');
insert into product_image (product_id, position, image_key) values (18, 0, 'products/flower_cupcakes.webp');
insert into product_image (product_id, position, image_key) values (19, 0, 'products/heart_cakes.webp');
insert into product_image (product_id, position, image_key) values (19, 1, 'products/heart_cakes2.webp');
insert into product_image (product_id, position, image_key) values (19, 2, 'products/heart_cakes3.webp');
insert into product_image (product_id, position, image_key) values (19, 3, 'products/heart_cakes4.webp');
insert into product_image (product_id, position, image_key) values (19, 4, 'products/heart_cakes5.webp');
insert into product_image (product_id, position, image_key) values (19, 5, 'products/heart_cakes6.webp');
insert into product_image (product_id, position, image_key) values (20, 0, 'products/lemon_berry_cake.webp');
insert into product_image (product_id, position, image_key) values (20, 1, 'products/lemon_berry_cake2.webp');
insert into product_image (product_id, position, image_key) values (20, 2, 'products/lemon_berry_cake3.webp');
insert into product_image (product_id, position, image_key) values (20, 3, 'products/lemon_berry_cake4.webp');
insert into product_image (product_id, position, image_key) values (20, 4, 'products/lemon_berry_cake5.webp');
insert into product_image (product_id, position, image_key) values (21, 0, 'products/macarons.webp');
insert into product_image (product_id, position, image_key) values (22, 0, 'products/moana_birthday_cake.webp');
insert into product_image (product_id, position, image_key) values (22, 1, 'products/moana_birthday_cake2.webp');
insert into product_image (product_id, position, image_key) values (23, 0, 'products/natalie_purple_birthday_cake.webp');
insert into product_image (product_id, position, image_key) values (23, 1, 'products/natalie_purple_birthday_cake2.webp');
insert into product_image (product_id, position, image_key) values (23, 2, 'products/natalie_purple_birthday_cake3.webp');
insert into product_image (product_id, position, image_key) values (24, 0, 'products/oreo_brownies.webp');
insert into product_image (product_id, position, image_key) values (25, 0, 'products/peanutbutter_cookie_cake.webp');
insert into product_image (product_id, position, image_key) values (25, 1, 'products/peanutbutter_cookie_cake2.webp');
insert into product_image (product_id, position, image_key) values (26, 0, 'products/pink_rose_birthday_cake.webp');
insert into product_image (product_id, position, image_key) values (27, 0, 'products/princeville_sugar_cookies.webp');
insert into product_image (product_id, position, image_key) values (27, 1, 'products/princeville_sugar_cookies2.webp');
insert into product_image (product_id, position, image_key) values (27, 2, 'products/princeville_sugar_cookies3.webp');
insert into product_image (product_id, position, image_key) values (27, 3, 'products/princeville_sugar_cookies4.webp');
insert into product_image (product_id, position, image_key) values (27, 4, 'products/princeville_sugar_cookies5.webp');
insert into product_image (product_id, position, image_key) values (28, 0, 'products/princeville_xc_sugar_cookies.webp');
insert into product_image (product_id, position, image_key) values (28, 1, 'products/princeville_xc_sugar_cookies2.webp');
insert into product_image (product_id, position, image_key) values (29, 0, 'products/pumpkin_birthday_cake.webp');
insert into product_image (product_id, position, image_key) values (29, 1, 'products/pumpkin_birthday_cake2.webp');
insert into product_image (product_id, position, image_key) values (29, 2, 'products/pumpkin_birthday_cake3.webp');
insert into product_image (product_id, position, image_key) values (30, 0, 'products/purple_birthday_cake.webp');
insert into product_image (product_id, position, image_key) values (31, 0, 'products/rainbow_sugar_cookies.webp');
insert into product_image (product_id, position, image_key) values (31, 1, 'products/rainbow_sugar_cookies2.webp');
insert into product_image (product_id, position, image_key) values (32, 0, 'products/retirement_cake.webp');
insert into product_image (product_id, position, image_key) values (33, 0, 'products/scones.webp');
insert into product_image (product_id, position, image_key) values (34, 0, 'products/soccer_sugar_cookies.webp');
insert into product_image (product_id, position, image_key) values (34, 1, 'products/soccer_sugar_cookies2.webp');
insert into product_image (product_id, position, image_key) values (35, 0, 'products/speciality_cookies.webp');
insert into product_image (product_id, position, image_key) values (36, 0, 'products/strawberry_pie.webp');
insert into product_image (product_id, position, image_key) values (36, 1, 'products/strawberry_pie2.webp');
insert into product_image (product_id, position, image_key) values (36, 2, 'products/strawberry_pie3.webp');
insert into product_image (product_id, position, image_key) values (37, 0, 'products/timecapsul_sugar_cookies.webp');
insert into product_image (product_id, position, image_key) values (38, 0, 'products/tractor_birthday_cake.webp');
insert into product_image (product_id, position, image_key) values (39, 0, 'products/valentines_cookie_cakes.webp');
insert into product_image (product_id, position, image_key) values (39, 1, 'products/valentines_cookie_cakes2.webp');
insert into product_image (product_id, position, image_key) values (39, 2, 'products/valentines_cookie_cakes3.webp');
insert into product_image (product_id, position, image_key) values (40, 0, 'products/yellow_wedding_cake.webp');
insert into product_image (product_id, position, image_key) values (40, 1, 'products/yellow_wedding_cake2.webp');
insert into product_image (product_id, position, image_key) values (40, 2, 'products/yellow_wedding_cake3.webp');
-- bigserial keeps its own counter; move it past the seeded ids so future inserts don't collide.
select setval('product_id_seq', 40);
@@ -0,0 +1,134 @@
package com.itsthevine.web;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
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.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.mail.MailSendException;
import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
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 com.itsthevine.web.domain.ContactEnquiry;
import com.itsthevine.web.domain.ContactEnquiryRepository;
@SpringBootTest
@Testcontainers
class ContactControllerTest {
@Container
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
@DynamicPropertySource
static void datasource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
// Activates the contact starter without pointing it at anything real.
registry.add("platform.contact.to", () -> "[email protected]");
registry.add("platform.contact.from", () -> "[email protected]");
}
/** Nothing in a test run may reach a real relay. */
@MockitoBean
JavaMailSender mailSender;
// Boot 4's starter-test no longer ships @AutoConfigureMockMvc, so build MockMvc from the context
// directly — it's plain spring-test and needs no extra module.
@Autowired
WebApplicationContext context;
@Autowired
ContactEnquiryRepository enquiries;
MockMvc mvc;
@BeforeEach
void setUp() {
mvc = MockMvcBuilders.webAppContextSetup(context).build();
enquiries.deleteAll();
}
private static String body(String name, String email, String message) {
return """
{"name":"%s","email":"%s","message":"%s"}
""".formatted(name, email, message);
}
@Test
void acceptsAnEnquiryEmailsItAndRecordsItAsDelivered() throws Exception {
mvc.perform(post("/api/contact").contentType(MediaType.APPLICATION_JSON)
.content(body("Ada", "[email protected]", "Do you do wedding cakes?")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.ok").value(true));
verify(mailSender).send(any(SimpleMailMessage.class));
assertThat(enquiries.findAll()).singleElement().satisfies(e -> {
assertThat(e.getName()).isEqualTo("Ada");
assertThat(e.getEmail()).isEqualTo("[email protected]");
assertThat(e.getMessage()).isEqualTo("Do you do wedding cakes?");
assertThat(e.isDelivered()).isTrue();
assertThat(e.getCreatedAt()).isNotNull();
});
}
@Test
void rejectsJunkWithoutStoringItOrEmailing() throws Exception {
mvc.perform(post("/api/contact").contentType(MediaType.APPLICATION_JSON)
.content(body("Ada", "not-an-address", "hello")))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("That email address does not look right."));
verifyNoInteractions(mailSender);
assertThat(enquiries.findAll()).isEmpty();
}
@Test
void keepsTheEnquiryWhenTheRelayIsDown() throws Exception {
// The whole reason the row is written before delivery: a broken relay must not lose business.
doThrow(new MailSendException("relay down")).when(mailSender).send(any(SimpleMailMessage.class));
mvc.perform(post("/api/contact").contentType(MediaType.APPLICATION_JSON)
.content(body("Ada", "[email protected]", "Cinnamon rolls for 30?")))
.andExpect(status().isBadGateway())
.andExpect(jsonPath("$.error").value("Could not send the message."));
assertThat(enquiries.findAll())
.singleElement()
.extracting(ContactEnquiry::isDelivered, ContactEnquiry::getMessage)
.containsExactly(false, "Cinnamon rolls for 30?");
}
@Test
void trimsBeforeStoring() throws Exception {
mvc.perform(post("/api/contact").contentType(MediaType.APPLICATION_JSON)
.content(body(" Ada ", " [email protected] ", " hello ")))
.andExpect(status().isOk());
assertThat(enquiries.findAll()).singleElement().satisfies(e -> {
assertThat(e.getName()).isEqualTo("Ada");
assertThat(e.getEmail()).isEqualTo("[email protected]");
});
}
}
@@ -0,0 +1,83 @@
package com.itsthevine.web;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.File;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.mock.web.MockHttpServletRequest;
/**
* Runs against the REAL frontend/index.html rather than a fixture: the controller finds its tags by
* pattern, so reformatting that file is exactly how this would silently break. Here it fails the build
* instead.
*/
class PageMetaControllerTest {
private static final File INDEX = new File("frontend/index.html");
private static PageMetaController controller() {
DefaultResourceLoader loader = new DefaultResourceLoader() {
@Override
public Resource getResource(String location) {
return new FileSystemResource(INDEX);
}
};
return new PageMetaController(loader, "https://itsthevine.com");
}
private static String get(String path) {
MockHttpServletRequest request = new MockHttpServletRequest("GET", path);
request.setRequestURI(path);
return controller().page(request);
}
@Test
void theIndexTemplateIsWhereTheControllerExpects() {
assertThat(INDEX).exists();
}
@Test
void productsPageGetsItsOwnTitleAndDescription() {
String html = get("/products");
assertThat(html).contains("<title>Our products · The Vine Coffeehouse + Bakery</title>");
assertThat(html).contains("<meta name=\"description\" content=\"Cinnamon rolls, caramel rolls");
assertThat(html).contains("<meta property=\"og:title\" content=\"Our products · The Vine");
assertThat(html).contains("<meta property=\"og:url\" content=\"https://itsthevine.com/products\">");
}
@Test
void everyRouteIsRewritten() {
// A route the controller maps but forgot to describe would silently serve the homepage's
// metadata, which is worse than none — it tells a crawler two URLs are the same page.
assertThat(get("/history")).contains("<title>Our story · ");
assertThat(get("/contact")).contains("<title>Contact us · ");
assertThat(get("/")).contains("<title>The Vine Coffeehouse + Bakery</title>");
}
@Test
void theHomepageOgUrlHasNoTrailingSlash() {
assertThat(get("/")).contains("<meta property=\"og:url\" content=\"https://itsthevine.com\">");
}
@Test
void noDefaultMetadataSurvivesOnASubPage() {
// The template ships with the homepage copy. If a replacement misses, that copy leaks onto
// every page and the whole exercise is pointless.
String html = get("/contact");
assertThat(html).doesNotContain("A locally owned coffeehouse and bakery in downtown Princeville, Illinois. We bake");
assertThat(html).doesNotContain("<title>The Vine Coffeehouse + Bakery</title>");
}
@Test
void theAppShellIsStillIntact() {
// Rewriting the head must not disturb what actually boots the SPA.
String html = get("/products");
assertThat(html).contains("<div id=\"root\"></div>");
assertThat(html).contains("/src/main.tsx");
}
}
@@ -0,0 +1,79 @@
package com.itsthevine.web;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
/** Exercises the catalogue against the real seeded data, so the migration is covered too. */
@SpringBootTest
@Testcontainers
class ProductCatalogTest {
@Container
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>(DockerImageName.parse("postgres:18-alpine"));
@DynamicPropertySource
static void datasource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
registry.add("site.assets.base-url", () -> "https://s3.example.test/itsthevine");
}
@Autowired
ProductCatalog catalog;
@Test
void theWholeCatalogueSurvivedTheMigrationFromTypeScript() {
assertThat(catalog.list(null)).hasSize(40);
assertThat(catalog.list("All")).hasSize(40);
assertThat(catalog.list(null).stream().mapToLong(p -> p.images().size()).sum()).isEqualTo(80);
}
@Test
void keepsTheCuratedOrderRatherThanIdOrAlphabetical() {
List<ProductCatalog.ProductView> all = catalog.list(null);
assertThat(all.get(0).name()).isEqualTo("76th Birthday Cake");
assertThat(all).extracting(ProductCatalog.ProductView::name).doesNotHaveDuplicates();
}
@Test
void filtersByCategoryServerSide() {
List<ProductCatalog.ProductView> cakes = catalog.list("Cakes");
assertThat(cakes).isNotEmpty();
assertThat(cakes).allSatisfy(p -> assertThat(p.category()).isEqualTo("Cakes"));
assertThat(cakes).hasSizeLessThan(40);
}
@Test
void anUnknownCategoryIsEmptyRatherThanEverything() {
// Returning the full catalogue for a bad filter would quietly lie about what's in it.
assertThat(catalog.list("Sourdough")).isEmpty();
}
@Test
void offersTheFilterButtonsInTheOrderTheSiteAlwaysUsed() {
assertThat(catalog.categories())
.containsExactly("All", "Cookies", "Cakes", "Rolls", "Pie", "Brownies", "Pastries");
}
@Test
void buildsAbsoluteImageUrlsAndEncodesSpaces() {
List<String> images = catalog.list(null).stream().flatMap(p -> p.images().stream()).toList();
assertThat(images).allSatisfy(url ->
assertThat(url).startsWith("https://s3.example.test/itsthevine/images/"));
// A raw space would not fetch; '+' (form encoding) would 404 against the bucket.
assertThat(images).noneMatch(url -> url.contains(" ") || url.contains("+"));
}
}
-10
View File
@@ -1,10 +0,0 @@
export interface Product {
id: number;
name: string;
// description: string;
// price: string;
category: string;
images: string[];
// ingredients: string;
// storage: string;
}