// /admin.html → CATALOG tab. Full product CRUD against D1.
//
// Two views, toggled by local state:
// - LIST: table of every product, "+ NEW" button, click to edit
// - EDITOR: form for one product, with image manager + features editor
//
// Every text/number field autosaves on blur (PATCH /api/admin/products/:id).
// Images and features have dedicated endpoints; both run instantly so the
// catalog reflects changes within ExecCatalog's next refresh window.
(function () {
const { useState, useEffect, useMemo, useCallback, useRef } = React;
// Category list is D1-backed (see js/categories.js). Components that
// render category chips should call useCategoryList() so they re-render
// when admin edits / adds / deletes a category.
function useCategoryList() {
window.ExecCategories && window.ExecCategories.useCategories();
return (window.ExecCategories ? window.ExecCategories.list() : []);
}
function slugifyCategoryId(s) {
return String(s || '')
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '');
}
// ---------- root: list / editor / draft (create) ----------
function AdminCatalogApp() {
// Products are now managed in catalog.json (edit the file + `git push`).
// This in-page editor is retired to avoid two conflicting sources of
// truth: it used to write product rows to D1, which the live storefront
// and checkout no longer read. The old ProductList/ProductEditor/etc.
// components below are left defined but unused.
window.ExecCatalog && window.ExecCatalog.useCatalog();
const products = (window.ExecCatalog ? window.ExecCatalog.list() : []);
return (
Products are managed in catalog.json
Product data lives in catalog.json in the GitHub repo. To add,
edit, remove, or reprice a product, edit that file and push to{' '}
main . The storefront and the checkout both read it, so
your change goes live automatically after Hostinger pulls the push.
This editor is retired. Editing here would write to the old database, which the
live store no longer uses — the changes would not appear. Order & key
management is unaffected; use the Orders tab.
Currently live: {products.length} products (from catalog.json).
);
}
function RestoredAdminCatalogApp() {
window.ExecCatalog && window.ExecCatalog.useCatalog();
const products = (window.ExecCatalog ? window.ExecCatalog.list() : []);
const [view, setView] = useState('list');
const [selectedId, setSelectedId] = useState(null);
const refresh = useCallback(async () => {
if (window.ExecCatalog) await window.ExecCatalog.fetchAll({ fresh: true });
}, []);
const backToList = () => {
setSelectedId(null);
setView('list');
};
const openEditor = (id) => {
setSelectedId(id);
setView('edit');
};
const onCreated = async (id) => {
await refresh();
setSelectedId(id);
setView('edit');
};
const selected = products.find((p) => p.id === selectedId) || null;
if (view === 'new') return ;
if (view === 'edit' && selected) {
return ;
}
return (
setView('new')}
/>
);
}
// ---------- LIST VIEW ----------
function ProductList({ products, onEdit, onRefresh, onNew }) {
const [filter, setFilter] = useState('all');
const [query, setQuery] = useState('');
const categories = useCategoryList();
const filtered = useMemo(() => {
let arr = products;
if (filter !== 'all') arr = arr.filter((p) => p.category === filter);
if (query.trim()) {
const q = query.trim().toLowerCase();
arr = arr.filter((p) =>
(p.name + ' ' + p.tag + ' ' + p.id + ' ' + p.blurb).toLowerCase().includes(q)
);
}
return arr;
}, [products, filter, query]);
const counts = useMemo(() => {
const out = { total: products.length, featured: 0 };
for (const p of products) if (p.featured) out.featured++;
for (const c of categories) out[c.id] = products.filter((p) => p.category === c.id).length;
return out;
}, [products, categories]);
// The actual "open the modal" handler is owned by AdminCatalogApp; this
// component just renders the trigger button.
const createNew = () => onNew && onNew();
return (
<>
`${c.label} ${counts[c.id] || 0}`).join(' · ')
: '—'}
/>
// CATALOG
PRODUCTS / {filtered.length}
+ NEW PRODUCT
{filtered.length === 0 && (
)}
{filtered.map((p) =>
)}
>
);
}
// Downloads the live catalog snapshot to the admin's machine. The
// GitHub Action handles the regular push-to-git backup every 15 min;
// this button is for ad-hoc "I want a copy right now" moments.
function ExportButton() {
const [busy, setBusy] = useState(false);
const onClick = async () => {
setBusy(true);
try {
const res = await fetch('/api/admin/catalog/export', { credentials: 'same-origin' });
if (!res.ok) { alert('Export failed: ' + res.status); return; }
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const a = document.createElement('a');
a.href = url;
a.download = `catalog-${stamp}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) { alert('Export failed: ' + e.message); }
finally { setBusy(false); }
};
return (
{busy ? 'EXPORTING…' : '↓ EXPORT NOW'}
);
}
function ProductRow({ product, onEdit }) {
const cover = product.images && product.images[0];
return (
onEdit(product.id)}>
{cover ?
:
{(product.name[0] || '?')} }
{product.tag} · {(product.category || '').toUpperCase()}
{product.name} {product.featured && ★ FEATURED }
{product.blurb}
${product.pricing?.day || 0}/d
${product.pricing?.week || 0}/w
${product.pricing?.month || 0}/mo
EDIT →
);
}
// Inline operational-state picker. Lives on every catalog row so admin
// can flip a product to OFFLINE / UPDATING without leaving CATALOG. The
// click handlers stopPropagation so changing the dropdown doesn't also
// open the editor row click.
function InlineStatePicker({ productId }) {
window.ExecStates && window.ExecStates.useStates();
const cur = window.ExecStates ? window.ExecStates.get(productId) : { state: 'updating' };
const [busy, setBusy] = useState(false);
const onChange = async (e) => {
e.stopPropagation();
const next = e.target.value;
if (next === cur.state) return;
setBusy(true);
try { await window.ExecStates.setState(productId, next, cur.note || ''); }
catch { /* errors surface in console; pill won't lie */ }
finally { setBusy(false); }
};
return (
e.stopPropagation()}
>
e.stopPropagation()}
aria-label="Operational status"
>
ONLINE
UPDATING
OFFLINE
);
}
// ---------- CATEGORY PICKER (chip row + inline create + manage popover) ----------
//
// Shared by ProductDraftEditor (new product) and BasicFields (existing
// product). Renders one chip per D1 category, plus a "+ NEW" chip that
// expands an inline create form, plus a tiny "✎" affordance on each chip
// that opens a popover for rename / image upload / delete.
//
// Props:
// value: selected category id (or null)
// onChange: (id) => void — called when the admin clicks a chip or after
// a brand-new category is created (auto-selects it)
function CategoryPicker({ value, onChange }) {
const categories = useCategoryList();
const [creating, setCreating] = useState(false);
const [managingId, setManagingId] = useState(null);
const managing = managingId ? categories.find((c) => c.id === managingId) : null;
return (
<>
{categories.map((c) => (
onChange(c.id)}
>{c.label}
{ e.stopPropagation(); setManagingId(c.id); }}
>✎
))}
{!creating && (
setCreating(true)}
title="Create a new category"
>+ NEW
)}
{creating && (
setCreating(false)}
onCreated={(newId) => { setCreating(false); onChange(newId); }}
/>
)}
{managing && (
setManagingId(null)}
onDeleted={(deletedId) => {
setManagingId(null);
if (value === deletedId) onChange(null);
}}
/>
)}
>
);
}
function CategoryCreateForm({ onCancel, onCreated }) {
const [label, setLabel] = useState('');
const [idEdited, setIdEdited] = useState(false);
const [idValue, setIdValue] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (idEdited) return;
setIdValue(slugifyCategoryId(label));
}, [label, idEdited]);
const canSubmit = !busy && label.trim().length >= 2 && idValue.length >= 2;
const submit = async () => {
if (!canSubmit) return;
setBusy(true); setError(null);
try {
const res = await fetch('/api/admin/categories', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: idValue, label: label.trim().toUpperCase() }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || ('http_' + res.status));
await (window.ExecCategories && window.ExecCategories.fetchAll({ fresh: true }));
onCreated(data.id || idValue);
} catch (e) {
setError(String(e.message || e));
setBusy(false);
}
};
return (
{error && ! {error}
}
Image banner is optional — upload one later via the ✎ button on the new chip.
);
}
function CategoryManageModal({ category, onClose, onDeleted }) {
const [label, setLabel] = useState(category.label);
const [busy, setBusy] = useState(null); // null | 'rename' | 'upload' | 'delete'
const [error, setError] = useState(null);
const [savedAt, setSavedAt] = useState(null);
const fileRef = useRef(null);
const renameSave = async () => {
const next = label.trim();
if (!next || next === category.label) return;
setBusy('rename'); setError(null);
try {
const res = await fetch(`/api/admin/categories/${encodeURIComponent(category.id)}`, {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label: next.toUpperCase() }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || ('http_' + res.status));
await (window.ExecCategories && window.ExecCategories.fetchAll({ fresh: true }));
setSavedAt(Date.now());
} catch (e) { setError(String(e.message || e)); }
finally { setBusy(null); }
};
const uploadImage = async (file) => {
if (!file || !file.type.startsWith('image/')) return;
setBusy('upload'); setError(null);
try {
const res = await fetch(`/api/admin/categories/${encodeURIComponent(category.id)}/image`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': file.type },
body: file,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || ('http_' + res.status));
await (window.ExecCategories && window.ExecCategories.fetchAll({ fresh: true }));
setSavedAt(Date.now());
} catch (e) { setError(String(e.message || e)); }
finally { setBusy(null); }
};
const doDelete = async () => {
if (!window.confirm(`Delete category "${category.label}"?\n\nThis can't be undone. Categories with products attached will refuse to delete — reassign or delete those products first.`)) return;
setBusy('delete'); setError(null);
try {
const res = await fetch(`/api/admin/categories/${encodeURIComponent(category.id)}`, {
method: 'DELETE',
credentials: 'same-origin',
});
const data = await res.json().catch(() => ({}));
if (res.status === 409 && data && data.error === 'category_in_use') {
throw new Error(`${data.count || 'some'} products still use this category. Reassign or delete them first.`);
}
if (!res.ok) throw new Error(data.error || ('http_' + res.status));
await (window.ExecCategories && window.ExecCategories.fetchAll({ fresh: true }));
onDeleted(category.id);
} catch (e) { setError(String(e.message || e)); setBusy(null); }
};
return (
e.stopPropagation()}>
MANAGE CATEGORY
✕
/ {category.id}
LABEL
setLabel(e.target.value)}
disabled={busy === 'rename'}
/>
{busy === 'rename' ? 'SAVING…' : 'RENAME'}
BANNER IMAGE
{category.image_url
?
:
no image }
uploadImage(e.target.files && e.target.files[0])}
/>
fileRef.current && fileRef.current.click()}
disabled={busy === 'upload'}
>{busy === 'upload' ? 'UPLOADING…' : '↑ UPLOAD BANNER'}
{savedAt &&
✓ saved
}
{error &&
! {error}
}
{busy === 'delete' ? 'DELETING…' : '✕ DELETE CATEGORY'}
);
}
// ---------- DRAFT EDITOR (used for "+ NEW PRODUCT") ----------
//
// Mirrors ProductEditor's chrome (same BASIC + PRICING + FEATURES + IMAGES
// cards) so creating feels identical to editing. The difference: nothing
// is sent to D1 until the user clicks CREATE PRODUCT at the bottom. That
// single submit runs three calls in order:
// 1. POST /api/admin/products → returns the new id
// 2. PUT /api/admin/products/:id/features (if any features entered)
// 3. POST /api/admin/products/:id/images for each staged file
// Then ExecCatalog refreshes and the user lands in the live ProductEditor
// for the freshly-created product.
function ProductDraftEditor({ onBack, onCreated }) {
const [category, setCategory] = useState(null);
const [name, setName] = useState('');
const [idEdited, setIdEdited] = useState(false);
const [idValue, setIdValue] = useState('');
const [version, setVersion] = useState('v1.0.0');
const [blurb, setBlurb] = useState('');
const [description, setDescription] = useState('');
const [runtime, setRuntime] = useState('Windows 10 / 11');
const [size, setSize] = useState('External loader');
const [license, setLicense] = useState('Time-based');
const [featured, setFeatured] = useState(false);
const [day, setDay] = useState('');
const [week, setWeek] = useState('');
const [month, setMonth] = useState('');
const [stateChoice, setStateChoice] = useState('updating'); // start unverified
const [stateNote, setStateNote] = useState('');
const [featuresRaw, setFeaturesRaw] = useState('');
const [stagedImages, setStagedImages] = useState([]); // [{file, previewUrl}]
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
// Derive id from name unless the user has explicitly edited it.
useEffect(() => {
if (idEdited) return;
setIdValue(slugify(name));
}, [name, idEdited]);
// Clean up object URLs we created for staged-image previews.
useEffect(() => () => stagedImages.forEach((s) => URL.revokeObjectURL(s.previewUrl)), []);
const parsedFeatures = useMemo(() => parseFeatures(featuresRaw), [featuresRaw]);
const onFilePicked = (files) => {
const next = Array.from(files || []).map((f) => ({
file: f,
previewUrl: URL.createObjectURL(f),
}));
setStagedImages((prev) => prev.concat(next));
};
const removeStaged = (i) => {
setStagedImages((prev) => {
const tile = prev[i];
if (tile) URL.revokeObjectURL(tile.previewUrl);
return prev.filter((_, j) => j !== i);
});
};
const canSubmit = !busy && !!category && !!name.trim() && !!idValue.trim();
const onSubmit = async () => {
if (!canSubmit) return;
setBusy(true); setError(null);
try {
// 1. Create the product row with every text/number field the user
// filled in. The Worker silently ignores anything not in its
// whitelist, so adding extra keys here is safe.
const createRes = await fetch('/api/admin/products', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: idValue.trim(),
name: name.trim(),
category,
version: version.trim(),
blurb: blurb.trim(),
description: description.trim(),
runtime: runtime.trim(),
size: size.trim(),
license: license.trim(),
featured,
price_day_cents: Math.round((Number(day) || 0) * 100),
price_week_cents: Math.round((Number(week) || 0) * 100),
price_month_cents: Math.round((Number(month) || 0) * 100),
}),
});
const createData = await createRes.json();
if (!createRes.ok) throw new Error(createData.error || ('http_' + createRes.status));
const newId = createData.id;
// 2. Push the chosen operational state. Worker treats this as an
// upsert so writing 'updating' (the default) is harmless.
if (window.ExecStates) {
try { await window.ExecStates.setState(newId, stateChoice, stateNote.trim()); }
catch { /* state write is non-fatal — admin can fix it from the row */ }
}
// 3. Replace features list in one shot if any were entered.
if (parsedFeatures.length) {
await fetch(`/api/admin/products/${encodeURIComponent(newId)}/features`, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ features: parsedFeatures }),
});
}
// 4. Upload staged images sequentially so order matches the staging UI.
for (const tile of stagedImages) {
const f = tile.file;
if (!f || !f.type.startsWith('image/')) continue;
await fetch(`/api/admin/products/${encodeURIComponent(newId)}/images`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': f.type },
body: f,
});
}
onCreated && onCreated(newId);
} catch (e) {
setError(String(e.message || e));
setBusy(false);
}
};
return (
<>
← BACK TO CATALOG
// NEW
{name || 'NEW PRODUCT'}
{idValue && / {idValue} }
{/* BASIC card ---------------------------------------------------- */}
{/* PRICING card -------------------------------------------------- */}
{/* STATUS card --------------------------------------------------- */}
STATUS visible to every visitor immediately after create
{/* FEATURES card ------------------------------------------------- */}
FEATURES paste comma / newline / [bracket] / (paren) separated
{/* IMAGES card --------------------------------------------------- */}
IMAGES optional · first one is the cover
{error && ⚠ {error}
}
CANCEL
{busy ? 'CREATING…' : '+ CREATE PRODUCT'}
>
);
}
function ImageStager({ files, onAdd, onRemove }) {
const ref = useRef(null);
const [dragOver, setDragOver] = useState(false);
const onDrop = (e) => {
e.preventDefault();
setDragOver(false);
onAdd(e.dataTransfer.files);
};
return (
ref.current && ref.current.click()}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={onDrop}
>
+ DROP IMAGES HERE OR CLICK TO UPLOAD
{ onAdd(e.target.files); e.target.value = ''; }}
/>
{files.length > 0 && (
{files.map((tile, i) => (
onRemove(i)} type="button" aria-label="Remove">✕
{i === 0 &&
COVER }
))}
)}
);
}
// ---------- helpers ----------
function slugify(s) {
return String(s || '')
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60);
}
// Accepts free-form text and returns an ordered, de-duped list of features.
// Priority of delimiters: newlines → bracketed/paren'd groups → semicolons →
// commas. Falls back to a single-item list if none of those split the input.
function parseFeatures(raw) {
const s = String(raw || '').trim();
if (!s) return [];
if (s.includes('\n')) return clean(s.split(/\r?\n/));
const bracketed = [...s.matchAll(/[\[(]([^\])]+)[\])]/g)].map((m) => m[1]);
if (bracketed.length >= 2) return clean(bracketed);
if (s.includes(';')) return clean(s.split(';'));
if (s.includes(',')) return clean(s.split(','));
return clean([s]);
}
function clean(arr) {
const seen = new Set();
const out = [];
for (const piece of arr) {
const t = String(piece).trim().replace(/^[•\-\*\d.]+\s*/, '');
if (!t) continue;
const key = t.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(t);
}
return out;
}
// ---------- EDITOR VIEW ----------
function ProductEditor({ product, onBack, onRefresh }) {
return (
<>
← BACK TO CATALOG
// EDIT
{product.name} / {product.tag}
>
);
}
function DeleteButton({ product, onDone }) {
const [busy, setBusy] = useState(false);
const onClick = async () => {
if (!window.confirm(
`Delete "${product.name}"?\n\nThis removes the product from D1 and all its images from R2. Cannot be undone.`
)) return;
setBusy(true);
try {
const res = await fetch(`/api/admin/products/${encodeURIComponent(product.id)}`, {
method: 'DELETE', credentials: 'same-origin',
});
if (!res.ok) { alert('Delete failed: ' + res.status); setBusy(false); return; }
await (window.ExecCatalog && window.ExecCatalog.fetchAll({ fresh: true }));
onDone();
} catch (e) { alert('Delete failed: ' + e.message); setBusy(false); }
};
return (
{busy ? 'DELETING…' : '✕ DELETE PRODUCT'}
);
}
// ---------- field group: name/tag/category/version/etc ----------
function BasicFields({ product, onSaved }) {
return (
);
}
function PricingFields({ product, onSaved }) {
return (
PRICING stored in cents · enter dollars
);
}
// ---------- live editor STATUS card ----------
function StatusCard({ product }) {
window.ExecStates && window.ExecStates.useStates();
const cur = window.ExecStates ? window.ExecStates.get(product.id) : { state: 'updating', note: '' };
const [note, setNote] = useState(cur.note || '');
const [saving, setSaving] = useState(null); // null | 'state' | 'note'
const [savedAt, setSavedAt] = useState(null);
useEffect(() => { setNote(cur.note || ''); }, [cur.note]);
const save = async (nextState, nextNote, kind) => {
setSaving(kind);
try {
await window.ExecStates.setState(product.id, nextState, nextNote);
setSavedAt(Date.now());
} finally { setSaving(null); }
};
const onPickState = (e) => save(e.target.value, note, 'state');
const onBlurNote = () => { if ((note || '') !== (cur.note || '')) save(cur.state, note, 'note'); };
return (
STATUS visible to every visitor immediately
);
}
// ---------- LOADER card ----------
// One external URL per product. Customers who have a paid order for this
// product will see a DOWNLOAD LOADER button that 302-redirects to whatever
// URL is set here (mega, github releases, our own CDN — anything https).
// Empty value clears it; the customer's button then disappears entirely.
function LoaderCard({ product, onSaved }) {
const initial = product.loader_url || '';
const [val, setVal] = useState(initial);
const [state, setState] = useState(null); // null | 'saving' | 'ok' | 'err'
const [err, setErr] = useState('');
useEffect(() => { setVal(product.loader_url || ''); }, [product.loader_url]);
const save = async () => {
const trimmed = val.trim();
if (trimmed === initial) { setErr(''); return; }
if (trimmed.length > 500) {
setErr('url too long (max 500 chars)'); setState('err'); return;
}
setErr(''); setState('saving');
try {
await patchProduct(product.id, { loader_url: trimmed });
setState('ok');
// The worker may have auto-prepended https://, so pull fresh data
// before our local `val` falls out of sync with what's stored.
await (window.ExecCatalog && window.ExecCatalog.fetchAll({ fresh: true }));
onSaved && onSaved();
} catch { setState('err'); setErr('save failed'); }
};
const clear = async () => {
if (!initial) { setVal(''); return; }
if (!window.confirm('Remove the loader URL?\n\nCustomers who already own this product will no longer see a download button.')) return;
setVal('');
setErr(''); setState('saving');
try {
await patchProduct(product.id, { loader_url: '' });
setState('ok');
await (window.ExecCatalog && window.ExecCatalog.fetchAll({ fresh: true }));
onSaved && onSaved();
} catch { setState('err'); setErr('save failed'); }
};
return (
LOADER external download URL · customers with a paid order get a button that redirects here
);
}
// ---------- generic patch helpers ----------
async function patchProduct(productId, body) {
const res = await fetch(`/api/admin/products/${encodeURIComponent(productId)}`, {
method: 'PATCH',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const t = await res.text().catch(() => '');
throw new Error('save_failed: ' + res.status + ' ' + t);
}
return res.json();
}
function Field({ product, field, label, onSaved, multiline, placeholder }) {
const initial = product[field] != null ? product[field] : '';
const [val, setVal] = useState(initial);
const [state, setState] = useState(null); // null | 'saving' | 'ok' | 'err'
useEffect(() => { setVal(product[field] != null ? product[field] : ''); }, [product[field]]);
const save = async () => {
if (val === initial) return;
setState('saving');
try {
await patchProduct(product.id, { [field]: val });
setState('ok');
await (window.ExecCatalog && window.ExecCatalog.fetchAll({ fresh: true }));
onSaved && onSaved();
} catch { setState('err'); }
};
return (
{label}
{multiline
?
);
}
// Wraps CategoryPicker for the live ProductEditor: changing the chip
// selection PATCHes the product immediately and refreshes the catalog.
// Also surfaces the inline "+ NEW" + "✎ manage" affordances here so
// admins can create categories without leaving the product editor.
function CategoryField({ product, onSaved }) {
const [state, setState] = useState(null);
const onChange = async (next) => {
if (!next || next === product.category) return;
setState('saving');
try {
await patchProduct(product.id, { category: next });
setState('ok');
await (window.ExecCatalog && window.ExecCatalog.fetchAll({ fresh: true }));
onSaved && onSaved();
} catch { setState('err'); }
};
return (
CATEGORY
);
}
function SelectField({ product, field, label, options, onSaved }) {
const [state, setState] = useState(null);
const onChange = async (e) => {
const next = e.target.value;
if (next === product[field]) return;
setState('saving');
try {
await patchProduct(product.id, { [field]: next });
setState('ok');
await (window.ExecCatalog && window.ExecCatalog.fetchAll({ fresh: true }));
onSaved && onSaved();
} catch { setState('err'); }
};
return (
{label}
{options.map((o) => {o.label} )}
);
}
function ToggleField({ product, field, label, onSaved }) {
const [state, setState] = useState(null);
const onChange = async (e) => {
setState('saving');
try {
await patchProduct(product.id, { [field]: e.target.checked });
setState('ok');
await (window.ExecCatalog && window.ExecCatalog.fetchAll({ fresh: true }));
onSaved && onSaved();
} catch { setState('err'); }
};
return (
{label}
{product[field] ? 'ON' : 'OFF'}
);
}
function PriceField({ product, field, label, currentDollars, onSaved }) {
const [val, setVal] = useState(String(currentDollars));
const [state, setState] = useState(null);
useEffect(() => { setVal(String(currentDollars)); }, [currentDollars]);
const save = async () => {
const dollars = Number(val);
if (!Number.isFinite(dollars) || dollars < 0) { setVal(String(currentDollars)); return; }
const cents = Math.round(dollars * 100);
const currentCents = Math.round(currentDollars * 100);
if (cents === currentCents) return;
setState('saving');
try {
await patchProduct(product.id, { [field]: cents });
setState('ok');
await (window.ExecCatalog && window.ExecCatalog.fetchAll({ fresh: true }));
onSaved && onSaved();
} catch { setState('err'); }
};
return (
{label}
setVal(e.target.value)}
onBlur={save}
onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur(); }}
/>
);
}
function SortField({ product, onSaved }) {
const [val, setVal] = useState(String(product.sort_order || 0));
const [state, setState] = useState(null);
useEffect(() => { setVal(String(product.sort_order || 0)); }, [product.sort_order]);
const save = async () => {
const n = parseInt(val, 10);
if (!Number.isFinite(n)) { setVal(String(product.sort_order || 0)); return; }
if (n === (product.sort_order || 0)) return;
setState('saving');
try {
await patchProduct(product.id, { sort_order: n });
setState('ok');
await (window.ExecCatalog && window.ExecCatalog.fetchAll({ fresh: true }));
onSaved && onSaved();
} catch { setState('err'); }
};
return (
SORT ORDER
setVal(e.target.value)}
onBlur={save}
onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur(); }}
/>
);
}
function FieldState({ state }) {
if (!state) return null;
if (state === 'saving') return · saving ;
if (state === 'ok') return · saved ;
if (state === 'err') return · save failed ;
return null;
}
// ---------- FEATURES EDITOR ----------
function FeaturesEditor({ product, onSaved }) {
const [features, setFeatures] = useState((product.features || []).slice());
const [busy, setBusy] = useState(false);
const [dirty, setDirty] = useState(false);
// Only resync from the server prop when the admin opens a different
// product. Watching `product.features` directly would clobber in-progress
// edits because ExecCatalog deep-clones on every refresh (so the array
// reference changes after every save, even though the contents are what
// we just wrote).
useEffect(() => {
setFeatures((product.features || []).slice());
setDirty(false);
}, [product.id]);
const save = async (next) => {
setBusy(true);
try {
await fetch(`/api/admin/products/${encodeURIComponent(product.id)}/features`, {
method: 'PUT', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ features: next }),
});
await (window.ExecCatalog && window.ExecCatalog.fetchAll({ fresh: true }));
setDirty(false);
onSaved && onSaved();
} finally { setBusy(false); }
};
const setOne = (i, v) => { const next = features.slice(); next[i] = v; setFeatures(next); setDirty(true); };
const removeOne = (i) => { const next = features.slice(); next.splice(i, 1); setFeatures(next); save(next); };
const addOne = () => { const next = features.concat(['']); setFeatures(next); setDirty(true); };
const move = (i, delta) => {
const j = i + delta;
if (j < 0 || j >= features.length) return;
const next = features.slice();
[next[i], next[j]] = [next[j], next[i]];
setFeatures(next); save(next);
};
return (
FEATURES {features.length} item{features.length === 1 ? '' : 's'}
{busy && saving… }
{dirty && !busy && (
save(features)}>SAVE ORDER + EDITS
)}
);
}
// ---------- IMAGES EDITOR ----------
function ImagesEditor({ product, onSaved }) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const fileRef = useRef(null);
const images = product.images || [];
const upload = async (file) => {
if (!file) return;
setBusy(true); setError(null);
try {
const res = await fetch(`/api/admin/products/${encodeURIComponent(product.id)}/images`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': file.type },
body: file,
});
if (!res.ok) {
const t = await res.text().catch(() => '');
throw new Error(res.status + ' ' + t);
}
await (window.ExecCatalog && window.ExecCatalog.fetchAll({ fresh: true }));
onSaved && onSaved();
} catch (e) { setError(String(e.message || e)); }
finally { setBusy(false); }
};
const removeAt = async (idx) => {
if (!window.confirm('Remove this image?')) return;
setBusy(true); setError(null);
try {
const res = await fetch(`/api/admin/products/${encodeURIComponent(product.id)}/images/${idx}`, {
method: 'DELETE', credentials: 'same-origin',
});
if (!res.ok) throw new Error(res.status);
await (window.ExecCatalog && window.ExecCatalog.fetchAll({ fresh: true }));
onSaved && onSaved();
} catch (e) { setError(String(e.message || e)); }
finally { setBusy(false); }
};
const move = async (i, delta) => {
const j = i + delta;
if (j < 0 || j >= images.length) return;
const next = images.slice();
[next[i], next[j]] = [next[j], next[i]];
setBusy(true);
try {
await fetch(`/api/admin/products/${encodeURIComponent(product.id)}/images`, {
method: 'PUT', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ urls: next }),
});
await (window.ExecCatalog && window.ExecCatalog.fetchAll({ fresh: true }));
onSaved && onSaved();
} finally { setBusy(false); }
};
return (
IMAGES {images.length} · idx 0 = cover
{busy && working… }
{error && · failed }
fileRef.current && fileRef.current.click()}>
+ UPLOAD
{ const f = e.target.files && e.target.files[0]; e.target.value = ''; upload(f); }}
/>
{images.length === 0 && (
no images yet · click UPLOAD to add the first one
)}
{images.map((url, i) => (
{i === 0 ? 'COVER' : '#' + String(i + 1).padStart(2, '0')}
move(i, -1)} disabled={i === 0} title="Move up in order">↑
move(i, +1)} disabled={i === images.length - 1} title="Move down">↓
removeAt(i)} title="Remove">✕
))}
);
}
window.AdminCatalogApp = RestoredAdminCatalogApp;
})();