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:
+100
-9
@@ -1,9 +1,18 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Layout, Typography, Switch, Row, Col, Spin, Button, theme, Badge } from 'antd';
|
||||
import { ShoppingCartOutlined } from '@ant-design/icons';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Item, fetchItems } from './api';
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { Layout, Typography, Switch, Row, Col, Spin, Button, theme, Badge, Empty } from 'antd';
|
||||
import { ShoppingCartOutlined, FilterOutlined } from '@ant-design/icons';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { Item, FilterOptions, fetchItems, fetchFilterOptions } from './api';
|
||||
import ItemCard from './components/ItemCard';
|
||||
import FilterDrawer from './components/FilterDrawer';
|
||||
import ActiveFilterChips from './components/ActiveFilterChips';
|
||||
import {
|
||||
ItemFilters,
|
||||
activeFilterCount,
|
||||
filtersFromSearchParams,
|
||||
filtersToSearchParams,
|
||||
hasActiveFilters
|
||||
} from './filters';
|
||||
import { useThemeMode } from './theme/ThemeContext';
|
||||
import { useCustomerAuth } from './customer/CustomerAuthContext';
|
||||
import { useCart } from './cart/CartContext';
|
||||
@@ -11,18 +20,63 @@ import { useCart } from './cart/CartContext';
|
||||
const { Header, Content, Footer } = Layout;
|
||||
const { Title } = Typography;
|
||||
|
||||
// Dragging the price slider fires a change per pixel; without this every one
|
||||
// would become its own request.
|
||||
const FILTER_DEBOUNCE_MS = 250;
|
||||
|
||||
export default function App() {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [options, setOptions] = useState<FilterOptions | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { mode, toggle } = useThemeMode();
|
||||
const { customer } = useCustomerAuth();
|
||||
const { items: cartItems } = useCart();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
// The URL is the single source of truth for filter state, so a reload, a
|
||||
// shared link, and the back button all restore the same view.
|
||||
const filters = useMemo(() => filtersFromSearchParams(searchParams), [searchParams]);
|
||||
const filterKey = filtersToSearchParams(filters).toString();
|
||||
|
||||
const applyFilters = useCallback(
|
||||
(next: ItemFilters) => {
|
||||
// replace, not push: dragging a slider shouldn't bury the previous page
|
||||
// under dozens of history entries.
|
||||
setSearchParams(filtersToSearchParams(next), { replace: true });
|
||||
},
|
||||
[setSearchParams]
|
||||
);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setSearchParams(new URLSearchParams(), { replace: true });
|
||||
}, [setSearchParams]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
fetchItems().then(setItems);
|
||||
return fetchItems(filtersFromSearchParams(new URLSearchParams(filterKey)))
|
||||
.then(setItems)
|
||||
.finally(() => setLoading(false));
|
||||
}, [filterKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const timer = setTimeout(load, FILTER_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFilterOptions().then(setOptions).catch(() => setOptions(null));
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
// Adding to cart flips an item to reserved, and the filter options' price
|
||||
// bounds shift as inventory changes.
|
||||
const reload = useCallback(() => {
|
||||
load();
|
||||
fetchFilterOptions().then(setOptions).catch(() => undefined);
|
||||
}, [load]);
|
||||
|
||||
const activeCount = activeFilterCount(filters);
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
@@ -54,11 +108,38 @@ export default function App() {
|
||||
</div>
|
||||
</Header>
|
||||
<Content style={{ padding: 24 }}>
|
||||
{!items.length ? <Spin /> : (
|
||||
<div className="filter-bar">
|
||||
<Button
|
||||
icon={<FilterOutlined />}
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
type={activeCount ? 'primary' : 'default'}
|
||||
>
|
||||
Filters{activeCount ? ` (${activeCount})` : ''}
|
||||
</Button>
|
||||
<ActiveFilterChips
|
||||
options={options}
|
||||
filters={filters}
|
||||
onChange={applyFilters}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading && !items.length ? <Spin /> : null}
|
||||
{!loading && !items.length ? (
|
||||
<Empty
|
||||
description={
|
||||
hasActiveFilters(filters)
|
||||
? 'No items match these filters'
|
||||
: 'No items yet — check back soon'
|
||||
}
|
||||
>
|
||||
{hasActiveFilters(filters) ? <Button onClick={clearFilters}>Clear filters</Button> : null}
|
||||
</Empty>
|
||||
) : (
|
||||
<Row gutter={[20, 20]}>
|
||||
{items.map(item => (
|
||||
<Col key={item.id} xs={24} sm={12} md={8} lg={6}>
|
||||
<ItemCard item={item} onChanged={load} />
|
||||
<ItemCard item={item} onChanged={reload} />
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
@@ -67,6 +148,16 @@ export default function App() {
|
||||
<Footer style={{ textAlign: 'center', background: token.colorBgContainer }}>
|
||||
<Link to="/privacy">Privacy Policy</Link>
|
||||
</Footer>
|
||||
|
||||
<FilterDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
options={options}
|
||||
filters={filters}
|
||||
onChange={applyFilters}
|
||||
onClear={clearFilters}
|
||||
resultCount={items.length}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user