reroute
This commit is contained in:
56
src/components/article/ResultsPagination.jsx
Normal file
56
src/components/article/ResultsPagination.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
src/components/article/articleRoute.js
Normal file
116
src/components/article/articleRoute.js
Normal 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}`;
|
||||
}
|
||||
57
src/components/article/artikelQuery.js
Normal file
57
src/components/article/artikelQuery.js
Normal 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);
|
||||
}
|
||||
8
src/components/article/searchFocus.js
Normal file
8
src/components/article/searchFocus.js
Normal 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();
|
||||
}
|
||||
32
src/components/article/searchFormat.js
Normal file
32
src/components/article/searchFormat.js
Normal 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(', ');
|
||||
}
|
||||
33
src/components/article/withSearchParams.jsx
Normal file
33
src/components/article/withSearchParams.jsx
Normal 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;
|
||||
}
|
||||
13
src/components/article/withSkuRoute.jsx
Normal file
13
src/components/article/withSkuRoute.jsx
Normal 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;
|
||||
}
|
||||
114
src/components/category/CategorySortable.js
Normal file
114
src/components/category/CategorySortable.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import Sortable from 'sortablejs';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
|
||||
/**
|
||||
* Encapsulates SortableJS logic for reordering category tiles inside a grid.
|
||||
*
|
||||
* Usage:
|
||||
* this.sortable = new CategorySortable(containerEl, { onReorder });
|
||||
* this.sortable.activate();
|
||||
* this.sortable.destroy();
|
||||
* this.sortable.sortableIds(); // current DOM order
|
||||
* this.sortable.handleMoveCategory(id, direction); // +1 / -1
|
||||
*/
|
||||
export default class CategorySortable {
|
||||
#instance = null;
|
||||
#container = null;
|
||||
#onReorder = null;
|
||||
#checkpointOrder = null;
|
||||
|
||||
constructor(container, { onReorder }) {
|
||||
this.#container = container;
|
||||
this.#onReorder = onReorder;
|
||||
}
|
||||
|
||||
activate() {
|
||||
const el = this.#container;
|
||||
if (!el || this.#instance) return;
|
||||
|
||||
this.#instance = Sortable.create(el, {
|
||||
draggable: '.sortable-item:not(.not-sortable)',
|
||||
handle: '.sortable-handle',
|
||||
animation: 150,
|
||||
ghostClass: 'sortable-ghost',
|
||||
dragClass: 'sortable-drag',
|
||||
filter: '.not-sortable, .sortable-handle-btn',
|
||||
forceFallback: true,
|
||||
fallbackOnBody: true,
|
||||
onStart: this.#handleSortStart,
|
||||
onEnd: this.#handleSort,
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.#instance) {
|
||||
this.#instance.destroy();
|
||||
this.#instance = null;
|
||||
}
|
||||
this.#checkpointOrder = null;
|
||||
document.body.classList.remove('tree-dragging');
|
||||
}
|
||||
|
||||
sortableIds() {
|
||||
const container = this.#container;
|
||||
if (!container) return [];
|
||||
return [...container.querySelectorAll('.sortable-item:not(.not-sortable)')]
|
||||
.map((el) => el.dataset.id)
|
||||
.filter(Boolean)
|
||||
.map(Number);
|
||||
}
|
||||
|
||||
handleMoveCategory = (categoryId, direction) => {
|
||||
const ids = this.sortableIds();
|
||||
const index = ids.indexOf(categoryId);
|
||||
if (index === -1) return;
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= ids.length) return;
|
||||
const next = [...ids];
|
||||
next[index] = ids[target];
|
||||
next[target] = categoryId;
|
||||
void this.#onReorder({ categoryIds: next }).unwrap().catch(notifyError);
|
||||
};
|
||||
|
||||
revertDomOrder = (order) => {
|
||||
const container = this.#container;
|
||||
if (!container) return;
|
||||
const items = [...container.querySelectorAll('.sortable-item')];
|
||||
const byId = new Map(items.map((el) => [Number(el.dataset.id), el]));
|
||||
const anchor =
|
||||
[...container.children].find((el) => !el.classList.contains('sortable-item')) ?? null;
|
||||
for (const id of order) {
|
||||
const el = byId.get(id);
|
||||
if (!el) continue;
|
||||
if (anchor) container.insertBefore(el, anchor);
|
||||
else container.appendChild(el);
|
||||
}
|
||||
};
|
||||
|
||||
// --- private ---
|
||||
|
||||
#handleSortStart = () => {
|
||||
this.#checkpointOrder = this.sortableIds();
|
||||
document.body.classList.add('tree-dragging');
|
||||
};
|
||||
|
||||
#handleSort = () => {
|
||||
document.body.classList.remove('tree-dragging');
|
||||
const ids = this.sortableIds();
|
||||
const before = this.#checkpointOrder ?? [];
|
||||
this.#checkpointOrder = null;
|
||||
if (
|
||||
before.length === 0 ||
|
||||
before.length !== ids.length ||
|
||||
before.every((id, index) => id === ids[index])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void this.#onReorder({ categoryIds: ids })
|
||||
.unwrap()
|
||||
.catch((err) => {
|
||||
notifyError(err);
|
||||
this.revertDomOrder(before);
|
||||
});
|
||||
};
|
||||
}
|
||||
42
src/components/category/categoryRoute.js
Normal file
42
src/components/category/categoryRoute.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
ROOT_CATEGORY_NAME,
|
||||
categorySlug,
|
||||
getCategoryBreadcrumbs,
|
||||
getCategoryChildren,
|
||||
} from '../../utils/categoryUtils';
|
||||
|
||||
export { ROOT_CATEGORY_NAME, categorySlug };
|
||||
|
||||
export const KATEGORIEN_BASE = '/kategorien';
|
||||
|
||||
export function isKategorienPath(pathname) {
|
||||
return pathname === KATEGORIEN_BASE || pathname.startsWith(`${KATEGORIEN_BASE}/`);
|
||||
}
|
||||
|
||||
function isRootSlug(slug) {
|
||||
return slug === ROOT_CATEGORY_NAME;
|
||||
}
|
||||
|
||||
export function categoryPathFromCrumbs(crumbs) {
|
||||
const segments = (crumbs ?? [])
|
||||
.filter((crumb) => crumb.slug && !isRootSlug(crumb.slug))
|
||||
.map((crumb) => encodeURIComponent(crumb.slug));
|
||||
if (segments.length === 0) {
|
||||
return KATEGORIEN_BASE;
|
||||
}
|
||||
return `${KATEGORIEN_BASE}/${segments.join('/')}`;
|
||||
}
|
||||
|
||||
export function buildCategoryPath(categories, rootCategoryId, categoryId) {
|
||||
const crumbs = getCategoryBreadcrumbs(categories, categoryId, rootCategoryId);
|
||||
return categoryPathFromCrumbs(crumbs);
|
||||
}
|
||||
|
||||
export {
|
||||
resolveCategoryIdFromSlugPath,
|
||||
cleanCategorySlugPath,
|
||||
} from '../../utils/categoryUtils';
|
||||
|
||||
export function defaultCategoryPath() {
|
||||
return KATEGORIEN_BASE;
|
||||
}
|
||||
19
src/components/category/withCategoryRoute.jsx
Normal file
19
src/components/category/withCategoryRoute.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
export default function withCategoryRoute(Wrapped) {
|
||||
function CategoryRouteWrapper(props) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { '*': categorySlugPath = '' } = useParams();
|
||||
|
||||
return (
|
||||
<Wrapped
|
||||
{...props}
|
||||
navigate={navigate}
|
||||
location={location}
|
||||
categorySlugPath={categorySlugPath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return CategoryRouteWrapper;
|
||||
}
|
||||
16
src/components/kunde/withEntityRoute.jsx
Normal file
16
src/components/kunde/withEntityRoute.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
/** Injects numeric `entityId` from `/kunde/:entityId`. */
|
||||
export default function withEntityRoute(Wrapped) {
|
||||
function EntityRouteWrapper(props) {
|
||||
const { entityId: raw } = useParams();
|
||||
const entityId = Number(raw);
|
||||
return (
|
||||
<Wrapped
|
||||
{...props}
|
||||
entityId={Number.isInteger(entityId) ? entityId : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return EntityRouteWrapper;
|
||||
}
|
||||
Reference in New Issue
Block a user