PageSourceSearch

https://www.goldimageprinting.com/design-iiv/js/header.js?v=1Idy6ECDJvCe8I_ln79yl4LEF3-ZmMd782JcsNNisDs

js goldimageprinting.com collected 2026-10-07 20:07:13 UTC 32,199 bytes, 755 lines download raw bytes

1/**
2 * GIP HEADER v4 — header.js
3 * Path: wwwroot/design-iiv/js/header.js
4 *
5 * Phase 4 modifications from new-header-design.html IIFE:
6 *
7 *  A. COUNTDOWN
8 *     - Hardcoded cutoff.setHours(14,0,0,0) REMOVED.
9 *     - Now reads #cutoff-time-miliseconds value (server-supplied absolute
10 *       Unix epoch ms from Model.CutOffTime.CutOffTimeMilliSeconds).
11 *     - cutoffEpoch = ms directly (NOT Date.now() + ms - this value is
12 *       already an absolute epoch, computed once at script execution time,
13 *       before the interval starts.
14 *     - All expired / urgent / weekend logic preserved intact.
15 *     - jquery.countdown dependency dropped entirely.
16 *     - gipCountdownSuffix element removed from original — suffix text
17 *       is now a static "PST" span in the Razor markup; only the
18 *       countdown time span and dot are updated by JS.
19 *
20 *  B. SEARCH
21 *     - GIP_PRODUCTS hardcoded array REMOVED.
22 *     - Uses Categories[] from categories-json.js (already loaded before
23 *       this script via defer ordering in _HomeLayout.cshtml).
24 *     - Field mapping: label→name, type→cat, link→url,
25 *       image→img (prefixed with CDN), StartingPrice→price, sizes→size,
26 *       isSameDay→isSameDay filter flag.
27 *     - gipSearchFilter(query, sameDayOnly) — new sameDayOnly parameter
28 *       added; if true, skips items where isSameDay !== true.
29 *     - gipRenderResults: "View All" footer link REMOVED per decision C.
30 *     - Meta line in result item fixed: was incorrectly repeating p.size
31 *       three times; now shows size once, matches categories-json.js data.
32 *     - gipBindSearch(inputId, containerId, openClass, sameDayCheckId)
33 *       — new sameDayCheckId parameter. Checkbox change event re-triggers
34 *       search on its bound input if query length >= 2.
35 *     - Document click-outside handler fixed: closest() guard now uses
36 *       the actual wrapper selectors for each input context so it does
37 *       not throw when called from panel search (which has no .gip-search
38 *       ancestor).
39 *
40 *  C. CORPORATE + ACCOUNT DROPDOWNS
41 *     - New gipBindDropdown() helper wires the Corporate and Account
42 *       dropdowns in the actions bar. Closes on outside click and Escape.
43 *       Manages aria-expanded on the trigger button.
44 *
45 *  D. MOBILE PANEL, MEGA MENU ARIA, ACCORDION
46 *     - Carried over from new design IIFE, no logic changes.
47 *     - Element IDs updated to match Default.cshtml:
48 *       gipMobileClose → gipPanelClose
49 *       gipMobileBackdrop → gipPanelBackdrop
50 *
51 *  E. DEBOUNCED RESIZE
52 *     - Carried over unchanged.
53 *
54 * Dependencies (must be loaded before this file, all defer):
55 *   - categories-json.js  (provides Categories[])
56 * No jQuery, no jQuery UI, no jquery.countdown required.
57 */
58
59(function () {
60  'use strict';
61
62
63  /* ═══════════════════════════════════════════════════════════
64     A. COUNTDOWN
65     Reads server-supplied milliseconds from hidden input.
66     Computes absolute cutoff epoch once at execution time.
67     Updates every second via setInterval.
68  ═══════════════════════════════════════════════════════════ */
69
70  /**
71   * Detect current PST/PDT UTC offset.
72   * PST = UTC-8, PDT (daylight) = UTC-7.
73   * Uses Jan vs Jul offset comparison — no timezone string parsing needed.
74   * @returns {number} offset in hours (−8 or −7)
75   */
76  function getPSTOffset() {
77    var jan = new Date(new Date().getFullYear(), 0, 1).getTimezoneOffset();
78    var jul = new Date(new Date().getFullYear(), 6, 1).getTimezoneOffset();
79    // If current offset is less than the max (i.e. we're in summer), we're in PDT (−7)
80    return new Date().getTimezoneOffset() < Math.max(jan, jul) ? -7 : -8;
81  }
82
83  /**
84   * Return the current moment expressed as a PST/PDT Date object.
85   * Converts local time → UTC → PST wall-clock time.
86   * @returns {Date}
87   */
88  function getNowPST() {
89    var now = new Date();
90    var utcMs = now.getTime() + now.getTimezoneOffset() * 60000;
91    return new Date(utcMs + getPSTOffset() * 3600000);
92  }
93
94  /** Zero-pad a number to 2 digits. */
95  function pad(n) { return String(n).padStart(2, '0'); }
96
97  /* ── Read server cutoff milliseconds once at script load time ── */
98  var cutoffEpoch = null;
99  (function () {
100    var el = document.getElementById('cutoff-time-miliseconds');
101    if (!el) return;
102    var ms = parseInt(el.value, 10);
103    // ms is the cutoff's own absolute Unix epoch in milliseconds
104    // (HomeContent.GetCutoffTime() / MilliTimeStamp() — same value the old
105    // jquery.countdown widget consumed directly as its target date), not a
106    // duration relative to now. Do not add Date.now() to it.
107    if (!isNaN(ms) && ms > 0) {
108      cutoffEpoch = ms;
109    }
110    // If ms <= 0 the cutoff has already passed today; cutoffEpoch stays null
111    // and updateCountdown will render the expired state immediately.
112  })();
113
114  function updateCountdown() {
115    var pill  = document.getElementById('gipSameDayPill');
116    var dot   = document.getElementById('gipPillDot');
117    var timer = document.getElementById('gipCountdownTime');
118    if (!pill || !dot || !timer) return;
119
120    var nowPST    = getNowPST();
121    var day       = nowPST.getDay();          // 0 = Sun, 6 = Sat
122    var isWeekday = day >= 1 && day <= 5;
123
124    /* ── Weekend: same grayed-out/frozen treatment as a passed weekday cutoff ── */
125    if (!isWeekday) {
126      pill.classList.add('gip-pill--expired');
127      dot.classList.add('gip-pill__dot--off');
128      timer.classList.remove('gip-pill__time--urgent');
129      timer.textContent = '00:00:00';
130      return;
131    }
132
133    /* ── Cutoff has passed / was not set: gray out but keep the HH:MM:SS
134       format frozen at 00:00:00 instead of swapping to a "closed" message ── */
135    if (cutoffEpoch === null || Date.now() >= cutoffEpoch) {
136      pill.classList.add('gip-pill--expired');
137      dot.classList.add('gip-pill__dot--off');
138      timer.classList.remove('gip-pill__time--urgent');
139      timer.textContent = '00:00:00';
140      return;
141    }
142
143    /* ── Active state ── */
144    pill.classList.remove('gip-pill--expired');
145    dot.classList.remove('gip-pill__dot--off');
146
147    var secsLeft = Math.floor((cutoffEpoch - Date.now()) / 1000);
148    var h = Math.floor(secsLeft / 3600);
149    var m = Math.floor((secsLeft % 3600) / 60);
150    var s = secsLeft % 60;
151    timer.textContent = pad(h) + ':' + pad(m) + ':' + pad(s);
152    // Urgent styling: under 30 minutes remaining
153    timer.classList.toggle('gip-pill__time--urgent', secsLeft < 1800);
154  }
155
156  updateCountdown();
157  setInterval(updateCountdown, 1000);
158
159
160  /* ═══════════════════════════════════════════════════════════
161     B. SEARCH ENGINE
162     Data source: Categories[] from categories-json.js.
163     Field mapping from categories-json.js schema:
164       label         → p.name  (product display name)
165       type          → p.cat   (category group label)
166       link          → p.url   (page href)
167       image         → p.img   (path; CDN prefix prepended in render)
168       StartingPrice → p.price (display string)
169       sizes         → p.size  (display string)
170       isSameDay     → p.isSameDay (boolean filter flag)
171  ═══════════════════════════════════════════════════════════ */
172
173  var CDN_BASE    = 'https://content.goldimageprinting.com/';
174
175  /**
176   * Filter Categories[] by query string and optional same-day flag.
177   * Matches on product name only (not category type) - shows every matching
178   * product, no per-category or total cap. The results dropdown
179   * (.gip-search__results) already scrolls (max-height + overflow-y:auto),
180   * so a long match list is fine.
181   * @param {string}  query       — search term (must be >= 2 chars)
182   * @param {boolean} sameDayOnly — if true, only include isSameDay === true items
183   * @returns {Array} matched items mapped to {name, cat, url, img, price, size}
184   */
185  function gipSearchFilter(query, sameDayOnly) {
186    var q = query.trim().toLowerCase();
187    if (q.length < 2) return [];
188
189    var results = [];
190
191    // Categories is the global array from categories-json.js
192    if (typeof Categories === 'undefined' || !Array.isArray(Categories)) return [];
193
194    Categories.forEach(function (p) {
195      if (sameDayOnly && p.isSameDay !== true) return;
196      if (!p.label || !p.label.toLowerCase().includes(q)) return;
197
198      results.push({
199        name     : p.label        || '',
200        cat      : p.type         || '',
201        url      : '/' + (p.link  || '').replace(/^\//, ''),
202        img      : p.image        ? CDN_BASE + p.image : '',
203        price    : p.StartingPrice ? '$' + p.StartingPrice + '+' : '',
204        size     : p.sizes        || '',
205        isSameDay: p.isSameDay    === true
206      });
207    });
208
209    return results;
210  }
211
212  /**
213   * Render search results into a container element.
214   * Results are grouped by category with a header row per group.
215   * "View All" footer is intentionally omitted per project decision C.
216   * @param {Array}       results   — from gipSearchFilter()
217   * @param {string}      query     — original query (for empty state message)
218   * @param {HTMLElement} container — results wrapper element
219   */
220  function gipRenderResults(results, query, container) {
221    container.innerHTML = '';
222
223    if (!results.length) {
224      var empty = document.createElement('div');
225      empty.className   = 'gip-search__results-empty';
226      empty.textContent = 'No results for "' + query + '"';
227      container.appendChild(empty);
228      return;
229    }
230
231    // Group results by category, preserving insertion order
232    var groups    = {};
233    var groupKeys = [];
234    results.forEach(function (p) {
235      if (!groups[p.cat]) {
236        groups[p.cat] = [];
237        groupKeys.push(p.cat);
238      }
239      groups[p.cat].push(p);
240    });
241
242    groupKeys.forEach(function (cat) {
243      // Category header
244      var catEl       = document.createElement('div');
245      catEl.className = 'gip-search__results-cat';
246      catEl.textContent = cat;
247      container.appendChild(catEl);
248
249      // Items
250      groups[cat].forEach(function (p) {
251        var a       = document.createElement('a');
252        a.className = 'gip-search__results-item';
253        a.href      = p.url;
254        a.setAttribute('role', 'option');
255
256        // Thumb
257        var thumb   = document.createElement('div');
258        thumb.className = 'gip-search__results-thumb';
259        if (p.img) {
260          var img    = document.createElement('img');
261          img.src    = p.img;
262          img.alt    = '';
263          img.loading = 'lazy';
264          img.width  = 52;
265          img.height = 52;
266          thumb.appendChild(img);
267        }
268
269        // Info
270        var info     = document.createElement('div');
271        info.className = 'gip-search__results-info';
272
273        var nameEl   = document.createElement('div');
274        nameEl.className   = 'gip-search__results-name';
275        nameEl.textContent = p.name;
276
277        var metaEl   = document.createElement('div');
278        metaEl.className   = 'gip-search__results-meta';
279        // Show size string once — original new-design bug (repeated p.size 3x) fixed here
280        metaEl.textContent = p.size ? 'Sizes: ' + p.size : '';
281
282        info.appendChild(nameEl);
283        info.appendChild(metaEl);
284
285        // Price
286        var priceEl  = document.createElement('div');
287        priceEl.className   = 'gip-search__results-price';
288        priceEl.textContent = p.price || '';
289
290        a.appendChild(thumb);
291        a.appendChild(info);
292        a.appendChild(priceEl);
293        container.appendChild(a);
294      });
295    });
296    // NOTE: "View all results" footer intentionally omitted (decision C).
297  }
298
299  /**
300   * Bind a search input to its results container.
301   * Debounces input at 180ms. Re-runs search when Same Day checkbox changes.
302   *
303   * @param {string} inputId       — element ID of the <input>
304   * @param {string} containerId   — element ID of the results container
305   * @param {string} openClass     — CSS class to add when results are visible
306   * @param {string} sameDayCheckId — element ID of the same-day <input type="checkbox">
307   */
308  function gipBindSearch(inputId, containerId, openClass, sameDayCheckId) {
309    var input     = document.getElementById(inputId);
310    var container = document.getElementById(containerId);
311    var checkbox  = sameDayCheckId ? document.getElementById(sameDayCheckId) : null;
312
313    if (!input || !container) return;
314
315    var debounceTimer = null;
316
317    /** Run search with current input value and checkbox state. */
318    function runSearch() {
319      var q          = input.value.trim();
320      var sameDayOnly = checkbox ? checkbox.checked : false;
321
322      if (q.length < 2) {
323        container.classList.remove(openClass);
324        container.innerHTML = '';
325        return;
326      }
327      var results = gipSearchFilter(q, sameDayOnly);
328      gipRenderResults(results, q, container);
329      container.classList.add(openClass);
330    }
331
332    // Debounced input handler
333    input.addEventListener('input', function () {
334      clearTimeout(debounceTimer);
335      debounceTimer = setTimeout(runSearch, 180);
336    });
337
338    // Same Day checkbox — re-run immediately on change if query is active
339    if (checkbox) {
340      checkbox.addEventListener('change', function () {
341        if (input.value.trim().length >= 2) {
342          clearTimeout(debounceTimer);
343          runSearch();
344        }
345      });
346    }
347
348    // Escape: close results, clear input
349    input.addEventListener('keydown', function (e) {
350      if (e.key === 'Escape') {
351        container.classList.remove(openClass);
352        container.innerHTML = '';
353        input.value = '';
354      }
355    });
356  }
357
358  // Bind all three search inputs with their respective Same Day checkbox IDs
359  gipBindSearch(
360    'gipDesktopSearch',
361    'gipDesktopResults',
362    'gip-search__results--open',
363    'gipDesktopSameDayCheck'
364  );
365  gipBindSearch(
366    'gipMobileHeaderSearch',
367    'gipMobileHeaderResults',
368    'gip-search__results--open',
369    'gipMobileHeaderSameDayCheck'
370  );
371  gipBindSearch(
372    'gipPanelSearch',
373    'gipPanelResults',
374    'gip-panel-search-results--open',
375    'gipPanelSameDayCheck'
376  );
377
378  // Single consolidated outside-click handler for ALL search result dropdowns.
379  // Replaces three separate per-input document listeners — one listener, zero
380  // duplicate closures, correct scoping for every search context.
381  document.addEventListener('click', function (e) {
382    // If the click is inside any search wrapper, leave results open
383    if (e.target.closest('.gip-search, .gip-mobile-panel__search, .gip-mobile-search')) {
384      return;
385    }
386    // Outside all search wrappers — close every open result container
387    document.querySelectorAll(
388      '.gip-search__results--open, .gip-panel-search-results--open'
389    ).forEach(function (el) {
390      el.classList.remove('gip-search__results--open');
391      el.classList.remove('gip-panel-search-results--open');
392    });
393  }, { passive: true });
394
395
396  /* ═══════════════════════════════════════════════════════════
397     C. ACTION BAR DROPDOWNS
398     Corporate and Account dropdowns in .gip-actions.
399     Each has a trigger button and a .gip-account-dropdown__menu.
400     - Click trigger  → toggle .show on menu, update aria-expanded
401     - Click outside  → close all
402     - Escape key     → close focused dropdown
403  ═══════════════════════════════════════════════════════════ */
404
405  /**
406   * Bind a simple toggle dropdown in the actions bar.
407   * @param {string} toggleId — ID of the <button> trigger
408   * @param {string} menuId   — ID of the <ul> menu
409   */
410  function gipBindDropdown(toggleId, menuId) {
411    var toggle = document.getElementById(toggleId);
412    var menu   = document.getElementById(menuId);
413    if (!toggle || !menu) return;
414
415    toggle.addEventListener('click', function (e) {
416      e.stopPropagation();
417      var isOpen = menu.classList.contains('show');
418
419      // Close all other action-bar dropdowns first
420      document.querySelectorAll('.gip-account-dropdown__menu.show').forEach(function (m) {
421        m.classList.remove('show');
422        var btn = document.getElementById(m.id.replace('Menu', 'Toggle'));
423        btn && btn.setAttribute('aria-expanded', 'false');
424      });
425
426      if (!isOpen) {
427        menu.classList.add('show');
428        toggle.setAttribute('aria-expanded', 'true');
429      }
430    });
431  }
432
433  gipBindDropdown('gipCorporateToggle', 'gipCorporateMenu');
434  gipBindDropdown('gipAccountToggle',   'gipAccountMenu');   // only present when signed in
435
436  // Close all action-bar dropdowns on outside click
437  document.addEventListener('click', function () {
438    document.querySelectorAll('.gip-account-dropdown__menu.show').forEach(function (m) {
439      m.classList.remove('show');
440      var btn = document.getElementById(m.id.replace('Menu', 'Toggle'));
441      btn && btn.setAttribute('aria-expanded', 'false');
442    });
443  }, { passive: true });
444
445  // Escape closes the currently open action-bar dropdown
446  document.addEventListener('keydown', function (e) {
447    if (e.key !== 'Escape') return;
448    document.querySelectorAll('.gip-account-dropdown__menu.show').forEach(function (m) {
449      m.classList.remove('show');
450      var btn = document.getElementById(m.id.replace('Menu', 'Toggle'));
451      if (btn) {
452        btn.setAttribute('aria-expanded', 'false');
453        btn.focus(); // return focus to trigger
454      }
455    });
456  });
457
458
459  /* ═══════════════════════════════════════════════════════════
460     D. MOBILE PANEL
461     Opened by both #gipMobileToggle (≤767px) and #gipHamburger (≥768px).
462     Focus trapped inside drawer while open.
463     Escape key closes panel.
464     Backdrop click closes panel.
465     Element IDs updated to match Default.cshtml:
466       gipMobileClose    → gipPanelClose
467       gipMobileBackdrop → gipPanelBackdrop
468  ═══════════════════════════════════════════════════════════ */
469
470  var mobileToggle   = document.getElementById('gipMobileToggle');
471  var hamburger      = document.getElementById('gipHamburger');
472  var mobilePanel    = document.getElementById('gipMobilePanel');
473  var panelClose     = document.getElementById('gipPanelClose');
474  var panelBackdrop  = document.getElementById('gipPanelBackdrop');
475
476  // Tracks which button last opened the panel so focus always returns to
477  // the correct opener (mobileToggle OR hamburger) regardless of how it
478  // was closed (X button, backdrop, Escape).
479  var lastPanelOpener = null;
480
481  // iOS Safari scroll-lock: save scroll position before fixing body,
482  // restore it on close so the page doesn't jump to top.
483  var savedScrollY = 0;
484
485  function openMobilePanel(openerEl) {
486    if (!mobilePanel) return;
487    lastPanelOpener = openerEl || null;
488    mobilePanel.classList.add('gip-mobile-panel--open');
489    mobilePanel.setAttribute('aria-hidden', 'false');
490    // Standard scroll-lock
491    document.body.style.overflow = 'hidden';
492    // iOS Safari requires position:fixed to prevent background scroll.
493    // Save current scroll position first so we can restore it on close.
494    savedScrollY = window.scrollY || window.pageYOffset;
495    document.body.style.position = 'fixed';
496    document.body.style.top      = '-' + savedScrollY + 'px';
497    document.body.style.width    = '100%';
498    if (openerEl) openerEl.setAttribute('aria-expanded', 'true');
499    // Defer focus to close button until drawer slide animation completes
500    setTimeout(function () { panelClose && panelClose.focus(); }, 300);
501  }
502
503  function closeMobilePanel() {
504    if (!mobilePanel) return;
505    mobilePanel.classList.remove('gip-mobile-panel--open');
506    mobilePanel.setAttribute('aria-hidden', 'true');
507    // Restore body scroll — must restore scrollY AFTER clearing position:fixed
508    document.body.style.overflow = '';
509    document.body.style.position = '';
510    document.body.style.top      = '';
511    document.body.style.width    = '';
512    window.scrollTo(0, savedScrollY);
513    savedScrollY = 0;
514    // Reset aria-expanded on both openers
515    [mobileToggle, hamburger].forEach(function (el) {
516      el && el.setAttribute('aria-expanded', 'false');
517    });
518    // Return focus to the button that originally opened the panel
519    if (lastPanelOpener) lastPanelOpener.focus();
520    lastPanelOpener = null;
521  }
522
523  mobileToggle && mobileToggle.addEventListener('click', function () {
524    openMobilePanel(mobileToggle);
525  });
526
527  hamburger && hamburger.addEventListener('click', function () {
528    openMobilePanel(hamburger);
529  });
530
531  panelClose && panelClose.addEventListener('click', function () {
532    closeDrilldown();
533    closeMobilePanel();
534  });
535
536  panelBackdrop && panelBackdrop.addEventListener('click', function () {
537    closeDrilldown();
538    closeMobilePanel();
539  });
540
541  // Escape closes panel
542  document.addEventListener('keydown', function (e) {
543    if (e.key === 'Escape' &&
544        mobilePanel &&
545        mobilePanel.classList.contains('gip-mobile-panel--open')) {
546      closeDrilldown();
547      closeMobilePanel();
548    }
549  });
550
551  // Focus trap inside drawer
552  mobilePanel && mobilePanel.addEventListener('keydown', function (e) {
553    if (e.key !== 'Tab') return;
554    var focusable = mobilePanel.querySelectorAll(
555      'a[href], button:not([disabled]), input, [tabindex]:not([tabindex="-1"])'
556    );
557    if (!focusable.length) return;
558    var first = focusable[0];
559    var last  = focusable[focusable.length - 1];
560    if (e.shiftKey) {
561      if (document.activeElement === first) { e.preventDefault(); last.focus(); }
562    } else {
563      if (document.activeElement === last)  { e.preventDefault(); first.focus(); }
564    }
565  });
566
567
568  /* ═══════════════════════════════════════════════════════════
569     E. MEGA DROPDOWN — ARIA STATE SYNC
570     CSS :hover handles show/hide. JS keeps aria-expanded in sync
571     so assistive technology knows the state.
572     Keyboard: Enter/Space opens, Escape closes, Tab stays inside
573     via :focus-within in CSS.
574  ═══════════════════════════════════════════════════════════ */
575
576  var navItems = document.querySelectorAll('.gip-nav__item');
577  navItems.forEach(function (item) {
578    var trigger = item.querySelector('.gip-nav__trigger');
579    if (!trigger) return;
580
581    // Mouse: sync aria-expanded with hover
582    item.addEventListener('mouseenter', function () {
583      trigger.setAttribute('aria-expanded', 'true');
584    });
585    item.addEventListener('mouseleave', function () {
586      // Only reset if not keyboard-forced open (style.display === 'block')
587      var mega = item.querySelector('.gip-mega');
588      if (!mega || mega.style.display !== 'block') {
589        trigger.setAttribute('aria-expanded', 'false');
590      }
591    });
592
593    // Keyboard: Enter/Space toggles mega open/closed
594    trigger.addEventListener('keydown', function (e) {
595      if (e.key === 'Enter' || e.key === ' ') {
596        e.preventDefault();
597        var mega = item.querySelector('.gip-mega');
598        if (!mega) return;
599        var isOpen = mega.style.display === 'block';
600        mega.style.display = isOpen ? '' : 'block';
601        trigger.setAttribute('aria-expanded', String(!isOpen));
602        if (!isOpen) {
603          var firstLink = mega.querySelector('a[href], button');
604          if (firstLink) firstLink.focus();
605        }
606      }
607      if (e.key === 'Escape') {
608        var mega = item.querySelector('.gip-mega');
609        if (mega) mega.style.display = '';
610        trigger.setAttribute('aria-expanded', 'false');
611        trigger.focus();
612      }
613    });
614  });
615
616  // Close any keyboard-forced-open mega on outside click
617  document.addEventListener('click', function (e) {
618    navItems.forEach(function (item) {
619      var mega = item.querySelector('.gip-mega');
620      if (mega && mega.style.display === 'block' && !item.contains(e.target)) {
621        mega.style.display = '';
622        var trigger = item.querySelector('.gip-nav__trigger');
623        trigger && trigger.setAttribute('aria-expanded', 'false');
624      }
625    });
626  }, { passive: true });
627
628
629  /* ═══════════════════════════════════════════════════════════
630     F. MOBILE ACCORDION — DRILL-DOWN NAVIGATION
631     Vistaprint pattern (verified live 2026-07-17/18): tapping a category
632     replaces the category list with a dedicated screen for just that
633     category (back arrow + title), not an inline accordion expand.
634     Only one screen visible at a time. .gip-mobile-item--active marks
635     the single item shown; .drilldown-active on .gip-mobile-panel__body
636     hides everything else (search, other items, Quick Links/Account).
637  ═══════════════════════════════════════════════════════════ */
638
639  var mobileItems      = document.querySelectorAll('.gip-mobile-item');
640  var mobilePanelBody  = document.querySelector('.gip-mobile-panel__body');
641  // .gip-mobile-panel__body itself has no overflow - the actual scrolling
642  // element (overflow-y:auto) is its parent .gip-mobile-panel__drawer, so
643  // scroll-reset has to target that, not .body (fixed 2026-07-18 - resetting
644  // .body's scrollTop was a no-op, so switching categories left the drawer
645  // wherever it had last been scrolled instead of snapping back to top).
646  var mobilePanelDrawer = document.querySelector('.gip-mobile-panel__drawer');
647  var drilldownBack    = document.getElementById('gipDrilldownBack');
648  var drilldownTitle   = document.getElementById('gipDrilldownTitle');
649  var drilldownViewAll = document.getElementById('gipDrilldownViewAll');
650
651  function closeDrilldown() {
652    mobileItems.forEach(function (other) {
653      other.classList.remove('gip-mobile-item--open', 'gip-mobile-item--active');
654      var otherTrigger = other.querySelector('.gip-mobile-item__trigger');
655      otherTrigger && otherTrigger.setAttribute('aria-expanded', 'false');
656    });
657    mobilePanelBody && mobilePanelBody.classList.remove('drilldown-active');
658    if (mobilePanelDrawer) mobilePanelDrawer.scrollTop = 0;
659  }
660
661  mobileItems.forEach(function (item) {
662    var trigger = item.querySelector('.gip-mobile-item__trigger');
663    if (!trigger) return;
664
665    trigger.addEventListener('click', function () {
666      // Collapse all items first, then open only this one — one screen
667      // visible at a time, matching how the drill-down back button works.
668      mobileItems.forEach(function (other) {
669        other.classList.remove('gip-mobile-item--open', 'gip-mobile-item--active');
670        var otherTrigger = other.querySelector('.gip-mobile-item__trigger');
671        otherTrigger && otherTrigger.setAttribute('aria-expanded', 'false');
672      });
673
674      item.classList.add('gip-mobile-item--open', 'gip-mobile-item--active');
675      trigger.setAttribute('aria-expanded', 'true');
676
677      if (mobilePanelBody) mobilePanelBody.classList.add('drilldown-active');
678      if (drilldownTitle) {
679        var label = trigger.querySelector('.gip-mobile-item__label');
680        drilldownTitle.textContent = label ? label.textContent.trim() : '';
681      }
682      // "View All X" now lives on the drill-down header row, not its own
683      // promoted band per category - the per-category .gip-mobile-item__sub-viewall
684      // anchor stays in the DOM (hidden) as the source of truth for href/label,
685      // copied into the shared link here each time a category is opened.
686      if (drilldownViewAll) {
687        var viewAllSource = item.querySelector('.gip-mobile-item__sub-viewall');
688        if (viewAllSource) {
689          drilldownViewAll.href = viewAllSource.getAttribute('href');
690          // Just "View All", not the full "View All <Category>" text - the category
691          // name is already shown in the title right next to it, and the full text
692          // (nowrap, never shrinks) was crushing long titles down to a truncated
693          // "Large For..." to make room for itself.
694          drilldownViewAll.textContent = 'View All';
695          drilldownViewAll.title = viewAllSource.textContent.trim();
696          drilldownViewAll.style.display = '';
697        } else {
698          drilldownViewAll.style.display = 'none';
699        }
700      }
701      if (mobilePanelDrawer) mobilePanelDrawer.scrollTop = 0;
702    });
703  });
704
705  drilldownBack && drilldownBack.addEventListener('click', closeDrilldown);
706
707  // Sub-groups and long flat lists (Business Cards, Same Day) render fully
708  // expanded now (UPrinting reference, 2026-07-18) - no per-group collapse
709  // toggle and no "show more" truncation, so no JS is needed for either.
710
711
712  /* ═══════════════════════════════════════════════════════════
713     G. DEBOUNCED RESIZE LISTENER
714     Passive, debounced at 150ms.
715     Also re-measures mega top offset on resize.
716  ═══════════════════════════════════════════════════════════ */
717
718  /**
719   * Measure the bottom edge of the sticky .gip-header element and apply
720   * it as an inline top value on all .gip-mega elements.
721   *
722   * Why: .gip-util is NOT sticky — it scrolls away. If we used a CSS calc
723   * based on all three row heights (util + top + nav = 152px), the mega
724   * would appear 38px too low once the user has scrolled past the util bar.
725   * getBoundingClientRect().bottom always returns the correct viewport Y
726   * of the sticky header's bottom edge regardless of scroll position.
727   */
728  function measureMegaTop() {
729    var header = document.querySelector('.gip-header');
730    if (!header) return;
731    var bottom = header.getBoundingClientRect().bottom;
732    document.querySelectorAll('.gip-mega').forEach(function (mega) {
733      mega.style.top = bottom + 'px';
734    });
735  }
736
737  // Set on load (deferred, so DOM is ready)
738  measureMegaTop();
739
740  var resizeTimer;
741  window.addEventListener('resize', function () {
742    clearTimeout(resizeTimer);
743    resizeTimer = setTimeout(function () {
744      measureMegaTop();
745    }, 150);
746  }, { passive: true });
747
748  // Re-measure on scroll because sticky header height can change
749  // (e.g. mobile search row visibility change at resize crossing 768px)
750  window.addEventListener('scroll', function () {
751    measureMegaTop();
752  }, { passive: true });
753
754
755})();

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.