PageSourceSearch

https://www.cardbear.com/js/store2.js?v=1785221473

js cardbear.com collected 2026-09-24 12:38:49 UTC 46,888 bytes, 1,365 lines download raw bytes

1(() => {
2  "use strict";
3
4  const escapeHtml = (value) =>
5    String(value).replace(
6      /[&<>"']/g,
7      (character) =>
8        ({
9          "&": "&amp;",
10          "<": "&lt;",
11          ">": "&gt;",
12          '"': "&quot;",
13          "'": "&#039;",
14        })[character]
15    );
16
17  const cardBearExtensionId = "amdhblopjkbjllnbbfboceeamjgpiekh";
18  const extensionPingTimeoutMs = 700;
19
20  const isDesktopGoogleChrome = () => {
21    const userAgentData = navigator.userAgentData;
22
23    if (userAgentData && Array.isArray(userAgentData.brands)) {
24      const brands = userAgentData.brands.map(({ brand }) => String(brand));
25      const hasGoogleChrome = brands.some((brand) => /^Google Chrome$/i.test(brand));
26      const hasExcludedBrand = brands.some((brand) =>
27        /(?:Microsoft Edge|Opera|Brave)/i.test(brand)
28      );
29
30      return (
31        hasGoogleChrome &&
32        userAgentData.mobile === false &&
33        !hasExcludedBrand &&
34        typeof navigator.brave === "undefined"
35      );
36    }
37
38    const userAgent = navigator.userAgent;
39    return (
40      /Chrome\//.test(userAgent) &&
41      !/(?:Edg|Edge|OPR|Opera|CriOS|SamsungBrowser|Vivaldi|YaBrowser|Whale)\//i.test(userAgent) &&
42      !/(?:Android|Mobile|iPhone|iPad|iPod)/i.test(userAgent) &&
43      typeof navigator.brave === "undefined"
44    );
45  };
46
47  const detectCardBearExtension = () =>
48    new Promise((resolve) => {
49      let settled = false;
50      const finish = (installed) => {
51        if (settled) {
52          return;
53        }
54
55        settled = true;
56        window.clearTimeout(timeout);
57        resolve(installed);
58      };
59      const timeout = window.setTimeout(
60        () => finish(false),
61        extensionPingTimeoutMs
62      );
63
64      try {
65        const runtime = globalThis.chrome?.runtime;
66
67        if (!runtime || typeof runtime.sendMessage !== "function") {
68          finish(false);
69          return;
70        }
71
72        runtime.sendMessage(
73          cardBearExtensionId,
74          { type: "CARDBEAR_EXTENSION_PING" },
75          (response) => {
76            const lastError = runtime.lastError;
77            finish(!lastError && response?.installed === true);
78          }
79        );
80      } catch (_error) {
81        finish(false);
82      }
83    });
84
85  const initializeExtensionCtas = async () => {
86    const extensionCtas = Array.from(
87      document.querySelectorAll("[data-chrome-extension-cta]")
88    );
89
90    if (extensionCtas.length === 0) {
91      return;
92    }
93
94    const setCtasVisible = (visible) => {
95      extensionCtas.forEach((cta) => {
96        cta.classList.toggle("hidden", !visible);
97        cta.classList.toggle("flex", visible);
98        cta.setAttribute("aria-hidden", String(!visible));
99
100        if (cta instanceof HTMLElement) {
101          cta.tabIndex = visible ? 0 : -1;
102        }
103      });
104    };
105
106    setCtasVisible(false);
107
108    if (!isDesktopGoogleChrome()) {
109      return;
110    }
111
112    const extensionInstalled = await detectCardBearExtension();
113    const desktopMediaQuery = window.matchMedia("(min-width: 768px)");
114    const updateCtas = () => {
115      setCtasVisible(desktopMediaQuery.matches && !extensionInstalled);
116    };
117
118    updateCtas();
119
120    if (typeof desktopMediaQuery.addEventListener === "function") {
121      desktopMediaQuery.addEventListener("change", updateCtas);
122    } else {
123      desktopMediaQuery.addListener(updateCtas);
124    }
125  };
126
127  if (document.readyState === "loading") {
128    document.addEventListener("DOMContentLoaded", initializeExtensionCtas, {
129      once: true,
130    });
131  } else {
132    initializeExtensionCtas();
133  }
134
135  /*
136   * Accessible, dependency-free gift-card autocomplete.
137   */
138  document.querySelectorAll("[data-gift-card-search]").forEach((searchRoot) => {
139    const input = searchRoot.querySelector("[data-search-input]");
140    const form = searchRoot.querySelector("[data-search-form]");
141    const results = searchRoot.querySelector("[data-search-results]");
142    const header = document.querySelector("[data-site-header]");
143    const headerInner = document.querySelector("[data-site-header-inner]");
144    const headerLogo = document.querySelector("[data-site-header-logo]");
145    const backButton = searchRoot.querySelector("[data-search-back]");
146    const cancelButton = searchRoot.querySelector("[data-search-cancel]");
147    const discovery = searchRoot.querySelector("[data-search-discovery]");
148    const liveResults = searchRoot.querySelector("[data-search-live-results]");
149    const loading = searchRoot.querySelector("[data-search-loading]");
150    const status = searchRoot.querySelector("[data-search-status]");
151    const recentSection = searchRoot.querySelector("[data-search-recent-section]");
152    const recentList = searchRoot.querySelector("[data-search-recent-list]");
153
154    if (
155      !(input instanceof HTMLInputElement) ||
156      !(results instanceof HTMLElement) ||
157      !(liveResults instanceof HTMLElement) ||
158      !(discovery instanceof HTMLElement)
159    ) {
160      return;
161    }
162
163    const recentStorageKey = "cardbear.recentGiftCards.v1";
164    let timer = 0;
165    let controller = null;
166    let activeIndex = -1;
167    let items = [];
168    let isExpanded = false;
169    let lockedScrollY = 0;
170    const resultsPlaceholder = document.createComment("cardbear-search-results");
171
172    const isMobileSearch = () => window.matchMedia("(max-width: 767px)").matches;
173    const searchPath = (query) =>
174      `/search/${encodeURIComponent(String(query).trim().replace(/\s+/g, "-"))}`;
175
176    const setStatus = (message) => {
177      if (status instanceof HTMLElement) {
178        status.textContent = message;
179      }
180    };
181
182    const getRecentItems = () => {
183      try {
184        const parsed = JSON.parse(window.localStorage.getItem(recentStorageKey) || "[]");
185        return Array.isArray(parsed) ? parsed.slice(0, 4) : [];
186      } catch {
187        return [];
188      }
189    };
190
191    const saveRecentItem = (record) => {
192      if (!record || !record.name || !record.url) {
193        return;
194      }
195
196      try {
197        const existing = getRecentItems().filter((item) => item.url !== record.url);
198        window.localStorage.setItem(
199          recentStorageKey,
200          JSON.stringify([
201            {
202              name: String(record.name).slice(0, 80),
203              url: String(record.url),
204              image: String(record.image || ""),
205            },
206            ...existing,
207          ].slice(0, 4))
208        );
209      } catch {
210        // localStorage may be unavailable in private browsing.
211      }
212    };
213
214    const removeRecentItem = (url) => {
215      try {
216        window.localStorage.setItem(
217          recentStorageKey,
218          JSON.stringify(getRecentItems().filter((item) => item.url !== url))
219        );
220      } catch {
221        // localStorage may be unavailable in private browsing.
222      }
223
224      renderRecentItems();
225    };
226
227    const renderRecentItems = () => {
228      if (!(recentSection instanceof HTMLElement) || !(recentList instanceof HTMLElement)) {
229        return;
230      }
231
232      const recentItems = getRecentItems();
233      recentSection.classList.toggle("hidden", recentItems.length === 0);
234      recentList.innerHTML = "";
235
236      recentItems.forEach((item) => {
237        const chip = document.createElement("span");
238        chip.className =
239          "inline-flex min-h-10 max-w-full items-center overflow-hidden rounded-full border border-slate-200 bg-slate-50 text-sm font-semibold text-slate-800";
240
241        const link = document.createElement("a");
242        link.href = item.url;
243        link.className = "min-w-0 truncate px-3 py-2 hover:text-blue-800";
244        link.textContent = item.name;
245        link.addEventListener("click", () => saveRecentItem(item));
246
247        const removeButton = document.createElement("button");
248        removeButton.type = "button";
249        removeButton.className =
250          "flex min-h-10 w-9 shrink-0 items-center justify-center text-slate-500 hover:text-slate-900 focus-visible:outline-2 focus-visible:outline-offset-[-3px] focus-visible:outline-blue-700";
251        removeButton.setAttribute("aria-label", `Remove ${item.name} from recent searches`);
252        removeButton.textContent = "×";
253        removeButton.addEventListener("click", () => removeRecentItem(item.url));
254
255        chip.append(link, removeButton);
256        recentList.append(chip);
257      });
258    };
259
260    const lockScroll = () => {
261      if (document.body.classList.contains("cb-search-scroll-locked")) {
262        return;
263      }
264
265      lockedScrollY = window.scrollY || document.documentElement.scrollTop || 0;
266      document.body.classList.add("cb-search-scroll-locked");
267      document.body.style.position = "fixed";
268      document.body.style.top = `-${lockedScrollY}px`;
269      document.body.style.left = "0";
270      document.body.style.right = "0";
271      document.body.style.width = "100%";
272    };
273
274    const unlockScroll = () => {
275      if (!document.body.classList.contains("cb-search-scroll-locked")) {
276        return;
277      }
278
279      document.body.classList.remove("cb-search-scroll-locked");
280      document.body.style.position = "";
281      document.body.style.top = "";
282      document.body.style.left = "";
283      document.body.style.right = "";
284      document.body.style.width = "";
285      window.scrollTo(0, lockedScrollY);
286    };
287
288    const setMobilePanelOffset = () => {
289      if (header instanceof HTMLElement) {
290        document.documentElement.style.setProperty(
291          "--cb-mobile-search-top",
292          `${Math.ceil(header.getBoundingClientRect().height)}px`
293        );
294      }
295    };
296
297    const openMobileSearch = () => {
298      if (!isMobileSearch() || isExpanded) {
299        return;
300      }
301
302      isExpanded = true;
303      renderRecentItems();
304      setMobilePanelOffset();
305      lockScroll();
306      header?.classList.add("fixed", "inset-x-0", "top-0", "z-[90]");
307      headerInner?.classList.add("gap-2");
308      headerLogo?.classList.add("w-0", "scale-95", "overflow-hidden", "opacity-0");
309      backButton?.classList.remove("hidden");
310      backButton?.classList.add("flex");
311      cancelButton?.classList.remove("hidden");
312      cancelButton?.classList.add("sm:inline-flex");
313      if (results.parentElement !== document.body) {
314        results.parentNode?.insertBefore(resultsPlaceholder, results);
315        document.body.appendChild(results);
316      }
317      results.classList.remove(
318        "absolute",
319        "top-[calc(100%+0.5rem)]",
320        "max-h-[26rem]",
321        "rounded-2xl",
322        "border",
323        "shadow-2xl",
324        "md:absolute"
325      );
326      results.classList.remove("hidden");
327      results.classList.add(
328        "fixed",
329        "inset-x-0",
330        "bottom-0",
331        "top-[var(--cb-mobile-search-top)]",
332        "z-[100]",
333        "max-h-none",
334        "rounded-none",
335        "border-t",
336        "border-x-0",
337        "border-b-0",
338        "shadow-none"
339      );
340      input.setAttribute("aria-expanded", "true");
341      setStatus("Search suggestions opened.");
342    };
343
344    const openSearch = () => {
345      if (isMobileSearch()) {
346        openMobileSearch();
347        return;
348      }
349
350      renderRecentItems();
351      discovery.classList.remove("hidden");
352      results.classList.remove("hidden");
353      input.setAttribute("aria-expanded", "true");
354      setStatus("Search suggestions opened.");
355    };
356
357    const closeResults = () => {
358      results.classList.add("hidden");
359      liveResults.classList.add("hidden");
360      liveResults.innerHTML = "";
361      loading?.classList.add("hidden");
362      discovery.classList.remove("hidden");
363      input.setAttribute("aria-expanded", "false");
364      input.removeAttribute("aria-activedescendant");
365      activeIndex = -1;
366      items = [];
367    };
368
369    const closeMobileSearch = ({ clearQuery = true, focusInput = false } = {}) => {
370      controller?.abort();
371      window.clearTimeout(timer);
372      closeResults();
373
374      if (clearQuery) {
375        input.value = "";
376      }
377
378      if (isExpanded) {
379        isExpanded = false;
380        header?.classList.remove("fixed", "inset-x-0", "top-0", "z-[90]");
381        headerInner?.classList.remove("gap-2");
382        headerLogo?.classList.remove("w-0", "scale-95", "overflow-hidden", "opacity-0");
383        backButton?.classList.add("hidden");
384        backButton?.classList.remove("flex");
385        cancelButton?.classList.add("hidden");
386        cancelButton?.classList.remove("sm:inline-flex");
387        results.classList.remove(
388          "fixed",
389          "inset-x-0",
390          "bottom-0",
391          "top-[var(--cb-mobile-search-top)]",
392          "z-[100]",
393          "max-h-none",
394          "rounded-none",
395          "border-t",
396          "border-x-0",
397          "border-b-0",
398          "shadow-none"
399        );
400        results.classList.add(
401          "absolute",
402          "top-[calc(100%+0.5rem)]",
403          "max-h-[26rem]",
404          "rounded-2xl",
405          "border",
406          "shadow-2xl",
407          "md:absolute"
408        );
409        if (resultsPlaceholder.parentNode) {
410          resultsPlaceholder.parentNode.insertBefore(results, resultsPlaceholder);
411          resultsPlaceholder.remove();
412        }
413        unlockScroll();
414      }
415
416      if (focusInput) {
417        input.focus();
418      } else {
419        input.blur();
420      }
421    };
422
423    const setActiveItem = (nextIndex) => {
424      if (items.length === 0) {
425        return;
426      }
427
428      activeIndex = (nextIndex + items.length) % items.length;
429
430      items.forEach((item, index) => {
431        const isActive = index === activeIndex;
432        item.setAttribute("aria-selected", isActive ? "true" : "false");
433        item.classList.toggle("bg-blue-50", isActive);
434
435        if (isActive) {
436          input.setAttribute("aria-activedescendant", item.id);
437          item.scrollIntoView({ block: "nearest" });
438        }
439      });
440    };
441
442    const renderResults = (records) => {
443      if (!Array.isArray(records) || records.length === 0) {
444        liveResults.innerHTML = `
445          <div class="p-4 text-sm text-slate-600">
446            <div class="font-bold text-slate-950">No gift cards found for “${escapeHtml(input.value.trim())}”</div>
447            <a class="mt-3 inline-flex min-h-10 items-center rounded-xl bg-blue-700 px-4 text-sm font-bold text-white hover:bg-blue-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-700" href="https://www.cardbear.com/gift-cards">View all gift cards</a>
448          </div>
449        `;
450        items = [];
451        activeIndex = -1;
452        discovery.classList.remove("hidden");
453        liveResults.classList.remove("hidden");
454        loading?.classList.add("hidden");
455        results.classList.remove("hidden");
456        input.setAttribute("aria-expanded", "true");
457        setStatus(`No gift cards found for ${input.value.trim()}.`);
458        return;
459      }
460
461      const query = input.value.trim();
462      liveResults.innerHTML = records
463        .map(
464          (record, index) => `
465            <a
466              id="cardbear-search-option-${index}"
467              href="${escapeHtml(record.url)}"
468              role="option"
469              aria-selected="false"
470              data-store-name="${escapeHtml(record.name)}"
471              data-store-url="${escapeHtml(record.url)}"
472              data-store-image="${escapeHtml(record.image)}"
473              class="flex items-center gap-3 rounded-xl px-3 py-2.5 text-left transition hover:bg-blue-50 focus:bg-blue-50 focus:outline-none md:gap-4 md:px-4 md:py-3"
474            >
475              <img
476                src="${escapeHtml(record.image)}"
477                alt=""
478                width="64"
479                height="40"
480                loading="lazy"
481                class="h-10 w-16 shrink-0 rounded-lg object-contain md:h-11 md:w-[4.4rem]"
482              >
483              <span class="min-w-0 flex-1">
484                <span class="block truncate text-sm font-bold text-slate-900 md:text-[17px] md:font-semibold">${escapeHtml(record.name)}</span>
485                ${
486                  record.discount
487                    ? `<span class="mt-0.5 block text-xs font-bold text-emerald-700 md:text-[15px] md:font-semibold">${escapeHtml(record.discount)}</span>`
488                    : record.category
489                      ? `<span class="mt-0.5 block text-xs text-slate-500">${escapeHtml(record.category)}</span>`
490                      : `<span class="mt-0.5 block text-xs text-slate-400">Check availability</span>`
491                }
492              </span>
493              <svg aria-hidden="true" class="size-4 shrink-0 text-slate-400" viewB
493ox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
494                <path d="m9 18 6-6-6-6"/>
495              </svg>
496            </a>
497          `
498        )
499        .join("") + `
500          <a
501            href="${escapeHtml(searchPath(query))}"
502            class="mt-1 flex min-h-11 items-center justify-between rounded-xl px-3 py-2.5 text-sm font-bold text-[#0066cc] transition hover:bg-blue-50 focus:bg-blue-50 focus:outline-none"
503          >
504            <span class="truncate">View all results for “${escapeHtml(query)}”</span>
505            <svg aria-hidden="true" class="size-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
506              <path d="m9 18 6-6-6-6"/>
507            </svg>
508          </a>
509        `;
510
511      items = Array.from(liveResults.querySelectorAll('[role="option"]'));
512      items.forEach((item) => {
513        item.addEventListener("click", () =>
514          saveRecentItem({
515            name: item.getAttribute("data-store-name") || item.textContent.trim(),
516            url: item.getAttribute("data-store-url") || item.href,
517            image: item.getAttribute("data-store-image") || "",
518          })
519        );
520      });
521      discovery.classList.add("hidden");
522      loading?.classList.add("hidden");
523      liveResults.classList.remove("hidden");
524      results.classList.remove("hidden");
525      input.setAttribute("aria-expanded", "true");
526      activeIndex = -1;
527      setStatus(`${records.length} gift card results available.`);
528    };
529
530    const requestResults = async (query) => {
531      controller?.abort();
532      controller = new AbortController();
533
534      try {
535        loading?.classList.remove("hidden");
536        liveResults.classList.add("hidden");
537        results.classList.remove("hidden");
538        input.setAttribute("aria-expanded", "true");
539        setStatus("Loading gift card results.");
540
541        const response = await fetch(
542          `/api/search-suggestions.php?q=${encodeURIComponent(query)}`,
543          {
544            headers: { Accept: "application/json" },
545            signal: controller.signal,
546          }
547        );
548
549        if (!response.ok) {
550          throw new Error("Search request failed");
551        }
552
553        const payload = await response.json();
554        renderResults(payload.results);
555      } catch (error) {
556        if (error.name !== "AbortError") {
557          loading?.classList.add("hidden");
558          discovery.classList.remove("hidden");
559          liveResults.innerHTML = `
560            <div class="p-4 text-sm text-slate-600">
561              <div class="font-bold text-slate-950">Search is taking a breather.</div>
562              <button type="button" class="mt-3 inline-flex min-h-10 items-center rounded-xl border border-slate-300 px-4 text-sm font-bold text-blue-700 hover:bg-blue-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-700" data-search-retry>Try again</button>
563            </div>
564          `;
565          liveResults.querySelector("[data-search-retry]")?.addEventListener("click", () => {
566            requestResults(input.value.trim());
567          });
568          liveResults.classList.remove("hidden");
569          results.classList.remove("hidden");
570          setStatus("Search results could not be loaded.");
571        }
572      }
573    };
574
575    input.addEventListener("focus", () => {
576      openSearch();
577    });
578
579    form?.addEventListener("submit", (event) => {
580      const query = input.value.trim();
581      if (!query) {
582        return;
583      }
584
585      event.preventDefault();
586      window.location.assign(searchPath(query));
587    });
588
589    input.addEventListener("input", () => {
590      window.clearTimeout(timer);
591      const query = input.value.trim();
592
593      if (query.length < 2) {
594        controller?.abort();
595        liveResults.classList.add("hidden");
596        liveResults.innerHTML = "";
597        loading?.classList.add("hidden");
598        discovery.classList.remove("hidden");
599
600        openSearch();
601
602        return;
603      }
604
605      timer = window.setTimeout(() => requestResults(query), 180);
606    });
607
608    input.addEventListener("keydown", (event) => {
609      if (event.key === "ArrowDown") {
610        if (results.classList.contains("hidden")) {
611          return;
612        }
613        event.preventDefault();
614        setActiveItem(activeIndex + 1);
615      } else if (event.key === "ArrowUp") {
616        if (results.classList.contains("hidden")) {
617          return;
618        }
619        event.preventDefault();
620        setActiveItem(activeIndex - 1);
621      } else if (event.key === "Enter" && activeIndex >= 0) {
622        event.preventDefault();
623        items[activeIndex].click();
624      } else if (event.key === "Escape") {
625        if (isExpanded) {
626          closeMobileSearch();
627        } else {
628          closeResults();
629        }
630      }
631    });
632
633    backButton?.addEventListener("click", () => closeMobileSearch());
634    cancelButton?.addEventListener("click", () => closeMobileSearch());
635
636    searchRoot.querySelectorAll("[data-search-store-link]").forEach((link) => {
637      link.addEventListener("click", () =>
638        saveRecentItem({
639          name: link.getAttribute("data-store-name") || link.textContent.trim(),
640          url: link.getAttribute("data-store-url") || link.href,
641          image: link.getAttribute("data-store-image") || "",
642        })
643      );
644    });
645
646    document.addEventListener("click", (event) => {
647      if (!searchRoot.contains(event.target) && !isExpanded) {
648        closeResults();
649      }
650    });
651
652    window.addEventListener("resize", () => {
653      if (!isMobileSearch() && isExpanded) {
654        closeMobileSearch({ clearQuery: false });
655      } else if (isExpanded) {
656        setMobilePanelOffset();
657      }
658    });
659  });
660
661  /*
662   * Reveal hidden unavailable marketplace rows on demand.
663   */
664  document.querySelectorAll("[data-unavailable-offers-toggle]").forEach((button) => {
665    const controlledIds = (button.getAttribute("aria-controls") || "")
666      .split(/\s+/)
667      .filter(Boolean);
668    const rows = controlledIds
669      .map((id) => document.getElementById(id))
670      .filter((row) => row instanceof HTMLElement);
671    const label = button.querySelector("[data-unavailable-offers-toggle-label]");
672    const icon = button.querySelector("[data-unavailable-offers-toggle-icon]");
673    const collapsedLabel = button.getAttribute("data-collapsed-label") || "Show unavailable sellers";
674    const expandedLabel = button.getAttribute("data-expanded-label") || "Hide unavailable sellers";
675
676    if (rows.length === 0) {
677      button.hidden = true;
678      return;
679    }
680
681    button.addEventListener("click", () => {
682      const isExpanded = button.getAttribute("aria-expanded") === "true";
683      const nextExpanded = !isExpanded;
684
685      rows.forEach((row) => {
686        row.hidden = !nextExpanded;
687      });
688
689      button.setAttribute("aria-expanded", String(nextExpanded));
690
691      if (label instanceof HTMLElement) {
692        label.textContent = nextExpanded ? expandedLabel : collapsedLabel;
693      }
694
695      icon?.classList.toggle("rotate-180", nextExpanded);
696
697      if (typeof window.gtag === "function") {
698        window.gtag("event", nextExpanded ? "show_unavailable_sellers" : "hide_unavailable_sellers", {
699          event_category: "offers",
700        });
701      }
702    });
703  });
704
705  /*
706   * Price alert dialog and form.
707   */
708  const alertDialog = document.querySelector("[data-price-alert-dialog]");
709  const alertOpenButtons = document.querySelectorAll("[data-price-alert-open]");
710
711  if (alertDialog instanceof HTMLElement && alertOpenButtons.length > 0) {
712    const alertBackdrop = alertDialog.querySelector("[data-price-alert-backdrop]");
713    const alertPanel = alertDialog.querySelector("[data-price-alert-panel]");
714    const alertForm = alertDialog.querySelector("[data-price-alert-form]");
715    const alertCloseButtons = alertDialog.querySelectorAll(
716      "[data-price-alert-close], [data-price-alert-done]"
717    );
718    const formView = alertDialog.querySelector("[data-price-alert-form-view]");
719    const successView = alertDialog.querySelector("[data-price-alert-success-view]");
720    const successMessage = alertDialog.querySelector("[data-price-alert-success-message]");
721    const summary = document.querySelector("[data-price-alert-summary]");
722
723    let lastFocusedElement = null;
724    let currentTarget = 1;
725
726    const focusableSelector = [
727      "a[href]",
728      "button:not([disabled])",
729      "input:not([disabled])",
730      "select:not([disabled])",
731      "textarea:not([disabled])",
732      '[tabindex]:not([tabindex="-1"])',
733    ].join(",");
734
735    const setOpenStyles = (isOpen) => {
736      if (!(alertBackdrop instanceof HTMLElement) || !(alertPanel instanceof HTMLElement)) {
737        return;
738      }
739
740      alertBackdrop.classList.toggle("opacity-100", isOpen);
741      alertBackdrop.classList.toggle("opacity-0", !isOpen);
742      alertPanel.classList.toggle("translate-y-0", isOpen);
743      alertPanel.classList.toggle("translate-y-full", !isOpen);
744      alertPanel.classList.toggle("md:scale-100", isOpen);
745      alertPanel.classList.toggle("md:scale-95", !isOpen);
746      alertPanel.classList.toggle("md:opacity-100", isOpen);
747      alertPanel.classList.toggle("md:opacity-0", !isOpen);
748    };
749
750    const getFocusableElements = () =>
751      Array.from(alertDialog.querySelectorAll(focusableSelector)).filter(
752        (element) =>
753          element instanceof HTMLElement &&
754          !element.hasAttribute("disabled") &&
755          element.offsetParent !== null
756      );
757
758    const updateTargetControls = (value) => {
759      if (!(alertForm instanceof HTMLFormElement)) {
760        return;
761      }
762
763      const min = Number(alertForm.dataset.min || "1");
764      const max = Number(alertForm.dataset.max || "60");
765      currentTarget = Math.max(min, Math.min(max, Math.round(Number(value) || min)));
766
767      const percentInput = alertForm.querySelector("[data-price-alert-percent]");
768      const valueOutput = alertForm.querySelector("[data-price-alert-value]");
769      const decreaseButton = alertForm.querySelector("[data-price-alert-decrease]");
770      const increaseButton = alertForm.querySelector("[data-price-alert-increase]");
771
772      if (percentInput instanceof HTMLInputElement) {
773        percentInput.value = String(currentTarget);
774      }
775
776      if (valueOutput instanceof HTMLElement) {
777        valueOutput.textContent = `${currentTarget}%`;
778      }
779
780      if (decreaseButton instanceof HTMLButtonElement) {
781        decreaseButton.disabled = currentTarget <= min;
782      }
783
784      if (increaseButton instanceof HTMLButtonElement) {
785        increaseButton.disabled = currentTarget >= max;
786      }
787
788      alertForm.querySelectorAll("[data-price-alert-chip]").forEach((chip) => {
789        if (!(chip instanceof HTMLButtonElement)) {
790          return;
791        }
792
793        const isActive = Number(chip.dataset.value || "0") === currentTarget;
794        chip.classList.toggle("bg-emerald-700", isActive);
795        chip.classList.toggle("text-white", isActive);
796        chip.classList.toggle("border-emerald-700", isActive);
797        chip.classList.toggle("bg-white", !isActive);
798        chip.classList.toggle("text-slate-700", !isActive);
799        chip.setAttribute("aria-pressed", isActive ? "true" : "false");
800      });
801    };
802
803    const resetDialog = () => {
804      if (formView instanceof HTMLElement) {
805        formView.classList.remove("hidden");
806      }
807
808      if (successView instanceof HTMLElement) {
809        successView.classList.add("hidden");
810      }
811
812      if (alertForm instanceof HTMLFormElement) {
813        const emailInput = alertForm.querySelector("[data-price-alert-email]");
814        const emailError = alertForm.querySelector("[data-price-alert-email-error]");
815        const status = alertForm.querySelector("[data-price-alert-status]");
816        const button = alertForm.querySelector('button[type="submit"]');
817
818        alertForm.reset();
819        updateTargetControls(Number(alertForm.dataset.default || alertForm.dataset.min || "1"));
820
821        if (emailInput instanceof HTMLInputElement) {
822          emailInput.removeAttribute("aria-invalid");
823        }
824
825        if (emailError instanceof HTMLElement) {
826          emailError.classList.add("hidden");
827          emailError.textContent = "";
828        }
829
830        if (status instanceof HTMLElement) {
831          status.className = "min-h-5 text-sm";
832          status.textContent = "";
833        }
834
835        if (button instanceof HTMLButtonElement) {
836          button.disabled = false;
837          button.textContent = "Create Price Alert";
838        }
839      }
840    };
841
842    const openDialog = () => {
843      lastFocusedElement = document.activeElement instanceof HTMLElement ? document.activeElement : null;
844      resetDialog();
845      alertDialog.classList.remove("hidden");
846      alertDialog.setAttribute("aria-hidden", "false");
847      document.body.classList.add("overflow-hidden");
848
849      window.requestAnimationFrame(() => {
850        setOpenStyles(true);
851        const firstFocusable = getFocusableElements()[0];
852        firstFocusable?.focus();
853      });
854    };
855
856    const closeDialog = () => {
857      setOpenStyles(false);
858      window.setTimeout(() => {
859        alertDialog.classList.add("hidden");
860        alertDialog.setAttribute("aria-hidden", "true");
861        document.body.classList.remove("overflow-hidden");
862        lastFocusedElement?.focus();
863      }, window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 180);
864    };
865
866    alertOpenButtons.forEach((button) => {
867      if (button instanceof HTMLButtonElement) {
868        button.addEventListener("click", openDialog);
869      }
870    });
871
872    if (alertBackdrop instanceof HTMLElement) {
873      alertBackdrop.addEventListener("click", closeDialog);
874    }
875
876    alertCloseButtons.forEach((button) => {
877      button.addEventListener("click", closeDialog);
878    });
879
880    alertDialog.addEventListener("keydown", (event) => {
881      if (event.key === "Escape") {
882        event.preventDefault();
883        closeDialog();
884        return;
885      }
886
887      if (event.key !== "Tab") {
888        return;
889      }
890
891      const focusableElements = getFocusableElements();
892      const first = focusableElements[0];
893      const last = focusableElements[focusableElements.length - 1];
894
895      if (!first || !last) {
896        return;
897      }
898
899      if (event.shiftKey && document.activeElement === first) {
900        event.preventDefault();
901        last.focus();
902      } else if (!event.shiftKey && document.activeElement === last) {
903        event.preventDefault();
904        first.focus();
905      }
906    });
907
908    if (alertForm instanceof HTMLFormElement) {
909      updateTargetControls(Number(alertForm.dataset.default || alertForm.dataset.min || "1"));
910
911      alertForm.querySelectorAll("[data-price-alert-chip]").forEach((chip) => {
912        chip.addEventListener("click", () => {
913          if (chip instanceof HTMLButtonElement) {
914            updateTargetControls(Number(chip.dataset.value || currentTarget));
915          }
916        });
917      });
918
919      const decreaseButton = alertForm.querySelector("[data-price-alert-decrease]");
920      const increaseButton = alertForm.querySelector("[data-price-alert-increase]");
921
922      decreaseButton?.addEventListener("click", () => {
923        const step = Number(alertForm.dataset.step || "1");
924        updateTargetControls(currentTarget - step);
925      });
926
927      increaseButton?.addEventListener("click", () => {
928        const step = Number(alertForm.dataset.step || "1");
929        updateTargetControls(currentTarget + step);
930      });
931
932      alertForm.addEventListener("submit", async (event) => {
933        event.preventDefault();
934
935        const emailInput = alertForm.querySelector("[data-price-alert-email]");
936        const emailError = alertForm.querySelector("[data-price-alert-email-error]");
937        const status = alertForm.querySelector("[data-price-alert-status]");
938        const button = alertForm.querySelector('button[type="submit"]');
939
940        if (emailInput instanceof HTMLInputElement && !emailInput.checkValidity()) {
941          emailInput.setAttribute("aria-invalid", "true");
942
943          if (emailError instanceof HTMLElement) {
944            emailError.classList.remove("hidden");
945            emailError.textContent = "Enter a valid email address.";
946          }
947
948          emailInput.focus();
949          return;
950        }
951
952        if (emailInput instanceof HTMLInputElement) {
953          emailInput.removeAttribute("aria-invalid");
954        }
955
956        if (emailError instanceof HTMLElement) {
957          emailError.classList.add("hidden");
958          emailError.textContent = "";
959        }
960
961        const formData = new FormData(alertForm);
962        const body = new URLSearchParams({
963          email: String(formData.get("email") || ""),
964          percent: String(formData.get("percent") || ""),
965          storeid: String(formData.get("storeid") || ""),
966        });
967
968        if (button instanceof HTMLButtonElement) {
969          button.disabled = true;
970          button.textContent = "Creating Alert...";
971        }
972
973        if (status instanceof HTMLElement) {
974          status.className = "min-h-5 text-sm text-slate-500";
975          status.textContent = "Creating your alert...";
976        }
977
978        try {
979          const response = await fetch("/ajax-setup-alert.php", {
980            method: "POST",
981            headers: {
982              "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
983            },
984            body,
985          });
986
987          if (!response.ok) {
988            throw new Error("Alert request failed");
989          }
990
991          const storeName = alertForm.dataset.storeName || "this gift card";
992          const message = `We'll email you when ${storeName} reaches ${currentTarget}% or higher.`;
993
994          if (successMessage instanceof HTMLElement) {
995            successMessage.textContent = message;
996          }
997
998          if (summary instanceof HTMLElement) {
999            summary.textContent = `Alert set for ${currentTarget}%`;
1000          }
1001
1002          if (formView instanceof HTMLElement) {
1003            formView.classList.add("hidden");
1004          }
1005
1006          if (successView instanceof HTMLElement) {
1007            successView.classList.remove("hidden");
1008            const doneButton = successView.querySelector("[data-price-alert-done]");
1009
1010            if (doneButton instanceof HTMLButtonElement) {
1011              doneButton.focus();
1012            }
1013          }
1014        } catch (_error) {
1015          if (status instanceof HTMLElement) {
1016            status.className = "min-h-5 text-sm font-semibold text-red-700";
1017            status.textContent =
1018              "We could not create the alert. Please check your email and try again.";
1019          }
1020        } finally {
1021          if (button instanceof HTMLButtonElement) {
1022            button.disabled = false;
1023            button.textContent = "Create Price Alert";
1024          }
1025        }
1026      });
1027    }
1028  }
1029
1030  /*
1031   * Responsive daily discount history charts.
1032   */
1033  const historyCanvases = document.querySelectorAll("canvas[data-history-chart]");
1034
1035  if (historyCanvases.length > 0 && typeof window.Chart === "function") {
1036    const annotationPlugin = window["chartjs-plugin-annotation"];
1037
1038    if (annotationPlugin) {
1039      window.Chart.register(annotationPlugin);
1040    }
1041
1042    const initializedCharts = new WeakSet();
1043    const formatPercent = (value) =>
1044      `${new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(Number(value))}%`;
1045    const formatHistoryDate = (timestamp) =>
1046      new Intl.DateTimeFormat("en-US", {
1047        month: "short",
1048        day: "numeric",
1049        year: "numeric",
1050      }).format(new Date(timestamp));
1051    const historyHoverGuide = {
1052      id: "historyHoverGuide",
1053      afterDatasetsDraw(chart) {
1054        const activePoint = chart.tooltip?.getActiveElements()[0];
1055
1056        if (!activePoint || !chart.chartArea) {
1057          return;
1058        }
1059
1060        const { ctx, chartArea } = chart;
1061        const x = activePoint.element.x;
1062
1063        ctx.save();
1064        ctx.beginPath();
1065        ctx.moveTo(x, chartArea.top);
1066        ctx.lineTo(x, chartArea.bottom);
1067        ctx.lineWidth = 1;
1068        ctx.strokeStyle = "rgba(148, 163, 184, 0.44)";
1069        ctx.setLineDash([3, 4]);
1070        ctx.stroke();
1071        ctx.restore();
1072
1073        activePoint.element.draw(ctx, chartArea);
1074      },
1075    };
1076
1077    const initializeHistoryChart = (canvas) => {
1078      if (!(canvas instanceof HTMLCanvasElement) || initializedCharts.has(canvas)) {
1079        return;
1080      }
1081
1082      const configId = canvas.getAttribute("data-history-config");
1083      const configElement = configId ? document.getElementById(configId) : null;
1084
1085      if (!(configElement instanceof HTMLScriptElement)) {
1086        return;
1087      }
1088
1089      let history;
1090
1091      try {
1092        history = JSON.parse(configElement.textContent || "");
1093      } catch (_error) {
1094        return;
1095      }
1096
1097      if (!Array.isArray(history.points) || history.points.length < 2) {
1098        return;
1099      }
1100
1101      const finalIndex = history.points.length - 1;
1102      const highest = Number(history.highest) || 0;
1103      const headroom = Math.max(1, highest * 0.08);
1104      const yMaximum = Math.max(10, Math.ceil((highest + headroom) / 2.5) * 2.5);
1105      const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
1106      const siteFont = window.getComputedStyle(document.body).fontFamily;
1107      const average = Number(history.average) || 0;
1108      const chartWrapper = canvas.parentElement;
1109      const tooltipElement = document.createElement("div");
1110      tooltipElement.className =
1111        "pointer-events-none absolute z-20 hidden min-w-40 rounded-xl border border-slate-200 bg-white px-3 py-2.5 shadow-[0_8px_24px_rgba(15,23,42,0.12)]";
1112      tooltipElement.setAttribute("aria-hidden", "true");
1113      tooltipElement.innerHTML = `
1114        <div class="text-xs font-medium leading-4 text-slate-500" data-history-tooltip-date></div>
1115        <div class="mt-1 text-base font-bold leading-5 text-[#2563EB]" data-history-tooltip-discount></div>
1116        <div class="mt-1 text-xs font-medium leading-4 text-slate-600" data-history-tooltip-comparison></div>
1117      `;
1118
1119      if (chartWrapper instanceof HTMLElement) {
1120        chartWrapper.appendChild(tooltipElement);
1121      }
1122
1123      const renderHistoryTooltip = ({ chart, tooltip }) => {
1124        if (!(chartWrapper instanceof HTMLElement) || tooltip.opacity === 0) {
1125          tooltipElement.classList.add("hidden");
1126          return;
1127        }
1128
1129        const activePoint = tooltip.dataPoints?.[0];
1130        const timestamp = activePoint?.parsed?.x;
1131        const discount = activePoint?.parsed?.y;
1132
1133        if (!Number.isFinite(timestamp) || !Number.isFinite(discount)) {
1134          tooltipElement.classList.add("hidden");
1135          return;
1136        }
1137
1138        const dateElement = tooltipElement.querySelector("[data-history-tooltip-date]");
1139        const discountElement = tooltipElement.querySelector("[data-history-tooltip-discount]");
1140        const comparisonElement = tooltipElement.querySelector("[data-history-tooltip-comparison]");
1141        const difference = discount - average;
1142        const comparison = Math.abs(difference) < 0.01
1143          ? "Matches 12-month average"
1144          : difference > 0
1145            ? "Above 12-month average"
1146            : "Below 12-month average";
1147
1148        if (dateElement) dateElement.textContent = formatHistoryDate(timestamp);
1149        if (discountElement) discountElement.textContent = `${formatPercent(discount)} Off`;
1150        if (comparisonElement) comparisonElement.textContent = comparison;
1151
1152        tooltipElement.classList.remove("hidden");
1153
1154        const desiredX = canvas.offsetLeft + tooltip.caretX - (tooltipElement.offsetWidth / 2);
1155        const maximumX = chartWrapper.clientWidth - tooltipElement.offsetWidth - 8;
1156        const left = Math.max(8, Math.min(desiredX, maximumX));
1157        const caretY = canvas.offsetTop + tooltip.caretY;
1158        const aboveY = caretY - tooltipElement.offsetHeight - 12;
1159        const belowY = caretY + 12;
1160        const maximumY = chartWrapper.clientHeight - tooltipElement.offsetHeight - 8;
1161        const top = Math.max(8, Math.min(aboveY >= 8 ? aboveY : belowY, maximumY));
1162
1163        tooltipElement.style.left = `${left}px`;
1164        tooltipElement.style.top = `${top}px`;
1165      };
1166
1167      const historyGradient = (context) => {
1168        const { chart } = context;
1169        const { ctx, chartArea } = chart;
1170
1171        if (!chartArea) {
1172          return "rgba(67, 97, 238, 0.04)";
1173        }
1174
1175        const gradientKey = `${chartArea.top}:${chartArea.bottom}`;
1176
1177        if (chart.$historyGradientKey !== gradientKey) {
1178          const gradient = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
1179          gradient.addColorStop(0, "rgba(67, 97, 238, 0.07)");
1180          gradient.addColorStop(0.45, "rgba(67, 97, 238, 0.025)");
1181          gradient.addColorStop(1, "rgba(67, 97, 238, 0)");
1182          chart.$historyGradient = gradient;
1183          chart.$historyGradientKey = gradientKey;
1184        }
1185
1186        return chart.$historyGradient;
1187      };
1188
1189      initializedCharts.add(canvas);
1190      new window.Chart(canvas, {
1191        type: "line",
1192        plugins: [historyHoverGuide],
1193        data: {
1194          datasets: [
1195            {
1196              label: "Daily discount",
1197              data: history.points,
1198              borderColor: "#2563EB",
1199              borderWidth: 1.75,
1200              borderCapStyle: "round",
1201              borderJoinStyle: "round",
1202              backgroundColor: historyGradient,
1203              fill: true,
1204              tension: 0,
1205              stepped: false,
1206              spanGaps: false,
1207              pointRadius: (context) => (context.dataIndex === finalIndex ? 6 : 0),
1208              pointHitRadius: 10,
1209              pointHoverRadius: (context) => (context.dataIndex === finalIndex ? 6 : 5),
1210              pointBorderWidth: (context) => (context.dataIndex === finalIndex ? 2 : 0),
1211              pointHoverBorderWidth: 2,
1212              pointBackgroundColor: "#2563EB",
1213              pointHoverBackgroundColor: "#2563EB",
1214              pointBorderColor: "#FFFFFF",
1215              pointHoverBorderColor: "#FFFFFF",
1216            },
1217          ],
1218        },
1219        options: {
1220          responsive: true,
1221          maintainAspectRatio: false,
1222          normalized: true,
1223          layout: {
1224            padding: {
1225              top: 8,
1226              right: 14,
1227            },
1228          },
1229          animation: reducedMotion ? false : { duration: 180 },
1230          interaction: {
1231            mode: "nearest",
1232            intersect: false,
1233            axis: "x",
1234          },
1235          plugins: {
1236            legend: { display: false },
1237            decimation: { enabled: false },
1238            annotation: {
1239              annotations: average > 0
1240                ? {
1241                    average: {
1242                      type: "line",
1243                      yMin: average,
1244                      yMax: average,
1245                      borderColor: "rgba(100, 116, 139, 0.44)",
1246                      borderWidth: 1.25,
1247                      borderDash: [6, 6],
1248                    },
1249                  }
1250                : {},
1251            },
1252            tooltip: {
1253              enabled: false,
1254              external: renderHistoryTooltip,
1255              displayColors: false,
1256            },
1257          },
1258          scales: {
1259            x: {
1260              type: "time",
1261              time: {
1262                unit: "month",
1263                displayFormats: { month: "MMM yy" },
1264              },
1265              grid: { display: false },
1266              border: { display: false },
1267              ticks: {
1268                autoSkip: true,
1269                maxTicksLimit: 5,
1270                maxRotation: 0,
1271                color: "#475569",
1272                font: { family: siteFont, size: 12, weight: "600" },
1273              },
1274            },
1275            y: {
1276              beginAtZero: true,
1277              max: yMaximum,
1278              grid: {
1279                color: "rgba(148, 163, 184, 0.22)",
1280                lineWidth: 1,
1281                drawTicks: false,
1282              },
1283              border: { display: false },
1284              ticks: {
1285                count: 5,
1286                padding: 10,
1287                color: "#475569",
1288                font: { family: siteFont, size: 12, weight: "600" },
1289                callback: (value) => formatPercent(value),
1290              },
1291            },
1292          },
1293        },
1294      });
1295    };
1296
1297    if ("IntersectionObserver" in window) {
1298      const chartObserver = new IntersectionObserver(
1299        (entries, observer) => {
1300          entries.forEach((entry) => {
1301            if (entry.isIntersecting) {
1302              initializeHistoryChart(entry.target);
1303              observer.unobserve(entry.target);
1304            }
1305          });
1306        },
1307        { rootMargin: "120px 0px" },
1308      );
1309
1310      historyCanvases.forEach((canvas) => chartObserver.observe(canvas));
1311    } else {
1312      historyCanvases.forEach((canvas) => {
1313        if (canvas.getClientRects().length > 0) {
1314          initializeHistoryChart(canvas);
1315        }
1316      });
1317    }
1318  }
1319
1320  /*
1321   * Share and copy-link controls.
1322   */
1323  document.querySelectorAll("[data-native-share]").forEach((button) => {
1324    button.addEventListener("click", async () => {
1325      const title = button.getAttribute("data-share-title") || document.title;
1326      const url = button.getAttribute("data-share-url") || window.location.href;
1327      const status = document.querySelector("[data-share-status]");
1328
1329      try {
1330        if (navigator.share) {
1331          await navigator.share({ title, url });
1332        } else {
1333          await navigator.clipboard.writeText(url);
1334
1335          if (status instanceof HTMLElement) {
1336            status.textContent = "Link copied.";
1337          }
1338        }
1339      } catch (error) {
1340        if (error.name !== "AbortError" && status instanceof HTMLElement) {
1341          status.textContent = "Unable to share this page.";
1342        }
1343      }
1344    });
1345  });
1346
1347  document.querySelectorAll("[data-copy-url]").forEach((button) => {
1348    button.addEventListener("click", async () => {
1349      const url = button.getAttribute("data-copy-url") || window.location.href;
1350      const status = document.querySelector("[data-share-status]");
1351
1352      try {
1353        await navigator.clipboard.writeText(url);
1354
1355        if (status instanceof HTMLElement) {
1356          status.textContent = "Link copied.";
1357        }
1358      } catch (_error) {
1359        if (status instanceof HTMLElement) {
1360          status.textContent = "Copying failed. Please copy the address from your browser.";
1361        }
1362      }
1363    });
1364  });
1365})();

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.