PageSourceSearch

https://changellenge.com/local/components/changellenge/popups.mana…/.default/script.js?177001793513322

js changellenge.com collected 2026-10-02 06:44:44 UTC 13,322 bytes, 365 lines download raw bytes

1class BannerManager {
2
3	/**
4	 * @param {boolean} isMobile
5	 */
6	constructor(isMobile = false) {
7		this.isMobile = isMobile;
8		this.cookiePrefix = 'CL_POPUP_HIDDEN_';
9		this.SESSION_LIFETIME = 1800;
10
11		this.config = {
12			closeSelectors: [
13				'.toast-banner__close',
14				'.banner-close',
15				'.promo-banner-short__close'
16			],
17			fullBannerSelector: '.promo-banner-wrapper',
18			shortBannerSelector: '.promo-banner-short',
19			menuContainerSelector: '.desktop-menu-container',
20			toastSelector: '.toast-banner',
21			cookieBannerSelector: 'cookie-consent-banner'
22		};
23
24		this.fullBanner = document.querySelector(this.config.fullBannerSelector);
25		this.shortBanner = document.querySelector(this.config.shortBannerSelector);
26		this.companyFilter = document.querySelector('.new-company__container .new-company__filter');
27		this.filterContainer = document.querySelector('.new-company__filter-container');
28		this.companyContainer = document.querySelector('.new-company__container');
29		this.pageWrapper = document.querySelector('.page-wrapper');
30
31		if (this.isMobile && this.shortBanner && !this.filterContainer && this.pageWrapper) {
32			this.pageWrapper.style.marginTop = '-14px';
33		}
34
35		if (this.isMobile && this.companyContainer && this.companyFilter && this.shortBanner) {
36			this.companyContainer.style.zIndex = 'auto';
37		}
38
39		if (this.companyFilter) {
40			this.SCROLL_THRESHOLD_DOWN = 250;
41			this.SCROLL_THRESHOLD_UP = 220;
42		} else {
43			this.SCROLL_THRESHOLD_DOWN = 120;
44			this.SCROLL_THRESHOLD_UP = 80;
45		}
46
47		this.isPromoDismissed = false;
48		this.cookieObserver = null;
49		this.menuResizeObserver = null;
50		this.activeBannerState = 'full';
51		this.ticking = false;
52
53		this.menuContainer = document.querySelector(this.config.menuContainerSelector);
54		this.toastBanner = document.querySelector(this.config.toastSelector);
55		this.cookieBanner = null;
56
57		this.refreshSessionCookies();
58		this.checkInitialState();
59
60		this.init();
61	}
62
63	/** Ищет все куки скрытых попапов и обновляет им срок жизни. */
64	refreshSessionCookies() {
65		const cookies = document.cookie.split(';');
66
67		cookies.forEach(cookie => {
68			const [name, value] = cookie.trim().split('=');
69			if (name.indexOf(this.cookiePrefix) === 0) {
70				this.setCookie(name, value, {
71					path: '/',
72					samesite: 'strict',
73					'max-age': this.SESSION_LIFETIME
74				});
75			}
76		});
77	}
78
79	/** Метод проверки сохраненного состояния (скрытие сразу при загрузке) */
80	checkInitialState() {
81		const promoId = this.fullBanner?.dataset.popupId || this.shortBanner?.dataset.popupIdShort;
82		const toastId = this.toastBanner?.dataset.toastId;
83		if (promoId && localStorage.getItem(this.storagePrefix + 'closed_' + promoId)) {
84			this.isPromoDismissed = true;
85			if (this.fullBanner) this.fullBanner.classList.add('d-none');
86			if (this.shortBanner) this.shortBanner.classList.add('d-none');
87		}
88		if (toastId && localStorage.getItem(this.storagePrefix + 'closed_' + toastId)) {
89			if (this.toastBanner) this.toastBanner.dataset.dismissedByCookie = "true";
90		}
91	}
92
93	/**
94	 * Инициализирует основную логику баннера, вызывая методы
95	 * для позиционирования и установки обработчиков событий.
96	 */
97	init() {
98		localStorage.setItem(this.sessionKeepaliveKey, Date.now().toString());
99		setInterval(() => localStorage.setItem(this.sessionKeepaliveKey, Date.now().toString()), 5000);
100
101		if (this.isMobile) {
102			this.updateMobileBannerPosition();
103		} else {
104			this.initMenuObserver();
105			this.updateShortBannerTopPosition();
106		}
107
108		this.initEventListeners();
109		this.initializeCookieBannerLogic();
110	}
111
112	/**
113	 * Обновляет вертикальное положение (top) короткого баннера на десктопе.
114	 * Позиция рассчитывается в зависимости от наличия и расположения
115	 * контейнера с фильтром компаний или высоты меню.
116	 */
117	updateShortBannerTopPosition() {
118		if (this.isMobile || !this.shortBanner || this.isPromoDismissed) {
119			return;
120		}
121
122		if (this.companyFilter && this.filterContainer) {
123			const menuStickyOffset = 72;
124			const filterHeight = this.filterContainer.offsetHeight;
125			const gap = 8;
126			const newTopPosition = menuStickyOffset + filterHeight + gap;
127
128			this.shortBanner.style.top = `${newTopPosition}px`;
129			this.shortBanner.style.zIndex = '999';
130			if (this.companyContainer) {
131				this.companyContainer.style.zIndex = 'auto';
132			}
133
134		} else if (this.menuContainer) {
135			const menuHeight = this.menuContainer.getBoundingClientRect().height;
136			this.shortBanner.style.top = `${menuHeight + 8}px`;
137		} else {
138			this.shortBanner.style.top = '72px';
139		}
140	}
141
142	/**
143	 * Инициализирует ResizeObserver для отслеживания изменений размера
144	 * контейнера меню и последующего обновления позиции баннера на десктопе.
145	 */
146	initMenuObserver() {
147		if (this.isMobile || !this.menuContainer) return;
148		this.menuResizeObserver = new ResizeObserver(() => this.updateShortBannerTopPosition());
149		this.menuResizeObserver.observe(this.menuContainer);
150	}
151
152	/**
153	 * Инициализирует все необходимые обработчики событий:
154	 * скролл, изменение размера окна и клики по кнопкам закрытия.
155	 */
156	initEventListeners() {
157		this.handleResize = this.handleResize.bind(this);
158		this.handleScroll = this.handleScroll.bind(this);
159		this.handleScrollRequest = this.handleScrollRequest.bind(this);
160		this.handleClose = this.handleClose.bind(this);
161
162		if (this.shortBanner) {
163			window.addEventListener('scroll', this.handleScrollRequest);
164			window.addEventListener('resize', this.handleResize);
165		}
166
167		this.config.closeSelectors.forEach(selector => {
168			document.querySelectorAll(selector).forEach(button => {
169				button.addEventListener('click', this.handleClose);
170			});
171		});
172	}
173
174	/**
175	 * Обрабатывает событие изменения размера окна, вызывая
176	 * соответствующий метод для обновления позиции баннера.
177	 */
178	handleResize() {
179		if (this.isMobile) {
180			this.updateMobileBannerPosition();
181		} else {
182			this.updateShortBannerTopPosition();
183		}
184	}
185
186	/**
187	 * Оптимизирует обработку скролла с помощью requestAnimationFrame,
188	 * чтобы избежать лишних вычислений и улучшить производительность.
189	 */
190	handleScrollRequest() {
191		if (!this.ticking) {
192			window.requestAnimationFrame(() => {
193				this.handleScroll();
194				this.ticking = false;
195			});
196			this.ticking = true;
197		}
198	}
199
200	/**
201	 * Обрабатывает событие скролла страницы. Отвечает за переключение
202	 * между полным и коротким баннером на десктопе и обновление
203	 * позиции баннера на мобильных устройствах.
204	 */
205	handleScroll() {
206		if (this.isPromoDismissed) return;
207
208		const scrollY = window.scrollY;
209
210		if (!this.isMobile && this.fullBanner && this.shortBanner) {
211			if (scrollY >= this.SCROLL_THRESHOLD_DOWN && this.activeBannerState === 'full') {
212				this.fullBanner.classList.add('d-none');
213				this.shortBanner.classList.remove('d-none');
214				this.activeBannerState = 'short';
215				this.updateShortBannerTopPosition();
216			} else if (scrollY <= this.SCROLL_THRESHOLD_UP && this.activeBannerState === 'short') {
217				this.shortBanner.classList.add('d-none');
218				this.fullBanner.classList.remove('d-none');
219				this.activeBannerState = 'full';
220			}
221		}
222
223		if (this.isMobile) {
224			this.updateMobileBannerPosition();
225		}
226	}
227
228	/**
229	 * Обновляет позицию баннера на мобильных устройствах.
230	 * Расчет зависит от положения фильтра компаний при скролле.
231	 */
232	updateMobileBannerPosition() {
233		if (!this.isMobile || !this.shortBanner || this.isPromoDismissed) return;
234
235		const scrollY = window.scrollY;
236		if (this.filterContainer && scrollY > 0) {
237			const filterRect = this.filterContainer.getBoundingClientRect();
238			const newTopPosition = filterRect.bottom > 0 ? filterRect.bottom : 0;
239			this.shortBanner.style.top = `${newTopPosition}px`;
240		} else {
241			this.shortBanner.style.top = '0px';
242		}
243		this.shortBanner.style.zIndex = '999';
244	}
245
246	/**
247	 * Инициализирует логику, связанную с баннером о cookie.
248	 * Ожидает появления cookie-баннера внутри элемента #analytics_area
249	 * и запускает зависимую логику, когда он появл
249яется
250	 */
251	initializeCookieBannerLogic() {
252		if (!this.toastBanner || this.toastBanner.dataset.dismissedByCookie === "true") return;
253
254		const handleCookieBannerFinished = (event) => {
255			if (event.detail.bannerShown) {
256				this.cookieBanner = document.querySelector(this.config.cookieBannerSelector);
257				if (this.cookieBanner) this.runCookieDependentLogic();
258			} else {
259				this.showToast();
260			}
261		};
262		document.addEventListener('cookieBanner:finished', handleCookieBannerFinished, { once: true });
263	}
264	/**
265	 * Запускает логику, которая зависит от состояния cookie-баннера.
266	 * Если cookie-баннер активен, ожидает его закрытия. Если неактивен,
267	 * сразу показывает toast-уведомление.
268	 */
269	runCookieDependentLogic() {
270		const isCookieBannerActive = this.cookieBanner && this.cookieBanner.classList.contains('active');
271		if (isCookieBannerActive) {
272			document.addEventListener('cookieBanner:closed', () => this.showToast(), { once: true });
273		} else {
274			this.showToast();
275		}
276	}
277
278	/**
279	 * Обработчик закрытия
280	 */
281	handleClose(event) {
282		event.preventDefault();
283		event.stopPropagation();
284
285		const closeButton = event.currentTarget;
286		const popupWrapper = closeButton.closest('[data-toast-id], [data-popup-id], [data-popup-id-short]');
287		if (!popupWrapper) return;
288
289		const popupId = popupWrapper.dataset.toastId || popupWrapper.dataset.popupId || popupWrapper.dataset.popupIdShort;
290
291		if (popupId) {
292			this.setCookie(this.cookiePrefix + popupId, '1', {
293				path: '/',
294				samesite: 'strict',
295				'max-age': this.SESSION_LIFETIME
296			});
297		}
298
299		if (popupWrapper.matches(this.config.toastSelector)) {
300			this.dismissToast();
301		} else {
302			this.dismissPromo();
303		}
304	}
305	/**
306	 * Хелпер установки куки
307	 */
308	setCookie(name, value, options = {}) {
309		options = { path: '/', ...options };
310		if (options.expires instanceof Date) options.expires = options.expires.toUTCString();
311
312		let updatedCookie = encodeURIComponent(name) + "=" + encodeURIComponent(value);
313		for (let optionKey in options) {
314			updatedCookie += "; " + optionKey;
315			let optionValue = options[optionKey];
316			if (optionValue !== true) updatedCookie += "=" + optionValue;
317		}
318		document.cookie = updatedCookie;
319	}
320
321	/**
322	 * Показывает toast-уведомление, делая его видимым
323	 * с плавной анимацией.
324	 */
325	showToast() {
326		if (!this.toastBanner || this.toastBanner.dataset.dismissedByCookie === "true") return;
327		this.toastBanner.classList.remove('d-none');
328		setTimeout(() => this.toastBanner.classList.add('active'), 50);
329	}
330
331	/**
332	 * Скрывает toast-уведомление с анимацией и удаляет
333	 * связанные с ним обработчики событий.
334	 */
335	dismissToast() {
336		if (!this.toastBanner) return;
337		this.cleanupEventListeners();
338		this.toastBanner.addEventListener('transitionend', () => this.toastBanner.classList.add('d-none'), {once: true});
339		this.toastBanner.classList.remove('active');
340	}
341
342	/**
343	 * Скрывает промо-баннеры (полный и короткий) и устанавливает флаг,
344	 * чтобы они больше не появлялись при скролле.
345	 */
346	dismissPromo() {
347		this.isPromoDismissed = true;
348		if (this.fullBanner) this.fullBanner.classList.add('d-none');
349		if (this.shortBanner) this.shortBanner.classList.add('d-none');
350		if (this.pageWrapper) this.pageWrapper.style.marginTop = '';
351
352		this.cleanupEventListeners();
353	}
354
355
356	/**
357	 * Удаляет все глобальные обработчики событий и отключает наблюдателей,
358	 * чтобы предотвратить утечки памяти.
359	 */
360	cleanupEventListeners() {
361		window.removeEventListener('scroll', this.handleScrollRequest);
362		window.removeEventListener('resize', this.handleResize);
363		this.menuResizeObserver?.disconnect();
364	}
365}

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.