PageSourceSearch

https://photohuman.space/wp-content/themes/photohuman/source/js/modules/applyCatalogFilter.js

js photohuman.space collected 2026-09-28 08:54:45 UTC 19,655 bytes, 697 lines download raw bytes

1import { serverReq, block, unblock, ICON_ARROW_HTML, setButtonLabel } from "./helpers.js";
2import { reinitCatalogFilter, syncCatalogSidebarScrollLock, captureExpandedFilterGroups, captureExtraFiltersOpen } from "./catalogFilter.js?v=1.6.797";
3
4const FILTER_PARAM_KEYS = [
5	"filter_interior_style",
6	"filter_cat",
7	"filter_color_gamut",
8	"filter_product_medium",
9	"filter_product_format",
10	"filter_product_elements",
11	"filter_product_type",
12	"filter_year_of_horse",
13	"filter_product_price",
14	"filter_author",
15	"filter_awards",
16	"filter_product_edition_range",
17];
18
19const DEFAULT_CATALOG_SORT = "date";
20
21/** Ignore outside-close briefly after open — mobile ghost pointerdown closes the menu instantly. */
22let catalogSortIgnoreOutsideUntil = 0;
23
24const armCatalogSortOutsideIgnore = (ms = 450) => {
25	catalogSortIgnoreOutsideUntil = Date.now() + ms;
26};
27
28const getCatalog = () => document.querySelector(".catalog");
29
30const getCatalogSortRoot = (catalog = getCatalog()) =>
31	catalog?.querySelector("[data-catalog-sort-wrap]");
32
33const getCatalogSortInput = (catalog = getCatalog()) =>
34	catalog?.querySelector("[data-catalog-sort]");
35
36const getCatalogSortCycleBtn = (catalog = getCatalog()) =>
37	catalog?.querySelector("[data-catalog-sort-cycle]");
38
39const isCatalogSortMobile = () => window.matchMedia("(max-width: 1023px)").matches;
40
41const getCatalogSortValue = (catalog = getCatalog()) => {
42	const input = getCatalogSortInput(catalog);
43
44	if (!(input instanceof HTMLInputElement) && !(input instanceof HTMLSelectElement)) {
45		return DEFAULT_CATALOG_SORT;
46	}
47
48	const value = (input.value || input.getAttribute("value") || "").trim();
49
50	return value || DEFAULT_CATALOG_SORT;
51};
52
53const syncCatalogSortCycleBtn = (catalog, value) => {
54	const btn = getCatalogSortCycleBtn(catalog);
55
56	if (!(btn instanceof HTMLElement)) {
57		return;
58	}
59
60	const next = value || DEFAULT_CATALOG_SORT;
61	const activeItem = getCatalogSortRoot(catalog)?.querySelector(
62		`.select-list li[data-id="${CSS.escape(next)}"]`,
63	);
64	const label = activeItem?.textContent?.trim() || next;
65
66	btn.dataset.sort = next;
67	btn.setAttribute("aria-label", `Сортировка: ${label}`);
68};
69
70const closeCatalogSortMenu = (catalog = getCatalog()) => {
71	const root = getCatalogSortRoot(catalog);
72	const btn = getCatalogSortCycleBtn(catalog);
73	const head = root?.querySelector(".select-head");
74
75	if (!(root instanceof HTMLElement)) {
76		return;
77	}
78
79	root.classList.remove("_show");
80	root.querySelector(".select-container")?.removeAttribute("style");
81	root.removeAttribute("style");
82	btn?.setAttribute("aria-expanded", "false");
83	head?.setAttribute("aria-expanded", "false");
84};
85
86const openCatalogSortMenu = (catalog = getCatalog()) => {
87	const root = getCatalogSortRoot(catalog);
88	const btn = getCatalogSortCycleBtn(catalog);
89	const head = root?.querySelector(".select-head");
90	const container = root?.querySelector(".select-container");
91	const list = root?.querySelector(".select-list");
92
93	if (!(root instanceof HTMLElement) || !(container instanceof HTMLElement) || !(list instanceof HTMLElement)) {
94		return;
95	}
96
97	root.classList.add("_show");
98	container.style.height = `${Math.max(list.scrollHeight, list.offsetHeight)}px`;
99	root.style.zIndex = "40";
100	btn?.setAttribute("aria-expanded", "true");
101	head?.setAttribute("aria-expanded", "true");
102	armCatalogSortOutsideIgnore();
103};
104
105const toggleCatalogSortMenu = (catalog = getCatalog()) => {
106	const root = getCatalogSortRoot(catalog);
107
108	if (!(root instanceof HTMLElement) || isCatalogFilterLoading(catalog)) {
109		return;
110	}
111
112	if (root.classList.contains("_show")) {
113		closeCatalogSortMenu(catalog);
114		return;
115	}
116
117	openCatalogSortMenu(catalog);
118};
119
120const setCatalogSortValue = (catalog, value) => {
121	const root = getCatalogSortRoot(catalog);
122	const input = getCatalogSortInput(catalog);
123
124	if (!root || !(input instanceof HTMLInputElement)) {
125		syncCatalogSortCycleBtn(catalog, value || DEFAULT_CATALOG_SORT);
126		return;
127	}
128
129	const next = value || DEFAULT_CATALOG_SORT;
130	const items = [...root.querySelectorAll(".select-list li[data-id]")];
131	const activeItem = items.find((item) => item.getAttribute("data-id") === next) || items[0];
132
133	if (!activeItem) {
134		syncCatalogSortCycleBtn(catalog, next);
135		return;
136	}
137
138	const nextValue = activeItem.getAttribute("data-id") || DEFAULT_CATALOG_SORT;
139	input.value = nextValue;
140	input.setAttribute("value", nextValue);
141
142	const text = root.querySelector(".select-head__text");
143
144	if (text) {
145		text.textContent = activeItem.textContent.trim();
146		text.classList.remove("_placeholder");
147	}
148
149	items.forEach((item) => {
150		const isActive = item === activeItem;
151		item.classList.toggle("_active", isActive);
152		item.setAttribute("aria-selected", isActive ? "true" : "false");
153	});
154
155	syncCatalogSortCycleBtn(catalog, nextValue);
156};
157
158const isCatalogFilterLoading = (catalog = getCatalog()) => catalog?.dataset.filterLoading === "1";
159
160const setCatalogFilterLoading = (catalog, loading) => {
161	if (!catalog) {
162		return;
163	}
164
165	if (loading) {
166		catalog.dataset.filterLoading = "1";
167		return;
168	}
169
170	delete catalog.dataset.filterLoading;
171};
172
173const getCatalogBlockContainer = (catalog) =>
174	catalog.querySelector(".catalog-box._to_block") ||
175	catalog.querySelector(".catalog-content") ||
176	catalog.querySelector(".catalog-container");
177
178const getCatalogSidebarBlockTarget = (catalog) => catalog?.querySelector(".catalog-sidebar .sidebar");
179
180const blockCatalogFilters = (catalog) => {
181	const sidebar = getCatalogSidebarBlockTarget(catalog);
182	const blockContainer = getCatalogBlockContainer(catalog);
183	const filterToggle = catalog?.querySelector(".catalog-head__filter");
184	const sortRoot = getCatalogSortRoot(catalog);
185	const sortCycleBtn = getCatalogSortCycleBtn(catalog);
186
187	if (sidebar) {
188		sidebar.classList.add("_to_block");
189		block(sidebar);
190	}
191
192	if (blockContainer) {
193		block(blockContainer);
194	}
195
196	if (filterToggle instanceof HTMLElement) {
197		filterToggle.style.pointerEvents = "none";
198		filterToggle.setAttribute("aria-disabled", "true");
199	}
200
201	if (sortRoot instanceof HTMLElement) {
202		sortRoot.classList.add("is-disabled");
203		sortRoot.classList.remove("_show");
204		sortRoot.querySelector(".select-container")?.removeAttribute("style");
205	}
206
207	if (sortCycleBtn instanceof HTMLElement) {
208		sortCycleBtn.classList.add("is-disabled");
209		sortCycleBtn.setAttribute("aria-disabled", "true");
210	}
211};
212
213const unblockCatalogFilters = (catalog) => {
214	const sidebar = getCatalogSidebarBlockTarget(catalog);
215	const blockContainer = getCatalogBlockContainer(catalog);
216	const filterToggle = catalog?.querySelector(".catalog-head__filter");
217	const sortRoot = getCatalogSortRoot(catalog);
218	const sortCycleBtn = getCatalogSortCycleBtn(catalog);
219
220	if (sidebar) {
221		unblock(sidebar);
222		sidebar.classList.remove("_to_block");
223	}
224
225	if (blockContainer) {
226		unblock(blockContainer);
227	}
228
229	if (filterToggle instanceof HTMLElement) {
230		filterToggle.style.pointerEvents = "";
231		filterToggle.removeAttribute("aria-disabled");
232	}
233
234	if (sortRoot instanceof HTMLElement) {
235		sortRoot.classList.remove("is-disabled");
236	}
237
238	if (sortCycleBtn instanceof HTMLElement) {
239		sortCycleBtn.classList.remove("is-disabled");
240		sortCycleBtn.removeAttribute("aria-disabled");
241	}
242};
243
244const appendCatalogContextToFormData = (data, catalog) => {
245	if (!catalog) {
246		return;
247	}
248
249	if (catalog.dataset.contextCat) {
250		data.append("context_cat", catalog.dataset.contextCat);
251	}
252
253	if (catalog.dataset.contextPodborki) {
254		data.append("context_podborki", catalog.dataset.contextPodborki);
255	}
256
257	if (catalog.dataset.contextPodborkiAll) {
258		data.append("context_podborki_all", "1");
259	}
260
261	if (catalog.dataset.contextGiftIdea) {
262		data.append("context_gift_idea", catalog.dataset.contextGiftIdea);
263	}
264};
265
266const collectCatalogFilters = (catalog) => {
267	const data = new FormData();
268	const sidebar = catalog?.querySelector(".catalog-sidebar");
269
270	if (sidebar) {
271		sidebar.querySelectorAll(".check__input:checked").forEach((input) => {
272			if (input.disabled) {
273				return;
274			}
275
276			const filterKey = input.dataset.filterGroup;
277
278			if (filterKey) {
279				data.append(`${filterKey}[]`, input.value);
280				return;
281			}
282
283			if (input.name) {
284				data.append(input.name, input.value);
285			}
286		});
287	}
288
289	data.append("catalog_sort", getCatalogSortValue(catalog));
290	appendCatalogContextToFormData(data, catalog);
291
292	return data;
293};
294
295const buildUrlSearchParams = (catalog) => {
296	const params = new URLSearchParams();
297	const sidebar = catalog?.querySelector(".catalog-sidebar");
298
299	if (sidebar) {
300		FILTER_PARAM_KEYS.forEach((key) => {
301			sidebar.querySelectorAll(`input[name="${key}[]"]:checked`).forEach((input) => {
302				params.append(`${key}[]`, input.value);
303			});
304		});
305	}
306
307	const sort = getCatalogSortValue(catalog);
308
309	if (sort && sort !== DEFAULT_CATALOG_SORT) {
310		params.set("catalog_sort", sort);
311	}
312
313	if (catalog?.dataset.contextCat) {
314		params.set("context_cat", catalog.dataset.contextCat);
315	}
316
317	if (catalog?.dataset.contextPodborki) {
318		params.set("context_podborki", catalog.dataset.contextPodborki);
319	}
320
321	if (catalog?.dataset.contextPodborkiAll) {
322		params.set("context_podborki_all", "1");
323	}
324
325	if (catalog?.dataset.contextGiftIdea) {
326		params.set("context_gift_idea", catalog.dataset.contextGiftIdea);
327	}
328
329	return params;
330};
331
332const syncUrl = (catalog) => {
333	const params = buildUrlSearchParams(catalog);
334	const query = params.toString();
335	const nextUrl = query ? `${window.location.pathname}?${query}` : window.location.pathname;
336
337	window.history.pushState({ catalogFilters: true }, "", nextUrl);
338};
339
340const applyFiltersFromUrl = (catalog) => {
341	restoreSidebarFilters(catalog, new URLSearchParams(window.location.search));
342};
343
344const restoreSidebarFilters = (catalog, params) => {
345	const sidebar = catalog?.querySelector(".catalog-sidebar");
346
347	setCatalogSortValue(catalog, params.get("catalog_sort") || DEFAULT_CATALOG_SORT);
348
349	if (!sidebar) {
350		return;
351	}
352
353	sidebar.querySelectorAll(".check__input").forEach((input) => {
354		input.checked = false;
355	});
356
357	FILTER_PARAM_KEYS.forEach((key) => {
358		// Accept both ?filter_x=slug (mega-menu / share links) and ?filter_x[]=slug (AJAX syncUrl).
359		const values = [...new Set([...params.getAll(`${key}[]`), ...params.getAll(key)])];
360
361		values.forEach((value) => {
362			const input = sidebar.querySelector(`input[name="${key}[]"][value="${CSS.escape(value)}"]`);
363			if (input && !input.disabled) {
364				input.checked = true;
365			}
366		});
367	});
368
369	sidebar.querySelectorAll(".filter").forEach((filter) => {
370		const titleEl = filter.querySelector(".filter-title");
371		const container = filter.querySelector(".filter-container");
372
373		if (!titleEl || !container || !titleEl.dataset.defaultTitle) {
374			return;
375		}
376
377		const checkedCount = container.querySelectorAll(".check__input:checked").length;
378
379		titleEl.textContent =
380			checkedCount > 0 ? `${titleEl.dataset.defaultTitle} [${checkedCount}]` : titleEl.dataset.defaultTitle;
381	});
382};
383
384const updateCatalogUi = (catalog, res) => {
385	const catalogContainer = catalog.querySelector(".catalog-container");
386	const catalogBox = catalog.querySelector(".catalog-box");
387	const infoText =
388		catalog.querySelector(".catalog-head__found") || catalog.querySelector(".catalog-head__info p");
389
390	if (res.is_empty) {
391		catalogBox?.remove();
392		catalog.querySelector(".catalog-more")?.remove();
393
394		let catalogContent = catalog.querySelector(".catalog-content");
395
396		if (res.empty_html) {
397			if (catalogContent) {
398				catalogContent.outerHTML = res.empty_html;
399			} else {
400				catalog.querySelector(".catalog-head")?.insertAdjacentHTML("afterend", res.empty_html);
401			}
402		}
403	} else {
404		catalog.querySelector(".catalog-content")?.remove();
405
406		if (!catalogBox && catalogContainer) {
407			catalog.querySelector(".catalog-head")?.insertAdjacentHTML(
408				"afterend",
409				'<div class="catalog-box _to_block"></div>',
410			);
411		}
412
413		const activeCatalogBox = catalog.querySelector(".catalog-box");
414
415		if (activeCatalogBox) {
416			activeCatalogBox.hidden = false;
417			activeCatalogBox.innerHTML = res.html || "";
418		}
419
420		window.photohumanMatchOperationCardHeights?.();
421	}
422
423	if (infoText) {
424		if (res.has_active_filters && res.found_label) {
425			infoText.textContent = res.found_label;
426			infoText.hidden = false;
427		} else {
428			infoText.hidden = true;
429		}
430	}
431	const moreWrap = catalog.querySelector(".catalog-more");
432	const activeFilterParams = res.facets_html ? buildUrlSearchParams(catalog) : null;
433
434	if (res.has_more) {
435		if (!moreWrap) {
436			const container = catalog.querySelector(".catalog-container");
437			container?.insertAdjacentHTML(
438				"beforeend",
439				`<div class="catalog-more"><button type="button" class="button _black" data-click="loadProduct" data-page="2">ПОКАЗАТЬ ЕЩЕ${ICON_ARROW_HTML}</button></div>`,
440			);
441		}
442
443		const btn = catalog.querySelector(".catalog-more .button[data-click='loadProduct']");
444		if (btn) {
445			btn.dataset.page = "2";
446			btn.dataset.total = String(res.total ?? "");
447			btn.disabled = false;
448			setButtonLabel(btn, "ПОКАЗАТЬ ЕЩЕ", true);
449		}
450	} else {
451		moreWrap?.remove();
452	}
453
454	if (res.facets_html) {
455		updateCatalogSidebarFacets(catalog, res.facets_html, activeFilterParams);
456	}
457};
458
459/**
460 * Refresh filter options inside the existing drawer.
461 * Keeps `.catalog-sidebar` (and `_show`) so the mobile modal does not flash.
462 */
463const updateCatalogSidebarFacets = (catalog, facetsHtml, activeFilterParams = null) => {
464	const currentSidebar = catalog.querySelector(".catalog-sidebar");
465
466	if (!currentSidebar) {
467		return;
468	}
469
470	const template = document.createElement("template");
471	template.innerHTML = String(facetsHtml || "").trim();
472	const nextSidebar = template.content.querySelector(".catalog-sidebar");
473	const nextInner = nextSidebar?.querySelector(":scope > .sidebar");
474	const currentInner = currentSidebar.querySelector(":scope > .sidebar");
475	const scrollTop = currentSidebar.querySelector(".sidebar-body")?.scrollTop || 0;
476
477	captureExpandedFilterGroups(catalog);
478	captureExtraFiltersOpen(catalog);
479
480	if (nextInner && currentInner) {
481		currentInner.replaceWith(nextInner);
482	} else if (nextSidebar) {
483		// Fallback: rare malformed response — replace the whole drawer.
484		const wasOpen = currentSidebar.classList.contains("_show");
485		currentSidebar.replaceWith(nextSidebar);
486
487		if (wasOpen) {
488			nextSidebar.classList.add("_show");
489		}
490	}
491
492	const sidebarBody = catalog.querySelector(".catalog-sidebar .sidebar-body");
493
494	if (sidebarBody) {
495		sidebarBody.scrollTop = scrollTop;
496	}
497
498	syncCatalogSidebarScrollLock(catalog);
499	reinitCatalogFilter();
500	restoreSidebarFilters(catalog, activeFilterParams ?? buildUrlSearchParams(catalog));
501};
502
503const applyCatalogFilters = () => {
504	const catalog = getCatalog();
505
506	if (!catalog || isCatalogFilterLoading(catalog)) {
507		return;
508	}
509
510	setCatalogFilterLoading(catalog, true);
511	blockCatalogFilters(catalog);
512
513	const formData = collectCatalogFilters(catalog);
514
515	formData.append("page", "1");
516
517	serverReq(formData, "filterCatalog")
518		.then((res) => {
519			if (!res?.success) {
520				return;
521			}
522
523			updateCatalogUi(catalog, res);
524			syncUrl(catalog);
525		})
526		.finally(() => {
527			setCatalogFilterLoading(catalog, false);
528			unblockCatalogFilters(catalog);
529		});
530};
531
532const handleSidebarFilterChange = (event) => {
533	const target = event.target;
534	const catalog = getCatalog();
535
536	if (!(target instanceof HTMLInputElement) || !target.classList.contains("check__input")) {
537		return;
538	}
539
540	if (!target.closest(".catalog-sidebar") || target.disabled) {
541		return;
542	}
543
544	if (isCatalogFilterLoading(catalog)) {
545		target.checked = !target.checked;
546		return;
547	}
548
549	applyCatalogFilters();
550};
551
552const handleCatalogSortPick = (event) => {
553	const target = event.target;
554	const catalog = getCatalog();
555
556	if (!(target instanceof Element) || !catalog) {
557		return;
558	}
559
560	const item = target.closest(".catalog-sort .select-list li[data-id]");
561
562	if (!item || !item.closest(".catalog")) {
563		return;
564	}
565
566	event.preventDefault();
567	event.stopPropagation();
568
569	if (isCatalogFilterLoading(catalog)) {
570		return;
571	}
572
573	const next = item.getAttribute("data-id") || DEFAULT_CATALOG_SORT;
574	const prev = getCatalogSortValue(catalog);
575
576	setCatalogSortValue(catalog, next);
577	closeCatalogSortMenu(catalog);
578
579	if (next === prev) {
580		return;
581	}
582
583	applyCatalogFilters();
584};
585
586const handleCatalogSortToggle = (event) => {
587	const target = event.target;
588	const catalog = getCatalog();
589
590	if (!(target instanceof Element) || !catalog) {
591		return;
592	}
593
594	const mobileBtn = target.closest("[data-catalog-sort-cycle]");
595	const desktopHead = target.closest(".catalog-sort .select-head");
596
597	if (!mobileBtn && !desktopHead) {
598		return;
599	}
600
601	if (!target.closest(".catalog")) {
602		return;
603	}
604
605	// Don't toggle when clicking the hidden input inside the head.
606	if (target instanceof HTMLInputElement && target.matches("[data-catalog-sort]")) {
607		return;
608	}
609
610	event.preventDefault();
611	event.stopPropagation();
612
613	toggleCatalogSortMenu(catalog);
614};
615
616const handleCatalogSortOutsidePointer = (event) => {
617	if (Date.now() < catalogSortIgnoreOutsideUntil) {
618		return;
619	}
620
621	const catalog = getCatalog();
622	const target = event.target;
623
624	if (!catalog || !(target instanceof Node)) {
625		return;
626	}
627
628	const root = getCatalogSortRoot(catalog);
629
630	if (!(root instanceof HTMLElement) || !root.classList.contains("_show")) {
631		return;
632	}
633
634	if (root.contains(target)) {
635		return;
636	}
637
638	closeCatalogSortMenu(catalog);
639};
640
641export const initApplyCatalogFilter = () => {
642	const catalog = getCatalog();
643
644	if (!catalog) {
645		return;
646	}
647
648	if (window.location.search) {
649		applyFiltersFromUrl(catalog);
650	}
651
652	syncCatalogSortCycleBtn(catalog, getCatalogSortValue(catalog));
653	getCatalogSortCycleBtn(catalog)?.setAttribute("aria-expanded", "false");
654	getCatalogSortRoot(catalog)?.querySelector(".select-head")?.setAttribute("aria-expanded", "false");
655
656	catalog.addEventListener("change", handleSidebarFilterChange);
657	catalog.addEventListener("click", handleCatalogSortPick);
658	catalog.addEventListener("click", handleCatalogSortToggle);
659	// Capture pointerdown closes on outside tap; ignore-window prevents open→instant-close on mobile.
660	document.addEventListener("pointerdown", handleCatalogSortOutsidePointer, true);
661
662	document.addEventListener("photohuman:catalog-filter-apply", applyCatalogFilters);
663
664	window.addEventListener("popstate", () => {
665		const currentCatalog = getCatalog();
666
667		if (!currentCatalog) {
668			return;
669		}
670
671		applyFiltersFromUrl(currentCatalog);
672		applyCatalogFilters();
673	});
674
675	// Close only when crossing desktop/mobile breakpoint — not on mobile chrome resize.
676	let sortMq = window.matchMedia("(max-width: 1023px)");
677	const onSortMqChange = () => closeCatalogSortMenu(getCatalog());
678	if (typeof sortMq.addEventListener === "function") {
679		sortMq.addEventListener("change", onSortMqChange);
680	} else if (typeof sortMq.addListener === "function") {
681		sortMq.addListener(onSortMqChange);
682	}
683};
684
685export const appendCatalogFiltersToFormData = (formData, catalog = getCatalog()) => {
686	if (!catalog) {
687		return formData;
688	}
689
690	const filters = collectCatalogFilters(catalog);
691
692	for (const [key, value] of filters.entries()) {
693		formData.append(key, value);
694	}
695
696	return formData;
697};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.