Feature/224 intake notification #263
@@ -34,6 +34,7 @@ import Settings from './Settings';
|
||||
import Categories from './Categories';
|
||||
import Tags from './Tags';
|
||||
import UploadLinks from './UploadLinks';
|
||||
import DraftQueue from './DraftQueue';
|
||||
import BuildStamp from './BuildStamp';
|
||||
import CategoryTreeSelect from './CategoryTreeSelect';
|
||||
import ItemCard from '../components/ItemCard';
|
||||
@@ -393,6 +394,7 @@ export default function Admin() {
|
||||
{ key: 'categories', label: 'Categories', children: <Categories /> },
|
||||
{ key: 'tags', label: 'Tags', children: <Tags /> },
|
||||
{ key: 'upload-links', label: 'Upload links', children: <UploadLinks /> },
|
||||
{ key: 'review-queue', label: 'Review queue', children: <DraftQueue /> },
|
||||
{ key: 'customers', label: 'Customers', children: <Customers /> },
|
||||
{ key: 'emails', label: 'Emails', children: <Emails /> },
|
||||
{ key: 'settings', label: 'Settings', children: <Settings /> }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -7,7 +7,8 @@ export type AdminTab =
|
||||
| 'Tags'
|
||||
| 'Customers'
|
||||
| 'Emails'
|
||||
| 'Settings';
|
||||
| 'Settings'
|
||||
| 'Review queue';
|
||||
|
||||
/**
|
||||
* The admin shell: the tab strip and the panel it swaps.
|
||||
|
||||
Reference in New Issue
Block a user