feat(frontend): preview an inventory item as a customer sees it (#89) #91
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import {
|
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,
|
Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs,
|
||||||
Select
|
Select
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@@ -20,6 +20,7 @@ import Settings from './Settings';
|
|||||||
import Categories from './Categories';
|
import Categories from './Categories';
|
||||||
import Tags from './Tags';
|
import Tags from './Tags';
|
||||||
import CategoryTreeSelect from './CategoryTreeSelect';
|
import CategoryTreeSelect from './CategoryTreeSelect';
|
||||||
|
import ItemCard from '../components/ItemCard';
|
||||||
import InventoryFilters from './InventoryFilters';
|
import InventoryFilters from './InventoryFilters';
|
||||||
import { ItemFilters, EMPTY_FILTERS } from '../filters';
|
import { ItemFilters, EMPTY_FILTERS } from '../filters';
|
||||||
|
|
||||||
@@ -34,6 +35,10 @@ function Inventory() {
|
|||||||
const [items, setItems] = useState<Item[]>([]);
|
const [items, setItems] = useState<Item[]>([]);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editingItem, setEditingItem] = useState<Item | null>(null);
|
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 [form] = Form.useForm();
|
||||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||||
const [description, setDescription] = useState<string>('');
|
const [description, setDescription] = useState<string>('');
|
||||||
@@ -181,7 +186,17 @@ function Inventory() {
|
|||||||
</span>
|
</span>
|
||||||
) : null
|
) : 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',
|
title: 'Category',
|
||||||
dataIndex: 'category_name',
|
dataIndex: 'category_name',
|
||||||
@@ -283,6 +298,26 @@ function Inventory() {
|
|||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,21 @@ const { Text, Title } = Typography;
|
|||||||
interface Props {
|
interface Props {
|
||||||
item: Item;
|
item: Item;
|
||||||
onChanged: () => void;
|
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 carouselRef = useRef<CarouselRef>(null);
|
||||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
@@ -48,6 +60,7 @@ export default function ItemCard({ item, onChanged }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleAddClick() {
|
function handleAddClick() {
|
||||||
|
if (preview) return;
|
||||||
// Until the session has resolved, `customer` is null for a signed-in
|
// Until the session has resolved, `customer` is null for a signed-in
|
||||||
// visitor too, and prompting them to sign in again would be wrong.
|
// visitor too, and prompting them to sign in again would be wrong.
|
||||||
if (authLoading) return;
|
if (authLoading) return;
|
||||||
@@ -104,6 +117,7 @@ export default function ItemCard({ item, onChanged }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleFavoriteClick() {
|
function handleFavoriteClick() {
|
||||||
|
if (preview) return;
|
||||||
if (authLoading) return;
|
if (authLoading) return;
|
||||||
if (!customer) {
|
if (!customer) {
|
||||||
setPendingAction('favorite');
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user