1(() => { 2 "use strict"; 3 4 const escapeHtml = (value) => 5 String(value).replace( 6 /[&<>"']/g, 7 (character) => 8 ({ 9 "&": "&", 10 "<": "<", 11 ">": ">", 12 '"': """, 13 "'": "'", 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.