PageSourceSearch

https://www.chocolatharlem.com/assets/banner-component-730ab5752d6…c32c885c4d07215b1fbd2a76a52cd94622d82ef7.js

js chocolatharlem.com collected 2026-09-29 22:34:38 UTC 19,069 bytes, 492 lines download raw bytes

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.