refactor(filters): compose the storefront from dimensions and delete the old components (#188)

Replaces App.tsx's hand-written Segmented/Filters-button/ActiveFilterChips
region with a single FilterBar composed from five dimensions, and removes
the FilterDrawer and ActiveFilterChips components along with the
activeFilterCount and hasActiveFilters helpers they were the only callers
of. Catalogue now receives a filtered boolean computed the same way
FilterBar computes its own chip tally, rather than the ItemFilters object
it only ever used for that one check.
This commit is contained in:
2026-08-25 15:39:18 -05:00
parent 2f0da9afe1
commit 147e280f88
4 changed files with 40 additions and 502 deletions
+40 -73
View File
@@ -10,25 +10,21 @@ import theme from 'antd/es/theme';
import Badge from 'antd/es/badge';
import Empty from 'antd/es/empty';
import Alert from 'antd/es/alert';
import Segmented from 'antd/es/segmented';
import { ShoppingCartOutlined, FilterOutlined } from '@ant-design/icons';
import { ShoppingCartOutlined } from '@ant-design/icons';
import { Link, useLocation, useSearchParams } from 'react-router-dom';
import { Item } from './api';
import { useCatalogue } from './useCatalogue';
import ItemCard from './components/ItemCard';
import BrandMark from './components/BrandMark';
import FilterDrawer from './components/FilterDrawer';
import ActiveFilterChips from './components/ActiveFilterChips';
import FilterBar from './components/filters/FilterBar';
import {
ItemFilters,
SaleState,
STOREFRONT_SALE_STATUSES,
activeFilterCount,
filtersFromSearchParams,
filtersToSearchParams,
hasActiveFilters,
saleStateFromStatuses
} from './filters';
availabilityDimension,
categoryDimension,
favoritesDimension,
priceDimension,
tagDimension
} from './components/filters/standardDimensions';
import { ItemFilters, filtersFromSearchParams, filtersToSearchParams } from './filters';
import AuthPromptModal from './customer/AuthPromptModal';
import { useThemeMode } from './theme/ThemeContext';
import { useCustomerAuth } from './customer/CustomerAuthContext';
@@ -44,7 +40,7 @@ type CatalogueProps = Readonly<{
failed: boolean;
loading: boolean;
items: Item[];
filters: ItemFilters;
filtered: boolean;
needsFavoritesAuth: boolean;
onRetry: () => void;
onSignIn: () => void;
@@ -61,7 +57,7 @@ function Catalogue({
failed,
loading,
items,
filters,
filtered,
needsFavoritesAuth,
onRetry,
onSignIn,
@@ -93,7 +89,6 @@ function Catalogue({
if (!loading && !items.length) {
// Distinguished so "no items match these filters" never reads as an empty
// shop, and so the way out is offered only when there is one.
const filtered = hasActiveFilters(filters);
return (
<Empty description={filtered ? 'No items match these filters' : 'No items yet — check back soon'}>
{filtered ? <Button onClick={onClearFilters}>Clear filters</Button> : null}
@@ -112,8 +107,18 @@ function Catalogue({
);
}
// Availability first and always visible, because it is the coarsest cut and
// worth seeing without opening anything. Favorites next, so someone who came
// for their favorites does not scroll past the catalogue controls.
const STOREFRONT_DIMENSIONS = [
availabilityDimension,
favoritesDimension,
categoryDimension,
tagDimension,
priceDimension
];
export default function App() {
const [drawerOpen, setDrawerOpen] = useState(false);
const [authModalOpen, setAuthModalOpen] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const location = useLocation();
@@ -143,7 +148,17 @@ export default function App() {
setSearchParams(new URLSearchParams(), { replace: true });
}, [setSearchParams]);
const activeCount = activeFilterCount(filters);
// The parent needs to know whether anything is filtering — for the empty
// state's wording — but has no chip row of its own to count. Computed the
// same way FilterBar computes its own tally, from the same dimensions.
const filterContext = {
filters,
onChange: applyFilters,
categories: options?.categories ?? [],
tags: options?.tags ?? [],
priceRange: options?.priceRange ?? null
};
const filtered = STOREFRONT_DIMENSIONS.some((dimension) => dimension.chips(filterContext).length > 0);
return (
<Layout style={{ minHeight: '100vh' }}>
@@ -191,50 +206,15 @@ export default function App() {
</Header>
<Content style={{ padding: 24 }}>
<div className="filter-bar">
{/* In the bar rather than inside the drawer, deliberately. The default
now hides sold pieces, so a customer who never opens the drawer
would otherwise have no way to know sold items exist — and on a
one-of-a-kind catalogue the sold pieces are part of the story. */}
<Segmented
aria-label="Filter by availability"
// The fallback matches the server's: the favorites view defaults to
// everything, so the control must not claim Not Sold while sold
// favorites are on screen.
value={saleStateFromStatuses(
filters.status,
STOREFRONT_SALE_STATUSES,
filters.favoritesOnly ? 'all' : 'not-sold'
)}
onChange={(value) => {
const state = value as SaleState;
applyFilters({
...filters,
// Not Sold is the default, so it is stored as "no preference"
// rather than as an explicit list. That keeps it out of the URL
// and out of the Filters (N) count, where it would otherwise
// show as an active filter nobody chose.
status: state === 'not-sold' ? null : STOREFRONT_SALE_STATUSES[state]
});
}}
options={[
{ label: 'Not sold', value: 'not-sold' },
{ label: 'Sold', value: 'sold' },
{ label: 'All', value: 'all' }
]}
/>
<Button
icon={<FilterOutlined />}
onClick={() => setDrawerOpen(true)}
type={activeCount ? 'primary' : 'default'}
>
Filters{activeCount ? ` (${activeCount})` : ''}
</Button>
<ActiveFilterChips
categories={options?.categories ?? []}
tags={options?.tags ?? []}
<FilterBar
dimensions={STOREFRONT_DIMENSIONS}
filters={filters}
onChange={applyFilters}
onClear={clearFilters}
categories={options?.categories ?? []}
tags={options?.tags ?? []}
priceRange={options?.priceRange ?? null}
resultCount={items.length}
/>
</div>
@@ -265,7 +245,7 @@ export default function App() {
failed={failed}
loading={loading}
items={items}
filters={filters}
filtered={filtered}
needsFavoritesAuth={needsFavoritesAuth}
onRetry={retry}
onSignIn={openAuthModal}
@@ -278,19 +258,6 @@ export default function App() {
<Link to="/privacy">Privacy Policy</Link>
</Footer>
<FilterDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
categories={options?.categories ?? []}
tags={options?.tags ?? []}
priceRange={options?.priceRange ?? null}
filters={filters}
onChange={applyFilters}
onClear={clearFilters}
resultCount={items.length}
showFavorites
/>
{/* The same prompt the heart button and Add to Cart use. Signing in
resolves the gate above, and the filter then applies on its own — the
customer never has to set it a second time. */}
@@ -1,127 +0,0 @@
import Tag from 'antd/es/tag';
import Button from 'antd/es/button';
import type { Category, Tag as ItemTag } from '../api';
import { ItemFilters, categoryPath, formatPriceRange, hasActiveFilters, statusLabel } from '../filters';
type Props = Readonly<{
categories: Category[];
tags: ItemTag[];
filters: ItemFilters;
onChange: (filters: ItemFilters) => void;
onClear: () => void;
// Where status is one of the drawer's controls rather than a preset beside
// it (#169), it needs a chip too — otherwise the one filter most likely to
// empty a table is the one filter invisible without opening the drawer.
showStatus?: boolean;
}>;
export default function ActiveFilterChips({
categories,
tags,
filters,
onChange,
onClear,
showStatus = false
}: Props) {
if (!hasActiveFilters(filters)) return null;
// `color` only ever set for tags, which are the only filter with one. That
// makes colour in this row mean "this is a tag", which is a useful thing for
// a row mixing four kinds of filter to say — and nothing depends on it, since
// every chip still carries its label.
const chips: { key: string; label: string; color?: string; onRemove: () => void }[] = [];
// Listed first so it matches the drawer's ordering, and because it is the
// chip most worth noticing when a customer wonders why the grid looks short.
if (filters.favoritesOnly) {
chips.push({
key: 'favorites',
label: 'My favorites',
onRemove: () => onChange({ ...filters, favoritesOnly: false })
});
}
// One chip per selected category, each removable on its own — removing the
// whole set at once is what Clear all is for.
for (const categoryId of filters.categoryIds) {
const path = categoryPath(categories, 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 ${categoryId}`;
chips.push({
key: `category-${categoryId}`,
// The chip shows the full path for context, since two categories can
// share a leaf name under different parents.
label,
onRemove: () =>
onChange({ ...filters, categoryIds: filters.categoryIds.filter((id) => id !== categoryId) })
});
}
for (const tagId of filters.tagIds) {
const tag = tags.find((candidate) => candidate.id === tagId);
chips.push({
key: `tag-${tagId}`,
label: tag?.name ?? `Tag ${tagId}`,
// The same colour the drawer's control and the product cards show, so a
// tag looks like itself wherever it appears. Undefined while
// /api/filters is still loading, which is the case the label fallback
// above already covers — an uncoloured chip beats a missing one.
color: tag?.color,
onRemove: () => onChange({ ...filters, tagIds: filters.tagIds.filter((id) => id !== tagId) })
});
}
if (showStatus && filters.status !== null) {
for (const status of filters.status) {
chips.push({
key: `status-${status}`,
label: statusLabel(status),
onRemove: () => {
const rest = (filters.status ?? []).filter((value) => value !== status);
// Back to null rather than an empty list: emptying the control means
// "no status filter", not "no statuses", which would empty the table.
onChange({ ...filters, status: rest.length ? rest : null });
}
});
}
}
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}
// Undefined for every filter that has no colour of its own, which is
// antd's default rendering — the same as before this distinguished
// tags. The custom close icon below inherits the tag's text colour,
// so a coloured chip gets a matching cross rather than a grey one.
color={chip.color}
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>
);
}
-277
View File
@@ -1,277 +0,0 @@
import Drawer from 'antd/es/drawer';
import Button from 'antd/es/button';
import TreeSelect from 'antd/es/tree-select';
import Select from 'antd/es/select';
import Tag from 'antd/es/tag';
import Slider from 'antd/es/slider';
import InputNumber from 'antd/es/input-number';
import Empty from 'antd/es/empty';
import Switch from 'antd/es/switch';
import Grid from 'antd/es/grid';
import type { Category, Tag as ItemTag } from '../api';
import { ItemFilters, ItemStatus, STATUS_OPTIONS, buildCategoryTree, toCategoryTreeData } from '../filters';
// One drawer for the storefront and the admin, with the sections that differ
// driven by props rather than by a second component that would drift (#169).
// What is shared is not just the markup but the phrasing of the rules — that
// categories are OR and tags are AND has to read the same on both screens or it
// stops being one rule.
type Props = Readonly<{
open: boolean;
onClose: () => void;
categories: Category[];
tags: ItemTag[];
// Bounds for the price slider, or null on a screen with no catalogue-wide
// range to draw one from, where the two number inputs stand alone. A slider
// needs real bounds: invented ones would misreport where the prices are.
priceRange: { min_cents: number; max_cents: number } | null;
filters: ItemFilters;
onChange: (filters: ItemFilters) => void;
onClear: () => void;
resultCount: number;
// Storefront only — signing in is what makes favorites mean anything.
showFavorites?: boolean;
// Admin only. The storefront keeps its three-way preset outside the drawer:
// pending is excluded from every public read, so Published and Unpublished
// are not distinctions a customer can draw.
showStatus?: boolean;
}>;
const sectionHeading: React.CSSProperties = {
margin: '0 0 8px',
fontSize: 12,
letterSpacing: '.06em',
textTransform: 'uppercase',
opacity: 0.65
};
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,
categories,
tags,
priceRange,
filters,
onChange,
onClear,
resultCount,
showFavorites = false,
showStatus = false
}: Props) {
const screens = Grid.useBreakpoint();
const bounds = priceRange ?? { min_cents: 0, max_cents: 0 };
function selectCategories(ids: number[]) {
onChange({ ...filters, categoryIds: ids });
}
// The selected pills are rendered by the Select, which is handed ids rather
// than tags, so the colour has to be looked up rather than carried along.
const tagColors = new Map(tags.map((tag) => [tag.id, tag.color]));
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>
}
>
{/* First because it is the broadest cut, and because a customer who came
here for their favorites should not have to scroll past the catalogue
controls to find it. Shown to signed-out visitors too: switching it on
prompts them to sign in, which is how they learn favorites exist. */}
{showFavorites && (
<section style={{ marginBottom: 28 }}>
<h4 style={sectionHeading}>
Favorites
</h4>
{/* Deliberately not wrapped in a <label>: antd renders the switch as a
button, which is labelable, so a wrapping label can forward a click
the switch already handled and toggle it twice. The accessible name
comes from aria-label instead. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Switch
checked={filters.favoritesOnly}
onChange={(checked) => onChange({ ...filters, favoritesOnly: checked })}
aria-label="Only my favorites"
/>
<span>Only my favorites</span>
</div>
</section>
)}
<section style={{ marginBottom: 28 }}>
<h4 style={sectionHeading}>
Categories any of these
</h4>
{categories.length ? (
// A TreeSelect rather than a Tree: it keeps the hierarchy a customer
// browses by while adding search and multi-select, and it lists what
// is chosen inside the control instead of leaving the selection to be
// read off highlighting. The admin's CategoryTreeSelect is the same
// control, so the two screens behave alike.
<TreeSelect
treeData={toCategoryTreeData(buildCategoryTree(categories))}
value={filters.categoryIds}
onChange={selectCategories}
multiple
showSearch
// Search the visible label, not the value, which is a numeric id.
treeNodeFilterProp="title"
treeDefaultExpandAll
allowClear
placeholder="Any category"
style={{ width: '100%' }}
aria-label="Filter by category"
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No categories yet" />
)}
</section>
<section style={{ marginBottom: 28 }}>
<h4 style={sectionHeading}>
Tags must have all of these
</h4>
{tags.length ? (
// Was a wall of every tag in the system, which read fine at a dozen
// and not at a hundred. A searchable multi-select scales with the
// taxonomy and, like the category control above it, states its
// selection inside the control instead of in chip colouring.
//
// The colours survive as the selected pills, since that is the only
// place a tag's colour was ever load-bearing.
<Select
mode="multiple"
showSearch
optionFilterProp="label"
allowClear
placeholder="Any tags"
style={{ width: '100%' }}
aria-label="Filter by tags"
value={filters.tagIds}
onChange={(tagIds: number[]) => onChange({ ...filters, tagIds })}
options={tags.map((tag) => ({ value: tag.id, label: tag.name }))}
tagRender={({ value, label, closable, onClose }) => (
<Tag
color={tagColors.get(Number(value))}
closable={closable}
onClose={onClose}
// antd's default, which the custom renderer replaces: without
// it the pill swallows the mousedown and reopens the list.
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
style={{ marginInlineEnd: 4 }}
>
{label}
</Tag>
)}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="No tags yet" />
)}
</section>
<section style={showStatus ? { marginBottom: 28 } : undefined}>
<h4 style={sectionHeading}>
Price
</h4>
{priceRange && (
<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]) =>
// antd types the slider's value as number[], so destructuring gives
// `number | undefined`. A range slider always emits both ends; the
// fallbacks are the bounds it was given rather than nulls, which
// would read as "no filter" and widen the results.
onChange({
...filters,
minPriceCents: min ?? bounds.min_cents,
maxPriceCents: max ?? sliderMax
})
}
/>
)}
<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>
{showStatus && (
<section>
<h4 style={sectionHeading}>
Status any of these
</h4>
{/* The status dimension itself rather than presets over it, which
#105's Sold / Not sold / All control was. Presets could not express
Published or Unpublished, could not isolate Reserved, and would
have grown a new button for every new question. Selecting statuses
answers all of them: Unpublished is Pending, Published is the other
three, and Not sold is everything except Sold.
A second control for publication would have read more naturally and
reintroduced what #105 avoided — Sold and Unpublished is an
impossible pair, since a sold item is necessarily published. One
dimension cannot contradict itself. See #132. */}
<Select
allowClear
mode="multiple"
showSearch
optionFilterProp="label"
placeholder="Any status"
aria-label="Filter by status"
style={{ width: '100%' }}
value={filters.status ?? []}
onChange={(value: ItemStatus[]) =>
// Empty means no filter, not "no statuses". A multi-select cleared
// back to nothing should show everything rather than an empty table.
onChange({ ...filters, status: value.length ? value : null })
}
options={STATUS_OPTIONS}
/>
</section>
)}
</Drawer>
);
}
-25
View File
@@ -150,13 +150,6 @@ export function filtersFromSearchParams(params: URLSearchParams): ItemFilters {
};
}
// One count for the "Filters (N)" button. A price range counts once however
// many ends are set, since it reads as a single filter to the user.
//
// Status is deliberately not counted. It has its own always-visible control
// beside this button rather than living in the drawer, so counting it would put
// a number on a button whose drawer shows nothing set — and the control already
// displays its own position.
// Named individually rather than grouped, because grouping is what the preset
// this replaced did. Pending is listed first: "what is waiting to be published"
// is the question that prompted #132.
@@ -175,24 +168,6 @@ export function statusLabel(status: ItemStatus): string {
return STATUS_OPTIONS.find((option) => option.value === status)?.label ?? status;
}
export function activeFilterCount(filters: ItemFilters): number {
let count = 0;
count += filters.categoryIds.length;
count += filters.tagIds.length;
if (filters.minPriceCents !== null || filters.maxPriceCents !== null) count++;
if (filters.favoritesOnly) count++;
return count;
}
// Broader than the count above, and intentionally so: this decides whether an
// empty result reads as "no items match these filters" with a way out, or as an
// empty shop. A status filter that matched nothing is exactly the case where
// that distinction matters, so it counts here even though it is not in the
// drawer's tally.
export function hasActiveFilters(filters: ItemFilters): boolean {
return activeFilterCount(filters) > 0 || filters.status !== null;
}
export interface CategoryNode extends Category {
children: CategoryNode[];
}