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>
212 lines
7.6 KiB
TypeScript
Executable File
212 lines
7.6 KiB
TypeScript
Executable File
import { useState, useRef } from 'react';
|
|
import { Card, Badge, Typography, Carousel, Button, message, Tag, Modal, Tooltip } from 'antd';
|
|
import { LeftOutlined, RightOutlined, HeartOutlined, HeartFilled } from '@ant-design/icons';
|
|
import type { CarouselRef } from 'antd/es/carousel';
|
|
import { Item } from '../api';
|
|
import MarkdownView from './MarkdownView';
|
|
import { addToCart } from '../cart/cartApi';
|
|
import { useCart } from '../cart/CartContext';
|
|
import { useCustomerAuth } from '../customer/CustomerAuthContext';
|
|
import AuthPromptModal from '../customer/AuthPromptModal';
|
|
import { useFavorites } from '../customer/FavoritesContext';
|
|
import { addFavorite, removeFavorite, setFavoriteAlerts } from '../customer/favoritesApi';
|
|
|
|
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, preview = false }: Readonly<Props>) {
|
|
const carouselRef = useRef<CarouselRef>(null);
|
|
const [authModalOpen, setAuthModalOpen] = useState(false);
|
|
const [adding, setAdding] = useState(false);
|
|
const [favoriting, setFavoriting] = useState(false);
|
|
// Which action to run once the auth modal succeeds — the modal is shared by
|
|
// Add to Cart and the heart.
|
|
const [pendingAction, setPendingAction] = useState<'cart' | 'favorite'>('cart');
|
|
const { customer, loading: authLoading, refresh: refreshCustomer } = useCustomerAuth();
|
|
const { itemIds, refresh: refreshCart } = useCart();
|
|
const { itemIds: favoriteIds, refresh: refreshFavorites } = useFavorites();
|
|
|
|
const inMyCart = itemIds.has(item.id);
|
|
const isFavorite = favoriteIds.has(item.id);
|
|
|
|
async function doAddToCart() {
|
|
setAdding(true);
|
|
try {
|
|
await addToCart(item.id);
|
|
message.success('Added to cart');
|
|
refreshCart();
|
|
onChanged();
|
|
} catch (err) {
|
|
message.error((err as Error).message);
|
|
} finally {
|
|
setAdding(false);
|
|
}
|
|
}
|
|
|
|
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;
|
|
if (!customer) {
|
|
setPendingAction('cart');
|
|
setAuthModalOpen(true);
|
|
return;
|
|
}
|
|
void doAddToCart();
|
|
}
|
|
|
|
// Asked only once a customer has actually favorited something, so the reason
|
|
// for asking is concrete rather than an abstract marketing prompt. This is a
|
|
// consent of its own — accepting it does not sign anyone up for marketing.
|
|
function offerAlerts() {
|
|
Modal.confirm({
|
|
title: 'Want to know if this sells?',
|
|
content:
|
|
'Every piece is one of a kind, so a favorite can be bought by someone else at any time. ' +
|
|
'We can email you if that happens, so you are not left waiting on something that has gone. ' +
|
|
'This is only about items you favorite — it is separate from any marketing email.',
|
|
okText: 'Yes, email me',
|
|
cancelText: 'No thanks',
|
|
onOk: async () => {
|
|
try {
|
|
await setFavoriteAlerts(true);
|
|
refreshCustomer();
|
|
message.success('We will let you know');
|
|
} catch (err) {
|
|
message.error((err as Error).message);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async function doToggleFavorite() {
|
|
setFavoriting(true);
|
|
const wasFavorite = isFavorite;
|
|
try {
|
|
if (wasFavorite) {
|
|
await removeFavorite(item.id);
|
|
} else {
|
|
await addFavorite(item.id);
|
|
}
|
|
refreshFavorites();
|
|
} catch (err) {
|
|
message.error((err as Error).message);
|
|
return;
|
|
} finally {
|
|
setFavoriting(false);
|
|
}
|
|
|
|
if (!wasFavorite && customer && !customer.favorite_alerts) offerAlerts();
|
|
}
|
|
|
|
function handleFavoriteClick() {
|
|
if (preview) return;
|
|
if (authLoading) return;
|
|
if (!customer) {
|
|
setPendingAction('favorite');
|
|
setAuthModalOpen(true);
|
|
return;
|
|
}
|
|
void doToggleFavorite();
|
|
}
|
|
|
|
const hasMultiple = item.images.length > 1;
|
|
|
|
const cover = item.images.length ? (
|
|
<div className="carousel-wrap">
|
|
<Carousel ref={carouselRef} dots={hasMultiple}>
|
|
{item.images.map(img => (
|
|
<div key={img.id}>
|
|
<img src={img.image_path} alt={item.name} className="card-cover-img" />
|
|
</div>
|
|
))}
|
|
</Carousel>
|
|
{hasMultiple && (
|
|
<>
|
|
<Button className="carousel-arrow carousel-arrow-left" shape="circle" size="small" icon={<LeftOutlined />}
|
|
onClick={(e) => { e.stopPropagation(); carouselRef.current?.prev(); }} />
|
|
<Button className="carousel-arrow carousel-arrow-right" shape="circle" size="small" icon={<RightOutlined />}
|
|
onClick={(e) => { e.stopPropagation(); carouselRef.current?.next(); }} />
|
|
<div className="carousel-count">{item.images.length} photos</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="card-cover-placeholder" />
|
|
);
|
|
|
|
let actionButton = null;
|
|
if (item.status === 'available') {
|
|
actionButton = (
|
|
<Button block type="primary" loading={adding} onClick={handleAddClick} style={{ marginTop: 8 }}>
|
|
Add to Cart
|
|
</Button>
|
|
);
|
|
} else if (item.status === 'reserved') {
|
|
actionButton = inMyCart
|
|
? <Button block disabled style={{ marginTop: 8 }}>In Your Cart</Button>
|
|
: <Button block disabled style={{ marginTop: 8 }}>Reserved</Button>;
|
|
}
|
|
|
|
const card = (
|
|
<Card hoverable cover={cover} className="item-card">
|
|
<div className="item-card-title">
|
|
<Title level={5} style={{ marginBottom: 4 }}>{item.name}</Title>
|
|
<Tooltip title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}>
|
|
<Button
|
|
type="text"
|
|
shape="circle"
|
|
loading={favoriting || authLoading}
|
|
aria-pressed={isFavorite}
|
|
aria-label={isFavorite ? `Remove ${item.name} from favorites` : `Add ${item.name} to favorites`}
|
|
icon={isFavorite ? <HeartFilled style={{ color: '#c41d7f' }} /> : <HeartOutlined />}
|
|
onClick={handleFavoriteClick}
|
|
/>
|
|
</Tooltip>
|
|
</div>
|
|
{item.category_name && <Text type="secondary" className="item-category">{item.category_name}</Text>}
|
|
<MarkdownView content={item.description} />
|
|
{item.tags.length > 0 && (
|
|
<div className="item-tags">
|
|
{item.tags.map(tag => (
|
|
<Tag key={tag.id} color={tag.color} style={{ marginInlineEnd: 4 }}>{tag.name}</Tag>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="price">${(item.price_cents / 100).toFixed(2)}</div>
|
|
{actionButton}
|
|
<AuthPromptModal
|
|
open={authModalOpen}
|
|
onClose={() => setAuthModalOpen(false)}
|
|
onSuccess={() => {
|
|
setAuthModalOpen(false);
|
|
if (pendingAction === 'favorite') void doToggleFavorite();
|
|
else void doAddToCart();
|
|
}}
|
|
/>
|
|
</Card>
|
|
);
|
|
|
|
if (item.status === 'sold') {
|
|
return <Badge.Ribbon text="SOLD" color="black">{card}</Badge.Ribbon>;
|
|
}
|
|
return card;
|
|
}
|