diff --git a/src/automats/articleIndexAutomat.js b/src/automats/articleIndexAutomat.js index 7523227..0bf6869 100644 --- a/src/automats/articleIndexAutomat.js +++ b/src/automats/articleIndexAutomat.js @@ -98,6 +98,7 @@ function scheduleRebuild() { function toIndexRecord(article, categoryId) { const eans = article.eans ?? article.eanCodes ?? article.ean ?? null; return { + ...article, id: article.id, sku: article.sku, name: article.name, diff --git a/src/automats/articleSearchAutomat.js b/src/automats/articleSearchAutomat.js index c9d02e7..1c41d26 100644 --- a/src/automats/articleSearchAutomat.js +++ b/src/automats/articleSearchAutomat.js @@ -1,23 +1,29 @@ /** * @file articleSearchAutomat.js - * @description In-memory article search state for the `/artikel` overview and - * search pages, querying against the derived `articleIndexAutomat`. + * @description In-memory article search and pagination state for the `/artikel` + * overview and search pages, querying against the derived `articleIndexAutomat`. * * Upstream Triggers: - * - UI / Search input: Components call `actions.search(query)` or `actions.reset()`. + * - UI / Search input / FillSpace: Components call `actions.search({ target, filter, offset, pageSize })`. * - Reads data from `articleIndexAutomat`: Ensures the derived index is available - * before filtering. The index is built from the loaded category views, so a - * search never issues a request of its own. + * before filtering and slicing. + * - `categoryAutomat`: Provides category hierarchy metadata for faceted counts. */ import { Automat } from 'react-automat'; import articleIndexAutomat from './articleIndexAutomat'; +import categoryAutomat from './categoryAutomat'; +import { parseFilter } from '../components/article/searchFormat'; /** * @typedef {Object} ArticleSearchResult * @property {Record} categoryFacets Facet counts per category id. * @property {number} totalCount Number of matching records. - * @property {Array} recordset Matching article index records. + * @property {Array} recordset Sliced matching article index records for the current page. + * @property {number} offset Current 0-based offset. + * @property {number|null} pageSize Current page size. + * @property {number} pageCount Total number of pages. + * @property {number} currentPage Current 1-based page number. * @property {Error|null} error Search failure, or `null`. */ @@ -25,6 +31,9 @@ import articleIndexAutomat from './articleIndexAutomat'; * @typedef {Object} ArticleSearchState * @property {ArticleSearchResult} result Latest search outcome. * @property {string} target Active search term. + * @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. */ @@ -33,6 +42,10 @@ const emptyResult = { categoryFacets: {}, totalCount: 0, recordset: [], + offset: 0, + pageSize: null, + pageCount: 1, + currentPage: 1, error: null, }; @@ -40,73 +53,210 @@ const emptyResult = { const initialState = { result: emptyResult, target: '', + filter: '', + offset: 0, + pageSize: null, loading: false, }; let articleSearchAutomat; /** - * Matches one index record against a lowercased search term. + * Matches one index record against a search query (checks tokens in name, sku, brand, eans). * * @param {object} row Article index record. * @param {string} query Lowercased search term. * @returns {boolean} Whether the record matches. */ function matches(row, query) { - if (String(row.name ?? '').toLowerCase().includes(query)) return true; - if (String(row.sku ?? '').toLowerCase().includes(query)) return true; - return (row.eans ?? []).some((ean) => String(ean).includes(query)); + if (!query) return true; + const tokens = query.split(/\s+/).filter(Boolean); + if (tokens.length === 0) return true; + const name = String(row.name ?? '').toLowerCase(); + const sku = String(row.sku ?? '').toLowerCase(); + const brand = String(row.brand?.name || row.brandName || '').toLowerCase(); + const eans = (row.eans ?? []).map((e) => String(e).toLowerCase()); + return tokens.every( + (token) => + name.includes(token) || + sku.includes(token) || + brand.includes(token) || + eans.some((ean) => ean.includes(token)), + ); +} + +/** + * Checks whether an article belongs to at least one of the selected categories. + * + * @param {object} row Article index record. + * @param {Array} selectedCategories Array of category ID strings. + * @returns {boolean} Whether the article matches. + */ +function matchesCategory(row, selectedCategories) { + if (!selectedCategories || selectedCategories.length === 0) return true; + if (!row.categories) return false; + const rowCats = String(row.categories).split(',').map((s) => s.trim()); + return selectedCategories.some((catId) => rowCats.includes(String(catId))); +} + +/** + * Computes facet counts for each category represented in matched articles. + * + * @param {Array} matchedArticles Articles matching text query. + * @returns {Record} Category facets. + */ +function buildCategoryFacets(matchedArticles) { + const facets = {}; + const categoriesMap = categoryAutomat.state?.categories ?? {}; + + for (const article of matchedArticles) { + if (!article.categories) continue; + const ids = String(article.categories).split(',').map((s) => s.trim()).filter(Boolean); + for (const id of ids) { + if (!facets[id]) { + const catNode = categoriesMap[id]; + facets[id] = { + count: 0, + name: catNode?.name ?? `Kategorie ${id}`, + parent: catNode?.parentId ?? null, + }; + } + facets[id].count += 1; + } + } + + return facets; } const actions = { /** - * Runs a client-side search over the article index. + * Runs a client-side search over the article index with pagination. * - * The index is already derived from the cached category views, so this does - * not hit the network once the tree has loaded. - * - * @param {string} target Search term; an empty string returns everything. + * @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(target) { - articleSearchAutomat.setState({ target, loading: true }); + async search(targetOrParams, options = {}) { + 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 = String(target ?? '').trim().toLowerCase(); - const recordset = query + const query = target.trim().toLowerCase(); + + // 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); + + // 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 result = { - categoryFacets: {}, - totalCount: recordset.length, + categoryFacets, + totalCount, recordset, + offset, + pageSize: safePageSize, + pageCount, + currentPage, error: null, }; + articleSearchAutomat.setState({ result, loading: false }); return result; } catch (error) { - const result = { ...emptyResult, error }; + const result = { + ...emptyResult, + pageSize, + offset, + error, + }; articleSearchAutomat.setState({ result, loading: false }); throw error; } }, - /** Reset the result set. */ + /** + * Updates the page size and re-derives the result set. + * + * @param {number} pageSize Number of articles per page. + * @returns {Promise} + */ + setPageSize(pageSize) { + const { target, filter, offset } = articleSearchAutomat.state; + return actions.search({ target, filter, offset, pageSize }); + }, + + /** + * Sets page number (1-based) and re-derives the result set. + * + * @param {number} page 1-based page number. + * @returns {Promise} + */ + setPage(page) { + const { target, filter, pageSize } = articleSearchAutomat.state; + const safeSize = pageSize ?? 1; + const offset = Math.max(0, (page - 1) * safeSize); + return actions.search({ target, filter, offset, pageSize }); + }, + + /** + * Sets 0-based offset and re-derives the result set. + * + * @param {number} offset 0-based record offset. + * @returns {Promise} + */ + setOffset(offset) { + const { target, filter, pageSize } = articleSearchAutomat.state; + return actions.search({ target, filter, offset, pageSize }); + }, + + /** Reset the search state. */ reset() { articleSearchAutomat.setState(initialState); }, }; /** - * Article search state for the `/artikel` overview page. + * Article search and pagination state for the `/artikel` overview page. * - * Replaces `StorageManager.doSearch()`; components subscribe to `result` - * instead of receiving a callback. + * Components subscribe to `result` to receive paginated records, facets, + * page counts, and total counts. * * @type {Automat} */ articleSearchAutomat = new Automat(initialState, actions, { name: 'articleSearch' }); +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(() => {}); + } +}); + /** * Selects the latest search result. * diff --git a/src/main.jsx b/src/main.jsx index 966e801..7ad3bc9 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -37,6 +37,19 @@ if (import.meta.env.DEV) { void clearDevServiceWorker(); } +// PWA auto-update: reload the page as soon as a new build's service worker +// takes control. The virtual module only exists in production builds (the +// plugin is registered for `build` only), hence the dynamic import. +if (import.meta.env.PROD && 'serviceWorker' in navigator) { + import('virtual:pwa-register') + .then(({ registerSW }) => { + registerSW({ immediate: true }); + }) + .catch(() => { + // No SW in this build — app still works, just without offline support. + }); +} + createRoot(document.getElementById('root')).render( diff --git a/src/routes/artikel.jsx b/src/routes/artikel.jsx index e168005..d3ed5ea 100644 --- a/src/routes/artikel.jsx +++ b/src/routes/artikel.jsx @@ -32,9 +32,6 @@ import { artikelDetailPath } from '../components/article/articleRoute'; import withSearchParams from '../components/article/withSearchParams'; import { setLastArticleSku } from '../automats'; import { BrandLabel } from '../components/brand'; -import { - BLUE_BG, -} from '../colors'; const resultRowSx = { alignItems: 'center', @@ -50,18 +47,31 @@ const resultRowSx = { function ResultRowMeasureSample() { return ( - + - SKU · Bestand 0 · Verfügbar 0 · Kategorie + +   } slotProps={{ primary: { fontWeight: 500, noWrap: true } }} /> - - 0,00 € + +   ); @@ -75,87 +85,35 @@ function initialSearchState(searchParams) { ? query.target : (stored?.target?.trim().length >= 3 ? stored.target : query.target); + const automatResult = articleSearchAutomat.state.result; + return { input, - error: null, - categoryFacets: null, - totalCount: null, - recordset: null, - pageSize: null, + error: automatResult.error, + categoryFacets: automatResult.categoryFacets, + totalCount: automatResult.totalCount, + recordset: automatResult.recordset, + pageSize: automatResult.pageSize, + pageCount: automatResult.pageCount, + currentPage: automatResult.currentPage, + offset: automatResult.offset, }; } class ArtikelSearchPage extends Component { state = initialSearchState(this.props.searchParams); - listSlotRef = { current: null }; - rowMeasureRef = { current: null }; paginationRef = createRef(); - setListSlotRef = (node) => { - if (this.listSlotRef.current === node) return; - this.listSlotRef.current = node; - if (!node) return; - this.updatePageSize(); - }; - - setRowMeasureRef = (node) => { - if (this.rowMeasureRef.current === node) return; - this.rowMeasureRef.current = node; - if (!node) return; - this.updatePageSize(); - }; - - measureRowHeight = () => { - // Always use the sample — real rows used to report a different height after paint - // (wrapped text), which made resize "fix" a wrong first measure. - const sample = this.rowMeasureRef.current; - if (!sample) return { height: 0, source: 'none' }; - return { - height: sample.getBoundingClientRect().height, - source: 'sample', - }; - }; - - updatePageSize = ({ fromResize = false } = {}) => { - const slot = this.listSlotRef.current; - if (!slot) return; - - // After the first measure, ignore layout churn from painting results. - // Only window resize may change pageSize and refetch. - if (this.state.pageSize != null && !fromResize) return; - - const slotTop = slot.getBoundingClientRect().top; - const available = window.innerHeight - slotTop; - const { height: rowHeight, source: rowSource } = this.measureRowHeight(); - if (available < 1 || rowHeight < 1) return; - - // Outlined Paper around the list is 1px top + 1px bottom — not in row height. - const listChrome = 2; - const next = Math.max(1, Math.floor((available - listChrome) / rowHeight)); - - console.log('[pageSize] measure', { - fromResize, - prevPageSize: this.state.pageSize, - next, - available, - rowHeight, - rowSource, - listChrome, - slotTop, - realRowHeight: - slot.querySelector('[data-result-row]')?.getBoundingClientRect().height ?? null, - }); - - if (next === this.state.pageSize) return; - - this.setState({ pageSize: next }, () => { - const target = this.props.searchParams.get('target') ?? ''; - if (target) this.fetchResults(); - }); - }; - - handleWindowResize = () => { - this.updatePageSize({ fromResize: true }); + handleMeasurement = (count) => { + if (!count || count === this.state.pageSize) return; + this.setState({ pageSize: count }); + const { target, filter, offset } = queryFromParams(this.props.searchParams); + void articleSearchAutomat.actions.search({ + target: target || this.state.input, + filter, + offset, + pageSize: count, + }).catch(() => {}); }; persistQuery = () => { @@ -170,19 +128,14 @@ class ArtikelSearchPage extends Component { componentDidMount() { document.addEventListener('mouseup', preserveSearchFocus, true); - window.addEventListener('resize', this.handleWindowResize); setLastArticleSku(null); // Push search results into local component state. this.unsubSearch = articleSearchAutomat.subscribe(this, (state) => state.result); - - // Kick off initial search (empty input = show all products). - void articleSearchAutomat.actions.search(this.state.input).catch(() => {}); } componentWillUnmount() { document.removeEventListener('mouseup', preserveSearchFocus, true); - window.removeEventListener('resize', this.handleWindowResize); this.unsubSearch?.(); this.unsubSearch = null; this.persistQuery(); @@ -221,7 +174,16 @@ class ArtikelSearchPage extends Component { handleInputChange = (event) => { const value = event.target.value; this.setState({ input: value }); - void articleSearchAutomat.actions.search(value).catch(() => {}); + const { filter } = queryFromParams(this.props.searchParams); + const { pageSize } = this.state; + if (pageSize != null) { + void articleSearchAutomat.actions.search({ + target: value, + filter, + offset: 0, + pageSize, + }).catch(() => {}); + } }; fetchResults = () => { @@ -232,13 +194,36 @@ class ArtikelSearchPage extends Component { writeArtikelQuery({ target, filter, offset }); - void articleSearchAutomat.actions.search(target.trim()).catch(() => {}); + void articleSearchAutomat.actions.search({ + target: target.trim(), + filter, + offset, + pageSize, + }).catch(() => {}); + }; + + handlePageChange = (_e, page) => { + const { pageSize } = this.state; + const safePageSize = pageSize ?? 1; + const nextOffset = (page - 1) * safePageSize; + const filterParam = this.props.searchParams.get('filter') ?? ''; + this.updateParams(this.state.input, filterParam, nextOffset); }; render() { - const { input, error, categoryFacets, totalCount, recordset, pageSize } = this.state; + const { + input, + error, + categoryFacets, + totalCount, + recordset, + pageSize, + pageCount, + currentPage, + offset: stateOffset, + } = this.state; const filterParam = this.props.searchParams.get('filter') ?? ''; - const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || 0); + 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; @@ -256,16 +241,9 @@ class ArtikelSearchPage extends Component { .sort((a, b) => Number(a.id) - Number(b.id)) : []; - const safePageSize = pageSize ?? 1; - const pageCount = Math.max(1, Math.ceil((totalCount ?? 0) / safePageSize)); - const currentPage = Math.floor(offset / safePageSize) + 1; const showPagination = rows.length > 0 && pageSize != null && totalCount != null && totalCount > pageSize; - const handlePageChange = (_e, page) => { - this.updateParams(input, filterParam, (page - 1) * safePageSize); - }; - return ( <> @@ -340,16 +318,6 @@ class ArtikelSearchPage extends Component { )} - {/* Same column width as the list; invisible, zero layout footprint. */} - - - - - - {searchActive && !error && totalCount === 0 && ( Keine Artikel gefunden. )} @@ -363,29 +331,17 @@ class ArtikelSearchPage extends Component { pageSize={pageSize ?? 1} pageCount={showPagination ? pageCount : 5} currentPage={showPagination ? currentPage : 1} - onPageChange={handlePageChange} + onPageChange={this.handlePageChange} /> - Einheit - - } + measureElement={} > - + {rows.length > 0 && ( {rows.map((row, index) => ( diff --git a/vite.config.js b/vite.config.js index ade9593..2c2c4c3 100644 --- a/vite.config.js +++ b/vite.config.js @@ -289,8 +289,13 @@ export default defineConfig(({ command }) => ({ ...(command === 'build' ? [ VitePWA({ + // autoUpdate: the SW calls clientsClaim()/skipWaiting() itself, so a + // new build takes over every open tab on its own. registerType: 'autoUpdate', - injectRegister: 'auto', + // Registration is done explicitly in src/main.jsx via + // `virtual:pwa-register` with `immediate: true`; that is what makes + // the *page* reload (a bare injected script only swaps the SW). + injectRegister: false, manifest: { id: '/', name: 'Dashfront', @@ -320,6 +325,13 @@ export default defineConfig(({ command }) => ({ ], }, workbox: { + // NOTE: vite-plugin-pwa only auto-sets these two when + // injectRegister === 'auto'. Because we register manually in + // src/main.jsx (injectRegister: false), they must be explicit — + // otherwise a new SW waits forever behind the old one and no tab + // is ever claimed. + skipWaiting: true, + clientsClaim: true, globPatterns: ['**/*.{js,css,html,ico,png,svg,webmanifest,woff2}'], globIgnores: ['**/*.wasm'], navigateFallback: 'index.html', @@ -327,7 +339,19 @@ export default defineConfig(({ command }) => ({ }, }), ] - : []), + : [ + { + name: 'virtual-pwa-register-stub', + resolveId(id) { + if (id === 'virtual:pwa-register') return '\0virtual:pwa-register'; + }, + load(id) { + if (id === '\0virtual:pwa-register') { + return 'export function registerSW() { return () => {}; }'; + } + }, + }, + ]), ], server: sharedServer, preview: sharedServer,