Feature/224 intake notification #263
@@ -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 /> }
|
||||||
|
|||||||
@@ -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'
|
| '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.
|
||||||
|
|||||||
Reference in New Issue
Block a user