This commit is contained in:
seb
2026-09-29 09:30:43 +02:00
parent c4f8ebb41b
commit 18419b1615
33 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,56 @@
import Box from '@mui/material/Box';
import Pagination from '@mui/material/Pagination';
import Typography from '@mui/material/Typography';
/** Shared with the search page layout reserve / touch targets. */
export const PAGINATION_ITEM_SIZE = 44;
export default function ResultsPagination({
offset,
totalCount,
pageSize,
pageCount,
currentPage,
onPageChange,
/** Keep layout height without showing controls (for page-size measure). */
invisible = false,
sx,
}) {
if (!invisible && (totalCount == null || totalCount <= pageSize)) return null;
return (
<Box
aria-hidden={invisible || undefined}
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 1,
width: '100%',
visibility: invisible ? 'hidden' : 'visible',
pointerEvents: invisible ? 'none' : 'auto',
...sx,
}}
>
<Typography variant="body2" color="text.secondary">
{offset + 1}–{Math.min(offset + pageSize, totalCount)} von {totalCount}
</Typography>
<Pagination
count={pageCount}
page={currentPage}
onChange={onPageChange}
color="primary"
size="large"
onMouseDown={(e) => e.preventDefault()}
sx={{
'& .MuiPaginationItem-root': {
minWidth: PAGINATION_ITEM_SIZE,
height: PAGINATION_ITEM_SIZE,
fontSize: '1rem',
},
}}
/>
</Box>
);
}

View File

@@ -0,0 +1,116 @@
import { matchPath } from 'react-router-dom';
export const ARTICLE_TAB = {
history: 'history',
supplier: 'supplier',
seo: 'seo',
label: 'label',
attributes: 'attributes',
compound: 'compound',
settings: 'settings',
};
/** Factory fallback when nothing is stored in localStorage. */
export const DEFAULT_ARTICLE_TAB = ARTICLE_TAB.seo;
const PREFERRED_ARTICLE_TAB_KEY = 'dashfront:article-default-tab';
const ALL_ARTICLE_TAB_IDS = new Set(Object.values(ARTICLE_TAB));
const BASE_ARTICLE_TAB_IDS = [
ARTICLE_TAB.history,
ARTICLE_TAB.supplier,
ARTICLE_TAB.seo,
ARTICLE_TAB.label,
ARTICLE_TAB.attributes,
];
export function articleSku(article) {
if (!article) return null;
const sku = article.sku?.trim();
return sku || null;
}
export function articleShowsCompoundTab(article) {
return (
article?.articleListCount > 0 ||
article?.optionsCount > 0 ||
article?.childCount > 0 ||
article?.fatherArticleId != null
);
}
export function articleTabIds(article) {
const ids = [...BASE_ARTICLE_TAB_IDS];
if (articleShowsCompoundTab(article)) {
ids.push(ARTICLE_TAB.compound);
}
ids.push(ARTICLE_TAB.settings);
return ids;
}
export function isArticleTabId(tab) {
return ALL_ARTICLE_TAB_IDS.has(tab);
}
export function readPreferredArticleTab() {
try {
const raw = localStorage.getItem(PREFERRED_ARTICLE_TAB_KEY);
if (raw && isArticleTabId(raw)) return raw;
} catch {
// ignore quota / private mode
}
return DEFAULT_ARTICLE_TAB;
}
export function writePreferredArticleTab(tab) {
if (!isArticleTabId(tab)) return;
try {
localStorage.setItem(PREFERRED_ARTICLE_TAB_KEY, tab);
} catch {
// ignore quota / private mode
}
}
/** Preferred default, clamped to tabs visible for this article. */
export function preferredArticleTab(article) {
const preferred = readPreferredArticleTab();
const ids = articleTabIds(article);
if (ids.includes(preferred)) return preferred;
return DEFAULT_ARTICLE_TAB;
}
export function resolveArticleTab(tabParam, article) {
const ids = articleTabIds(article);
if (tabParam && ids.includes(tabParam)) return tabParam;
return preferredArticleTab(article);
}
/** Tab segment from an article-detail pathname, or undefined. */
export function articleTabFromPathname(pathname) {
const match = matchPath('/artikel/:sku/:tab?', pathname);
return match?.params?.tab;
}
/**
* @param {object|string} articleOrSku
* @param {string} [tab] omitted → preferred default from localStorage
*/
export function artikelDetailPath(articleOrSku, tab) {
const sku =
typeof articleOrSku === 'string'
? articleOrSku.trim() || null
: articleSku(articleOrSku);
if (!sku) return null;
let segment;
if (isArticleTabId(tab)) {
segment = tab;
} else if (articleOrSku && typeof articleOrSku === 'object') {
segment = preferredArticleTab(articleOrSku);
} else {
segment = readPreferredArticleTab();
}
return `/artikel/${encodeURIComponent(sku)}/${segment}`;
}

View File

@@ -0,0 +1,57 @@
const ARTIKEL_QUERY_KEY = 'dashfront:artikel-query';
export function queryFromParams(searchParams) {
return {
target: searchParams.get('target') ?? '',
filter: searchParams.get('filter') ?? '',
offset: Math.max(0, Number(searchParams.get('offset')) || 0),
};
}
export function buildSearchUrl(target, filter = '', offset = 0) {
const params = new URLSearchParams();
if (target) params.set('target', target);
if (filter) params.set('filter', filter);
if (offset > 0) params.set('offset', String(offset));
const qs = params.toString();
return qs ? `/artikel?${qs}` : '/artikel';
}
export function readArtikelQuery() {
try {
const raw = localStorage.getItem(ARTIKEL_QUERY_KEY);
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
export function writeArtikelQuery({ target, filter = '', offset = 0 }) {
try {
if (!target || target.trim().length < 3) {
localStorage.removeItem(ARTIKEL_QUERY_KEY);
return;
}
localStorage.setItem(
ARTIKEL_QUERY_KEY,
JSON.stringify({ target, filter, offset }),
);
} catch {
// ignore quota / private mode
}
}
export function clearArtikelQuery() {
try {
localStorage.removeItem(ARTIKEL_QUERY_KEY);
} catch {
// ignore
}
}
/** Build `/artikel?target=…` from the last stored query, or plain `/artikel`. */
export function artikelQueryPath() {
const query = readArtikelQuery();
if (!query?.target || query.target.trim().length < 3) return '/artikel';
return buildSearchUrl(query.target, query.filter ?? '', query.offset ?? 0);
}

View File

@@ -0,0 +1,8 @@
const FOCUSABLE =
'a, button, input, textarea, select, [role="button"], [role="menuitem"], [tabindex]:not([tabindex="-1"]), [contenteditable="true"]';
/** Keep the search field focused when clicking non-interactive chrome. */
export function preserveSearchFocus(e) {
if (e.target.closest(FOCUSABLE)) return;
e.preventDefault();
}

View File

@@ -0,0 +1,32 @@
export function parseFilter(filterStr) {
if (!filterStr) return [];
return filterStr.split(',').map((s) => s.trim()).filter(Boolean);
}
export function buildFilter(categories) {
return categories.length ? categories.join(',') : '';
}
export function formatStock(value) {
if (value == null) return '—';
return new Intl.NumberFormat('de-DE', { maximumFractionDigits: 2 }).format(value);
}
export function getCategoryDepth(id, facets) {
let depth = 0;
let parent = facets[id]?.parent;
while (parent && parent !== 0 && facets[String(parent)]) {
depth += 1;
parent = facets[String(parent)]?.parent;
}
return depth;
}
export function formatCategoryList(catStr, facets) {
if (!catStr) return '';
return catStr
.split(',')
.map((id) => facets[id]?.name)
.filter(Boolean)
.join(', ');
}

View File

@@ -0,0 +1,33 @@
import { useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { buildSearchUrl, queryFromParams, readArtikelQuery } from './artikelQuery';
export default function withSearchParams(Wrapped) {
function SearchParamsWrapper(props) {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
useEffect(() => {
const query = queryFromParams(searchParams);
if (query.target.trim().length >= 3) return;
const stored = readArtikelQuery();
if (!stored?.target || stored.target.trim().length < 3) return;
navigate(
buildSearchUrl(stored.target, stored.filter ?? '', stored.offset ?? 0),
{ replace: true },
);
}, [searchParams, navigate]);
return (
<Wrapped
{...props}
searchParams={searchParams}
setSearchParams={setSearchParams}
navigate={navigate}
/>
);
}
return SearchParamsWrapper;
}

View File

@@ -0,0 +1,13 @@
import { useNavigate, useParams } from 'react-router-dom';
/** Injects `sku`, `tab`, and `navigate` from the article detail route. */
export default function withSkuRoute(Wrapped) {
function SkuRouteWrapper(props) {
const navigate = useNavigate();
const { sku: skuParam, tab } = useParams();
const sku = skuParam ? decodeURIComponent(skuParam) : '';
return <Wrapped {...props} sku={sku} tab={tab} navigate={navigate} />;
}
return SkuRouteWrapper;
}