From 147e280f884dbc78d84b59fcd8fda1598ef40083 Mon Sep 17 00:00:00 2001 From: Thom Lamb Date: Tue, 25 Aug 2026 15:39:18 -0500 Subject: [PATCH] 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. --- frontend/src/App.tsx | 113 +++---- frontend/src/components/ActiveFilterChips.tsx | 127 -------- frontend/src/components/FilterDrawer.tsx | 277 ------------------ frontend/src/filters.ts | 25 -- 4 files changed, 40 insertions(+), 502 deletions(-) delete mode 100644 frontend/src/components/ActiveFilterChips.tsx delete mode 100644 frontend/src/components/FilterDrawer.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cad4bdb..b0c8949 100755 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( {filtered ? : 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 ( @@ -191,50 +206,15 @@ export default function App() {
- {/* 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. */} - { - 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' } - ]} - /> - -
@@ -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() { Privacy Policy - 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. */} diff --git a/frontend/src/components/ActiveFilterChips.tsx b/frontend/src/components/ActiveFilterChips.tsx deleted file mode 100644 index f12c508..0000000 --- a/frontend/src/components/ActiveFilterChips.tsx +++ /dev/null @@ -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. -
- {chips.map((chip) => ( - { - 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={ - × - } - > - {chip.label} - - ))} - -
- ); -} diff --git a/frontend/src/components/FilterDrawer.tsx b/frontend/src/components/FilterDrawer.tsx deleted file mode 100644 index 84a2f03..0000000 --- a/frontend/src/components/FilterDrawer.tsx +++ /dev/null @@ -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 ( - - - - - } - > - {/* 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 && ( -
-

- Favorites -

- {/* Deliberately not wrapped in a
- )} - -
-

- Categories — any of these -

- {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. - - ) : ( - - )} -
- -
-

- Tags — must have all of these -

- {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. - - // 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} - /> -
- )} -
- ); -} diff --git a/frontend/src/filters.ts b/frontend/src/filters.ts index 02d82a5..a928915 100644 --- a/frontend/src/filters.ts +++ b/frontend/src/filters.ts @@ -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[]; }