feat(frontend): preview an inventory item as a customer sees it (#89)
SonarQube Analysis / sonarqube (pull_request) Failing after 34m39s
Tests / lint (pull_request) Successful in 5m14s
Tests / backend-unit (pull_request) Successful in 1m38s
Tests / frontend-e2e (pull_request) Failing after 23m16s

Clicking an item's name in the admin Inventory opens a drawer rendering the real storefront ItemCard for it, so the way a listing will look can be checked without publishing it and going to see.

The name cell is a link-styled button rather than a clickable cell, so it stays reachable by keyboard and announces itself as an action. Admin already imports Item from ../api, the same type the storefront uses, so the row object goes straight into the card with no adapter and nothing to drift.

The part that needed care is that ItemCard is not a passive component. It wires into the cart and favorites contexts and has working buttons, and both providers wrap the whole app — so a naive preview would have been fully functional, and an admin browsing inventory could have added their own stock to their own cart. On a one-of-a-kind catalogue that reserves the item and takes it off sale.

ItemCard therefore takes an optional preview prop that short-circuits its two click handlers. Those two are the only entry points, so guarding them also covers the shared auth modal and the favorite-alerts consent prompt hanging off them.

Deliberately not `disabled` on the buttons. A disabled antd button renders in a different colour with a different cursor and no hover, and the whole point of this panel is to show what a customer will actually see. The controls keep their normal appearance and their correct state for the item's status; only the handlers stop. The comment on the prop says so, because "simplifying" this to a disabled button would quietly defeat the feature while appearing to implement it.

Four end-to-end tests, two of which are the ones worth having. Clicking Add to Cart in the preview must do nothing — asserted by the sign-in prompt never appearing, which a real click on a signed-out card always raises, so its absence proves the handler stopped before doing any work. And the storefront card must still be live where it is actually used, or this change would have quietly broken buying things.

ItemCard's props are now Readonly, which was an existing lint warning on a file this change already touches: frontend warnings drop from 31 to 30.

Verified: build clean, lint 0 errors, 91 end-to-end tests passing against a freshly created database.

Refs #89
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 11:41:59 -05:00
co-authored by Claude Opus 5
parent e3d5475fa6
commit 03f08074d1
3 changed files with 136 additions and 3 deletions
+37 -2
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import {
Layout, Table, Button, Form, Input, InputNumber, Upload, Modal,
Layout, Table, Button, Drawer, Form, Input, InputNumber, Upload, Modal,
Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs,
Select
} from 'antd';
@@ -20,6 +20,7 @@ import Settings from './Settings';
import Categories from './Categories';
import Tags from './Tags';
import CategoryTreeSelect from './CategoryTreeSelect';
import ItemCard from '../components/ItemCard';
import InventoryFilters from './InventoryFilters';
import { ItemFilters, EMPTY_FILTERS } from '../filters';
@@ -34,6 +35,10 @@ function Inventory() {
const [items, setItems] = useState<Item[]>([]);
const [modalOpen, setModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState<Item | null>(null);
// The item whose storefront appearance is being previewed, or null when the
// panel is closed. Holds the row object itself — admin and storefront share
// one Item type, so there is nothing to convert and nothing to drift.
const [previewItem, setPreviewItem] = useState<Item | null>(null);
const [form] = Form.useForm();
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [description, setDescription] = useState<string>('');
@@ -181,7 +186,17 @@ function Inventory() {
</span>
) : null
},
{ title: 'Name', dataIndex: 'name' },
{
title: 'Name',
dataIndex: 'name',
// A button rather than a clickable cell so it is reachable by keyboard
// and announces itself as an action.
render: (name: string, item: Item) => (
<Button type="link" style={{ padding: 0, height: 'auto', textAlign: 'left' }} onClick={() => setPreviewItem(item)}>
{name}
</Button>
)
},
{
title: 'Category',
dataIndex: 'category_name',
@@ -283,6 +298,26 @@ function Inventory() {
</Form>
</div>
</Modal>
{/* The real storefront card, rendered inert. Width is pinned to what the
storefront grid actually gives a card at its widest column, so the
proportions here match what a customer sees rather than stretching to
fill the drawer. */}
<Drawer
title={previewItem ? `Preview: ${previewItem.name}` : 'Preview'}
open={previewItem !== null}
onClose={() => setPreviewItem(null)}
width={420}
destroyOnHidden
>
{previewItem && (
<div style={{ maxWidth: 340, margin: '0 auto' }}>
{/* onChanged never fires: every handler that would call it is
short-circuited by `preview`. */}
<ItemCard item={previewItem} onChanged={() => undefined} preview />
</div>
)}
</Drawer>
</div>
);
}
+15 -1
View File
@@ -16,9 +16,21 @@ const { Text, Title } = Typography;
interface Props {
item: Item;
onChanged: () => void;
// Render exactly as the storefront does, but inert. The admin's inventory
// preview uses this: the cart and favorites providers wrap the whole app, so
// without it an admin looking at an item could add their own stock to their
// own cart — and on a one-of-a-kind catalogue that reserves the item and
// takes it off sale.
//
// Deliberately not `disabled` on the buttons. A disabled antd button renders
// in a different colour with a different cursor and no hover, and the entire
// point of the preview is to show what a customer will actually see. The
// controls keep their normal appearance and their correct state for the
// item's status; only the handlers stop.
preview?: boolean;
}
export default function ItemCard({ item, onChanged }: Props) {
export default function ItemCard({ item, onChanged, preview = false }: Readonly<Props>) {
const carouselRef = useRef<CarouselRef>(null);
const [authModalOpen, setAuthModalOpen] = useState(false);
const [adding, setAdding] = useState(false);
@@ -48,6 +60,7 @@ export default function ItemCard({ item, onChanged }: Props) {
}
function handleAddClick() {
if (preview) return;
// Until the session has resolved, `customer` is null for a signed-in
// visitor too, and prompting them to sign in again would be wrong.
if (authLoading) return;
@@ -104,6 +117,7 @@ export default function ItemCard({ item, onChanged }: Props) {
}
function handleFavoriteClick() {
if (preview) return;
if (authLoading) return;
if (!customer) {
setPendingAction('favorite');
@@ -0,0 +1,84 @@
import { test, expect } from './fixtures';
const suffix = () => `p${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
async function createItem(page: import('@playwright/test').Page, name: string, price: string) {
const created = await page.request.post('/api/admin/items', {
multipart: { name, description: 'A preview subject', price, category_id: '', tags: '[]' }
});
expect(created.ok()).toBeTruthy();
return (await created.json()).id as number;
}
test.describe('Admin item preview', () => {
test('opens the storefront card from the item name', async ({ page }) => {
const name = `Preview ${suffix()}`;
await createItem(page, name, '42');
await page.goto('/admin');
await page.getByRole('button', { name }).click();
const drawer = page.getByRole('dialog', { name: `Preview: ${name}` });
await expect(drawer).toBeVisible();
// The card itself, not just an empty panel: the storefront renders the
// price formatted from cents, so this only passes if ItemCard rendered.
await expect(drawer.getByText('$42.00')).toBeVisible();
});
// The claim the preview has to make: it looks live, and it is not. The
// buttons must keep their normal appearance rather than being disabled,
// because showing a customer's view is the entire purpose of the panel.
test('shows the Add to Cart button in its normal enabled state', async ({ page }) => {
const name = `Preview ${suffix()}`;
await createItem(page, name, '15');
await page.goto('/admin');
await page.getByRole('button', { name }).click();
const drawer = page.getByRole('dialog', { name: `Preview: ${name}` });
const addToCart = drawer.getByRole('button', { name: 'Add to Cart' });
await expect(addToCart).toBeVisible();
await expect(addToCart).toBeEnabled();
});
// The assertion that matters, and the one a reviewer cannot make by looking
// at the screen. Signed out, a real Add to Cart opens the sign-in prompt
// before it can add anything — so if that modal never appears, the handler
// short-circuited before reaching any of its real work.
test('does not act when the preview card is clicked', async ({ page }) => {
const name = `Preview ${suffix()}`;
await createItem(page, name, '99');
await page.goto('/admin');
await page.getByRole('button', { name }).click();
const drawer = page.getByRole('dialog', { name: `Preview: ${name}` });
await drawer.getByRole('button', { name: 'Add to Cart' }).click();
// Nothing succeeded and nothing prompted.
await expect(page.getByText('Added to cart')).toHaveCount(0);
await expect(page.getByRole('dialog', { name: /sign in/i })).toHaveCount(0);
// And the drawer is still simply sitting there, unchanged.
await expect(drawer).toBeVisible();
await expect(drawer.getByRole('button', { name: 'Add to Cart' })).toBeEnabled();
});
// The storefront card must stay live where it is actually used, or this
// change would have quietly broken buying things.
test('leaves the real storefront card working', async ({ page }) => {
const name = `Live ${suffix()}`;
await createItem(page, name, '20');
await page.goto('/');
const card = page.locator('.ant-card').filter({ hasText: name });
await expect(card).toBeVisible();
await card.getByRole('button', { name: 'Add to Cart' }).click();
// Signed out, the real card prompts for sign-in — proof the handler ran.
await expect(page.getByRole('dialog')).toBeVisible();
});
});