Archived
Rewrite on the Bennett platform: Spring Boot + Vite/React SPA
build-and-publish / build (push) Successful in 1m9s
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:
@@ -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 });
|
||||
}
|
||||
@@ -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.
@@ -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;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import HomePage from '@/components/HomePage';
|
||||
|
||||
export default function Home() {
|
||||
return <HomePage />;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,59 +0,0 @@
|
||||
|
||||
const HistoryPage = () => {
|
||||
return (
|
||||
<div className="bg-bakery-50">
|
||||
{/* Page header */}
|
||||
<header className="container mx-auto px-4 pt-14 pb-10 md:pt-20 md:pb-12 text-center">
|
||||
<h1 className="font-adbhashitha text-4xl md:text-5xl text-bakery-900">
|
||||
Our story
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
{/* Story */}
|
||||
<div className="container mx-auto px-4 pb-16 md:pb-24">
|
||||
<div className="max-w-2xl mx-auto space-y-12">
|
||||
<section>
|
||||
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">How it started</h2>
|
||||
<p className="text-bakery-800 leading-relaxed">
|
||||
Morissa Bennett opened The Vine in 2024, at 215 E Main Street, in the middle of downtown
|
||||
Princeville. The plan was not complicated. Bake it ourselves, sell it ourselves, and keep
|
||||
enough tables that nobody feels rushed out the door.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">What we make</h2>
|
||||
<p className="text-bakery-800 leading-relaxed">
|
||||
We opened with coffee and pastries. The menu kept growing. Now there are cinnamon rolls
|
||||
and caramel rolls, scones, cookie bars, macarons, brownies, and pies, plus sandwiches and
|
||||
paninis once the lunch crowd shows up.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">The cakes are the fun part</h2>
|
||||
<p className="text-bakery-800 leading-relaxed">
|
||||
Cakes and decorated cookies are made to order, which means we mostly bake whatever
|
||||
Princeville is celebrating that week. We have done a tractor, a cow, a 76th birthday, a
|
||||
retirement, a wedding, and a cake for the class of 1964. We have iced sugar cookies for
|
||||
the cross country team and for a bridal party. If you can describe it, we will have a go
|
||||
at it.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="font-adbhashitha text-2xl md:text-3xl text-bakery-900 mb-4">Around town</h2>
|
||||
<p className="text-bakery-800 leading-relaxed">
|
||||
We turn out for Christmas in the Village every year and for other civic events, and the
|
||||
Princeville Civic Association counts us among the town's small businesses. Enjoy
|
||||
Illinois and Discover Peoria have both pointed travelers our way. If you are one of them,
|
||||
we open at 7:00am, Tuesday through Saturday.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HistoryPage;
|
||||
@@ -1,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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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("+"));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
// description: string;
|
||||
// price: string;
|
||||
category: string;
|
||||
images: string[];
|
||||
// ingredients: string;
|
||||
// storage: string;
|
||||
}
|
||||
Reference in New Issue
Block a user