1// Pop-up suppression is persisted in localStorage (survives tab close and is 2// shared across tabs, unlike sessionStorage) and keyed by the banner's id, so a 3// new campaign is never suppressed by a prior one's timer. Each record is 4// { lastShownAt: <epoch ms>, intervalDays: <positive int | null> }. 5const POPUP_STORAGE_KEY_PREFIX = 'banner-popup-seen-'; 6const MS_PER_DAY = 86400000; 7 8function heapTrack(name) { 9 if (typeof window.heap !== 'undefined' && window.heap.track) { 10 window.heap.track(name); 11 } 12} 13 14function getPopUpPositionStyles(placement) { 15 const px = '24px'; 16 switch (placement) { 17 case 'upper_left': 18 return { top: px, left: px }; 19 case 'upper_right': 20 return { top: px, right: px }; 21 case 'lower_left': 22 return { bottom: px, left: px }; 23 case 'lower_right': 24 return { bottom: px, right: px }; 25 case 'center': 26 default: 27 return { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }; 28 } 29} 30 31class SiteBanner extends HTMLElement { 32 async connectedCallback() { 33 try { 34 const organizationId = this.getOrganizationId(); 35 if (!organizationId) return; 36 37 const banners = await this.fetchBanner(organizationId); 38 if (!banners || banners.length === 0) return; 39 40 const locationId = this.getLocationId(); 41 const visibleBanners = banners.filter((b) => this.isVisibleForLocation(b, locationId)); 42 43 const bannerEntry = visibleBanners.find((b) => b.announcement_type === 'banner'); 44 const popUpEntry = visibleBanners.find((b) => b.announcement_type === 'pop_up'); 45 46 if (bannerEntry?.message_body) { 47 this.hideDefaultBanner(); 48 this.render(bannerEntry); 49 } 50 51 if (popUpEntry?.message_body && this.shouldShowPopUp(popUpEntry)) { 52 this.renderPopUp(popUpEntry); 53 } 54 } catch (error) { 55 console.error('Banner: Failed to initialize', error); 56 } 57 } 58 59 getOrganizationId() { 60 return document.body.getAttribute('data-organization-id') || 61 document.querySelector('meta[name="organization-id"]')?.getAttribute('content') || 62 window.organizationId || 63 null; 64 } 65 66 // The chowly-api partner.id of the location subpage being rendered, or null on 67 // the org homepage / listing page where no data-location-id attribute is set. 68 // Rendered into <body data-location-id="..."> by the shared theme layout. 69 getLocationId() { 70 const raw = document.body.getAttribute('data-location-id'); 71 return raw != null && raw !== '' ? raw : null; 72 } 73 74 // A banner is visible on the current page when it is org-wide (location_id is 75 // null/blank -> shown everywhere) or its location_id matches the current 76 // location's partner id. The API emits location_id as an integer, while the 77 // DOM attribute is a string, so both are normalized to strings before 78 // comparison. 79 isVisibleForLocation(banner, locationId) { 80 const bannerLocationId = banner?.location_id; 81 if (bannerLocationId == null || bannerLocationId === '') return true; 82 if (locationId == null) return false; 83 return String(bannerLocationId) === String(locationId); 84 } 85 86 // Coerces the API's redisplay_interval_days into a positive integer number of 87 // days, or null when absent/invalid. null means "show once per campaign". 88 normalizePopUpInterval(value) { 89 const days = Number(value); 90 return Number.isFinite(days) && days > 0 ? Math.trunc(days) : null; 91 } 92 93 popUpStorageKey(bannerId) { 94 return POPUP_STORAGE_KEY_PREFIX + bannerId; 95 } 96 97 // Reads and validates the stored suppression record for a banner, returning 98 // null when there is no usable record (never shown, or storage unavailable). 99 readPopUpRecord(bannerId) { 100 try { 101 const record = JSON.parse(localStorage.getItem(this.popUpStorageKey(bannerId))); 102 if (record && Number.isFinite(record.lastShownAt)) return record; 103 } catch (err) {} 104 return null; 105 } 106 107 // Decides whether the pop-up should render now. Shows when there is no prior 108 // record on this device; with no interval set, shows only once per campaign; 109 // otherwise re-shows once at least intervalDays have elapsed since last shown. 110 // Fails open (shows) when the banner has no id to key suppression by. 111 shouldShowPopUp(popUpEntry) { 112 const bannerId = popUpEntry?.id; 113 if (bannerId == null) return true; 114 115 const record = this.readPopUpRecord(bannerId); 116 if (!record) return true; 117 118 const intervalDays = this.normalizePopUpInterval(popUpEntry.redisplay_interval_days); 119 if (intervalDays == null) return false;
120 121 return (Date.now() - record.lastShownAt) / MS_PER_DAY >= intervalDays; 122 } 123 124 // Records that the pop-up was just shown, so subsequent loads/tabs honor the 125 // re-show interval. Keyed by banner id; a no-op if localStorage is unavailable. 126 markPopUpShown(popUpEntry) { 127 const bannerId = popUpEntry?.id; 128 if (bannerId == null) return; 129 130 try { 131 localStorage.setItem( 132 this.popUpStorageKey(bannerId), 133 JSON.stringify({ 134 lastShownAt: Date.now(), 135 intervalDays: this.normalizePopUpInterval(popUpEntry.redisplay_interval_days), 136 }) 137 ); 138 } catch (err) {} 139 } 140 141 async fetchBanner(organizationId) { 142 const isPreview = window.location.origin === window.siteHost; 143 const url = `${window.chowlyApiHost}/api/v3/public/banners?organization_id=${organizationId}${isPreview ? '&preview=1' : ''}`; 144 145 const response = await fetch(url); 146 if (!response.ok) throw new Error(`API request failed: ${response.status}`); 147 148 const data = await response.json(); 149 const banners = Array.isArray(data) 150 ? data 151 : (Array.isArray(data?.banners) 152 ? data.banners 153 : (data && typeof data === 'object' && data.announcement_type ? [data] : [])); 154 return banners; 155 } 156 157 hideDefaultBanner() { 158 const defaultBanner = document.querySelector('[data-maglev-section-type="site_notification_01"]'); 159 if (defaultBanner) { 160 defaultBanner.style.display = 'none'; 161 } 162 } 163 render(banner) { 164 const { message_body, cta_link_url, cta_text, ticker_tape_style, icon } = banner; 165 if (!message_body) return; 166 167 this.className = 'w-full h-[40px] flex justify-center items-center px-4 col-span-2'; 168 this.style.backgroundColor = 'var(--brand-primary, #000)'; 169 this.setAttribute('role', 'banner'); 170 this.setAttribute('aria-label', 'Site notification banner'); 171 172 if (ticker_tape_style) { 173 this.style.overflow = 'hidden'; 174 } 175 176 const safeIconClass = icon && /^[a-zA-Z0-9\-_\s]+$/.test(icon) ? icon.trim() : ''; 177 const messageContent = `${message_body}${cta_link_url ? ` 178 <a href="${cta_link_url}" 179 class="text-white text-[14px] font-normal leading-[24px] underline hover:no-underline ml-2" 180 target="_blank" 181 rel="noopener noreferrer"> 182 ${cta_text || cta_link_url} 183 </a> 184 ` : ''}`; 185 186 const textClasses = 'text-white text-[14px] font-normal leading-[24px]'; 187 const textCenterClasses = ticker_tape_style ? '' : 'text-center line-clamp-2 overflow-hidden'; 188 const textStyle = `display: inline-block; ${ticker_tape_style ? 'white-space: nowrap;' : ''}`; 189 190 const iconBlock = safeIconClass 191 ? `<div class="w-[30px] h-[30px] overflow-hidden shrink-0 flex items-center justify-center"><span class="text-white ${safeIconClass}" aria-hidden="true"></span></div>` 192 : ''; 193 194 if (ticker_tape_style && safeIconClass) { 195 this.innerHTML = ` 196 <section class="flex items-center max-w-[1440px] w-full justify-start"> 197 <span class="message ${textClasses} ${textCenterClasses} inline-flex items-center gap-5 flex-nowrap" style="white-space: nowrap;"> 198 ${iconBlock} 199 <span>${messageContent}</span> 200 </span> 201 </section> 202 `; 203 } else if (safeIconClass) { 204 this.innerHTML = ` 205 <section class="flex items-center gap-[2px] max-w-[1440px] w-full justify-center"> 206 <div class="w-[30px] h-[30px] overflow-hidden shrink-0 flex items-center justify-center"> 207 <span class="text-white ${safeIconClass}" aria-hidden="true"></span> 208 </div> 209 <span class="message ${textClasses} ${textCenterClasses}" style="${textStyle}">${messageContent}</span> 210 </section> 211 `; 212 } else { 213 this.innerHTML = ` 214 <section class="flex items-center space-x-2 max-w-[1440px] w-full ${ticker_tape_style ? 'justify-start' : 'justify-center'}"> 215 <span class="message ${textClasses} ${textCenterClasses}" style="${textStyle}">${messageContent}</span> 216 </section> 217 `; 218 } 219 220 if (ticker_tape_style) { 221 this.setupTickerAnimation(); 222 } 223 } 224 225 renderPopUp(banner) { 226 if (!document.getElementById('banner-popup-styles')) { 227 const style = document.createElement('style'); 228 style.id = 'banner-popup-styles'; 229 style.textContent = ` 230 .banner-popup-overlay { 231 position: fixed; 232 inset: 0;
233 background: var(--dim-overlay, rgba(0,0,0,0.4)); 234 z-index: 9999; 235 } 236 .banner-popup-modal { 237 position: absolute; 238 width: 350px; 239 max-width: calc(100vw - 2rem); 240 background: var(--bg-primary, #fff); 241 color: var(--text-primary, #212121); 242 border-radius: 4px; 243 box-shadow: 0 20px 24px -4px rgba(10, 13, 18, 0.10), 0 8px 8px -4px rgba(10, 13, 18, 0.04); 244 overflow: hidden; 245 display: flex; 246 flex-direction: column; 247 max-height: 90vh; 248 } 249 @media (max-width: 640px) { 250 .banner-popup-modal { 251 width: calc(100vw - 2rem) !important; 252 left: 50% !important; 253 top: 50% !important; 254 right: auto !important; 255 bottom: auto !important; 256 transform: translate(-50%, -50%) !important; 257 } 258 } 259 .banner-popup-modal img.banner-popup-image { width: 100%; display: block; max-height: 240px; object-fit: contain; object-position: center; background: var(--bg-secondary, #f2f2f2); } 260 .banner-popup-body { padding: 24px; flex: 1; overflow: auto; } 261 .banner-popup-header { font-size: 18px; font-weight: 600; margin-bottom: 8px; text-align: center; } 262 .banner-popup-message { font-size: 14px; line-height: 24px; text-align: center; } 263 .banner-popup-cta { margin-top: 16px; margin-bottom: 16px; } 264 .banner-popup-cta.banner-popup-cta-standalone { margin-bottom: 0; } 265 .banner-popup-cta .banner-popup-form-button { width: 100%; padding: 10px 16px; background: var(--brand-primary); color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; text-align: center; text-decoration: none; display: block; box-sizing: border-box; } 266 .banner-popup-form { margin-top: 10px; } 267 .banner-popup-form input { width: 100%; padding: 10px 12px; margin-bottom: 12px; border: 1px solid var(--border-secondary); border-radius: 8px; background: var(--bg-form); color: var(--text-primary); font-size: 14px; } 268 .banner-popup-form label { display: block; font-size: 14px; margin-bottom: 4px; color: var(--text-secondary); } 269 .banner-popup-form button, .banner-popup-form .banner-popup-form-button { width: 100%; padding: 10px 16px; background: var(--brand-primary); color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; text-align: center; text-decoration: none; display: block; box-sizing: border-box; } 270 .banner-popup-form .banner-popup-form-button { margin-top: 0; } 271 .banner-popup-close { 272 position: absolute; 273 top: 10px; 274 right: 10px; 275 z-index: 1; 276 padding: 10px; 277 min-width: 44px; 278 min-height: 44px; 279 box-sizing: border-box; 280 border: none; 281 background: transparent; 282 color: var(--text-primary, #212121); 283 cursor: pointer; 284 display: inline-flex; 285 align-items: center; 286 justify-content: center; 287 } 288 .banner-popup-close:hover { opacity: 0.8; } 289 .banner-popup-close-over-image { 290 background: var(--dim-overlay, rgba(0, 0, 0, 0.35)); 291 color: #fff; 292 border-radius: 50%; 293 } 294 .banner-popup-close-over-image:hover { opacity: 0.9; } 295 .banner-popup-close-until-image-loads { 296 opacity: 0; 297 pointer-events: none; 298 transition: opacity 0.2s ease; 299 } 300 .banner-popup-close svg { width: 24px; height: 24px; } 301 `; 302 document.head.appendChild(style); 303 } 304 305 const placement = banner.pop_up_placement || 'center'; 306 const posStyles = getPopUpPositionStyles(placement); 307 308 const overlay = document.createElement('div'); 309 overlay.className = 'banner-popup-overlay'; 310 overlay.setAttribute('role', 'dialog'); 311 overlay.setAttribute('aria-modal', 'true'); 312 313 const modal = document.createElement('div'); 314 modal.className = 'banner-popup-modal'; 315 Object.assign(modal.style, posStyles); 316 317 const closePopup = () => { 318 document.removeEventListener('keydown', escapeHandler); 319 overlay.remove(); 320 }; 321 const escapeHandler = (e) => { 322 if (e.key === 'Escape') { 323 heapTrack('popup_dismiss'); 324 closePopup(); 325 } 326 }; 327 328 const closeBtn = document.createElement('button'); 329 closeBtn.type = 'button'; 330 closeBtn.className = 'banner-popup-close'; 331 if (banner.image_url) { 332 closeBtn.classList.add('banner-popup-close-over-image', 'banner-popup-close-until-image-loads'); 333 } 334 closeBtn.setAttribute('aria-label', 'Close popup'); 335 closeBtn.innerHTML = '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path></svg>'; 336 closeBtn.addEventListener('click', (e) => { 337 e.stopPropagation(); 338 heapTrack('popup_dismiss'); 339 closePopup(); 340 }); 341 modal.appendChild(closeBtn); 342 343 if (banner.image_url) { 344 const img = document.createElement('img'); 345 img.alt = ''; 346 img.className = 'banner-popup-image'; 347 const showCloseBtn = () => closeBtn.classList.remove('banner-popup-close-until-image-loads'); 348 img.onload = showCloseBtn; 349 img.onerror = function () { 350 console.warn('[Banner pop-up] Image failed to load:', banner.image_url, '- Check DevTools â Network for that request (e.g. 403 = forbidden, 404 = not found).'); 351 showCloseBtn(); 352 }; 353 img.src = banner.image_url; 354 if (img.complete) showCloseBtn(); 355 modal.appendChild(img); 356 } 357 358 const body = document.createElement('div'); 359 body.className = 'banner-popup-body'; 360 361 if (banner.header) { 362 const h = document.createElement('div'); 363 h.className = 'banner-popup-header'; 364 h.textContent = banner.header; 365 body.appendChild(h); 366 } 367 368 const msg = document.createElement('div'); 369 msg.className = 'banner-popup-message';
370 msg.textContent = banner.message_body; 371 body.appendChild(msg); 372 373 const hasForm = banner.phone_field || banner.email_field; 374 375 if (banner.cta_link_url && !hasForm) { 376 const cta = document.createElement('div'); 377 cta.className = 'banner-popup-cta banner-popup-cta-standalone'; 378 const a = document.createElement('a'); 379 a.href = banner.cta_link_url; 380 a.target = '_blank'; 381 a.rel = 'noopener noreferrer'; 382 a.className = 'banner-popup-form-button'; 383 a.textContent = banner.cta_text || banner.cta_link_url; 384 a.addEventListener('click', (e) => { 385 heapTrack('popup_cta_click'); 386 closePopup(); 387 }); 388 cta.appendChild(a); 389 body.appendChild(cta); 390 } 391 392 if (hasForm) { 393 const form = document.createElement('form'); 394 form.className = 'banner-popup-form'; 395 if (banner.email_field) { 396 const label = document.createElement('label'); 397 label.textContent = 'Email address'; 398 form.appendChild(label); 399 const input = document.createElement('input'); 400 input.type = 'email'; 401 input.placeholder = 'Email address'; 402 input.name = 'email'; 403 form.appendChild(input); 404 } 405 if (banner.phone_field) { 406 const label = document.createElement('label'); 407 label.textContent = 'Phone number'; 408 form.appendChild(label); 409 const input = document.createElement('input'); 410 input.type = 'tel'; 411 input.placeholder = 'Phone number'; 412 input.name = 'phone'; 413 form.appendChild(input); 414 } 415 const buttonLabel = banner.cta_text || 'Submit'; 416 if (banner.cta_link_url) { 417 const linkBtn = document.createElement('a'); 418 linkBtn.href = banner.cta_link_url; 419 linkBtn.target = '_blank'; 420 linkBtn.rel = 'noopener noreferrer'; 421 linkBtn.className = 'banner-popup-form-button'; 422 linkBtn.textContent = buttonLabel; 423 linkBtn.addEventListener('click', () => { 424 heapTrack('popup_cta_click'); 425 closePopup(); 426 }); 427 form.appendChild(linkBtn); 428 } else { 429 const submit = document.createElement('button'); 430 submit.type = 'submit'; 431 submit.textContent = buttonLabel; 432 form.appendChild(submit); 433 } 434 form.addEventListener('submit', (e) => { 435 e.preventDefault(); 436 heapTrack('popup_form_submit'); 437 closePopup(); 438 }); 439 body.appendChild(form); 440 } 441 442 modal.appendChild(body); 443 overlay.appendChild(modal); 444 445 this.markPopUpShown(banner); 446 447 overlay.addEventListener('click', (e) => { 448 if (e.target === overlay) { 449 heapTrack('popup_dismiss'); 450 closePopup(); 451 } 452 }); 453 modal.addEventListener('click', (e) => e.stopPropagation()); 454 document.addEventListener('keydown', escapeHandler); 455 456 document.body.appendChild(overlay); 457 heapTrack('popup_open'); 458 } 459 460 setupTickerAnimation() { 461 if (!document.getElementById('banner-ticker-animation')) { 462 const style = document.createElement('style'); 463 style.id = 'banner-ticker-animation'; 464 style.textContent = ` 465 @keyframes ticker { 466 from { transform: translateX(0); } 467 to { transform: translateX(-100%); } 468 } 469 470 site-banner .message { 471 padding-left: 100%; 472 animation: ticker 20s linear infinite; 473 } 474 `; 475 document.head.appendChild(style); 476 } 477 } 478} 479 480customElements.define('site-banner', SiteBanner); 481 482if (document.readyState === 'loading') { 483 document.addEventListener('DOMContentLoaded', () => { 484 const skipLink = document.querySelector('a[href="#main-content"]'); 485 const insertionPoint = skipLink?.nextElementSibling || document.body.firstChild; 486 insertionPoint?.insertAdjacentHTML('beforebegin', '<site-banner></site-banner>'); 487 }); 488} else { 489 const skipLink = document.querySelector('a[href="#main-content"]'); 490 const insertionPoint = skipLink?.nextElementSibling || document.body.firstChild; 491 insertionPoint?.insertAdjacentHTML('beforebegin', '<site-banner></site-banner>'); 492};
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.