diff --git a/frontend/src/admin/Admin.tsx b/frontend/src/admin/Admin.tsx index 7edd080..5c41af9 100755 --- a/frontend/src/admin/Admin.tsx +++ b/frontend/src/admin/Admin.tsx @@ -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([]); const [modalOpen, setModalOpen] = useState(false); const [editingItem, setEditingItem] = useState(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(null); const [form] = Form.useForm(); const [fileList, setFileList] = useState([]); const [description, setDescription] = useState(''); @@ -181,7 +186,17 @@ function Inventory() { ) : 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) => ( + + ) + }, { title: 'Category', dataIndex: 'category_name', @@ -283,6 +298,26 @@ function Inventory() { + + {/* 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. */} + setPreviewItem(null)} + width={420} + destroyOnHidden + > + {previewItem && ( +
+ {/* onChanged never fires: every handler that would call it is + short-circuited by `preview`. */} + undefined} preview /> +
+ )} +
); } diff --git a/frontend/src/components/ItemCard.tsx b/frontend/src/components/ItemCard.tsx index 1b7c881..77c196a 100755 --- a/frontend/src/components/ItemCard.tsx +++ b/frontend/src/components/ItemCard.tsx @@ -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) { const carouselRef = useRef(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'); diff --git a/frontend/tests/e2e/admin-item-preview.spec.ts b/frontend/tests/e2e/admin-item-preview.spec.ts new file mode 100644 index 0000000..dec1fa1 --- /dev/null +++ b/frontend/tests/e2e/admin-item-preview.spec.ts @@ -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(); + }); +});