feat(ui): storefront filters and admin category/tag management (#23)
Storefront gains a Filters drawer holding the category tree, colour-coded tag pills, and a price range, with applied filters shown as removable chips. Filter state lives in the URL query string, so a filtered view is shareable and the back button works. Item cards now show their category and tags. Admin gains Categories and Tags tabs, and the item form gains a category TreeSelect plus a tags Select that creates new tags on the fly. The admin category tree tracks expansion in state rather than using defaultExpandAll: that prop is evaluated once at mount, so a branch added afterwards rendered collapsed and its children were unreachable. Creating or moving a node now expands its parent. Caught by the new admin e2e spec. The chip row is marked as a named group so its "Clear all" stays distinguishable from the drawer's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import Tag from 'antd/lib/tag';
|
||||
import Button from 'antd/lib/button';
|
||||
import type { FilterOptions } from '../api';
|
||||
import { ItemFilters, categoryPath, formatPriceRange, hasActiveFilters } from '../filters';
|
||||
|
||||
interface Props {
|
||||
options: FilterOptions | null;
|
||||
filters: ItemFilters;
|
||||
onChange: (filters: ItemFilters) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export default function ActiveFilterChips({ options, filters, onChange, onClear }: Props) {
|
||||
if (!hasActiveFilters(filters)) return null;
|
||||
|
||||
const categories = options?.categories ?? [];
|
||||
const tags = options?.tags ?? [];
|
||||
|
||||
const chips: { key: string; label: string; onRemove: () => void }[] = [];
|
||||
|
||||
if (filters.categoryId !== null) {
|
||||
const path = categoryPath(categories, filters.categoryId);
|
||||
// Falls back to the raw id while /api/filters is still loading, so the chip
|
||||
// never renders as an empty box.
|
||||
const label = path || `Category ${filters.categoryId}`;
|
||||
chips.push({
|
||||
key: `category-${filters.categoryId}`,
|
||||
// The removable name is the leaf, matching what the user clicked in the
|
||||
// tree, while the chip itself shows the full path for context.
|
||||
label,
|
||||
onRemove: () => onChange({ ...filters, categoryId: null })
|
||||
});
|
||||
}
|
||||
|
||||
for (const tagId of filters.tagIds) {
|
||||
const tag = tags.find((candidate) => candidate.id === tagId);
|
||||
chips.push({
|
||||
key: `tag-${tagId}`,
|
||||
label: tag?.name ?? `Tag ${tagId}`,
|
||||
onRemove: () => onChange({ ...filters, tagIds: filters.tagIds.filter((id) => id !== tagId) })
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.minPriceCents !== null || filters.maxPriceCents !== null) {
|
||||
chips.push({
|
||||
key: 'price',
|
||||
label: formatPriceRange(filters.minPriceCents, filters.maxPriceCents),
|
||||
onRemove: () => onChange({ ...filters, minPriceCents: null, maxPriceCents: null })
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
// Named as a group so the chip row's own "Clear all" stays distinguishable
|
||||
// from the identically-labelled one in the filter drawer.
|
||||
<div className="active-filter-chips" role="group" aria-label="Active filters">
|
||||
{chips.map((chip) => (
|
||||
<Tag
|
||||
key={chip.key}
|
||||
closable
|
||||
onClose={(event) => {
|
||||
event.preventDefault();
|
||||
chip.onRemove();
|
||||
}}
|
||||
// antd renders the close control as an icon with no text, so name it
|
||||
// for screen readers and for anything driving the page by role.
|
||||
closeIcon={
|
||||
<span role="button" aria-label={`Remove filter ${chip.label.split(' / ').pop()}`}>×</span>
|
||||
}
|
||||
>
|
||||
{chip.label}
|
||||
</Tag>
|
||||
))}
|
||||
<Button size="small" type="link" onClick={onClear}>Clear all</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import Drawer from 'antd/lib/drawer';
|
||||
import Button from 'antd/lib/button';
|
||||
import Tree from 'antd/lib/tree';
|
||||
import Tag from 'antd/lib/tag';
|
||||
import Slider from 'antd/lib/slider';
|
||||
import InputNumber from 'antd/lib/input-number';
|
||||
import Empty from 'antd/lib/empty';
|
||||
import Grid from 'antd/lib/grid';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import type { FilterOptions } from '../api';
|
||||
import { ItemFilters, buildCategoryTree, CategoryNode } from '../filters';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
options: FilterOptions | null;
|
||||
filters: ItemFilters;
|
||||
onChange: (filters: ItemFilters) => void;
|
||||
onClear: () => void;
|
||||
resultCount: number;
|
||||
}
|
||||
|
||||
function toTreeData(nodes: CategoryNode[]): DataNode[] {
|
||||
return nodes.map((node) => ({
|
||||
key: node.id,
|
||||
title: node.name,
|
||||
children: node.children.length ? toTreeData(node.children) : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
const centsToDollars = (cents: number | null): number | null => (cents === null ? null : cents / 100);
|
||||
const dollarsToCents = (dollars: number | null): number | null =>
|
||||
dollars === null || Number.isNaN(dollars) ? null : Math.round(dollars * 100);
|
||||
|
||||
export default function FilterDrawer({
|
||||
open,
|
||||
onClose,
|
||||
options,
|
||||
filters,
|
||||
onChange,
|
||||
onClear,
|
||||
resultCount
|
||||
}: Props) {
|
||||
const screens = Grid.useBreakpoint();
|
||||
const categories = options?.categories ?? [];
|
||||
const tags = options?.tags ?? [];
|
||||
const bounds = options?.priceRange ?? { min_cents: 0, max_cents: 0 };
|
||||
|
||||
function toggleTag(tagId: number) {
|
||||
const next = filters.tagIds.includes(tagId)
|
||||
? filters.tagIds.filter((id) => id !== tagId)
|
||||
: [...filters.tagIds, tagId];
|
||||
onChange({ ...filters, tagIds: next });
|
||||
}
|
||||
|
||||
// Selecting the already-selected node clears the filter, so the tree doubles
|
||||
// as its own "all items" control.
|
||||
function selectCategory(keys: React.Key[]) {
|
||||
const picked = keys.length ? Number(keys[0]) : null;
|
||||
onChange({ ...filters, categoryId: picked === filters.categoryId ? null : picked });
|
||||
}
|
||||
|
||||
const sliderMax = Math.max(bounds.max_cents, bounds.min_cents + 100);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="Filters"
|
||||
placement="right"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
// Unmounting on close keeps a single copy of controls like "Clear all" in
|
||||
// the document at any time.
|
||||
destroyOnHidden
|
||||
width={screens.md ? 380 : '90%'}
|
||||
footer={
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button block onClick={onClear}>Clear all</Button>
|
||||
<Button block type="primary" onClick={onClose}>
|
||||
Show {resultCount} {resultCount === 1 ? 'item' : 'items'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<section style={{ marginBottom: 28 }}>
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
|
||||
Category
|
||||
</h4>
|
||||
{categories.length ? (
|
||||
<Tree
|
||||
treeData={toTreeData(buildCategoryTree(categories))}
|
||||
selectedKeys={filters.categoryId === null ? [] : [filters.categoryId]}
|
||||
onSelect={selectCategory}
|
||||
defaultExpandAll
|
||||
blockNode
|
||||
/>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No categories yet" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section style={{ marginBottom: 28 }}>
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
|
||||
Tags — must have all
|
||||
</h4>
|
||||
{tags.length ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
{tags.map((tag) => {
|
||||
const selected = filters.tagIds.includes(tag.id);
|
||||
return (
|
||||
// A real button rather than a styled span, so the pills are
|
||||
// reachable by keyboard and announce their on/off state.
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
style={{ background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
|
||||
>
|
||||
<Tag
|
||||
color={selected ? tag.color : undefined}
|
||||
style={{ margin: 0, opacity: selected ? 1 : 0.75 }}
|
||||
>
|
||||
{tag.name}
|
||||
</Tag>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No tags yet" />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4 style={{ margin: '0 0 8px', fontSize: 12, letterSpacing: '.06em', textTransform: 'uppercase', opacity: 0.65 }}>
|
||||
Price
|
||||
</h4>
|
||||
<Slider
|
||||
range
|
||||
min={bounds.min_cents}
|
||||
max={sliderMax}
|
||||
step={100}
|
||||
value={[filters.minPriceCents ?? bounds.min_cents, filters.maxPriceCents ?? sliderMax]}
|
||||
tooltip={{ formatter: (value) => `$${((value ?? 0) / 100).toFixed(0)}` }}
|
||||
onChange={([min, max]) =>
|
||||
onChange({ ...filters, minPriceCents: min, maxPriceCents: max })
|
||||
}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
|
||||
<InputNumber
|
||||
aria-label="Minimum price"
|
||||
prefix="$"
|
||||
min={0}
|
||||
style={{ width: '100%' }}
|
||||
value={centsToDollars(filters.minPriceCents)}
|
||||
onChange={(value) => onChange({ ...filters, minPriceCents: dollarsToCents(value) })}
|
||||
/>
|
||||
<span style={{ opacity: 0.6 }}>to</span>
|
||||
<InputNumber
|
||||
aria-label="Maximum price"
|
||||
prefix="$"
|
||||
min={0}
|
||||
style={{ width: '100%' }}
|
||||
value={centsToDollars(filters.maxPriceCents)}
|
||||
onChange={(value) => onChange({ ...filters, maxPriceCents: dollarsToCents(value) })}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { Card, Badge, Typography, Carousel, Button, message } from 'antd';
|
||||
import { Card, Badge, Typography, Carousel, Button, message, Tag } from 'antd';
|
||||
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import type { CarouselRef } from 'antd/es/carousel';
|
||||
import { Item } from '../api';
|
||||
@@ -88,7 +88,15 @@ export default function ItemCard({ item, onChanged }: Props) {
|
||||
const card = (
|
||||
<Card hoverable cover={cover} className="item-card">
|
||||
<Title level={5} style={{ marginBottom: 4 }}>{item.name}</Title>
|
||||
{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
|
||||
|
||||
Reference in New Issue
Block a user