458 lines
15 KiB
JavaScript
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,
|
|
};
|
|
}
|