chore: initial import

This commit is contained in:
2025-11-09 00:26:00 -06:00
commit 67fb60e6ca
76 changed files with 3925 additions and 0 deletions

3
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
.next
npm-debug.log

5
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.next
out
npm-debug.log
.DS_Store

14
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
# syntax=docker/dockerfile:1
FROM node:20-bullseye
WORKDIR /app
COPY package.json package-lock.json* ./
RUN if [ -f package.json ]; then npm install; fi
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev", "--", "-H", "0.0.0.0"]

20
frontend/README.md Normal file
View File

@@ -0,0 +1,20 @@
# Frontend
This directory contains the Next.js frontend. The current scaffold renders a placeholder page so the Docker stack can start without additional setup.
## Development
Inside Docker (recommended):
```bash
docker compose up frontend-gpt
```
Local node tooling:
```bash
npm install
npm run dev
```
The development server is available at http://localhost:3000.

View File

@@ -0,0 +1,39 @@
import Link from 'next/link';
import { useRouter } from 'next/router';
const navLinks = [
{ href: '/admin', label: 'Dashboard' },
{ href: '/admin/menu', label: 'Menu' },
{ href: '/admin/orders', label: 'Orders' },
{ href: '/admin/inventory', label: 'Inventory' },
{ href: '/admin/delivery', label: 'Delivery' },
];
export function AdminLayout({ children, title = 'Admin' }) {
const router = useRouter();
return (
<div className="admin-shell">
<div className="admin-container">
<header style={{ marginBottom: '2.5rem' }}>
<div className="admin-badge">Full Moon Bakehouse Operations</div>
<h1 className="admin-section-title">{title}</h1>
<nav className="admin-nav" style={{ marginTop: '1.2rem' }}>
{navLinks.map((link) => {
const isActive = router.pathname.startsWith(link.href);
return (
<Link
key={link.href}
href={link.href}
className={isActive ? 'active' : 'inactive'}
>
{link.label}
</Link>
);
})}
</nav>
</header>
<section className="admin-card">{children}</section>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
export function FaqSection({ items }) {
if (!items?.length) {
return null;
}
return (
<div className="faq-grid">
{items.map((item) => (
<article key={item.question} className="faq-item">
<h4>{item.question}</h4>
<p>{item.answer}</p>
</article>
))}
</div>
);
}

View File

@@ -0,0 +1,52 @@
import Link from 'next/link';
import { useCart } from '../context/CartContext';
const FALLBACK_IMAGE =
'https://images.unsplash.com/photo-1608198093002-ad4e005484ec?auto=format&fit=crop&w=1200&q=80';
export default function ProductCard({ product }) {
const { addItem } = useCart();
const imageUrl = product?.imageUrl || product?.hero_image || FALLBACK_IMAGE;
const price = Number(product?.price || 0).toFixed(2);
const canPurchase = Boolean(product?.id) && !product?.isSample;
const hasDetail = Boolean(product?.slug) && !product?.isSample;
return (
<article className="product-card">
<div className="product-card__image">
<img src={imageUrl} alt={product?.name || 'Bakehouse item'} loading="lazy" />
</div>
<div>
<h3>{product?.name || 'Menu item'}</h3>
{product?.short_description ? <p>{product.short_description}</p> : null}
</div>
<div className="product-card__meta">
<span>${price}</span>
<span>{product?.inventory_quantity ?? '—'} available</span>
</div>
<div className="product-card__tags">
<span>Max {product?.max_per_order ?? '—'} / order</span>
{product?.allergens ? <span>{product.allergens}</span> : null}
</div>
<div className="product-card__actions">
{canPurchase ? (
<button type="button" className="btn btn-outline" onClick={() => addItem(product)}>
Add to cart
</button>
) : (
<span className="sample-tag">Sample listing</span>
)}
{hasDetail ? (
<Link href={`/products/${product.slug}`}>View details</Link>
) : null}
</div>
</article>
);
}

View File

@@ -0,0 +1,66 @@
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useCart } from '../context/CartContext';
const NAV_LINKS = [
{ href: '/', label: 'Home' },
{ href: '/products', label: 'Products' },
{ href: '/cart', label: 'Cart' },
];
export default function SiteLayout({ children }) {
const router = useRouter();
const { itemCount } = useCart();
return (
<div className="site-shell">
<header className="site-header">
<div className="site-header__inner">
<Link href="/" className="logo">
<img src="/logo.png" alt="Full Moon Bakehouse" />
<span>Full Moon Bakehouse</span>
</Link>
<nav className="nav-links">
{NAV_LINKS.map((link) => {
const isActive = router.pathname === link.href || router.pathname.startsWith(`${link.href}/`);
const isCart = link.href === '/cart';
return (
<Link key={link.href} href={link.href} className={isActive ? 'active' : ''}>
{link.label}
{isCart && itemCount > 0 ? <span className="badge">{itemCount}</span> : null}
</Link>
);
})}
</nav>
<div className="header-actions">
<Link href="/admin" className="ghost-btn">
Admin
</Link>
</div>
</div>
</header>
<main className="site-main">{children}</main>
<footer className="site-footer">
<div className="site-footer__inner">
<div>
<strong>Full Moon Bakehouse</strong>
<p>Eau Claire & Altoona, Wisconsin</p>
</div>
<div className="footer-links">
<Link href="mailto:bonna@fullmoonbakehouse.com">bonna@fullmoonbakehouse.com</Link>
<Link href="https://www.instagram.com" target="_blank" rel="noreferrer">
Instagram
</Link>
</div>
<span className="copyright">© {new Date().getFullYear()} Full Moon Bakehouse</span>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,139 @@
import { createContext, useContext, useEffect, useMemo, useReducer } from 'react';
const CartContext = createContext(undefined);
const initialState = {
items: [],
};
function cartReducer(state, action) {
switch (action.type) {
case 'HYDRATE':
return { ...state, items: action.payload };
case 'ADD_ITEM': {
const { item } = action.payload;
const existing = state.items.find((entry) => entry.id === item.id);
if (!existing) {
return { ...state, items: [...state.items, item] };
}
const updated = state.items.map((entry) =>
entry.id === item.id ? { ...entry, quantity: item.quantity, limit: item.limit } : entry
);
return { ...state, items: updated };
}
case 'UPDATE_QUANTITY': {
const { id, quantity } = action.payload;
const updated = state.items
.map((entry) => (entry.id === id ? { ...entry, quantity } : entry))
.filter((entry) => entry.quantity > 0);
return { ...state, items: updated };
}
case 'REMOVE_ITEM': {
const { id } = action.payload;
return { ...state, items: state.items.filter((entry) => entry.id !== id) };
}
case 'CLEAR':
return { ...state, items: [] };
default:
return state;
}
}
const STORAGE_KEY = 'fullmoon.cart';
export function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, initialState);
useEffect(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
dispatch({ type: 'HYDRATE', payload: parsed });
}
} catch (error) {
console.warn('Cart hydration failed', error);
}
}, []);
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.items));
}, [state.items]);
const itemCount = useMemo(
() => state.items.reduce((sum, entry) => sum + entry.quantity, 0),
[state.items]
);
const subtotal = useMemo(
() => state.items.reduce((sum, entry) => sum + entry.price * entry.quantity, 0),
[state.items]
);
const addItem = (product) => {
if (!product?.id) return;
const price = Number(product.price || 0);
const maxPerOrder = Number(product.max_per_order || 0) > 0 ? Number(product.max_per_order) : Infinity;
const inventoryLimit =
typeof product.inventory_quantity === 'number' && product.inventory_quantity >= 0
? product.inventory_quantity
: Infinity;
const limit = Math.min(maxPerOrder, inventoryLimit);
if (limit <= 0) return;
const numericLimit = Number.isFinite(limit) ? limit : Infinity;
const existing = state.items.find((entry) => entry.id === product.id);
const nextQuantity = existing ? Math.min(existing.quantity + 1, numericLimit) : 1;
dispatch({
type: 'ADD_ITEM',
payload: {
item: {
id: product.id,
slug: product.slug,
name: product.name,
price,
quantity: nextQuantity,
imageUrl: product.imageUrl,
limit: Number.isFinite(limit) ? limit : null,
},
},
});
};
const updateQuantity = (id, quantity) => {
if (quantity < 0) return;
const entry = state.items.find((item) => item.id === id);
if (!entry) return;
const limit = Number.isFinite(entry.limit) ? entry.limit : Infinity;
const clamped = Math.min(quantity, limit);
dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity: clamped } });
};
const removeItem = (id) => dispatch({ type: 'REMOVE_ITEM', payload: { id } });
const clearCart = () => dispatch({ type: 'CLEAR' });
const value = useMemo(
() => ({
items: state.items,
addItem,
updateQuantity,
removeItem,
clearCart,
itemCount,
subtotal,
}),
[state.items, itemCount, subtotal]
);
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}
export function useCart() {
const context = useContext(CartContext);
if (!context) {
throw new Error('useCart must be used within a CartProvider');
}
return context;
}

View File

@@ -0,0 +1,41 @@
import { useCallback, useState } from 'react';
const API_BASE = process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, '') || 'http://localhost:8000';
export function useAdminApi(token) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const authorizedFetch = useCallback(
async (path, options = {}) => {
setLoading(true);
setError(null);
try {
const response = await fetch(`${API_BASE}${path}`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Token ${token}`,
...(options.headers || {}),
},
...options,
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `Request failed ${response.status}`);
}
const isJson = response.headers.get('content-type')?.includes('application/json');
return isJson ? response.json() : null;
} catch (err) {
setError(err.message);
throw err;
} finally {
setLoading(false);
}
},
[token]
);
return { loading, error, request: authorizedFetch };
}

48
frontend/lib/api.js Normal file
View File

@@ -0,0 +1,48 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, '') || 'http://localhost:8000';
async function request(path, init = {}) {
const response = await fetch(`${API_BASE}${path}`, {
headers: {
'Content-Type': 'application/json',
...(init.headers || {}),
},
...init,
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Request failed: ${response.status} ${text}`);
}
return response.json();
}
export function buildMediaUrl(path) {
if (!path) return null;
if (path.startsWith('http')) return path;
return `${API_BASE}${path}`;
}
export async function fetchProducts() {
return request('/api/commerce/products/');
}
export async function fetchProduct(slug) {
if (!slug) throw new Error('Product slug is required');
return request(`/api/commerce/products/${slug}/`);
}
export async function fetchCategories() {
return request('/api/commerce/categories/');
}
export async function fetchDeliveryZones() {
return request('/api/commerce/delivery-zones/');
}
export async function submitOrder(payload) {
return request('/api/commerce/orders/', {
method: 'POST',
body: JSON.stringify(payload),
});
}

9
frontend/next.config.js Normal file
View File

@@ -0,0 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
experimental: {
appDir: false,
},
};
module.exports = nextConfig;

17
frontend/package-lock.json generated Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "fullmooncgpt-frontend",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fullmooncgpt-frontend",
"version": "0.1.0",
"dependencies": {
"next": "14.2.0",
"react": "18.2.0",
"react-dom": "18.2.0"
}
}
}
}

15
frontend/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "fullmooncgpt-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "14.2.0",
"react": "18.2.0",
"react-dom": "18.2.0"
}
}

11
frontend/pages/_app.js Normal file
View File

@@ -0,0 +1,11 @@
import '../styles/globals.css';
import { CartProvider } from '../context/CartContext';
export default function App({ Component, pageProps }) {
return (
<CartProvider>
<Component {...pageProps} />
</CartProvider>
);
}

View File

@@ -0,0 +1,56 @@
import Head from 'next/head';
import { AdminLayout } from '../../components/AdminLayout';
const cards = [
{
title: 'Menu builder',
body: 'Create offerings, upload photos, and set inventory caps without leaving the page.',
},
{
title: 'Orders & pick tickets',
body: 'Review todays bake list, tap through stages, and open iPad-friendly pick tickets.',
},
{
title: 'Inventory & ingredients',
body: 'Track flour, butter, and fillings with automatic depletion and restock alerts.',
},
{
title: 'Analytics',
body: 'See weekly order trends, best sellers, and forecast upcoming bake volumes.',
},
];
export default function AdminHome() {
return (
<>
<Head>
<title>Full Moon Bakehouse · Admin</title>
</Head>
<AdminLayout title="Operations dashboard">
<div className="admin-section">
<p className="admin-muted" style={{ maxWidth: '540px' }}>
Built for a one-woman bakehouse: fast edits, real-time inventory, and workflows that
translate beautifully to an iPad on the counter.
</p>
<div className="admin-products-grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))' }}>
{cards.map((card) => (
<article key={card.title} className="admin-product-card">
<header>
<h3>{card.title}</h3>
</header>
<p>{card.body}</p>
</article>
))}
</div>
<p className="empty-state" style={{ textAlign: 'left' }}>
Authentication and role-aware dashboards land alongside the order boardthis page is a
stub until those pieces go live.
</p>
</div>
</AdminLayout>
</>
);
}

View File

@@ -0,0 +1,457 @@
import { useEffect, useMemo, useState } from 'react';
import Head from 'next/head';
import { AdminLayout } from '../../../components/AdminLayout';
import { useAdminApi } from '../../../hooks/useAdminApi';
const LOCAL_TOKEN_KEY = 'fullmoon_admin_token';
export default function AdminMenu() {
const [token, setToken] = useState('');
const [products, setProducts] = useState([]);
const [categories, setCategories] = useState([]);
const [isEditing, setIsEditing] = useState(false);
const [formState, setFormState] = useState(initialProductState);
useEffect(() => {
if (typeof window !== 'undefined') {
const stored = window.localStorage.getItem(LOCAL_TOKEN_KEY);
if (stored) {
setToken(stored);
}
}
}, []);
const { request, loading, error } = useAdminApi(token);
useEffect(() => {
if (!token) return;
async function loadData() {
try {
const [productsData, categoriesData] = await Promise.all([
request('/api/commerce/admin/products/'),
request('/api/commerce/admin/categories/'),
]);
setProducts(productsData);
setCategories(categoriesData);
} catch (err) {
console.error(err);
}
}
loadData();
}, [token, request]);
const featuredProducts = useMemo(
() => products.filter((product) => product.is_featured),
[products]
);
const handleSubmit = async (event) => {
event.preventDefault();
try {
const payload = buildPayload(formState);
const url = isEditing
? `/api/commerce/admin/products/${formState.id}/`
: '/api/commerce/admin/products/';
const method = isEditing ? 'PUT' : 'POST';
const saved = await request(url, {
method,
body: JSON.stringify(payload),
});
setProducts((prev) => {
if (isEditing) {
return prev.map((product) => (product.id === saved.id ? saved : product));
}
return [saved, ...prev];
});
resetForm();
} catch (err) {
console.error(err);
}
};
const handleEdit = (product) => {
setIsEditing(true);
setFormState(mapProductToForm(product));
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleDelete = async (productId) => {
if (!window.confirm('Delete this product?')) return;
try {
await request(`/api/commerce/admin/products/${productId}/`, {
method: 'DELETE',
});
setProducts((prev) => prev.filter((product) => product.id !== productId));
} catch (err) {
console.error(err);
}
};
const resetForm = () => {
setIsEditing(false);
setFormState(initialProductState);
};
if (!token) {
return <AdminTokenPrompt onSubmit={setToken} />;
}
return (
<>
<Head>
<title>Menu · Admin · Full Moon Bakehouse</title>
</Head>
<AdminLayout title="Menu manager">
<div className="admin-section">
<section>
<header>
<div className="admin-badge">Create offering</div>
<h2 className="admin-section-title" style={{ marginTop: '1rem' }}>
{isEditing ? 'Update product' : 'Publish new product'}
</h2>
<p className="form-subtext">
Add breads, bagels, sweets, and weekly specials. Quantities decrement automatically
with each order so you never oversell.
</p>
</header>
<form onSubmit={handleSubmit} className="form-grid two-column">
<label className="form-field">
Name
<input
required
type="text"
className="input"
value={formState.name}
onChange={(event) =>
setFormState((state) => ({ ...state, name: event.target.value }))
}
/>
</label>
<label className="form-field">
Category
<select
className="select"
value={formState.category_id || ''}
onChange={(event) =>
setFormState((state) => ({
...state,
category_id: event.target.value || null,
}))
}
>
<option value="">Uncategorized</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
</label>
<label className="form-field" style={{ gridColumn: '1 / -1' }}>
Short description
<textarea
rows={2}
className="textarea"
value={formState.short_description}
onChange={(event) =>
setFormState((state) => ({
...state,
short_description: event.target.value,
}))
}
/>
</label>
<label className="form-field" style={{ gridColumn: '1 / -1' }}>
Description
<textarea
rows={4}
className="textarea"
value={formState.description}
onChange={(event) =>
setFormState((state) => ({
...state,
description: event.target.value,
}))
}
/>
</label>
<label className="form-field">
Price ($)
<input
type="number"
step="0.01"
min="0"
className="input"
value={formState.price}
onChange={(event) =>
setFormState((state) => ({ ...state, price: event.target.value }))
}
/>
</label>
<label className="form-field">
Inventory on hand
<input
type="number"
min="0"
className="input"
value={formState.inventory_quantity}
onChange={(event) =>
setFormState((state) => ({
...state,
inventory_quantity: event.target.value,
}))
}
/>
</label>
<label className="form-field">
Max per order
<input
type="number"
min="1"
className="input"
value={formState.max_per_order}
onChange={(event) =>
setFormState((state) => ({
...state,
max_per_order: event.target.value,
}))
}
/>
</label>
<label className="form-field">
Allergens (comma separated)
<input
type="text"
className="input"
value={formState.allergens}
onChange={(event) =>
setFormState((state) => ({
...state,
allergens: event.target.value,
}))
}
/>
</label>
<label className="form-field" style={{ gridColumn: '1 / -1' }}>
Ingredients
<textarea
rows={3}
className="textarea"
value={formState.ingredients}
onChange={(event) =>
setFormState((state) => ({
...state,
ingredients: event.target.value,
}))
}
/>
</label>
<div className="form-grid" style={{ gridColumn: '1 / -1', gap: '0.75rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input
type="checkbox"
className="checkbox"
checked={formState.is_active}
onChange={(event) =>
setFormState((state) => ({
...state,
is_active: event.target.checked,
}))
}
/>
Visible on storefront
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<input
type="checkbox"
className="checkbox"
checked={formState.is_featured}
onChange={(event) =>
setFormState((state) => ({
...state,
is_featured: event.target.checked,
}))
}
/>
Featured on homepage
</label>
</div>
<div className="form-actions" style={{ gridColumn: '1 / -1', marginTop: '0.5rem' }}>
<button type="submit" className="btn btn-primary" disabled={loading}>
{isEditing ? 'Save changes' : 'Publish product'}
</button>
<button type="button" className="btn btn-outline" onClick={resetForm}>
Reset form
</button>
{loading ? <span className="admin-muted">Saving...</span> : null}
{error ? <span style={{ color: '#b91c1c' }}>{error}</span> : null}
</div>
</form>
</section>
<section>
<div className="section-header" style={{ marginBottom: '1.5rem' }}>
<div>
<h2 style={{ fontFamily: 'Fraunces, serif', fontSize: '2rem', margin: 0 }}>
Current offerings
</h2>
<p className="admin-muted" style={{ marginTop: '0.35rem' }}>
{products.length} product{products.length === 1 ? '' : 's'} · {featuredProducts.length}{' '}
featured
</p>
</div>
</div>
<div className="admin-products-grid">
{products.map((product) => (
<article key={product.id} className="admin-product-card">
<header>
<div>
<h3>{product.name}</h3>
<span className="admin-muted">
{(product.category && product.category.name) || 'Uncategorized'}
</span>
</div>
<strong style={{ fontSize: '1.1rem' }}>${Number(product.price).toFixed(2)}</strong>
</header>
{product.short_description ? <p>{product.short_description}</p> : null}
<div className="admin-product-meta">
<span>
<strong>{product.inventory_quantity}</strong> units available · max{' '}
{product.max_per_order} per order
</span>
<span>
Visibility: {product.is_active ? 'Storefront' : 'Hidden'} · Featured:{' '}
{product.is_featured ? 'Yes' : 'No'}
</span>
</div>
<div className="admin-product-actions">
<button type="button" className="btn-small primary" onClick={() => handleEdit(product)}>
Edit
</button>
<button type="button" className="btn-small danger" onClick={() => handleDelete(product.id)}>
Delete
</button>
</div>
</article>
))}
</div>
{!products.length ? (
<div className="empty-state" style={{ marginTop: '1.5rem' }}>
No products yet. Use the form above to add your first offering.
</div>
) : null}
</section>
</div>
</AdminLayout>
</>
);
}
function AdminTokenPrompt({ onSubmit }) {
const [value, setValue] = useState('');
return (
<>
<Head>
<title>Authenticate · Admin · Full Moon Bakehouse</title>
</Head>
<AdminLayout title="Authenticate">
<div style={{ display: 'grid', gap: '1.4rem' }}>
<p className="admin-muted">
Paste your API token to unlock the admin tools. Generate one via Django Admin Tokens.
</p>
<form
style={{ display: 'grid', gap: '1rem', maxWidth: '420px' }}
onSubmit={(event) => {
event.preventDefault();
onSubmit(value);
if (typeof window !== 'undefined') {
window.localStorage.setItem(LOCAL_TOKEN_KEY, value);
}
}}
>
<input
type="password"
className="input"
placeholder="Paste token here"
value={value}
onChange={(event) => setValue(event.target.value)}
/>
<button type="submit" className="btn btn-primary" style={{ width: 'fit-content' }}>
Continue
</button>
</form>
</div>
</AdminLayout>
</>
);
}
const initialProductState = {
id: null,
name: '',
category_id: null,
short_description: '',
description: '',
price: '',
inventory_quantity: 0,
max_per_order: 6,
allergens: '',
ingredients: '',
is_active: true,
is_featured: false,
};
function mapProductToForm(product) {
return {
id: product.id,
name: product.name,
category_id: product.category?.id || null,
short_description: product.short_description || '',
description: product.description || '',
price: product.price,
inventory_quantity: product.inventory_quantity,
max_per_order: product.max_per_order,
allergens: product.allergens || '',
ingredients: product.ingredients || '',
is_active: product.is_active,
is_featured: product.is_featured,
};
}
function buildPayload(state) {
return {
name: state.name,
short_description: state.short_description,
description: state.description,
price: state.price,
inventory_quantity: state.inventory_quantity,
max_per_order: state.max_per_order,
allergens: state.allergens,
ingredients: state.ingredients,
is_active: state.is_active,
is_featured: state.is_featured,
category_id: state.category_id,
};
}

336
frontend/pages/cart.js Normal file
View File

@@ -0,0 +1,336 @@
import { useEffect, useRef, useState } from 'react';
import Head from 'next/head';
import SiteLayout from '../components/SiteLayout';
import { submitOrder } from '../lib/api';
import { useCart } from '../context/CartContext';
const INITIAL_CUSTOMER = {
name: '',
email: '',
phone: '',
fulfillment_method: 'pickup',
scheduled_date: '',
scheduled_time_slot: '',
delivery_address_line1: '',
delivery_city: '',
delivery_state: 'WI',
delivery_postal_code: '',
override_code: '',
notes: '',
};
export default function CartPage() {
const { items, updateQuantity, removeItem, clearCart, subtotal } = useCart();
const [customer, setCustomer] = useState(INITIAL_CUSTOMER);
const [notice, setNotice] = useState(null);
const [submitting, setSubmitting] = useState(false);
const [confirmation, setConfirmation] = useState(null);
const timeoutRef = useRef(null);
useEffect(() => () => clearTimeout(timeoutRef.current), []);
const showNotice = (message, variant = 'info') => {
clearTimeout(timeoutRef.current);
setNotice({ message, variant });
timeoutRef.current = setTimeout(() => setNotice(null), 4000);
};
const handleQuantity = (id, delta) => {
const entry = items.find((item) => item.id === id);
if (!entry) return;
const limit = Number.isFinite(entry.limit) ? entry.limit : Infinity;
const next = Math.max(0, Math.min(entry.quantity + delta, limit));
updateQuantity(id, next);
};
const deliveryFee = customer.fulfillment_method === 'delivery' ? 5 : 0;
const total = subtotal + deliveryFee;
const hasCartItems = items.length > 0;
const updateCustomerField = (field, value) => {
setCustomer((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = async (event) => {
event.preventDefault();
if (!hasCartItems) {
showNotice('Add at least one item to your cart before checking out.', 'error');
return;
}
if (!customer.name.trim() || !customer.email.trim()) {
showNotice('Name and email are required.', 'error');
return;
}
if (customer.fulfillment_method === 'delivery') {
if (!customer.delivery_address_line1.trim() || !customer.delivery_city.trim() || !customer.delivery_postal_code.trim()) {
showNotice('Delivery address, city, and postal code are required for delivery orders.', 'error');
return;
}
}
if (items.some((item) => !item.id)) {
showNotice('Cart contains placeholder items. Add products from the live menu once available.', 'info');
return;
}
const payload = {
customer_name: customer.name.trim(),
customer_email: customer.email.trim(),
customer_phone: customer.phone.trim(),
fulfillment_method: customer.fulfillment_method,
scheduled_date: customer.scheduled_date || null,
scheduled_time_slot: customer.scheduled_time_slot,
delivery_address_line1:
customer.fulfillment_method === 'delivery' ? customer.delivery_address_line1.trim() : '',
delivery_address_line2: '',
delivery_city: customer.fulfillment_method === 'delivery' ? customer.delivery_city.trim() : '',
delivery_state: customer.fulfillment_method === 'delivery' ? customer.delivery_state.trim() : '',
delivery_postal_code:
customer.fulfillment_method === 'delivery' ? customer.delivery_postal_code.trim() : '',
delivery_override_code: customer.override_code.trim() || null,
notes: customer.notes,
items: items.map((item) => ({ product: item.id, quantity: item.quantity })),
};
try {
setSubmitting(true);
const order = await submitOrder(payload);
setConfirmation(order.id);
clearCart();
setCustomer(INITIAL_CUSTOMER);
showNotice('Order received! Check your email for confirmation.', 'success');
} catch (error) {
showNotice('We were unable to place your order. Please try again.', 'error');
} finally {
setSubmitting(false);
}
};
return (
<SiteLayout>
<Head>
<title>Cart · Full Moon Bakehouse</title>
</Head>
<div className="wrapper cart-page">
<div className="section-header">
<div>
<h2>Your cart</h2>
<p>Add or adjust items, then submit your preorder request.</p>
</div>
</div>
{notice ? <div className={`flash ${notice.variant}`}>{notice.message}</div> : null}
<div className="cart-layout">
<div className="cart-card">
<div className="cart-header">
<h3>Items</h3>
{hasCartItems ? (
<button className="btn-small danger" type="button" onClick={clearCart}>
Clear cart
</button>
) : null}
</div>
{hasCartItems ? (
<div className="cart-list">
{items.map((item) => (
<div key={item.id} className="cart-item">
<div className="cart-item__row">
<strong>{item.name}</strong>
<span>${(item.price * item.quantity).toFixed(2)}</span>
</div>
<div className="cart-item__controls">
<button className="stepper" type="button" onClick={() => handleQuantity(item.id, -1)}>
</button>
<span>{item.quantity}</span>
<button className="stepper" type="button" onClick={() => handleQuantity(item.id, 1)}>
+
</button>
<button className="btn-small danger" type="button" onClick={() => removeItem(item.id)}>
Remove
</button>
</div>
{Number.isFinite(item.limit) ? (
<small className="cart-note">Max {item.limit} per order.</small>
) : null}
</div>
))}
</div>
) : (
<div className="cart-empty">Your cart is empty. Browse the menu to get started.</div>
)}
<div className="cart-summary">
<div className="cart-item__row">
<span>Subtotal</span>
<span>${subtotal.toFixed(2)}</span>
</div>
<div className="cart-item__row" style={{ fontWeight: 500 }}>
<span>Delivery fee</span>
<span>{customer.fulfillment_method === 'delivery' ? '$5.00' : '$0.00'}</span>
</div>
<div className="cart-item__row" style={{ fontSize: '1.1rem' }}>
<span>Total</span>
<span>${total.toFixed(2)}</span>
</div>
</div>
</div>
<form className="cart-card checkout-form" onSubmit={handleSubmit}>
<h3>Checkout</h3>
{confirmation ? (
<div className="notice success">Order #{confirmation} received!</div>
) : null}
<div className="form-grid two-column">
<label className="form-field">
Name *
<input
className="input"
value={customer.name}
onChange={(event) => updateCustomerField('name', event.target.value)}
required
/>
</label>
<label className="form-field">
Email *
<input
type="email"
className="input"
value={customer.email}
onChange={(event) => updateCustomerField('email', event.target.value)}
required
/>
</label>
<label className="form-field">
Phone
<input
className="input"
value={customer.phone}
onChange={(event) => updateCustomerField('phone', event.target.value)}
/>
</label>
<label className="form-field">
Preferred pickup date
<input
type="date"
className="input"
value={customer.scheduled_date}
onChange={(event) => updateCustomerField('scheduled_date', event.target.value)}
/>
</label>
<label className="form-field">
Time window
<input
className="input"
placeholder="e.g., Thu 5:30-5:45pm"
value={customer.scheduled_time_slot}
onChange={(event) => updateCustomerField('scheduled_time_slot', event.target.value)}
/>
</label>
</div>
<div className="radio-group">
<span>Fulfillment method</span>
<label>
<input
type="radio"
name="fulfillment_method"
value="pickup"
checked={customer.fulfillment_method === 'pickup'}
onChange={(event) => updateCustomerField('fulfillment_method', event.target.value)}
/>{' '}
Pickup (free)
</label>
<label>
<input
type="radio"
name="fulfillment_method"
value="delivery"
checked={customer.fulfillment_method === 'delivery'}
onChange={(event) => updateCustomerField('fulfillment_method', event.target.value)}
/>{' '}
Delivery in Eau Claire or Altoona ($5)
</label>
</div>
{customer.fulfillment_method === 'delivery' ? (
<div className="form-grid two-column">
<label className="form-field">
Address line 1 *
<input
className="input"
value={customer.delivery_address_line1}
onChange={(event) =>
updateCustomerField('delivery_address_line1', event.target.value)
}
required
/>
</label>
<label className="form-field">
City *
<input
className="input"
value={customer.delivery_city}
onChange={(event) => updateCustomerField('delivery_city', event.target.value)}
required
/>
</label>
<label className="form-field">
State
<input
className="input"
value={customer.delivery_state}
onChange={(event) => updateCustomerField('delivery_state', event.target.value)}
/>
</label>
<label className="form-field">
Postal code *
<input
className="input"
value={customer.delivery_postal_code}
onChange={(event) =>
updateCustomerField('delivery_postal_code', event.target.value)
}
required
/>
</label>
</div>
) : null}
<label className="form-field">
Secret friend code
<input
className="input"
placeholder="Optional override code"
value={customer.override_code}
onChange={(event) => updateCustomerField('override_code', event.target.value)}
/>
</label>
<label className="form-field">
Notes
<textarea
rows={3}
className="textarea"
placeholder="Allergies, delivery notes, or special requests."
value={customer.notes}
onChange={(event) => updateCustomerField('notes', event.target.value)}
/>
</label>
<button className="btn btn-primary" type="submit" disabled={submitting || !hasCartItems}>
{submitting ? 'Submitting…' : 'Place order'}
</button>
</form>
</div>
</div>
</SiteLayout>
);
}

265
frontend/pages/index.js Normal file
View File

@@ -0,0 +1,265 @@
import Head from 'next/head';
import Link from 'next/link';
import ProductCard from '../components/ProductCard';
import SiteLayout from '../components/SiteLayout';
import { buildMediaUrl, fetchProducts } from '../lib/api';
const SAMPLE_PRODUCTS = [
{
id: null,
slug: 'sprouted-heritage-loaf',
name: 'Sprouted Heritage Loaf',
short_description: 'Organic, freshly milled grains with a caramelized crust and pillowy crumb.',
price: 12,
inventory_quantity: 8,
max_per_order: 2,
allergens: 'Contains: Wheat, Dairy',
imageUrl:
'https://images.unsplash.com/photo-1608198093002-ad4e005484ec?auto=format&fit=crop&w=1200&q=80',
isSample: true,
},
{
id: null,
slug: 'malted-maple-bagels',
name: 'Malted Maple Bagels',
short_description: 'Slow-fermented bagels with malted barley glaze and maple butter finish.',
price: 18,
inventory_quantity: 12,
max_per_order: 3,
allergens: 'Contains: Wheat',
imageUrl:
'https://images.unsplash.com/photo-1589367920476-0be109c12e82?auto=format&fit=crop&w=1200&q=80',
isSample: true,
},
{
id: null,
slug: 'moonbeam-cookie-flight',
name: 'Moonbeam Cookie Flight',
short_description: 'Rotating selection of award-winning cookies in seasonal flavors.',
price: 16,
inventory_quantity: 15,
max_per_order: 4,
allergens: 'Contains: Wheat, Eggs, Dairy',
imageUrl:
'https://images.unsplash.com/photo-1499636136210-6f4ee915583e?auto=format&fit=crop&w=1200&q=80',
isSample: true,
},
];
const FAQS = [
{
question: 'Do you offer gluten free or vegan baked goods?',
answer:
'At this time, we do not offer any gluten free baked goods. Some items are naturally vegan friendly. If you have any dietary or allergy concerns, please reach out before placing an order.',
},
{
question: 'Where do you deliver to?',
answer:
'We currently deliver to Eau Claire and Altoona, Wisconsin. Need an out-of-area drop-off? Use the secret friend code and Ill reach out personally to coordinate.',
},
{
question: 'What whole grains do you use in your fresh milled flour?',
answer:
'Our exact proportions are proprietary, but we regularly mill the following grains:\n- Hard Red Wheat Berries (varying varietals)\n- Soft Red Wheat Berries (varying varietals)\n- Buckwheat\n\nAnd we are always experimenting with other grains and blends. NOTE: If you have a dietary or allergy concern, please reach out before placing an order.',
},
{
question: 'What are sprouted grains, and why should I eat them?',
answer:
'Sprouted grains are whole grains that are allowed to just begin sprouting before they are dehydrated and milled. When a grain sprouts, natural enzymes break down starches and proteins, unlocking flavor and nutrition. Sprouted grain flours are often easier to digest, lower on the glycemic index, and richer in vitamins, minerals, and antioxidants than unsprouted flour.',
},
{
question: "What's a CSB?",
answer:
'A Community Supported Bakery share works just like a CSA—pre-purchase 48 weeks of weekly boxes and receive breads, bagels, and sweets on a set schedule.',
},
];
function normalizeProducts(products = []) {
if (!products.length) {
return SAMPLE_PRODUCTS;
}
return products.map((product) => ({
...product,
imageUrl: buildMediaUrl(product.hero_image) || SAMPLE_PRODUCTS[0].imageUrl,
isSample: false,
}));
}
export default function Home({ products }) {
const displayProducts = normalizeProducts(products);
const featuredProducts = displayProducts.filter((product) => product.is_featured).slice(0, 3);
const highlight = featuredProducts.length ? featuredProducts : displayProducts.slice(0, 3);
return (
<SiteLayout>
<Head>
<title>Full Moon Bakehouse · Eau Claire Microbakery</title>
<meta
name="description"
content="Sprouted grain breads, laminated pastries, and microbatch sweets crafted by Bonna Moon in Eau Claire, WI."
/>
</Head>
<div className="wrapper">
<section className="hero">
<div>
<h1>Sprouted grain breads & sweets baked under the Full Moon.</h1>
<p>
From award-winning cookies to laminated croissants, every bake starts with organic
midwest grains sprouted and milled in-house. Reserve your favorites for pickup or
delivery in Eau Claire and Altoona.
</p>
<div className="hero-cta">
<Link className="btn btn-primary" href="/products">
Browse products
</Link>
<Link className="btn btn-outline" href="/cart">
View cart & checkout
</Link>
</div>
</div>
<aside className="product-detail__gallery">
<h3>How it works</h3>
<p>
Preorders open Saturday morning and close Monday afternoon. Choose a pickup window on
Thursday evening or add $5 contactless delivery within Eau Claire and Altoona on
Thursday or Friday afternoon.
</p>
<div className="product-detail__meta">
<span> Pickup: Thursday evening (15 minute windows)</span>
<span> Delivery: Thu or Fri · Eau Claire & Altoona ($5)</span>
<span> Preorder window: Sat morning Mon afternoon</span>
</div>
</aside>
</section>
</div>
<section className="section">
<div className="wrapper">
<div className="section-header">
<div>
<h2>Fresh from the bakehouse</h2>
<p>
Small-batch menus rotate weekly. These highlights rotate in and out depending on the
season and the grains that inspire us.
</p>
</div>
<Link className="btn btn-outline" href="/products">
See all offerings
</Link>
</div>
<div className="product-grid">
{highlight.map((product) => (
<ProductCard key={product.slug || product.name} product={product} />
))}
</div>
</div>
</section>
<section className="section">
<div className="wrapper">
<div className="section-header">
<div>
<h2>How to order</h2>
<p>Pick the flow that fits your schedule right now or join the CSB launch list.</p>
</div>
</div>
<div className="product-grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))' }}>
<article className="product-card">
<h3>Option 1 · Weekly preorders</h3>
<p>
Pre-orders open Saturday morning and close Monday afternoon each week. Pickups happen
Thursday evening in 15 minute windows (address provided after purchase). Delivery is
available within Eau Claire & Altoona on Thursday or Friday afternoon for $5.
</p>
<div className="product-card__actions">
<Link className="btn btn-primary" href="/products">
Shop this week&apos;s menu
</Link>
</div>
</article>
<article className="product-card">
<h3>Option 2 · CSB boxes</h3>
<p>
Community Supported Bakery (CSB) shares are coming soon. Each weekly box includes a
bread (loaf, pita, focaccia), a pack of bagels or English muffins, and a sweet treat.
Pricing operates on a sliding scalepay a little more if you can to feed more
families.
</p>
<div className="product-card__actions">
<a
className="btn btn-outline"
href="https://www.fullmoonbakehouse.com/#newsletter"
target="_blank"
rel="noreferrer"
>
Join the email list
</a>
</div>
</article>
</div>
</div>
</section>
<section className="section" id="about">
<div className="wrapper">
<div className="section-header">
<div>
<h2>Meet Bonna Moon</h2>
<p>Scratch baker, ceramicist, and late-night laminator.</p>
</div>
</div>
<article className="product-card" style={{ gridTemplateColumns: '1fr', gap: '1rem' }}>
<p>
I&apos;m Bonna Moonpart-time baker, part-time artist working in ceramics, wood, and metal.
Full Moon Bakehouse is the microbakery I run from my Eau Claire kitchen, built on a
decade of dough experiments, award-winning cookies, and a deep love for sprouted
grains.
</p>
<p>
We sprout and mill our grains in-house, source ingredients with care, and craft every
bake to be something I&apos;d be proud to feed to my own family. Join the email list for
preorder reminders, seasonal menus, and CSB launches.
</p>
</article>
</div>
</section>
<section className="section" id="faq">
<div className="wrapper">
<div className="section-header">
<div>
<h2>FAQ</h2>
<p>Your top questions about ingredients, deliveries, and the CSB shares.</p>
</div>
</div>
<div className="product-grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))' }}>
{FAQS.map((item) => (
<article key={item.question} className="product-card">
<h3>{item.question}</h3>
<p style={{ whiteSpace: 'pre-line' }}>{item.answer}</p>
</article>
))}
</div>
</div>
</section>
</SiteLayout>
);
}
export async function getServerSideProps() {
try {
const products = await fetchProducts();
return { props: { products } };
} catch (error) {
console.warn('Home page data fallback', error);
return { props: { products: [] } };
}
}

View File

@@ -0,0 +1,95 @@
import Head from 'next/head';
import SiteLayout from '../../components/SiteLayout';
import { buildMediaUrl, fetchProduct, fetchProducts } from '../../lib/api';
import ProductCard from '../../components/ProductCard';
import { useCart } from '../../context/CartContext';
export default function ProductPage({ product, suggestions }) {
const { addItem } = useCart();
const imageUrl = buildMediaUrl(product.hero_image) || product.imageUrl;
const price = Number(product.price || 0).toFixed(2);
return (
<SiteLayout>
<Head>
<title>{product.name} · Full Moon Bakehouse</title>
<meta name="description" content={product.short_description || product.description || ''} />
</Head>
<div className="wrapper">
<section className="product-detail">
<div className="product-detail__gallery">
<img src={imageUrl} alt={product.name} />
</div>
<div className="product-detail__info">
<button className="ghost-btn" onClick={() => history.back()} style={{ width: 'fit-content' }}>
Back to products
</button>
<h1>{product.name}</h1>
<div className="product-detail__price">${price}</div>
{product.short_description ? (
<p className="product-detail__description">{product.short_description}</p>
) : null}
{product.description ? (
<p className="product-detail__description">{product.description}</p>
) : null}
<div className="product-detail__meta">
<span>Inventory: {product.inventory_quantity ?? '—'} available</span>
<span>Max {product.max_per_order ?? '—'} per order</span>
{product.allergens ? <span>Allergens: {product.allergens}</span> : null}
{product.ingredients ? <span>Ingredients: {product.ingredients}</span> : null}
</div>
<div className="product-card__actions">
<button className="btn btn-primary" type="button" onClick={() => addItem(product)}>
Add to cart
</button>
{Number.isFinite(product.max_per_order) ? (
<span className="cart-note">Max {product.max_per_order} per order</span>
) : null}
</div>
</div>
</section>
{suggestions.length ? (
<section className="section" style={{ paddingTop: 0 }}>
<div className="section-header">
<div>
<h2>More from this week&apos;s bake</h2>
<p>Round out your order with another loaf, bagel pack, or sweet.</p>
</div>
</div>
<div className="product-grid">
{suggestions.map((item) => (
<ProductCard key={item.id || item.slug} product={item} />
))}
</div>
</section>
) : null}
</div>
</SiteLayout>
);
}
export async function getServerSideProps({ params }) {
const { slug } = params;
try {
const product = await fetchProduct(slug);
const products = await fetchProducts();
const suggestions = products
.filter((item) => item.slug !== slug)
.slice(0, 4)
.map((item) => ({ ...item, imageUrl: buildMediaUrl(item.hero_image), isSample: false }));
return {
props: {
product: { ...product, imageUrl: buildMediaUrl(product.hero_image), isSample: false },
suggestions,
},
};
} catch (error) {
console.warn('Product detail fallback', error);
return { notFound: true };
}
}

View File

@@ -0,0 +1,94 @@
import Head from 'next/head';
import ProductCard from '../../components/ProductCard';
import SiteLayout from '../../components/SiteLayout';
import { buildMediaUrl, fetchCategories, fetchProducts } from '../../lib/api';
const SAMPLE_PRODUCTS = [];
function normalizeProducts(products = []) {
return products.map((product) => ({
...product,
imageUrl: buildMediaUrl(product.hero_image),
isSample: false,
}));
}
export default function ProductsPage({ products, categories }) {
const items = normalizeProducts(products);
const groups = categories.reduce((acc, category) => {
acc[category.id] = { category, items: [] };
return acc;
}, {});
const uncategorized = [];
items.forEach((product) => {
const categoryId = product.category?.id;
if (categoryId && groups[categoryId]) {
groups[categoryId].items.push(product);
} else {
uncategorized.push(product);
}
});
const orderedGroups = Object.values(groups).filter((group) => group.items.length);
if (uncategorized.length) {
orderedGroups.push({
category: { name: 'Weekly specials' },
items: uncategorized,
});
}
const hasProducts = orderedGroups.length > 0;
return (
<SiteLayout>
<Head>
<title>Products · Full Moon Bakehouse</title>
<meta name="description" content="Browse the current Full Moon Bakehouse offerings." />
</Head>
<div className="wrapper">
<section className="section">
<div className="section-header">
<div>
<h2>Weekly menu</h2>
<p>
Availability updates in real time. Add items to your cart and checkout when you&apos;re
ready.
</p>
</div>
</div>
{hasProducts ? (
orderedGroups.map((group) => (
<div key={group.category.id || group.category.name} className="section" style={{ paddingTop: 0 }}>
<h3 className="section-title" style={{ fontFamily: 'Fraunces, serif', fontSize: '1.6rem' }}>
{group.category.name}
</h3>
<div className="product-grid">
{group.items.map((product) => (
<ProductCard key={product.id || product.slug} product={product} />
))}
</div>
</div>
))
) : (
<div className="flash info">Menu items will appear here once the API is populated.</div>
)}
</section>
</div>
</SiteLayout>
);
}
export async function getServerSideProps() {
try {
const [products, categories] = await Promise.all([fetchProducts(), fetchCategories()]);
return { props: { products, categories } };
} catch (error) {
console.warn('Products page data fallback', error);
return { props: { products: SAMPLE_PRODUCTS, categories: [] } };
}
}

BIN
frontend/public/logo.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

654
frontend/styles/globals.css Normal file
View File

@@ -0,0 +1,654 @@
@import url('https://fonts.googleapis.com/css2?family=Fraunces:wght@400;600;700&family=Work+Sans:wght@300;400;500;600;700&display=swap');
:root {
--ink: #2d2a26;
--ink-soft: #4b4942;
--ink-muted: #77736a;
--cream: #f8f6f0;
--paper: #ffffff;
--accent: #8daabd;
--accent-dark: #5f7f96;
--butter: #d9b583;
--border: rgba(95, 113, 124, 0.16);
--shadow-soft: 0 24px 48px rgba(67, 60, 48, 0.14);
--shadow-card: 0 18px 36px rgba(62, 50, 26, 0.12);
font-family: 'Work Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: var(--ink);
background-color: var(--cream);
}
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background-color: var(--cream);
}
a {
color: inherit;
text-decoration: none;
}
img {
display: block;
max-width: 100%;
}
.site-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.site-header {
position: sticky;
top: 0;
z-index: 20;
backdrop-filter: blur(12px);
background: rgba(248, 246, 240, 0.88);
border-bottom: 1px solid var(--border);
}
.site-header__inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
max-width: 1180px;
margin: 0 auto;
padding: 1.15rem 1.5rem;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
font-family: 'Fraunces', serif;
letter-spacing: 0.1em;
font-size: 1.1rem;
}
.logo img {
width: 44px;
height: 44px;
border-radius: 10px;
}
.nav-links {
display: flex;
align-items: center;
gap: 1.2rem;
font-weight: 600;
font-size: 0.95rem;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--ink-soft);
}
.nav-links a {
position: relative;
padding: 0.35rem 0;
}
.nav-links a.active::after,
.nav-links a:hover::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -0.35rem;
height: 2px;
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 0.35rem;
background: var(--accent-dark);
color: #fdfcfb;
font-size: 0.7rem;
border-radius: 999px;
padding: 0 0.45rem;
}
.header-actions {
display: flex;
align-items: center;
}
.ghost-btn {
display: inline-flex;
align-items: center;
padding: 0.55rem 1.1rem;
border-radius: 999px;
border: 1px solid var(--border);
font-weight: 600;
letter-spacing: 0.04em;
transition: background 160ms ease;
}
.ghost-btn:hover {
background: rgba(255, 255, 255, 0.9);
}
.site-main {
flex: 1;
}
.wrapper {
max-width: 1180px;
margin: 0 auto;
padding: 0 1.5rem;
}
.hero {
display: grid;
gap: 3rem;
padding: 6rem 0 4rem;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
align-items: center;
}
.hero h1 {
font-family: 'Fraunces', serif;
font-size: clamp(2.8rem, 4vw, 3.8rem);
line-height: 1.1;
margin: 0 0 1.5rem;
}
.hero p {
color: var(--ink-soft);
font-size: 1.05rem;
margin: 0 0 2rem;
}
.hero-cta {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
padding: 0.85rem 1.6rem;
border-radius: 999px;
font-weight: 600;
letter-spacing: 0.03em;
border: none;
cursor: pointer;
transition: transform 160ms ease, box-shadow 180ms ease, background 180ms ease;
}
.btn-primary {
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
color: #fdfcfb;
box-shadow: var(--shadow-soft);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 24px 48px rgba(95, 127, 150, 0.28);
}
.btn-outline {
background: rgba(255, 255, 255, 0.7);
border: 1px solid var(--border);
color: var(--ink);
}
.section {
padding: 4rem 0 3rem;
}
.section-header {
display: flex;
gap: 1.5rem;
justify-content: space-between;
flex-wrap: wrap;
align-items: flex-end;
margin-bottom: 2.5rem;
}
.section-header h2 {
font-family: 'Fraunces', serif;
font-size: 2.2rem;
margin: 0 0 0.6rem;
}
.section-header p {
max-width: 34rem;
color: var(--ink-muted);
margin: 0;
}
.pill-row {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
}
.pill {
display: inline-flex;
align-items: center;
padding: 0.35rem 0.85rem;
border-radius: 999px;
background: rgba(141, 170, 189, 0.18);
color: var(--accent-dark);
font-size: 0.85rem;
letter-spacing: 0.03em;
}
.product-grid {
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.product-card {
background: var(--paper);
border-radius: 26px;
padding: 1.6rem;
border: 1px solid var(--border);
box-shadow: var(--shadow-card);
display: grid;
gap: 1rem;
}
.product-card__image {
position: relative;
overflow: hidden;
border-radius: 18px;
aspect-ratio: 4 / 3;
}
.product-card__image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.product-card h3 {
margin: 0;
font-family: 'Fraunces', serif;
font-size: 1.3rem;
}
.product-card p {
margin: 0;
color: var(--ink-muted);
line-height: 1.5;
}
.product-card__meta,
.product-card__actions {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.7rem;
}
.product-card__meta {
font-weight: 600;
}
.product-card__tags {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
font-size: 0.85rem;
color: var(--ink-muted);
}
.product-card__tags span {
background: rgba(217, 181, 131, 0.25);
padding: 0.25rem 0.7rem;
border-radius: 999px;
}
.product-card__actions .btn {
padding: 0.6rem 1.3rem;
font-size: 0.95rem;
}
.product-card__actions a {
font-weight: 600;
letter-spacing: 0.03em;
}
.sample-tag {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ink-muted);
}
.notice,
.flash {
border-radius: 18px;
padding: 0.9rem 1.1rem;
font-size: 0.95rem;
margin-bottom: 1.5rem;
}
.notice.info,
.flash.info {
background: rgba(141, 170, 189, 0.18);
color: var(--accent-dark);
}
.notice.success,
.flash.success {
background: rgba(217, 181, 131, 0.25);
color: var(--ink);
}
.notice.error,
.flash.error {
background: rgba(214, 75, 71, 0.1);
color: #8f1d1a;
}
.product-detail {
display: grid;
gap: 3rem;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
align-items: start;
padding: 4rem 0 3rem;
}
.product-detail__gallery {
background: var(--paper);
border-radius: 30px;
padding: 1.5rem;
border: 1px solid var(--border);
box-shadow: var(--shadow-card);
}
.product-detail__info {
display: grid;
gap: 1.4rem;
}
.product-detail__info h1 {
margin: 0;
font-family: 'Fraunces', serif;
font-size: 2.6rem;
}
.product-detail__price {
font-size: 1.4rem;
font-weight: 600;
}
.product-detail__description {
line-height: 1.6;
color: var(--ink-soft);
}
.product-detail__meta {
display: grid;
gap: 0.5rem;
font-size: 0.95rem;
color: var(--ink-muted);
}
.cart-page {
padding: 4rem 0 3rem;
}
.cart-layout {
display: grid;
gap: 2rem;
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
}
.cart-card {
background: var(--paper);
border-radius: 26px;
border: 1px solid var(--border);
box-shadow: var(--shadow-card);
padding: 1.8rem;
display: grid;
gap: 1.2rem;
}
.cart-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.cart-list {
display: grid;
gap: 1rem;
}
.cart-item {
border-bottom: 1px solid var(--border);
padding-bottom: 1rem;
display: grid;
gap: 0.6rem;
}
.cart-item:last-of-type {
border-bottom: none;
padding-bottom: 0;
}
.cart-item__row {
display: flex;
justify-content: space-between;
gap: 0.7rem;
align-items: center;
}
.cart-item__controls {
display: flex;
align-items: center;
gap: 0.6rem;
}
.cart-item__controls .stepper {
width: 30px;
height: 30px;
border-radius: 999px;
border: 1px solid var(--border);
background: rgba(233, 229, 220, 0.8);
font-weight: 600;
cursor: pointer;
}
.cart-item__controls .btn-small {
padding: 0.35rem 0.9rem;
background: rgba(214, 75, 71, 0.12);
color: #a42a27;
border: 1px solid rgba(214, 75, 71, 0.2);
}
.cart-note {
font-size: 0.85rem;
color: var(--ink-muted);
}
.btn-small {
display: inline-flex;
align-items: center;
border-radius: 999px;
font-size: 0.85rem;
font-weight: 600;
letter-spacing: 0.03em;
border: none;
cursor: pointer;
}
.btn-small.danger {
padding: 0.4rem 0.9rem;
background: rgba(214, 75, 71, 0.12);
color: #a42a27;
border: 1px solid rgba(214, 75, 71, 0.2);
}
.cart-empty {
text-align: center;
color: var(--ink-muted);
padding: 1.5rem 0;
}
.cart-summary {
display: grid;
gap: 0.4rem;
font-weight: 600;
}
.cart-summary small {
font-weight: 500;
color: var(--ink-muted);
}
.checkout-form .form-grid {
display: grid;
gap: 1.1rem;
}
.checkout-form .two-column {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.form-field {
display: grid;
gap: 0.45rem;
font-weight: 600;
color: var(--ink-soft);
}
.input,
.textarea,
.select {
padding: 0.8rem 1rem;
border-radius: 12px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.95);
font-family: inherit;
font-size: 1rem;
}
.textarea {
resize: vertical;
}
.radio-group {
display: grid;
gap: 0.4rem;
font-size: 0.95rem;
color: var(--ink-soft);
}
.site-footer {
border-top: 1px solid var(--border);
background: rgba(248, 246, 240, 0.88);
padding: 3rem 0 2.5rem;
}
.site-footer__inner {
max-width: 1180px;
margin: 0 auto;
padding: 0 1.5rem;
display: grid;
gap: 1rem;
align-items: center;
}
.footer-links {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.copyright {
color: var(--ink-muted);
font-size: 0.9rem;
}
.admin-shell {
min-height: 100vh;
background: linear-gradient(180deg, #f9f6ef 0%, #eef2f4 100%);
padding: 0 1.5rem 3rem;
}
.admin-container {
max-width: 1200px;
margin: 0 auto;
padding: 3rem 0;
}
.admin-nav {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin: 1.5rem 0 2rem;
}
.admin-nav a {
display: inline-flex;
align-items: center;
padding: 0.6rem 1.4rem;
border-radius: 999px;
font-weight: 600;
letter-spacing: 0.03em;
border: 1px solid var(--border);
}
.admin-nav a.active {
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
color: #fdfcfb;
border: none;
}
.admin-card {
background: var(--paper);
border-radius: 26px;
border: 1px solid var(--border);
box-shadow: var(--shadow-card);
padding: 2rem;
}
.admin-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.45rem 0.9rem;
border-radius: 999px;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
background: rgba(141, 170, 189, 0.18);
color: var(--accent-dark);
}
@media (max-width: 760px) {
.site-header__inner {
flex-direction: column;
align-items: flex-start;
}
.hero {
padding: 4.5rem 0 3.5rem;
}
.hero-cta {
width: 100%;
}
.product-detail {
padding-top: 3rem;
}
}