feat(frontend): preview an inventory item as a customer sees it (#89) #91

Merged
bermudalamb merged 1 commits from feature/89-item-preview-panel into main 2026-08-21 11:46:54 -05:00
3 changed files with 136 additions and 3 deletions
Showing only changes of commit 03f08074d1 - Show all commits
+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();
});
});