Files
FullMoonBakehouse-archive-2025/frontend/pages/admin/menu/index.js
2025-11-09 00:26:00 -06:00

458 lines
15 KiB
JavaScript

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,
};
}