Feature/224 intake notification #263

Merged
bermudalamb merged 8 commits from feature/224-intake-notification into main 2026-09-01 15:08:49 -05:00
4 changed files with 272 additions and 1 deletions
Showing only changes of commit 2b06538ef9 - Show all commits
+2
View File
@@ -34,6 +34,7 @@ import Settings from './Settings';
import Categories from './Categories'; import Categories from './Categories';
import Tags from './Tags'; import Tags from './Tags';
import UploadLinks from './UploadLinks'; import UploadLinks from './UploadLinks';
import DraftQueue from './DraftQueue';
import BuildStamp from './BuildStamp'; import BuildStamp from './BuildStamp';
import CategoryTreeSelect from './CategoryTreeSelect'; import CategoryTreeSelect from './CategoryTreeSelect';
import ItemCard from '../components/ItemCard'; import ItemCard from '../components/ItemCard';
@@ -393,6 +394,7 @@ export default function Admin() {
{ key: 'categories', label: 'Categories', children: <Categories /> }, { key: 'categories', label: 'Categories', children: <Categories /> },
{ key: 'tags', label: 'Tags', children: <Tags /> }, { key: 'tags', label: 'Tags', children: <Tags /> },
{ key: 'upload-links', label: 'Upload links', children: <UploadLinks /> }, { key: 'upload-links', label: 'Upload links', children: <UploadLinks /> },
{ key: 'review-queue', label: 'Review queue', children: <DraftQueue /> },
{ key: 'customers', label: 'Customers', children: <Customers /> }, { key: 'customers', label: 'Customers', children: <Customers /> },
{ key: 'emails', label: 'Emails', children: <Emails /> }, { key: 'emails', label: 'Emails', children: <Emails /> },
{ key: 'settings', label: 'Settings', children: <Settings /> } { key: 'settings', label: 'Settings', children: <Settings /> }
+195
View File
@@ -0,0 +1,195 @@
import { useCallback, useEffect, useState } from 'react';
import Card from 'antd/es/card';
import Button from 'antd/es/button';
import Input from 'antd/es/input';
import InputNumber from 'antd/es/input-number';
import Space from 'antd/es/space';
import Tag from 'antd/es/tag';
import Select from 'antd/es/select';
import Empty from 'antd/es/empty';
import Alert from 'antd/es/alert';
import Modal from 'antd/es/modal';
import message from 'antd/es/message';
import { Draft, PriceSource, actOnDraft, fetchDrafts, publishDraft } from './draftsApi';
const { TextArea } = Input;
/** Mirrors isUnconfirmed on the server: anything a person did not choose. */
function isUnconfirmed(source: PriceSource): boolean {
return source !== 'admin';
}
function priceLabel(source: PriceSource): string {
if (source === 'admin') return 'you set this price';
if (source === 'ai') return 'suggested by the model — nobody chose this';
return 'default price — nobody chose this';
}
/**
* One submission, with everything needed to judge it.
*
* The price is why this screen exists. Items are priced on arrival, so nothing
* stops a number nobody chose from reaching the storefront except this saying
* so — and 80.00 is a plausible price rather than an obvious sentinel, which is
* exactly why it has to be called out rather than left to be noticed.
*/
function DraftCard({ draft, onChanged }: Readonly<{ draft: Draft; onChanged: () => void }>) {
const [name, setName] = useState(draft.ai_name ?? draft.item_name);
const [description, setDescription] = useState(
draft.ai_description ?? draft.item_description ?? ''
);
const [priceCents, setPriceCents] = useState(draft.price_cents);
const [busy, setBusy] = useState(false);
// Unconfirmed until the number is actually changed. Opening the field and
// leaving it alone is not a decision and must not be recorded as one — the
// server applies the same rule, this only has to agree with it.
const unconfirmed = isUnconfirmed(draft.price_source) && priceCents === draft.price_cents;
const run = async (work: () => Promise<void>) => {
setBusy(true);
try {
await work();
onChanged();
} catch (err) {
message.error(err instanceof Error ? err.message : 'that did not work');
} finally {
setBusy(false);
}
};
const publish = () => {
const go = () => run(() => publishDraft(draft.item_id, { name, description, priceCents }));
if (!unconfirmed) {
void go();
return;
}
// Said before, not after. Publishing at an unconfirmed price is allowed —
// it is a decision someone is entitled to make — but not by accident.
Modal.confirm({
title: 'Publish at a price nobody chose?',
content: `This will go on sale at $${(priceCents / 100).toFixed(2)}, which is ${
draft.price_source === 'ai' ? "the model's suggestion" : 'the default'
} rather than a price you set.`,
okText: 'Publish anyway',
okButtonProps: { danger: true },
onOk: go
});
};
return (
<Card title={draft.item_name} extra={<Tag>{draft.state}</Tag>} style={{ marginBottom: 16 }}>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
{draft.ai_error && <Alert type="warning" message={`Drafting failed: ${draft.ai_error}`} />}
<Space wrap>
{draft.images.map((image) => (
<img
key={image.id}
src={image.image_path}
alt=""
style={{ width: 120, height: 120, objectFit: 'cover' }}
/>
))}
</Space>
{draft.submitter_note && (
<Alert type="info" message={`Sender's note: ${draft.submitter_note}`} />
)}
{draft.upload_link_label && <Tag>via {draft.upload_link_label}</Tag>}
<Input value={name} onChange={(e) => setName(e.target.value)} aria-label="Name" />
<TextArea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
aria-label="Description"
/>
<Space direction="vertical" size={4}>
<InputNumber
value={priceCents / 100}
min={0}
step={0.01}
prefix="$"
aria-label="Price"
onChange={(value) => setPriceCents(Math.round((value ?? 0) * 100))}
/>
<Tag color={unconfirmed ? 'orange' : 'green'}>
{unconfirmed ? priceLabel(draft.price_source) : 'you set this price'}
</Tag>
</Space>
<Space>
<Button type="primary" loading={busy} onClick={publish}>
Publish
</Button>
<Button loading={busy} onClick={() => void run(() => actOnDraft(draft.item_id, 'regenerate'))}>
Regenerate
</Button>
{draft.state === 'discarded' ? (
<Button loading={busy} onClick={() => void run(() => actOnDraft(draft.item_id, 'restore'))}>
Restore
</Button>
) : (
<Button
danger
loading={busy}
onClick={() => void run(() => actOnDraft(draft.item_id, 'discard'))}
>
Discard
</Button>
)}
</Space>
</Space>
</Card>
);
}
export default function DraftQueue() {
const [drafts, setDrafts] = useState<Draft[]>([]);
const [state, setState] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setDrafts(await fetchDrafts(state));
setError(null);
} catch {
setError('Could not load the review queue.');
}
}, [state]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void load();
}, [load]);
return (
<div>
<Space style={{ marginBottom: 16 }}>
<Select
value={state}
onChange={setState}
style={{ width: 220 }}
placeholder="All except discarded"
allowClear
aria-label="State"
options={[
{ value: 'queued', label: 'Queued' },
{ value: 'ready', label: 'Ready' },
{ value: 'failed', label: 'Failed' },
{ value: 'discarded', label: 'Discarded' }
]}
/>
<Button onClick={() => void load()}>Refresh</Button>
</Space>
{error && <Alert type="error" message={error} />}
{!error && drafts.length === 0 && <Empty description="Nothing waiting for review" />}
{drafts.map((draft) => (
<DraftCard key={draft.item_id} draft={draft} onChanged={() => void load()} />
))}
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
export type PriceSource = 'default' | 'ai' | 'admin';
export interface DraftImage {
id: number;
image_path: string;
}
export interface Draft {
item_id: number;
state: string;
attempts: number;
submitter_note: string | null;
ai_error: string | null;
ai_name: string | null;
ai_description: string | null;
ai_suggested_price_cents: number | null;
price_source: PriceSource;
model: string | null;
item_name: string;
item_description: string | null;
price_cents: number;
status: string;
upload_link_label: string | null;
images: DraftImage[];
}
async function send(path: string, init?: RequestInit): Promise<Response> {
return fetch(`/api/admin/item-drafts${path}`, {
...init,
headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) }
});
}
export async function fetchDrafts(state?: string): Promise<Draft[]> {
const query = state ? `?state=${encodeURIComponent(state)}` : '';
const res = await send(query);
if (!res.ok) throw new Error('could not load the review queue');
return (await res.json()).drafts;
}
export interface PublishInput {
name: string;
description: string;
priceCents: number;
}
/**
* The server's message is preferred over a generic one because its refusals are
* specific and actionable — a missing name, a fractional price — and replacing
* them with "could not publish" would throw away the only thing that says how
* to fix it.
*/
export async function publishDraft(itemId: number, input: PublishInput): Promise<void> {
const res = await send(`/${itemId}/publish`, { method: 'POST', body: JSON.stringify(input) });
if (res.ok) return;
let message = 'could not publish';
try {
message = (await res.json()).error ?? message;
} catch {
// A non-JSON body is a proxy or gateway error rather than the app refusing.
// The generic message above is the honest thing to show in that case.
}
throw new Error(message);
}
export async function actOnDraft(
itemId: number,
action: 'regenerate' | 'discard' | 'restore'
): Promise<void> {
const res = await send(`/${itemId}/${action}`, { method: 'POST' });
if (!res.ok) throw new Error(`could not ${action} this draft`);
}
+2 -1
View File
@@ -7,7 +7,8 @@ export type AdminTab =
| 'Tags' | 'Tags'
| 'Customers' | 'Customers'
| 'Emails' | 'Emails'
| 'Settings'; | 'Settings'
| 'Review queue';
/** /**
* The admin shell: the tab strip and the panel it swaps. * The admin shell: the tab strip and the panel it swaps.