diff --git a/frontend/src/admin/Admin.tsx b/frontend/src/admin/Admin.tsx index 00a3745..0b115ac 100755 --- a/frontend/src/admin/Admin.tsx +++ b/frontend/src/admin/Admin.tsx @@ -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: }, { key: 'tags', label: 'Tags', children: }, { key: 'upload-links', label: 'Upload links', children: }, + { key: 'review-queue', label: 'Review queue', children: }, { key: 'customers', label: 'Customers', children: }, { key: 'emails', label: 'Emails', children: }, { key: 'settings', label: 'Settings', children: } diff --git a/frontend/src/admin/DraftQueue.tsx b/frontend/src/admin/DraftQueue.tsx new file mode 100644 index 0000000..9d271ed --- /dev/null +++ b/frontend/src/admin/DraftQueue.tsx @@ -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) => { + 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 ( + {draft.state}} style={{ marginBottom: 16 }}> + + {draft.ai_error && } + + + {draft.images.map((image) => ( + + ))} + + + {draft.submitter_note && ( + + )} + {draft.upload_link_label && via {draft.upload_link_label}} + + setName(e.target.value)} aria-label="Name" /> +