diff --git a/src/automats/articleSearchAutomat.js b/src/automats/articleSearchAutomat.js index 1c41d26..8f4cc21 100644 --- a/src/automats/articleSearchAutomat.js +++ b/src/automats/articleSearchAutomat.js @@ -5,8 +5,8 @@ * * Upstream Triggers: * - UI / Search input / FillSpace: Components call `actions.search({ target, filter, offset, pageSize })`. - * - Reads data from `articleIndexAutomat`: Ensures the derived index is available - * before filtering and slicing. + * - Reads data from `articleIndexAutomat`: Derives and slices matching records + * synchronously, updating continuously as category views load during launch. * - `categoryAutomat`: Provides category hierarchy metadata for faceted counts. */ @@ -34,7 +34,7 @@ import { parseFilter } from '../components/article/searchFormat'; * @property {string} filter Active category filter string. * @property {number} offset Current 0-based offset. * @property {number|null} pageSize Current page size. - * @property {boolean} loading Whether a search is in flight. + * @property {boolean} loading Whether a search or index load is in flight. */ /** @type {ArticleSearchResult} */ @@ -130,108 +130,121 @@ function buildCategoryFacets(matchedArticles) { const actions = { /** - * Runs a client-side search over the article index with pagination. + * Executes in-memory search and derivation synchronously. * - * @param {string|object} targetOrParams Search term or options object. - * @param {object} [options] Secondary options when first argument is target string. - * @returns {Promise} Search outcome. + * @param {object} [params] Search parameters to merge. + * @returns {ArticleSearchResult} Search outcome. */ - async search(targetOrParams, options = {}) { + runSearch(params = {}) { const currentState = articleSearchAutomat.state; - const params = typeof targetOrParams === 'object' && targetOrParams !== null - ? targetOrParams - : { target: targetOrParams, ...options }; - const target = params.target !== undefined ? String(params.target ?? '') : currentState.target; const filter = params.filter !== undefined ? String(params.filter ?? '') : currentState.filter; const pageSize = params.pageSize !== undefined ? params.pageSize : currentState.pageSize; const offset = params.offset !== undefined ? Math.max(0, Number(params.offset) || 0) : currentState.offset; - articleSearchAutomat.setState({ target, filter, offset, pageSize, loading: true }); - try { - const articles = await articleIndexAutomat.actions.ensureLoaded(); - const query = target.trim().toLowerCase(); + const articles = articleIndexAutomat.state.items ?? []; + const query = target.trim().toLowerCase(); - // Filter by text query across all tokens - const textMatches = query - ? articles.filter((row) => matches(row, query)) - : articles; + // Filter by text query across all tokens + const textMatches = query + ? articles.filter((row) => matches(row, query)) + : articles; - // Build facets for all text matches (before category filtering) - const categoryFacets = buildCategoryFacets(textMatches); + // Build facets for all text matches (before category filtering) + const categoryFacets = buildCategoryFacets(textMatches); - // Apply category filter if set - const selectedCategories = parseFilter(filter); - const filteredArticles = selectedCategories.length > 0 - ? textMatches.filter((row) => matchesCategory(row, selectedCategories)) - : textMatches; + // Apply category filter if set + const selectedCategories = parseFilter(filter); + const filteredArticles = selectedCategories.length > 0 + ? textMatches.filter((row) => matchesCategory(row, selectedCategories)) + : textMatches; - const totalCount = filteredArticles.length; - const safePageSize = pageSize != null && pageSize > 0 ? pageSize : null; - const pageCount = safePageSize ? Math.max(1, Math.ceil(totalCount / safePageSize)) : 1; - const currentPage = safePageSize ? Math.floor(offset / safePageSize) + 1 : 1; - const recordset = safePageSize - ? filteredArticles.slice(offset, offset + safePageSize) - : filteredArticles; + const totalCount = filteredArticles.length; + const safePageSize = pageSize != null && pageSize > 0 ? pageSize : null; + const pageCount = safePageSize ? Math.max(1, Math.ceil(totalCount / safePageSize)) : 1; + const currentPage = safePageSize ? Math.floor(offset / safePageSize) + 1 : 1; + const recordset = safePageSize + ? filteredArticles.slice(offset, offset + safePageSize) + : filteredArticles; - const result = { - categoryFacets, - totalCount, - recordset, - offset, - pageSize: safePageSize, - pageCount, - currentPage, - error: null, - }; + const result = { + categoryFacets, + totalCount, + recordset, + offset, + pageSize: safePageSize, + pageCount, + currentPage, + error: null, + }; - articleSearchAutomat.setState({ result, loading: false }); - return result; - } catch (error) { - const result = { - ...emptyResult, - pageSize, - offset, - error, - }; - articleSearchAutomat.setState({ result, loading: false }); - throw error; + articleSearchAutomat.setState({ + result, + target, + filter, + offset, + pageSize: safePageSize, + loading: articleIndexAutomat.state.status === 'loading', + }); + + return result; + }, + + /** + * Runs a client-side search over the article index with pagination. + * + * Does not block on network calls: evaluates immediately against whatever + * articles are currently loaded, and ensures index derivation is running in the background. + * + * @param {string|object} [targetOrParams] Search term or options object. + * @param {object} [options] Secondary options when first argument is target string. + * @returns {Promise} Search outcome. + */ + async search(targetOrParams, options = {}) { + const params = typeof targetOrParams === 'object' && targetOrParams !== null + ? targetOrParams + : targetOrParams !== undefined + ? { target: targetOrParams, ...options } + : options; + + // Kick off index loading in the background if not ready yet + if (articleIndexAutomat.state.status === 'idle') { + void articleIndexAutomat.actions.ensureLoaded().catch(() => {}); } + + return actions.runSearch(params); }, /** * Updates the page size and re-derives the result set. * * @param {number} pageSize Number of articles per page. - * @returns {Promise} + * @returns {ArticleSearchResult} */ setPageSize(pageSize) { - const { target, filter, offset } = articleSearchAutomat.state; - return actions.search({ target, filter, offset, pageSize }); + return actions.runSearch({ pageSize }); }, /** * Sets page number (1-based) and re-derives the result set. * * @param {number} page 1-based page number. - * @returns {Promise} + * @returns {ArticleSearchResult} */ setPage(page) { - const { target, filter, pageSize } = articleSearchAutomat.state; - const safeSize = pageSize ?? 1; + const safeSize = articleSearchAutomat.state.pageSize ?? 1; const offset = Math.max(0, (page - 1) * safeSize); - return actions.search({ target, filter, offset, pageSize }); + return actions.runSearch({ offset }); }, /** * Sets 0-based offset and re-derives the result set. * * @param {number} offset 0-based record offset. - * @returns {Promise} + * @returns {ArticleSearchResult} */ setOffset(offset) { - const { target, filter, pageSize } = articleSearchAutomat.state; - return actions.search({ target, filter, offset, pageSize }); + return actions.runSearch({ offset }); }, /** Reset the search state. */ @@ -250,10 +263,15 @@ const actions = { */ articleSearchAutomat = new Automat(initialState, actions, { name: 'articleSearch' }); +// Continuously re-derive and update subscribers as category views load their article lists. articleIndexAutomat.subscribe(() => { - const { target, filter, offset, pageSize, loading } = articleSearchAutomat.state; - if (pageSize != null && !loading && articleIndexAutomat.state.status === 'ready') { - void actions.search({ target, filter, offset, pageSize }).catch(() => {}); + actions.runSearch(); +}); + +// Update facet names whenever the category tree is loaded/updated. +categoryAutomat.subscribe((catState) => { + if (catState.treeStatus === 'succeeded') { + actions.runSearch(); } }); diff --git a/src/automats/index.js b/src/automats/index.js index abad189..31c4e17 100644 --- a/src/automats/index.js +++ b/src/automats/index.js @@ -10,7 +10,15 @@ export { default as authAutomat } from './authAutomat'; export * from './authAutomat'; export { default as navigationAutomat } from './navigationAutomat'; -export { setLastDeliveryPath, setLastArticleSku, clearNavigation } from './navigationAutomat'; +export { + setLastDeliveryPath, + setLastArticleSku, + setLastSearchTerm, + setLastArticleSearchPath, + setLastCategorySlug, + setLastCategoryPath, + clearNavigation, +} from './navigationAutomat'; export { default as userSettingsAutomat } from './userSettingsAutomat'; export { setViewerPosition } from './userSettingsAutomat'; diff --git a/src/automats/navigationAutomat.js b/src/automats/navigationAutomat.js index 9b2dc49..4a2e93e 100644 --- a/src/automats/navigationAutomat.js +++ b/src/automats/navigationAutomat.js @@ -1,11 +1,13 @@ /** * @file navigationAutomat.js - * @description Stores the user's most recently visited delivery document path - * and article SKU to preserve navigation context across primary section switches. + * @description Stores the user's most recently visited delivery document path, + * article SKU, article search term/path, and category slug/path to preserve + * navigation context across primary and secondary section switches. * * Upstream Triggers: - * - UI / Routes: Set by article and delivery views/routes via `setLastDeliveryPath(path)`, - * `setLastArticleSku(sku)`, and `clearNavigation()`. + * - UI / Routes: Set by article search, category navigation, and delivery views/routes + * via `setLastDeliveryPath(path)`, `setLastArticleSku(sku)`, `setLastSearchTerm(term, path)`, + * `setLastCategorySlug(slug)`, and `clearNavigation()`. */ import { Automat } from 'react-automat'; @@ -14,17 +16,25 @@ import { Automat } from 'react-automat'; * @typedef {Object} NavigationState * @property {string|null} lastDeliveryPath Last delivery path visited by the user. * @property {string|null} lastArticleSku Last article SKU visited by the user. + * @property {string|null} lastSearchTerm Last article search query term. + * @property {string|null} lastArticleSearchPath Full last search path (e.g. `/artikel?target=apfel`). + * @property {string|null} lastCategorySlug Last visited category slug (e.g. `obst-gemuese/obst`). + * @property {string|null} lastCategoryPath Full last category path (e.g. `/kategorien/obst-gemuese/obst`). */ /** @type {NavigationState} */ const initialState = { lastDeliveryPath: null, lastArticleSku: null, + lastSearchTerm: null, + lastArticleSearchPath: null, + lastCategorySlug: null, + lastCategoryPath: null, }; /** - * Stores the user's most recently visited delivery and article so main-menu - * navigation can restore the previous context. + * Stores the user's most recently visited delivery, article, search term, and category + * so main-menu and sub-menu navigation can restore the previous context. * * @type {Automat} */ @@ -33,9 +43,9 @@ const navigationAutomat = new Automat( { /** * Set the last visited delivery path. - * - * @param {string|null} path Full path such as `/lieferung/19` or `/lieferung/neu`. - * @returns {void} + * + * @param {string|null} path Full path such as `/lieferung/19` or `/lieferung/neu`. + * @returns {void} */ setLastDeliveryPath(path) { navigationAutomat.setState({ lastDeliveryPath: path }); @@ -43,24 +53,81 @@ const navigationAutomat = new Automat( /** * Set the last visited article SKU. - * + * * @param {string|null} sku - * @returns {void} + * @returns {void} */ setLastArticleSku(sku) { navigationAutomat.setState({ lastArticleSku: sku }); }, + /** + * Set the last active article search term and optional search URL. + * + * @param {string|null} term Search input term. + * @param {string|null} [path] Full search URL. + * @returns {void} + */ + setLastSearchTerm(term, path = null) { + const trimmed = term != null ? String(term) : ''; + const searchPath = path ?? (trimmed ? `/artikel?target=${encodeURIComponent(trimmed)}` : '/artikel'); + navigationAutomat.setState({ + lastSearchTerm: trimmed || null, + lastArticleSearchPath: searchPath, + }); + }, + + /** + * Set the full last article search URL path. + * + * @param {string|null} path Full search URL path (e.g. `/artikel?target=apfel`). + * @param {string|null} [term] Search term. + * @returns {void} + */ + setLastArticleSearchPath(path, term = null) { + navigationAutomat.setState({ + lastArticleSearchPath: path, + ...(term !== null ? { lastSearchTerm: term } : {}), + }); + }, + + /** + * Set the last visited category slug. + * + * @param {string|null} slug Category slug (e.g. `obst-gemuese/obst` or empty string for root). + * @returns {void} + */ + setLastCategorySlug(slug) { + const clean = slug ? String(slug).replace(/^\/+|\/+$/g, '') : ''; + const path = clean ? `/kategorien/${clean}` : '/kategorien'; + navigationAutomat.setState({ + lastCategorySlug: clean || null, + lastCategoryPath: path, + }); + }, + + /** + * Set the full last category path. + * + * @param {string|null} path Full category path (e.g. `/kategorien/obst-gemuese/obst`). + * @returns {void} + */ + setLastCategoryPath(path) { + const cleanPath = path ? String(path).replace(/^\/+/, '/') : '/kategorien'; + const slug = cleanPath.replace(/^\/kategorien\/?/, '').replace(/\/+$/, ''); + navigationAutomat.setState({ + lastCategorySlug: slug || null, + lastCategoryPath: cleanPath, + }); + }, + /** * Clear all navigation history. - * - * @returns {void} + * + * @returns {void} */ clearNavigation() { - navigationAutomat.setState({ - lastDeliveryPath: null, - lastArticleSku: null, - }); + navigationAutomat.setState(initialState); }, }, { @@ -70,4 +137,12 @@ const navigationAutomat = new Automat( ); export default navigationAutomat; -export const { setLastDeliveryPath, setLastArticleSku, clearNavigation } = navigationAutomat.actions; +export const { + setLastDeliveryPath, + setLastArticleSku, + setLastSearchTerm, + setLastArticleSearchPath, + setLastCategorySlug, + setLastCategoryPath, + clearNavigation, +} = navigationAutomat.actions; diff --git a/src/components/AppShell.jsx b/src/components/AppShell.jsx index a7e4358..fc1999d 100644 --- a/src/components/AppShell.jsx +++ b/src/components/AppShell.jsx @@ -10,7 +10,7 @@ import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import UserMenu from './UserMenu'; import HelpBadge from './HelpBadge'; import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav'; -import { artikelQueryPath } from './article/artikelQuery'; +import { artikelQueryPath, buildSearchUrl } from './article/artikelQuery'; import { defaultCategoryPath } from './category/categoryRoute'; import { navigationAutomat } from '../automats'; import { @@ -31,6 +31,8 @@ class AppSidebar extends PureComponent { this.state = { lastDeliveryPath: navigationAutomat.state.lastDeliveryPath, lastArticleSku: navigationAutomat.state.lastArticleSku, + lastArticleSearchPath: navigationAutomat.state.lastArticleSearchPath, + lastSearchTerm: navigationAutomat.state.lastSearchTerm, }; } @@ -38,6 +40,8 @@ class AppSidebar extends PureComponent { this.unsub = navigationAutomat.subscribe(this, (state) => ({ lastDeliveryPath: state.lastDeliveryPath, lastArticleSku: state.lastArticleSku, + lastArticleSearchPath: state.lastArticleSearchPath, + lastSearchTerm: state.lastSearchTerm, })); } @@ -47,7 +51,12 @@ class AppSidebar extends PureComponent { render() { const { activeNav, navigate } = this.props; - const { lastDeliveryPath, lastArticleSku } = this.state; + const { + lastDeliveryPath, + lastArticleSku, + lastArticleSearchPath, + lastSearchTerm, + } = this.state; return ( ({ + lastArticleSearchPath: state.lastArticleSearchPath, + lastSearchTerm: state.lastSearchTerm, + lastCategoryPath: state.lastCategoryPath, + lastCategorySlug: state.lastCategorySlug, + })); + } + + componentWillUnmount() { + this.unsub?.(); + } + render() { const { activeNav, activeSub, navigate } = this.props; + const { + lastArticleSearchPath, + lastSearchTerm, + lastCategoryPath, + lastCategorySlug, + } = this.state; const subNav = activeNav ? SUB_NAV[activeNav] : null; if (!subNav?.length) { @@ -178,11 +220,19 @@ class AppSubNav extends PureComponent { onMouseDown={(e) => e.preventDefault()} onClick={() => { if (!segment && activeNav === '/artikel') { - navigate(artikelQueryPath()); + const target = + lastArticleSearchPath || + (lastSearchTerm ? buildSearchUrl(lastSearchTerm) : null) || + artikelQueryPath(); + navigate(target); return; } if (segment === 'kategorien') { - navigate(defaultCategoryPath()); + const target = + lastCategoryPath || + (lastCategorySlug ? `/kategorien/${lastCategorySlug}` : null) || + defaultCategoryPath(); + navigate(target); return; } navigate(segment ? `${activeNav}/${segment}` : activeNav); diff --git a/src/components/article/withSearchParams.jsx b/src/components/article/withSearchParams.jsx index fc2d928..9fa1e9f 100644 --- a/src/components/article/withSearchParams.jsx +++ b/src/components/article/withSearchParams.jsx @@ -8,8 +8,8 @@ export default function withSearchParams(Wrapped) { const navigate = useNavigate(); useEffect(() => { - const query = queryFromParams(searchParams); - if (query.target.trim().length >= 3) return; + const currentTarget = searchParams.get('target'); + if (currentTarget != null && currentTarget.trim().length > 0) return; const stored = readArtikelQuery(); if (!stored?.target || stored.target.trim().length < 3) return; @@ -18,7 +18,7 @@ export default function withSearchParams(Wrapped) { buildSearchUrl(stored.target, stored.filter ?? '', stored.offset ?? 0), { replace: true }, ); - }, [searchParams, navigate]); + }, []); return ( = 3 + query.target.trim().length > 0 ? query.target - : (stored?.target?.trim().length >= 3 ? stored.target : query.target); + : (navTerm?.trim().length > 0 + ? navTerm + : (stored?.target?.trim().length >= 3 ? stored.target : '')); const automatResult = articleSearchAutomat.state.result; return { input, + categoryFilterOpen: false, error: automatResult.error, categoryFacets: automatResult.categoryFacets, totalCount: automatResult.totalCount, @@ -103,13 +114,22 @@ function initialSearchState(searchParams) { class ArtikelSearchPage extends Component { state = initialSearchState(this.props.searchParams); paginationRef = createRef(); + filterHeaderRef = createRef(); + fillSpaceRef = createRef(); + categoryFillSpaceRef = createRef(); + + toggleCategoryFilter = () => { + this.setState((prev) => ({ categoryFilterOpen: !prev.categoryFilterOpen })); + }; handleMeasurement = (count) => { if (!count || count === this.state.pageSize) return; this.setState({ pageSize: count }); - const { target, filter, offset } = queryFromParams(this.props.searchParams); + const { filter, offset } = queryFromParams(this.props.searchParams); + const activeTarget = this.state.input; + setLastSearchTerm(activeTarget, buildSearchUrl(activeTarget, filter, offset)); void articleSearchAutomat.actions.search({ - target: target || this.state.input, + target: activeTarget, filter, offset, pageSize: count, @@ -118,7 +138,7 @@ class ArtikelSearchPage extends Component { persistQuery = () => { const query = queryFromParams(this.props.searchParams); - const target = query.target || this.state.input; + const target = this.state.input; writeArtikelQuery({ target, filter: query.filter, @@ -132,6 +152,17 @@ class ArtikelSearchPage extends Component { // Push search results into local component state. this.unsubSearch = articleSearchAutomat.subscribe(this, (state) => state.result); + + // Initial search: kicks off index loading if idle and derives current items + const { filter, offset } = queryFromParams(this.props.searchParams); + const activeTarget = this.state.input; + setLastSearchTerm(activeTarget, buildSearchUrl(activeTarget, filter, offset)); + void articleSearchAutomat.actions.search({ + target: activeTarget, + filter, + offset, + pageSize: this.state.pageSize, + }).catch(() => {}); } componentWillUnmount() { @@ -139,21 +170,35 @@ class ArtikelSearchPage extends Component { this.unsubSearch?.(); this.unsubSearch = null; this.persistQuery(); + const query = queryFromParams(this.props.searchParams); + const target = this.state.input; + setLastSearchTerm(target, buildSearchUrl(target, query.filter, query.offset)); } - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps, prevState) { const { searchParams } = this.props; const prev = prevProps.searchParams; const target = searchParams.get('target') ?? ''; const prevTarget = prev.get('target') ?? ''; + if ( + this.state.categoryFilterOpen !== prevState?.categoryFilterOpen || + searchParams.get('filter') !== prev.get('filter') + ) { + requestAnimationFrame(() => { + this.fillSpaceRef.current?.measure(); + this.categoryFillSpaceRef.current?.measure(); + }); + } + if (target !== prevTarget && target !== this.state.input) { this.setState({ input: target }); + this.fetchResults(); + return; } if ( - target !== prevTarget - || searchParams.get('filter') !== prev.get('filter') + searchParams.get('filter') !== prev.get('filter') || searchParams.get('offset') !== prev.get('offset') ) { this.fetchResults(); @@ -166,7 +211,9 @@ class ArtikelSearchPage extends Component { filter: nextFilter, offset: nextOffset, }); - this.props.navigate(buildSearchUrl(nextTarget, nextFilter, nextOffset), { + const nextUrl = buildSearchUrl(nextTarget, nextFilter, nextOffset); + setLastSearchTerm(nextTarget, nextUrl); + this.props.navigate(nextUrl, { replace: true, }); }; @@ -176,29 +223,29 @@ class ArtikelSearchPage extends Component { this.setState({ input: value }); const { filter } = queryFromParams(this.props.searchParams); const { pageSize } = this.state; - if (pageSize != null) { - void articleSearchAutomat.actions.search({ - target: value, - filter, - offset: 0, - pageSize, - }).catch(() => {}); - } + const currentUrl = buildSearchUrl(value, filter, 0); + setLastSearchTerm(value, currentUrl); + writeArtikelQuery({ target: value, filter, offset: 0 }); + this.props.navigate(currentUrl, { replace: true }); + void articleSearchAutomat.actions.search({ + target: value, + filter, + offset: 0, + pageSize: pageSize ?? undefined, + }).catch(() => {}); }; fetchResults = () => { const { target, filter, offset } = queryFromParams(this.props.searchParams); const { pageSize } = this.state; - if (pageSize == null) return; - writeArtikelQuery({ target, filter, offset }); void articleSearchAutomat.actions.search({ target: target.trim(), filter, offset, - pageSize, + pageSize: pageSize ?? undefined, }).catch(() => {}); }; @@ -213,6 +260,7 @@ class ArtikelSearchPage extends Component { render() { const { input, + categoryFilterOpen, error, categoryFacets, totalCount, @@ -225,8 +273,6 @@ class ArtikelSearchPage extends Component { const filterParam = this.props.searchParams.get('filter') ?? ''; const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || stateOffset || 0); const categories = parseFilter(filterParam); - // Drive UI from input so hint vs results switch in one paint (URL lags one tick). - const searchActive = input.trim().length > 0 || (recordset ?? []).length > 0; const rows = recordset ?? []; const facetEntries = categoryFacets @@ -246,9 +292,18 @@ class ArtikelSearchPage extends Component { return ( <> - + @@ -269,6 +325,34 @@ class ArtikelSearchPage extends Component { }, }} /> + {facetEntries.length > 0 && ( + + )} + {categories.map((catId) => { + const catName = categoryFacets?.[catId]?.name ?? `Kategorie ${catId}`; + return ( + { + const next = categories.filter((id) => id !== catId); + this.updateParams(input, buildFilter(next), 0); + }} + sx={{ height: 40, borderRadius: 1.5, fontWeight: 500, flexShrink: 0 }} + /> + ); + })} {error && ( @@ -279,17 +363,47 @@ class ArtikelSearchPage extends Component { /> )} - {searchActive && ( - - {facetEntries.length > 0 && ( - - + {categoryFilterOpen && facetEntries.length > 0 && ( + + + - Kategorien - - + + Kategorien + + + + + + {facetEntries.map(({ id, count, name, depth }) => { const selected = categories.includes(id); return ( @@ -299,6 +413,7 @@ class ArtikelSearchPage extends Component { onMouseDown={(e) => e.preventDefault()} onClick={() => { const next = selected ? [] : [id]; + this.setState({ categoryFilterOpen: false }); this.updateParams(input, buildFilter(next), 0); }} sx={{ pl: 2 + depth * 1.5 }} @@ -315,101 +430,102 @@ class ArtikelSearchPage extends Component { })} + + )} + + + {!error && input.trim().length > 0 && totalCount === 0 && ( + Keine Artikel gefunden. )} - - {searchActive && !error && totalCount === 0 && ( - Keine Artikel gefunden. - )} - - {/* Always mount real pagination height (invisible until needed) so pageSize is correct. */} - - - - - } - > - - {rows.length > 0 && ( - - {rows.map((row, index) => ( - e.preventDefault()} - onClick={() => { - this.persistQuery(); - this.props.navigate(artikelDetailPath(row)); - }} - sx={resultRowSx} - > - - {(row.brand?.name || row.brandName) && ( - <> - - {' · '} - - )} - {row.sku} - {' · '} - Bestand {formatStock(row.stock)} - {' · '} - Verfügbar {formatStock(row.available)} - {row.categories - ? ` · ${formatCategoryList(row.categories, categoryFacets ?? {})}` - : ''} - - } - slotProps={{ primary: { fontWeight: 500, noWrap: true } }} - /> - - {row.price != null ? formatPrice(row.price) : '—'} - - - ))} - - )} - - + {/* Always mount real pagination height (invisible until needed) so pageSize is correct. */} + + + + } + > + + {rows.length > 0 && ( + + {rows.map((row, index) => ( + e.preventDefault()} + onClick={() => { + this.persistQuery(); + this.props.navigate(artikelDetailPath(row)); + }} + sx={resultRowSx} + > + + {(row.brand?.name || row.brandName) && ( + <> + + {' · '} + + )} + {row.sku} + {' · '} + Bestand {formatStock(row.stock)} + {' · '} + Verfügbar {formatStock(row.available)} + {row.categories + ? ` · ${formatCategoryList(row.categories, categoryFacets ?? {})}` + : ''} + + } + slotProps={{ primary: { fontWeight: 500, noWrap: true } }} + /> + + {row.price != null ? formatPrice(row.price) : '—'} + + + ))} + + )} + + - )} + ); } diff --git a/src/routes/artikel/kategorien.jsx b/src/routes/artikel/kategorien.jsx index c30ab23..632271f 100644 --- a/src/routes/artikel/kategorien.jsx +++ b/src/routes/artikel/kategorien.jsx @@ -28,7 +28,7 @@ import { import categoryAutomat from '../../automats/categoryAutomat'; import posterminalAutomat from '../../automats/posterminalAutomat'; import companyAutomat from '../../automats/companyAutomat'; -import { setLastArticleSku } from '../../automats'; +import { setLastArticleSku, setLastCategorySlug } from '../../automats'; import { categoryDisplayName, getCategoryBreadcrumbs, @@ -98,6 +98,7 @@ class ArtikelKategorienPage extends PureComponent { componentDidMount() { setLastArticleSku(null); + setLastCategorySlug(this.props.categorySlugPath); this._unsubCat = categoryAutomat.subscribe(this, selectFromCategory); this._unsubTerm = posterminalAutomat.subscribe(this, selectTerminals); this._unsubCompany = companyAutomat.subscribe(this, (s) => ({ @@ -110,6 +111,9 @@ class ArtikelKategorienPage extends PureComponent { } componentDidUpdate(prevProps, prevState) { + if (this.props.categorySlugPath !== prevProps.categorySlugPath) { + setLastCategorySlug(this.props.categorySlugPath); + } if (this.state.companyId !== prevState.companyId) { this.ensureTreeLoaded(); } @@ -132,6 +136,7 @@ class ArtikelKategorienPage extends PureComponent { } componentWillUnmount() { + setLastCategorySlug(this.props.categorySlugPath); this._unsubCat?.(); this._unsubTerm?.(); this._unsubCompany?.();