Желания
Добавьте товары в желания
0
Добавьте товары в корзину

Тональный крем для лица: ровный тон и комфорт

Фильтр
Сортировка:
Отображение:

Мы заботимся о вашей конфиденциальности

Этот веб-сайт использует файлы cookie для маркетинга и статистических целей, а также для безопасной и оптимальной работы сайта. Вы можете изменить это в настройках вашего браузера. Нажмите на кнопку «Согласиться», чтобы дать согласие на использование файлов cookie. Подробнее можно ознакомиться на странице Пользовательское соглашение.

/** * YK Brands Filter v4 for Horoshop * * One-file, dependency-free enhancement for a paginated brands page. * The module waits for a stable brands DOM, loads every pagination page, * builds a safe in-memory model, and renders search/alphabet results from it. * * Optional configuration (define BEFORE this script): * window.YKBrandsFilterV4Config = { * debug: true, * cacheTtl: 24 * 60 * 60 * 1000, * readyTimeout: 45000, * fetchTimeout: 15000, * concurrency: 2, * containerSelectors: ['.your-brands-list'], * itemSelectors: ['.your-brand-card'] * }; * * Public API: window.YKBrandsFilterV4.init(), .refresh(), .destroy(), .state */ (function (window, document) { 'use strict'; /** * @typedef {Object} BrandModel * @property {string} id Stable deduplication identifier. * @property {string} name Human-readable brand name. * @property {string} url Absolute brand page URL. * @property {string} image Absolute logo URL or an empty string. * @property {string} imageAlt Logo alternative text. * @property {string} imageSrcset Responsive image candidates. * @property {string} imageSizes Responsive image sizes. * @property {string} title Link title. * @property {boolean} lazy Whether the source image was lazy-loaded. * @property {string[]} extraClasses Source card classes retained for compatibility. */ const VERSION = '4.1.0'; const NS = 'ykbf4'; const userConfig = window.YKBrandsFilterV4Config || {}; const defaults = { debug: false, cacheTtl: 24 * 60 * 60 * 1000, readyTimeout: 45000, fetchTimeout: 15000, domQuietTime: 500, concurrency: 2, searchDebounce: 50, containerSelectors: [ '.brands-list', '.brand-list', '.brands-page__list', '.brands__list', '.catalog-brands', '[data-brands-list]' ], itemSelectors: [ '.brands-list__item', '.brand-list__item', '.brands-page__item', '.brands__item', '.brand-item', '[data-brand-item]' ], paginationSelectors: [ '.pagination', '.pager', '.pages', '[class*="pagination"]', '[data-pagination]' ] }; const config = Object.assign({}, defaults, userConfig); config.readyTimeout = Number(userConfig.readyTimeout || userConfig.catalogTimeout || defaults.readyTimeout); config.domQuietTime = Math.max(500, Number(config.domQuietTime) || 500); config.containerSelectors = mergeUnique(userConfig.containerSelectors, defaults.containerSelectors); config.itemSelectors = mergeUnique(userConfig.itemSelectors, defaults.itemSelectors); config.paginationSelectors = mergeUnique(userConfig.paginationSelectors, defaults.paginationSelectors); const state = { status: 'idle', brands: [], filteredCount: 0, pageCount: 0, failedPages: [], fromCache: false, query: '', letter: '*', diagnostics: null }; let elements = {}; let abortController = null; let debounceTimer = null; let rebuildTimer = null; let initPromise = null; let lifecycleId = 0; let destroyed = false; let readinessObserver = null; let rebuildObserver = null; let rendering = false; let managedNodes = new Set(); let scanCache = new WeakMap(); let brandMeta = new Map(); const activeTimers = new Map(); const trackedListeners = []; const diagnostics = createDiagnostics(); const i18n = getTranslations(); function mergeUnique(preferred, fallback) { return Array.from(new Set([].concat(preferred || [], fallback || []).filter(Boolean))); } function log(...args) { if (config.debug && window.console) console.log(`[${NS}]`, ...args); } function warn(...args) { if (config.debug && window.console) console.warn(`[${NS}]`, ...args); } /** @returns {Object} Mutable diagnostic counters for the current lifecycle. */ function createDiagnostics() { return { pagesFound: 0, pagesLoaded: 0, brandsLoaded: 0, duplicatesRemoved: 0, cacheHit: false, retryCount: 0, renderTime: 0, totalInitTime: 0, domRebuilds: 0 }; } function getTranslations() { const lang = (document.documentElement.lang || 'uk').toLowerCase(); if (lang.startsWith('ru')) { return { placeholder: 'Поиск бренда', all: 'Все', found: 'Найдено: ', loading: 'Загружаем все бренды…', empty: 'Бренды не найдены', partial: 'Не все страницы удалось загрузить. Доступна стандартная пагинация.', clear: 'Очистить поиск', searchLabel: 'Поиск по брендам', alphabetLabel: 'Фильтр по алфавиту' }; } if (lang.startsWith('en')) { return { placeholder: 'Search brands', all: 'All', found: 'Found: ', loading: 'Loading all brands…', empty: 'No brands found', partial: 'Some pages could not be loaded. Standard pagination remains available.', clear: 'Clear search', searchLabel: 'Search brands', alphabetLabel: 'Alphabet filter' }; } return { placeholder: 'Пошук бренду', all: 'Усі', found: 'Знайдено: ', loading: 'Завантажуємо всі бренди…', empty: 'Брендів не знайдено', partial: 'Не всі сторінки вдалося завантажити. Стандартна пагінація доступна.', clear: 'Очистити пошук', searchLabel: 'Пошук брендів', alphabetLabel: 'Алфавітний фільтр' }; } function normalize(value) { const text = String(value || '').trim().toLocaleLowerCase(); try { return text.normalize('NFKD').replace(/[\u0300-\u036f]/g, ''); } catch (error) { return text; } } function brandLetter(name) { const first = Array.from(String(name || '').trim().toLocaleUpperCase())[0] || '#'; if (/\d/.test(first)) return '0-9'; return /[\p{L}]/u.test(first) ? first : '#'; } /** Creates a tracked, destroy-safe delay. */ function wait(ms) { return new Promise((resolve) => { const timer = window.setTimeout(() => { activeTimers.delete(timer); resolve(); }, ms); activeTimers.set(timer, resolve); }); } function addTrackedListener(target, type, handler, options, cancel) { target.addEventListener(type, handler, options); trackedListeners.push({ target, type, handler, options, cancel }); } /** Waits for window.load, a discoverable brands container, and 500 ms of DOM quietness. */ async function waitForStableBrandsDom() { const started = Date.now(); await waitForWindowLoad(); while (Date.now() - started < config.readyTimeout) { if (destroyed) throw new Error('Module destroyed'); scanCache = new WeakMap(); const container = findBrandContainer(document); if (container) { await waitForQuietDom(container, config.domQuietTime, config.readyTimeout - (Date.now() - started)); scanCache = new WeakMap(); const stableContainer = findBrandContainer(document); if (stableContainer && stableContainer.isConnected) { const builderPresent = typeof window.CatalogBuilder !== 'undefined'; log('Brands DOM is stable', { catalogBuilderPresent: builderPresent }); return stableContainer; } } await wait(100); } throw new Error(`Brands DOM was not stable within ${config.readyTimeout} ms`); } function waitForWindowLoad() { if (document.readyState === 'complete') return Promise.resolve(); return new Promise((resolve) => { const onLoad = () => { const index = trackedListeners.findIndex((entry) => entry.handler === onLoad); if (index !== -1) trackedListeners.splice(index, 1); resolve(); }; addTrackedListener(window, 'load', onLoad, { once: true }, resolve); }); } function waitForQuietDom(container, quietMs, remainingMs) { return new Promise((resolve, reject) => { let quietTimer = null; let deadlineTimer = null; let settled = false; const finish = (error) => { if (settled) return; settled = true; if (quietTimer) clearTrackedTimer(quietTimer); if (deadlineTimer) clearTrackedTimer(deadlineTimer); if (readinessObserver) readinessObserver.disconnect(); readinessObserver = null; if (error) reject(error); else resolve(); }; const schedule = (callback, delay) => { const timer = window.setTimeout(() => { activeTimers.delete(timer); callback(); }, delay); activeTimers.set(timer, () => finish(new Error('Module destroyed'))); return timer; }; const resetQuietTimer = () => { if (quietTimer) clearTrackedTimer(quietTimer); quietTimer = schedule(() => finish(), quietMs); }; readinessObserver = new MutationObserver(resetQuietTimer); readinessObserver.observe(container, { childList: true, subtree: true, attributes: true }); if (container.parentNode) readinessObserver.observe(container.parentNode, { childList: true }); deadlineTimer = schedule(() => finish(new Error('Brands DOM kept changing')), Math.max(1, remainingMs)); resetQuietTimer(); }); } function clearTrackedTimer(timer) { window.clearTimeout(timer); activeTimers.delete(timer); } /** Scans each Document/Element once and caches all relevant nodes. */ function scanRoot(root) { if (scanCache.has(root)) return scanCache.get(root); const selectors = [ 'a[href]', '[data-page]', ...config.containerSelectors, ...config.paginationSelectors ]; let nodes = []; const validSelectors = mergeUnique(selectors, []).filter(isValidSelector); try { nodes = Array.from(root.querySelectorAll(validSelectors.join(','))); } catch (error) { warn('DOM scan failed', error); } const scan = { anchors: nodes.filter((node) => node.tagName === 'A' && node.hasAttribute('href')), hints: nodes.filter((node) => matchesAny(node, config.containerSelectors)), paginations: nodes.filter((node) => matchesAny(node, config.paginationSelectors)) }; scanCache.set(root, scan); return scan; } function isValidSelector(selector) { try { document.documentElement.matches(selector); return true; } catch (error) { return false; } } function matchesAny(node, selectors) { return selectors.some((selector) => { try { return node.matches(selector); } catch (error) { return false; } }); } function isExcludedLink(link) { return Boolean(link.closest(`header,footer,nav,.breadcrumbs,.breadcrumb,.${NS}-controls`) || closestAny(link, config.paginationSelectors)); } function closestAny(node, selectors) { for (const selector of selectors) { try { const match = node.closest(selector); if (match) return match; } catch (error) { /* invalid optional selector */ } } return null; } /** Selects the deepest block containing the largest set of unique content links. */ function findBrandContainer(root, baseUrl) { const scan = scanRoot(root); const hintSet = new Set(scan.hints); const candidates = new Map(); scan.anchors.forEach((link) => { if (isExcludedLink(link) || !getBrandName(link)) return; let href; try { href = new URL(link.getAttribute('href'), baseUrl || root.baseURI || window.location.href).href.replace(/[?#].*$/, ''); } catch (error) { return; } let node = link.parentElement; while (node && node !== root.body && node !== document.body) { if (!node.closest(`.${NS}-controls`) && !matchesAny(node, config.paginationSelectors)) { if (!candidates.has(node)) candidates.set(node, new Set()); candidates.get(node).add(href); } node = node.parentElement; } }); let best = null; let bestCount = 1; let bestHint = false; let bestDepth = -1; candidates.forEach((urls, candidate) => { const count = urls.size; const hinted = hintSet.has(candidate); const depth = elementDepth(candidate); if (count > bestCount || (count === bestCount && hinted && !bestHint) || (count === bestCount && hinted === bestHint && depth > bestDepth)) { best = candidate; bestCount = count; bestHint = hinted; bestDepth = depth; } }); candidates.clear(); return best; } function elementDepth(node) { let depth = 0; while (node && node.parentElement) { depth += 1; node = node.parentElement; } return depth; } function linksForContainer(container, root) { const cached = scanCache.get(container); if (cached) return cached.anchors; const anchors = scanRoot(root).anchors.filter((link) => container.contains(link) && !isExcludedLink(link)); scanCache.set(container, { anchors }); return anchors; } function getBrandName(link) { const image = link.getElementsByTagName('img')[0] || null; return String( link.getAttribute('title') || (image && image.getAttribute('alt')) || link.textContent || '' ).replace(/\s+/g, ' ').trim(); } /** @returns {Array} Safe serializable brand models without stored markup. */ function modelFromDocument(root, baseUrl) { const container = findBrandContainer(root, baseUrl); if (!container) throw new Error('Brands container was not found'); return linksForContainer(container, root).map((link) => modelFromLink(link, container, baseUrl)).filter(Boolean); } function modelFromLink(link, container, baseUrl) { const name = getBrandName(link); if (!name) return null; let url; try { url = new URL(link.getAttribute('href'), baseUrl || window.location.href).href; } catch (error) { return null; } const item = findItemNode(link, container); const imageNode = link.getElementsByTagName('img')[0] || null; const imageValue = imageNode && (imageNode.getAttribute('src') || imageNode.getAttribute('data-src') || imageNode.getAttribute('data-lazy-src')); let image = ''; try { image = imageValue ? new URL(imageValue, baseUrl || window.location.href).href : ''; } catch (error) { image = imageValue || ''; } const id = normalize(url.replace(/[?#].*$/, '').replace(/\/$/, '') || name); return { id, name, url, image, imageAlt: imageNode ? imageNode.getAttribute('alt') || name : name, imageSrcset: imageNode ? imageNode.getAttribute('srcset') || imageNode.getAttribute('data-srcset') || '' : '', imageSizes: imageNode ? imageNode.getAttribute('sizes') || '' : '', title: link.getAttribute('title') || name, lazy: Boolean(imageNode && (imageNode.getAttribute('loading') === 'lazy' || imageNode.hasAttribute('data-src') || imageNode.hasAttribute('data-lazy-src'))), extraClasses: item ? Array.from(item.classList).filter((namePart) => !namePart.startsWith(NS)) : [] }; } function findItemNode(link, container) { const hinted = closestAny(link, config.itemSelectors); if (hinted && container.contains(hinted)) return hinted; let item = link; while (item.parentElement && item.parentElement !== container) item = item.parentElement; return item; } function deduplicate(brands) { const map = new Map(); brands.forEach((brand) => { if (brand && brand.name && !map.has(brand.id)) map.set(brand.id, brand); }); const unique = Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })); map.clear(); return unique; } function paginationRoots(root) { const scan = scanRoot(root); return Array.from(new Set(scan.paginations)).filter((node) => scan.anchors.some((anchor) => node.contains(anchor)) ); } function getPageNumber(url, anchor) { const dataValue = anchor && (anchor.dataset.page || anchor.dataset.pageNumber); if (/^\d+$/.test(dataValue || '')) return Number(dataValue); const keys = ['page', 'p', 'pageNumber', 'PAGEN_1']; for (const key of keys) { const value = url.searchParams.get(key); if (/^\d+$/.test(value || '')) return Number(value); } const pathMatch = url.pathname.match(/(?:\/|[-_])page(?:\/|-|_)?(\d+)(?:\/|$)/i); if (pathMatch) return Number(pathMatch[1]); const text = String(anchor && anchor.textContent || '').trim(); return /^\d+$/.test(text) ? Number(text) : null; } function discoverPageUrls(root, baseUrl) { const pages = new Map(); const origin = window.location.origin; const anchors = scanRoot(root).anchors; paginationRoots(root).forEach((pagination) => { anchors.filter((anchor) => pagination.contains(anchor)).forEach((anchor) => { const raw = anchor.getAttribute('href') || anchor.getAttribute('data-href'); if (!raw || raw === '#') return; let url; try { url = new URL(raw, baseUrl); } catch (error) { return; } if (url.origin !== origin) return; url.hash = ''; const number = getPageNumber(url, anchor); if (number && number > 0) pages.set(number, url.href); }); }); // If the paginator exposes a last page but collapses intermediate links, // infer missing URLs from a real numbered link (query and path styles). const numbers = Array.from(pages.keys()); const max = numbers.length ? Math.max(...numbers) : 1; const sampleNumber = numbers.find((number) => number !== currentPageNumber()) || numbers[0]; const sampleUrl = sampleNumber && pages.get(sampleNumber); if (sampleUrl && max > 1) { for (let number = 1; number <= max; number += 1) { if (!pages.has(number)) { const inferred = replacePageNumber(sampleUrl, sampleNumber, number); if (inferred) pages.set(number, inferred); } } } return pages; } function currentPageNumber() { return getPageNumber(new URL(window.location.href), null) || 1; } function replacePageNumber(sampleHref, sampleNumber, wantedNumber) { const url = new URL(sampleHref); for (const key of ['page', 'p', 'pageNumber', 'PAGEN_1']) { if (url.searchParams.has(key)) { if (wantedNumber === 1) url.searchParams.delete(key); else url.searchParams.set(key, String(wantedNumber)); return url.href; } } const pattern = new RegExp(`((?:/|[-_])page(?:/|-|_)?)${sampleNumber}(?=/|$)`, 'i'); if (pattern.test(url.pathname)) { if (wantedNumber === 1) url.pathname = url.pathname.replace(pattern, '').replace(/\/\/{2,}/g, '/'); else url.pathname = url.pathname.replace(pattern, `$1${wantedNumber}`); return url.href; } return null; } async function fetchDocument(url) { const retryStatuses = new Set([429, 500, 502, 503, 504]); const delays = [500, 1000]; let lastError = null; for (let attempt = 0; attempt < 3; attempt += 1) { if (abortController && abortController.signal.aborted) throw new DOMException('Fetch aborted', 'AbortError'); const pageController = new AbortController(); let timedOut = false; const onAbort = () => pageController.abort(); if (abortController) abortController.signal.addEventListener('abort', onAbort, { once: true }); const timeout = window.setTimeout(() => { activeTimers.delete(timeout); timedOut = true; pageController.abort(); }, config.fetchTimeout); activeTimers.set(timeout, onAbort); try { log(`Fetching (attempt ${attempt + 1})`, url); const response = await window.fetch(url, { method: 'GET', credentials: 'same-origin', headers: { Accept: 'text/html,application/xhtml+xml' }, signal: pageController.signal }); if (!response.ok) { const error = new Error(`HTTP ${response.status}`); error.status = response.status; throw error; } const html = await response.text(); return new DOMParser().parseFromString(html, 'text/html'); } catch (error) { lastError = error; const externallyAborted = abortController && abortController.signal.aborted; const retryable = timedOut || retryStatuses.has(error.status); if (externallyAborted || !retryable || attempt === 2) throw error; diagnostics.retryCount += 1; await wait(delays[attempt]); } finally { clearTrackedTimer(timeout); if (abortController) abortController.signal.removeEventListener('abort', onAbort); } } throw lastError; } async function loadAllPages() { const firstPlan = discoverPageUrls(document, window.location.href); const currentUrl = canonicalUrl(window.location.href); const visited = new Set(); const queued = new Set(); const queue = []; const failures = []; const allBrands = []; const enqueue = (href) => { const canonical = canonicalUrl(href); if (!visited.has(canonical) && !queued.has(canonical)) { queued.add(canonical); queue.push(href); } }; enqueue(currentUrl); firstPlan.forEach(enqueue); while (queue.length) { const batch = queue.splice(0, Math.max(1, Number(config.concurrency) || 1)); const results = await Promise.all(batch.map(async (url) => { const canonical = canonicalUrl(url); queued.delete(canonical); visited.add(canonical); try { const pageDocument = await fetchDocument(url); const brands = modelFromDocument(pageDocument, url); const foundPages = discoverPageUrls(pageDocument, url); diagnostics.pagesLoaded += 1; return { url, brands, foundPages }; } catch (error) { warn('Failed to load brands page', url, error); failures.push({ url, message: error.message || String(error) }); return null; } })); results.filter(Boolean).forEach((result) => { allBrands.push(...result.brands); result.foundPages.forEach(enqueue); }); } const uniqueBrands = deduplicate(allBrands); diagnostics.pagesFound = visited.size; diagnostics.brandsLoaded = uniqueBrands.length; diagnostics.duplicatesRemoved = allBrands.length - uniqueBrands.length; visited.clear(); queued.clear(); return { brands: uniqueBrands, complete: failures.length === 0, failedPages: failures, pageCount: diagnostics.pagesFound, duplicatesRemoved: diagnostics.duplicatesRemoved }; } function canonicalUrl(href) { const url = new URL(href, window.location.href); url.hash = ''; for (const key of ['page', 'p', 'pageNumber', 'PAGEN_1']) { if (url.searchParams.get(key) === '1') url.searchParams.delete(key); } url.pathname = url.pathname.replace(/(?:\/|[-_])page(?:\/|-|_)?1\/?$/i, '/').replace(/\/\/{2,}/g, '/'); const entries = Array.from(url.searchParams.entries()).sort(([a], [b]) => a.localeCompare(b)); url.search = ''; entries.forEach(([key, value]) => url.searchParams.append(key, value)); return url.href; } function cacheKey() { const path = window.location.pathname.replace(/\/page(?:\/|-)?\d+\/?$/i, '/'); return `${NS}:${VERSION}:${window.location.host}:${path}:${document.documentElement.lang || ''}`; } function readCache(expectedPageCount, sourceFingerprint) { try { const raw = window.localStorage.getItem(cacheKey()); if (!raw) return null; const cached = JSON.parse(raw); const fresh = cached.version === VERSION && Date.now() - cached.savedAt < config.cacheTtl; const sameCount = !expectedPageCount || !cached.pageCount || cached.pageCount === expectedPageCount; if (!fresh || !cached.complete || !sameCount || !Array.isArray(cached.brands)) return null; if (cached.fingerprint !== createFingerprint(cached.pageCount, cached.brands)) return null; if (cached.sourceFingerprint && sourceFingerprint && cached.sourceFingerprint !== sourceFingerprint) return null; diagnostics.cacheHit = true; diagnostics.pagesFound = cached.pageCount; diagnostics.pagesLoaded = 0; diagnostics.brandsLoaded = cached.brands.length; diagnostics.duplicatesRemoved = cached.duplicatesRemoved || 0; log('Using cached brands', cached.brands.length); return cached; } catch (error) { log('Cache read failed', error); return null; } } function writeCache(result) { if (!result.complete) return; try { window.localStorage.setItem(cacheKey(), JSON.stringify({ version: VERSION, savedAt: Date.now(), complete: true, pageCount: result.pageCount, fingerprint: createFingerprint(result.pageCount, result.brands), duplicatesRemoved: result.duplicatesRemoved || 0, sourceFingerprint: result.sourceFingerprint || '', brands: result.brands })); } catch (error) { log('Cache write failed (storage may be full or disabled)', error); } } function createFingerprint(pageCount, brands) { const lastBrand = brands.length ? brands[brands.length - 1] : null; return `${Number(pageCount) || 0}|${lastBrand ? lastBrand.url : ''}|${brands.length}`; } function estimatedPageCount() { const pages = discoverPageUrls(document, window.location.href); return pages.size ? Math.max(...pages.keys()) : 1; } function injectStyles() { if (document.getElementById(`${NS}-styles`)) return; const style = document.createElement('style'); style.id = `${NS}-styles`; style.textContent = ` .${NS}-controls{margin:0 0 24px;padding:16px;background:#fff;border:1px solid rgba(0,0,0,.1);border-radius:8px} .${NS}-search-wrap{position:relative;max-width:560px} .${NS}-search{box-sizing:border-box;width:100%;min-height:44px;padding:10px 42px 10px 14px;border:1px solid #c7c7c7;border-radius:6px;font:inherit;background:#fff;color:inherit} .${NS}-search:focus{outline:2px solid currentColor;outline-offset:1px} .${NS}-clear{position:absolute;right:4px;top:50%;transform:translateY(-50%);width:36px;height:36px;border:0;background:transparent;font-size:24px;line-height:1;cursor:pointer;opacity:.7} .${NS}-alphabet{display:flex;flex-wrap:wrap;gap:6px;margin-top:14px} .${NS}-letter{min-width:36px;min-height:36px;padding:6px 9px;border:1px solid #c7c7c7;border-radius:5px;background:#fff;color:inherit;font:inherit;cursor:pointer} .${NS}-letter[aria-pressed="true"]{background:#222;color:#fff;border-color:#222} .${NS}-letter:disabled{opacity:.35;cursor:not-allowed} .${NS}-meta{margin-top:10px;font-size:.92em;opacity:.75} .${NS}-status{margin:0 0 16px;padding:10px 12px;border-radius:5px;background:#f5f5f5} .${NS}-status--warning{background:#fff3cd;color:#664d03} .${NS}-empty{padding:32px 12px;text-align:center} .${NS}-grid{display:grid!important;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:16px;margin:0;padding:0;list-style:none} .${NS}-brand-card{box-sizing:border-box;min-width:0;margin:0;padding:0;list-style:none} .${NS}-brand-link{display:flex;box-sizing:border-box;min-height:112px;height:100%;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:14px;border:1px solid rgba(0,0,0,.1);border-radius:8px;background:#fff;color:inherit;text-align:center;text-decoration:none} .${NS}-brand-link:hover,.${NS}-brand-link:focus{border-color:currentColor;text-decoration:none} .${NS}-brand-image{display:block;max-width:100%;width:auto;height:auto;max-height:64px;object-fit:contain} .${NS}-brand-name{display:block;overflow-wrap:anywhere;line-height:1.25} .${NS}-pagination-hidden{display:none!important} @media(max-width:767px){.${NS}-controls{margin-bottom:16px;padding:12px}.${NS}-alphabet{gap:5px}.${NS}-letter{min-width:34px;min-height:34px;padding:5px 7px}.${NS}-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.${NS}-brand-link{min-height:100px;padding:10px}} `; document.head.appendChild(style); } function buildUi(container) { injectStyles(); const controls = document.createElement('section'); controls.className = `${NS}-controls`; controls.setAttribute('aria-label', i18n.searchLabel); const searchWrap = document.createElement('div'); searchWrap.className = `${NS}-search-wrap`; const search = document.createElement('input'); search.className = `${NS}-search`; search.type = 'search'; search.autocomplete = 'off'; search.spellcheck = false; search.placeholder = i18n.placeholder; search.setAttribute('aria-label', i18n.searchLabel); const clear = document.createElement('button'); clear.className = `${NS}-clear`; clear.type = 'button'; clear.setAttribute('aria-label', i18n.clear); clear.hidden = true; clear.textContent = '\u00d7'; const alphabet = document.createElement('div'); alphabet.className = `${NS}-alphabet`; alphabet.setAttribute('role', 'group'); alphabet.setAttribute('aria-label', i18n.alphabetLabel); const meta = document.createElement('div'); meta.className = `${NS}-meta`; meta.setAttribute('aria-live', 'polite'); searchWrap.append(search, clear); controls.append(searchWrap, alphabet, meta); const status = document.createElement('div'); status.className = `${NS}-status`; status.setAttribute('role', 'status'); status.textContent = i18n.loading; container.parentNode.insertBefore(controls, container); container.parentNode.insertBefore(status, container); elements = { container, controls, search, clear, alphabet, alphabetButtons: [], meta, status, pagination: paginationRoots(document) }; addTrackedListener(elements.search, 'input', onSearchInput); addTrackedListener(elements.clear, 'click', clearSearch); addTrackedListener(elements.alphabet, 'click', onAlphabetClick); } function renderAlphabet() { const letters = Array.from(new Set(state.brands.map((brand) => getBrandMeta(brand).letter))); letters.sort((a, b) => { if (a === '0-9') return -1; if (b === '0-9') return 1; if (a === '#') return 1; if (b === '#') return -1; return a.localeCompare(b, undefined, { sensitivity: 'base' }); }); const fragment = document.createDocumentFragment(); elements.alphabetButtons = [['*', i18n.all], ...letters.map((letter) => [letter, letter])].map(([letter, label]) => { const button = document.createElement('button'); button.type = 'button'; button.className = `${NS}-letter`; button.dataset.letter = letter; button.setAttribute('aria-pressed', String(state.letter === letter)); button.textContent = label; fragment.appendChild(button); return button; }); elements.alphabet.replaceChildren(fragment); } function onSearchInput(event) { if (debounceTimer) clearTrackedTimer(debounceTimer); state.query = event.target.value; elements.clear.hidden = !state.query; debounceTimer = window.setTimeout(() => { activeTimers.delete(debounceTimer); debounceTimer = null; applyFilters(); }, config.searchDebounce); activeTimers.set(debounceTimer, applyFilters); } function clearSearch() { state.query = ''; elements.search.value = ''; elements.clear.hidden = true; applyFilters(); elements.search.focus(); } function onAlphabetClick(event) { const button = event.target.closest('[data-letter]'); if (!button) return; state.letter = button.dataset.letter; elements.alphabetButtons.forEach((node) => { node.setAttribute('aria-pressed', String(node === button)); }); applyFilters(); } function applyFilters() { const query = normalize(state.query); const filtered = state.brands.filter((brand) => { const meta = getBrandMeta(brand); const queryMatches = !query || meta.search.includes(query); const letterMatches = state.letter === '*' || meta.letter === state.letter; return queryMatches && letterMatches; }); state.filteredCount = filtered.length; renderBrands(filtered); elements.meta.textContent = `${i18n.found}${filtered.length}`; } function renderBrands(brands) { const started = performance.now(); rendering = true; if (rebuildObserver) rebuildObserver.disconnect(); const fragment = document.createDocumentFragment(); const nextManagedNodes = new Set(); elements.container.classList.add(`${NS}-grid`); if (!brands.length) { const empty = document.createElement('div'); empty.className = `${NS}-empty`; empty.textContent = i18n.empty; fragment.appendChild(empty); nextManagedNodes.add(empty); } else { brands.forEach((brand) => { const node = createBrandNode(brand); fragment.appendChild(node); nextManagedNodes.add(node); }); } elements.container.replaceChildren(fragment); managedNodes.clear(); managedNodes = nextManagedNodes; diagnostics.renderTime = Math.round((performance.now() - started) * 100) / 100; rendering = false; observeRenderedDom(); } /** Creates one safe brand card using DOM methods only. */ function createBrandNode(brand) { const listContainer = /^(UL|OL)$/.test(elements.container.tagName); const item = document.createElement(listContainer ? 'li' : 'div'); item.classList.add(`${NS}-brand-card`); brand.extraClasses.forEach((className) => { try { item.classList.add(className); } catch (error) { /* ignore invalid source class */ } }); item.dataset.ykbfId = brand.id; const link = document.createElement('a'); link.className = `${NS}-brand-link`; link.href = brand.url; link.title = brand.title; if (brand.image) { const image = document.createElement('img'); image.className = `${NS}-brand-image`; image.src = brand.image; image.alt = brand.imageAlt; if (brand.imageSrcset) image.srcset = brand.imageSrcset; if (brand.imageSizes) image.sizes = brand.imageSizes; image.loading = brand.lazy ? 'lazy' : 'eager'; image.decoding = 'async'; link.appendChild(image); } const name = document.createElement('span'); name.className = `${NS}-brand-name`; name.textContent = brand.name; link.appendChild(name); item.appendChild(link); return item; } function getBrandMeta(brand) { if (!brandMeta.has(brand.id)) brandMeta.set(brand.id, { search: normalize(brand.name), letter: brandLetter(brand.name) }); return brandMeta.get(brand.id); } /** Watches for Horoshop/CatalogBuilder replacing the managed list and restores it once. */ function observeRenderedDom() { if (destroyed || !elements.container || !elements.container.parentNode) return; if (rebuildObserver) rebuildObserver.disconnect(); rebuildObserver = new MutationObserver(() => { if (rendering || destroyed || rebuildTimer) return; rebuildTimer = window.setTimeout(() => { activeTimers.delete(rebuildTimer); rebuildTimer = null; if (!managedDomIsIntact()) restoreRenderedDom(); }, 80); activeTimers.set(rebuildTimer, () => {}); }); rebuildObserver.observe(elements.container, { childList: true, subtree: true }); rebuildObserver.observe(elements.container.parentNode, { childList: true }); } function managedDomIsIntact() { if (!elements.container || !elements.container.isConnected || elements.container.children.length !== managedNodes.size) return false; return Array.from(managedNodes).every((node) => node.isConnected && node.parentNode === elements.container); } function restoreRenderedDom() { if (destroyed || rendering || !state.brands.length) return; if (!elements.container || !elements.container.isConnected) { scanCache = new WeakMap(); const replacement = findBrandContainer(document); if (!replacement) return; elements.container = replacement; } diagnostics.domRebuilds += 1; applyFilters(); reportDiagnostics(); } function setLoadResult(result, fromCache) { state.brands = result.brands; brandMeta.clear(); state.pageCount = result.pageCount || 1; state.failedPages = result.failedPages || []; state.fromCache = fromCache; state.status = result.complete ? 'ready' : 'partial'; diagnostics.cacheHit = fromCache; diagnostics.brandsLoaded = state.brands.length; diagnostics.duplicatesRemoved = result.duplicatesRemoved || diagnostics.duplicatesRemoved; renderAlphabet(); applyFilters(); if (result.complete) { if (elements.status) elements.status.remove(); elements.status = null; setPaginationHidden(true); } else { ensureStatus(); elements.status.textContent = i18n.partial; elements.status.classList.add(`${NS}-status--warning`); setPaginationHidden(false); } state.diagnostics = Object.assign({}, diagnostics); } function ensureStatus() { if (elements.status || !elements.container || !elements.container.parentNode) return; const status = document.createElement('div'); status.className = `${NS}-status`; status.setAttribute('role', 'status'); elements.container.parentNode.insertBefore(status, elements.container); elements.status = status; } function reportDiagnostics() { state.diagnostics = Object.assign({}, diagnostics); if (!config.debug || !window.console) return; console.log('YK Brands Filter v4', { 'Pages found': diagnostics.pagesFound, 'Pages loaded': diagnostics.pagesLoaded, 'Brands loaded': diagnostics.brandsLoaded, 'Duplicates removed': diagnostics.duplicatesRemoved, 'Cache hit': diagnostics.cacheHit, 'Retry count': diagnostics.retryCount, 'Render time': `${diagnostics.renderTime} ms`, 'Total init time': `${diagnostics.totalInitTime} ms`, 'DOM rebuilds': diagnostics.domRebuilds }); } function setPaginationHidden(hidden) { (elements.pagination || []).forEach((node) => node.classList.toggle(`${NS}-pagination-hidden`, hidden)); } async function init(options = {}) { if (initPromise && !options.force) return initPromise; destroyed = false; const runId = ++lifecycleId; const initStarted = performance.now(); Object.assign(diagnostics, createDiagnostics()); initPromise = (async () => { state.status = 'waiting'; const container = await waitForStableBrandsDom(); if (destroyed || runId !== lifecycleId) return api; if (!elements.container) buildUi(container); else elements.container = container; if (abortController) abortController.abort(); abortController = new AbortController(); const expectedPageCount = estimatedPageCount(); const sourceFingerprint = createFingerprint(expectedPageCount, modelFromDocument(document, window.location.href)); const cached = !options.ignoreCache && readCache(expectedPageCount, sourceFingerprint); if (cached) { setLoadResult({ brands: cached.brands, complete: true, pageCount: cached.pageCount, failedPages: [], duplicatesRemoved: cached.duplicatesRemoved }, true); diagnostics.totalInitTime = Math.round((performance.now() - initStarted) * 100) / 100; reportDiagnostics(); return api; } state.status = 'loading'; const result = await loadAllPages(); if (destroyed || runId !== lifecycleId) return api; result.sourceFingerprint = sourceFingerprint; writeCache(result); setLoadResult(result, false); diagnostics.totalInitTime = Math.round((performance.now() - initStarted) * 100) / 100; reportDiagnostics(); return api; })().catch((error) => { if (destroyed) return api; state.status = 'error'; warn('Initialization failed; native brands page remains available.', error); setPaginationHidden(false); if (elements.status) { elements.status.textContent = config.debug ? `${i18n.partial} ${error.message}` : i18n.partial; elements.status.classList.add(`${NS}-status--warning`); } return api; }); return initPromise; } async function refresh() { try { window.localStorage.removeItem(cacheKey()); } catch (error) { /* storage unavailable */ } if (abortController) abortController.abort(); if (rebuildObserver) rebuildObserver.disconnect(); if (rebuildTimer) clearTrackedTimer(rebuildTimer); rebuildTimer = null; initPromise = null; state.query = ''; state.letter = '*'; if (elements.search) elements.search.value = ''; return init({ force: true, ignoreCache: true }); } function destroy() { destroyed = true; lifecycleId += 1; if (abortController) abortController.abort(); abortController = null; if (readinessObserver) readinessObserver.disconnect(); if (rebuildObserver) rebuildObserver.disconnect(); readinessObserver = null; rebuildObserver = null; activeTimers.forEach((cleanup, timer) => { window.clearTimeout(timer); try { cleanup(); } catch (error) { /* cleanup must never block destroy */ } }); activeTimers.clear(); debounceTimer = null; rebuildTimer = null; trackedListeners.splice(0).forEach(({ target, type, handler, options, cancel }) => { target.removeEventListener(type, handler, options); if (cancel) cancel(); }); setPaginationHidden(false); if (elements.container) elements.container.classList.remove(`${NS}-grid`); if (elements.controls) elements.controls.remove(); if (elements.status) elements.status.remove(); const style = document.getElementById(`${NS}-styles`); if (style) style.remove(); managedNodes.clear(); managedNodes = new Set(); brandMeta.clear(); brandMeta = new Map(); scanCache = new WeakMap(); elements = {}; initPromise = null; state.brands = []; state.filteredCount = 0; state.failedPages = []; state.query = ''; state.letter = '*'; state.diagnostics = null; state.status = 'destroyed'; } const api = { init, refresh, destroy, state, version: VERSION }; window.YKBrandsFilterV4 = api; // Automatic start. init() waits for window.load and a stable brands DOM. init(); })(window, document);