PageSourceSearch

https://navypier.org/wp-content/themes/navypiercore/js/event-favorites.js?ver=2.7.3.1770919749

js navypier.org collected 2026-10-02 02:45:15 UTC 30,481 bytes, 933 lines download raw bytes

1/*
2  Plan Your Day — Favorites Core JS
3  -------------------------------------------------
4  Anthony, this module wires up:
5  - LocalStorage-backed favorites for two post types: events & locations
6  - URL-share format: /plan-your-day/?events=ID-DATE,ID-DATE&locations=ID,ID
7  - Sticky FAB counter + toast
8  - Favorite toggles across the site (.favorite-toggle)
9  - Modal rendering (events first, then locations) with remove buttons
10  - /plan-your-day/ page rendering & URL sync (query string replaces local store)
11
12  HOW TO USE
13  1) Include this file once per page.
14  2) Call PlanYourDay.init({ ...options }) after DOMContentLoaded.
15     At minimum, pass selectors that already exist in your theme markup:
16
17     PlanYourDay.init({
18       modal: {
19         openTrigger: '#favorites-counter',       // the sticky FAB
20         container: '#favorites-modal',           // your modal outer element
21         closeTrigger: '#favorites-modal .close', // your modal close button
22         list: '#favorites-modal .favorites-list',// where cards will render
23         shareBtn: '#favorites-modal [data-share]'
24       },
25       page: {
26         isPlanYourDay: () => location.pathname.replace(/\/$/, '') === '/plan-your-day',
27         container: '#plan-your-day-list',
28         shareBtn: '#plan-your-day [data-share]'
29       },
30       datasetSelector: '#favorites-dataset'      // optional <script type="application/json"> with item metadata
31     });
32
33  3) Ensure your favorite toggle HTML uses:
34     <div class="favorite-toggle" data-type="event|location" data-title="..."
35          data-id="11497" data-date="20251209" role="button" aria-pressed="false" tabindex="0"></div>
36
37  4) Optionally, render a JSON dataset for richer cards (image, url, excerpt) via:
38     <script id="favorites-dataset" type="application/json">
39       {
40         "event:11497:20251209": {"title":"...","url":"/events/slug/","img":"/path.jpg","excerpt":"..."},
41         "location:11515": {"title":"...","url":"/locations/slug/","img":"/path.jpg","excerpt":"..."}
42       }
43     </script>
44
45  Notes
46  - Events are stored as key `${id}:${yyyymmdd}` to disambiguate series dates.
47  - All UI updates are ARIA-friendly (aria-pressed, live toast).
48*/
49
50(function () {
51  const STORAGE_KEY = "planYourDay";
52  const FALLBACK_IMG = new URL("/wp-content/uploads/2022/11/np-fallback-1.jpg", location.origin)
53    .href;
54
55  const utils = {
56    clampStr(s) {
57      return (s || "").toString().trim();
58    },
59    // Accepts 2025-12-09 or 20251209; returns 20251209
60    normalizeDate(str) {
61      const s = utils.clampStr(str).replace(/[^0-9]/g, "");
62      if (s.length === 8) return s; // YYYYMMDD
63      return "";
64    },
65    // Format 20251209 -> 2025-12-09
66    hyphenate(date8) {
67      return date8 && date8.length === 8
68        ? `${date8.slice(0, 4)}-${date8.slice(4, 6)}-${date8.slice(6, 8)}`
69        : "";
70    },
71    // Format 20251209 -> "SUN, DEC 9, 2025" for display
72    formatDateForDisplay(date8) {
73      if (!date8 || date8.length !== 8) return "";
74
75      const year = date8.slice(0, 4);
76      const month = parseInt(date8.slice(4, 6), 10);
77      const day = parseInt(date8.slice(6, 8), 10);
78
79      const months = [
80        "JAN",
81        "FEB",
82        "MAR",
83        "APR",
84        "MAY",
85        "JUN",
86        "JUL",
87        "AUG",
88        "SEP",
89        "OCT",
90        "NOV",
91        "DEC",
92      ];
93
94      const days = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
95
96      const date = new Date(year, month - 1, day);
97      const dayOfWeek = days[date.getDay()];
98      const monthName = months[month - 1];
99
100      return `${dayOfWeek}, ${monthName} ${day}, ${year}`;
101    },
102    qs(sel, root = document) {
103      return root.querySelector(sel);
104    },
105    qsa(sel, root = document) {
106      return Array.from(root.querySelectorAll(sel));
107    },
108    on(el, evt, fn, opts) {
109      el && el.addEventListener(evt, fn, opts || false);
110    },
111    delegate(root, selector, events, handler) {
112      events.split(/\s+/).forEach((evt) => {
113        utils.on(root, evt, (e) => {
114          const t = e.target.closest(selector);
115          if (t && root.contains(t)) handler(e, t);
116        });
117      });
118    },
119    canStorage() {
120      try {
121        const k = "__py_test__";
122        localStorage.setItem(k, "1");
123        localStorage.removeItem(k);
124        return true;
125      } catch {
126        return false;
127      }
128    },
129    copyToClipboard(text) {
130      if (navigator.clipboard && navigator.clipboard.writeText) {
131        return navigator.clipboard.writeText(text);
132      }
133      // Fallback
134      const ta = document.createElement("textarea");
135      ta.value = text;
136      document.body.appendChild(ta);
137      ta.select();
138      try {
139        document.execCommand("copy");
140      } finally {
141        document.body.removeChild(ta);
142      }
143      return Promise.resolve();
144    },
145    isEnterOrSpace(e) {
146      return e.key === "Enter" || e.key === " ";
147    },
148    liveRegion(text) {
149      let lr = utils.qs("#favorites-live-region");
150      if (!lr) {
151        lr = document.createElement("div");
152        lr.id = "favorites-live-region";
153        lr.setAttribute("aria-live", "polite");
154        lr.setAttribute("aria-atomic", "true");
155        lr.className = "sr-only";
156        document.body.appendChild(lr);
157      }
158      lr.textContent = text;
159    },
160    // Strip HTML/script/style tags and collapse whitespace
161    stripTags(html) {
162      const s = (html || "").toString();
163      return s
164        .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
165        .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
166        .replace(/<[^>]+>/g, " ")
167        .replace(/&nbsp;/gi, " ")
168        .replace(/\s+/g, " ")
169        .trim();
170    },
171    truncate(text, limit) {
172      const t = (text || "").trim();
173      if (t.length <= limit) return t;
174      const cut = t.slice(0, limit).replace(/\s+\S*$/, "");
175      return `${cut}…`;
176    },
177  };
178
179  const storage = {
180    _fallback: { events: [], locations: [] },
181    read() {
182      if (!utils.canStorage()) return this._fallback;
183      try {
184        const raw = localStorage.getItem(STORAGE_KEY);
185        if (!raw) return { events: [], locations: [] };
186        const parsed = JSON.parse(raw);
187        return {
188          events: Array.isArray(parsed.events) ? parsed.events : [],
189          locations: Array.isArray(parsed.locations) ? parsed.locations : [],
190        };
191      } catch {
192        return { events: [], locations: [] };
193      }
194    },
195    write(obj) {
196      const clean = {
197        events: Array.from(new Set((obj.events || []).filter(Boolean))),
198        locations: Array.from(new Set((obj.locations || []).filter(Boolean))),
199      };
200      if (!utils.canStorage()) {
201        this._fallback = clean;
202        return;
203      }
204      localStorage.setItem(STORAGE_KEY, JSON.stringify(clean));
205    },
206    replaceFromQuery() {
207      const url = new URL(location.href);
208      const evParam = utils.clampStr(url.searchParams.get("events"));
209      const lcParam = utils.clampStr(url.searchParams.get("locations"));
210      if (!evParam && !lcParam) return false;
211
212      const toIdDate = (token) => {
213        if (!token) return null;
214        const parts = token.split("-"); // ["11497","2025","12","09"]
215        const idRaw = parts.shift(); // "11497"
216        const dateRaw = parts.join("-"); // "2025-12-09"
217        const id = utils.clampStr(idRaw);
218        const date = utils.normalizeDate(dateRaw || "");
219        if (!id || !date) return null;
220        return `${id}:${date}`;
221      };
222
223      const events = evParam
224        ? evParam.split(",").map(utils.clampStr).map(toIdDate).filter(Boolean)
225        : [];
226
227      const locations = lcParam
228        ? lcParam
229            .split(",")
230            .map((v) => utils.clampStr(v))
231            .filter(Boolean)
232        : [];
233
234      storage.write({ events, locations });
235      return true;
236    },
237  };
238
239  const dataset = {
240    _map: {},
241    load(selector) {
242      const el = selector && utils.qs(selector);
243      if (!el) return;
244      try {
245        this._map = JSON.parse(el.textContent || "{}") || {};
246      } catch {
247        this._map = {};
248      }
249    },
250    keyFor(type, id, date8) {
251      return type === "event" ? `event:${id}:${date8}` : `location:${id}`;
252    },
253    get(type, id, date8) {
254      const key = this.keyFor(type, id, date8);
255      return this._map[key] || null;
256    },
257  };
258
259  const meta = {
260    _cache: new Map(),
261    _ajaxURL:
262      (window.favoritesVars && favoritesVars.ajax_url) ||
263      (window.eventFilterVars && eventFilterVars.ajax_url) ||
264      location.origin + "/wp-admin/admin-ajax.php",
265
266    _key(type, id, date8) {
267      return type === "event" ? `event:${id}:${date8}` : `location:${id}`;
268    },
269
270    async prefetchFromAjax(state) {
271      const evIds = Array.from(new Set(state.events.map((k) => k.split(":")[0])));
272      const locIds = Array.from(new Set(state.locations.map(String)));
273      if (!evIds.length && !locIds.length) return;
274
275      const fd = new FormData();
276      fd.append("action", "favorites_meta");
277      evIds.forEach((id) => fd.append("events[]", id));
278      locIds.forEach((id) => fd.append("locations[]", id));
279
280      const res = await fetch(this._ajaxURL, {
281        method: "POST",
282        credentials: "same-origin",
283        body: fd,
284      });
285      const json = await res.json().catch(() => null);
286      const payload = json && json.success && json.data && json.data.meta ? json.data.meta : null;
287      if (!payload) return;
288
289      // Fill cache for all event dates currently stored
290      for (const k in payload) {
291        const val = payload[k];
292        if (k.startsWith("event:")) {
293          const id = k.split(":")[1];
294          state.events.forEach((evk) => {
295            const [eid, date8] = evk.split(":");
296            if (eid === id) this._cache.set(`event:${id}:${date8}`, val);
297          });
298        } else if (k.startsWith("location:")) {
299          const id = k.split(":")[1];
300          this._cache.set(`location:${id}`, val);
301        }
302      }
303    },
304
305    async get(type, id, date8) {
306      const key = this._key(type, id, date8);
307
308      // 1) Inline dataset wins, if present
309      const fromData = dataset.get(type, id, date8);
310      if (fromData) {
311        this._cache.set(key, fromData);
312        return fromData;
313      }
314
315      // 2) Cache
316      if (this._cache.has(key)) return this._cache.get(key);
317
318      // 3) Batch fetch via Ajax
319      await this.prefetchFromAjax(storage.read());
320      if (this._cache.has(key)) return this._cache.get(key);
321
322      // 4) Placeholder last resort
323      return {
324        title: `${type === "event" ? "Event" : "Location"} #${id}`,
325        url: "#",
326        img: "",
327        excerpt: "",
328      };
329    },
330  };
331
332  const ui = {
333    selectors: {
334      toggle: ".favorite-toggle",
335      fab: "#favorites-counter",
336      fabCount: "#favorites-counter .count",
337      fabToast: "#favorites-counter .fav-toast",
338      fabToastText: "#favorites-counter .fav-toast__text",
339    },
340
341    setActive(toggleEl, isActive) {
342      if (!toggleEl) return;
343      toggleEl.classList.toggle("is-active", !!isActive);
344      toggleEl.setAttribute("aria-pressed", String(!!isActive));
345      toggleEl.setAttribute("title", isActive ? "Remove from favorites" : "Add to favorites");
346    },
347
348    refreshAllToggles() {
349      const { events, locations } = storage.read();
350      const eventSet = new Set(events);
351      const locSet = new Set(locations.map(String));
352
353      utils.qsa(ui.selectors.toggle).forEach((el) => {
354        const type = el.dataset.type;
355        const id = utils.clampStr(el.dataset.id);
356        const date8 = utils.normalizeDate(el.dataset.date || "");
357
358        let isActive;
359        if (type === "event") {
360          isActive = date8
361            ? eventSet.has(`${id}:${date8}`)
362            : Array.from(eventSet).some((k) => k.split(":")[0] === id);
363        } else {
364          isActive = locSet.has(id);
365        }
366        ui.setActive(el, isActive);
367      });
368    },
369
370    updateCounter() {
371      const countEl = utils.qs(ui.selectors.fabCount);
372      if (!countEl) return;
373      const { events, locations } = storage.read();
374      const total = events.length + locations.length;
375      countEl.textContent = String(total);
376      countEl.setAttribute("data-count", total);
377    },
378
379    showToast(msg) {
380      const toast = utils.qs(ui.selectors.fabToast);
381      const text = utils.qs(ui.selectors.fabToastText);
382      if (!toast || !text) return;
383      text.textContent = msg;
384      toast.classList.add("is-visible");
385      utils.liveRegion(msg);
386      window.clearTimeout(ui._toastTimer);
387      ui._toastTimer = window.setTimeout(() => toast.classList.remove("is-visible"), 2400);
388    },
389
390    observeToggles() {
391      const root = document.documentElement || document.body;
392      if (!window.MutationObserver || !root) return;
393
394      const mo = new MutationObserver((muts) => {
395        for (const m of muts) {
396          for (const n of m.addedNodes) {
397            if (
398              n &&
399              n.nodeType === 1 &&
400              ((n.matches && n.matches(".favorite-toggle")) ||
401                (n.querySelector && n.querySelector(".favorite-toggle")))
402            ) {
403              window.clearTimeout(ui._refreshTimer);
404              ui._refreshTimer = window.setTimeout(() => ui.refreshAllToggles(), 50);
405              return;
406            }
407          }
408        }
409      });
410
411      mo.observe(root, { childList: true, subtree: true });
412    },
413
414    // Refresh only within a specific container after AJAX renders
415    refreshTogglesIn(root) {
416      const scope = root && (root.nodeType === 1 ? root : document.querySelector(root));
417      if (!scope) return;
418
419      const { events, locations } = storage.read();
420      const eventSet = new Set(events);
421      const locSet = new Set(locations.map(String));
422
423      utils.qsa(ui.selectors.toggle, scope).forEach((el) => {
424        const type = el.dataset.type;
425        const id = utils.clampStr(el.dataset.id);
426        const date8 = utils.normalizeDate(el.dataset.date || "");
427
428        let isActive;
429        if (type === "event") {
430          isActive = date8
431            ? eventSet.has(`${id}:${date8}`)
432            : Array.from(eventSet).some((k) => k.split(":")[0] === id);
433        } else {
434          isActive = locSet.has(id);
435        }
436        ui.setActive(el, isActive);
437      });
438    },
439  };
440
441  const actions = {
442    addEvent(id, date8, title) {
443      const st = storage.read();
444      const key = `${id}:${date8}`;
445      if (!st.events.includes(key)) st.events.push(key);
446      storage.write(st);
447      ui.updateCounter();
448      ui.observeToggles();
449      ui.refreshAllToggles();
450      ui.showToast(`Added ${title || "Event"}`);
451    },
452    removeEvent(id, date8) {
453      const st = storage.read();
454      const key = `${id}:${date8}`;
455      st.events = st.events.filter((k) => k !== key);
456      storage.write(st);
457      ui.updateCounter();
458      ui.refreshAllToggles();
459      ui.showToast("Removed event");
460    },
461    addLocation(id, title) {
462      const st = storage.read();
463      id = String(id);
464      if (!st.locations.map(String).includes(id)) st.locations.push(id);
465      storage.write(st);
466      ui.updateCounter();
467      ui.refreshAllToggles();
468      ui.showToast(`Added ${title || "Location"}`);
469    },
470
471    removeLocation(id) {
472      const st = storage.read();
473      id = String(id);
474      st.locations = st.locations.filter((k) => String(k) !== id);
475      storage.write(st);
476      ui.updateCounter();
477      ui.refreshAllToggles();
478      ui.showToast("Removed location");
479    },
480    toggleFromEl(el) {
481      const type = el.dataset.type;
482      const id = utils.clampStr(el.dataset.id);
483      const title = utils.clampStr(el.dataset.title);
484      if (type === "event") {
485        const date8 = utils.normalizeDate(el.dataset.date || "");
486        const st = storage.read();
487        const key = `${id}:${date8}`;
488        if (st.events.includes(key)) actions.removeEvent(id, date8);
489        else actions.addEvent(id, date8, title);
490      } else if (type === "location") {
491        const st = storage.read();
492        const sid = String(id);
493        if (st.locations.map(String).includes(sid)) actions.removeLocation(sid);
494        else actions.addLocation(sid, title);
495      }
496    },
497  };
498
499  const sharing = {
500    buildShareURL() {
501      const st = storage.read();
502      const ev = st.events
503        .map((k) => {
504          const [id, date] = k.split(":");
505          return `${id}-${utils.hyphenate(date)}`;
506        })
507        .join(",");
508
509      const lc = st.locations.map(String).join(",");
510
511      const u = new URL(location.href);
512      u.pathname = "/plan-your-day/";
513      if (ev) u.searchParams.set("events", ev);
514      else u.searchParams.delete("events");
515      if (lc) u.searchParams.set("locations", lc);
516      else u.searchParams.delete("locations");
517      return u.toString();
518    },
519    attach(btnSelector) {
520      const btn = utils.qs(btnSelector);
521      if (!btn) return;
522      utils.on(btn, "click", async (e) => {
523        e.preventDefault();
524        e.stopPropagation();
525        const url = sharing.buildShareURL();
526        await utils.copyToClipboard(url);
527        ui.showToast("Link copied");
528      });
529    },
530  };
531
532  const renderer = {
533    EMPTY_HTML: `
534  <div class="favorites-empty-state">
535    <p>
536      Looking for an exciting event or fun location for your next visit? 
537      Look no further, we got you covered!
538    </p>
539    <div class="button-group-wrap">
540      <a href="/pier-events/" class="button-alt">View Events</a>
541      <a href="/pier-locations/" class="button-alt">Explore Locations</a>
542    </div>
543  </div>
544`,
545
546
547    card(type, id, date8, m) {
548      const t = (m && m.title) || "";
549      const url = (m && m.url) || "#";
550      const imgSrc = m && m.img ? m.img : FALLBACK_IMG;
551      // Use excerpt from PHP backend
552      const excerpt = (m && m.excerpt) || "";
553      const label = type === "event" ? "Event" : "Location";
554      const badge = type === "event" ? "EVENT" : "LOCATION";
555
556      // Format date for display if it's an event
557      const dateDisplay = type === "event" && date8 ? utils.formatDateForDisplay(date8) : "";
558
559      return `
560      <article class="fav-card" data-type="${type}" data-id="${id}" data-date="${date8 || ""}">
561        <a href="${url}" class="fav-card__link">
562          <div class="fav-card__image">
563            <img src="${imgSrc}" alt="" onerror="this.onerror=null;this.src='${FALLBACK_IMG}'">
564            <div class="tag-wrap"><span class="event-tag">${badge}</span></div>
565          </div>
566          <div class="fav-card__body">
567            ${dateDisplay ? `<p class="fav-card__date eyebrow">${dateDisplay}</p>` : ""}
568            <h3 class="fav-card__title">${t}</h3>
569            ${
570              excerpt
571                ? `<div class="excerpt-wrap"><p class="fav-card__excerpt">${excerpt}</p></div>`
572                : ""
573            }
574            <div class="button-wrap">
575              <div class="button smaller-button">View ${label}</div>
576            </div>
577          </div>
578        </a>
579        <button class="fav-card__remove" aria-label="Remove ${label}">×</button>
580      </article>`;
581    },
582
583    async renderListAsync(container, st) {
584      if (!container) return;
585
586      const hasItems =
587        (st.events && st.events.length > 0) ||
588        (st.locations && st.locations.length > 0);
589
590      // If nothing is saved, show the empty state and bail
591      if (!hasItems) {
592        container.innerHTML = this.EMPTY_HTML;
593        return;
594      }
595
596      container.innerHTML = "<p>Loading your favorites…</p>";
597
598      // single network call for all IDs
599      await meta.prefetchFromAjax(st);
600
601      const items = [];
602      for (const k of st.events) {
603        const [id, date8] = k.split(":");
604        const m = await meta.get("event", id, date8);
605        items.push(this.card("event", id, date8, m));
606      }
607      for (const id of st.locations) {
608        const m = await meta.get("location", id, "");
609        items.push(this.card("location", id, "", m));
610      }
611
612      container.innerHTML = items.join("") || this.EMPTY_HTML;
613      this.bindRemovals(container);
614    },
615
616
617    bindRemovals(container) {
618      if (!container || container.dataset.favRemBound === "1") return;
619      container.dataset.favRemBound = "1";
620
621      utils.delegate(container, ".fav-card__remove", "click", (e, btn) => {
622        e.preventDefault();
623        e.stopPropagation();
624        const card = btn.closest(".fav-card");
625        const type = card?.dataset.type;
626        const id = (card?.dataset.id || "").trim();
627        const date8 = (card?.dataset.date || "").replace(/[^0-9]/g, "");
628        if (type === "event") actions.removeEvent(id, date8);
629        else actions.removeLocation(id);
630        card.remove();
631
632        // If no more cards, show empty message
633        if (!container.querySelector(".fav-card")) {
634          container.innerHTML = renderer.EMPTY_HTML;
635        }
636      });
637
638      // Prevent remove button from triggering card link
639      utils.delegate(container, ".fav-card__link", "click", (e, link) => {
640        if (e.target.closest(".fav-card__remove")) {
641          e.preventDefault();
642          e.stopPropagation();
643        }
644      });
645    },
646
647  };
648
649  const printView = {
650    printCard(type, id, date8, m) {
651      const title = (m && m.title) || "";
652      const url = (m && m.url) || "#";
653      const imgSrc = (m && m.img) || FALLBACK_IMG;
654      const excerpt = (m && m.excerpt) || "";
655      const badge = type === "event" ? "EVENT" : "LOCATION";
656      const dateDisplay = type === "event" && date8 ? utils.formatDateForDisplay(date8) : "";
657
658      return `
659      <article class="print-card" data-type="${type}" data-id="${id}">
660        <div class="print-card__image">
661          <img src="${imgSrc}" alt="">
662        </div>
663        <div class="print-card__body">
664          ${dateDisplay ? `<p class="print-card__eyebrow">${dateDisplay}</p>` : ""}
665          <h3 class="print-card__title">
666            ${utils.stripTags(title)}
667            <span class="badge">${badge}</span>
668          </h3>
669          ${excerpt ? `<p class="print-card__excerpt">${utils.stripTags(excerpt)}</p>` : ""}
670          ${url && url !== "#" ? `<p class="print-card__link">${url}</p>` : ""}
671        </div>
672      </article>
673    `;
674    },
675
676    async render(container, state) {
677      if (!container) return;
678      container.innerHTML = "<p>Preparing print view…</p>";
679
680      // Reuse your existing batched meta prefetch
681      await meta.prefetchFromAjax(state);
682
683      const items = [];
684      for (const k of state.events) {
685        const [id, date8] = k.split(":");
686        const m = await meta.get("event", id, date8);
687        items.push(this.printCard("event", id, date8, m));
688      }
689      for (const id of state.locations) {
690        const m = await meta.get("location", id, "");
691        items.push(this.printCard("location", id, "", m));
692      }
693
694      container.innerHTML = `
695      <div class="print-wrap">
696        <h1 class="print-title">My Plan at Navy Pier</h1>
697        <div class="print-grid">
698          ${items.join("") || `<p>No favorites selected.</p>`}
699        </div>
700      </div>
701    `;
702    }
703  };
704
705  async function openFavoritesPrintWindow(opts = {}) {
706    const {
707      selector = "#favorites-print-area .print-favorites-list",
708      cssHref = "/wp-content/themes/navypier-2023/dist/print-favorites.css",
709      title = "Print - Plan My Visit"
710    } = opts;
711
712    const printContainer = utils.qs(selector);
713    if (!printContainer) {
714      console.warn("Print container not found:", selector);
715      window.print(); // graceful fallback
716      return;
717    }
718
719    // Fill/refresh the hidden print area with current favorites
720    const state = storage.read();
721    await printView.render(printContainer, state);
722
723    const html = `
724    <!doctype html>
725    <html>
726      <head>
727        <meta charset="utf-8">
728        <meta name="viewport" content="width=device-width, initial-scale=1">
729        <title>${title}</title>
730        ${cssHref ? `<link rel="stylesheet" href="${cssHref}">` : ""}
731        <style>
732          @media print { *{ -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
733          body { margin: 0; }
734        </style>
735      </head>
736      <body>${printContainer.innerHTML}</body>
737    </html>
738  `;
739
740    const w = window.open("", "", "width=900,height=650");
741    if (!w) return;
742
743    w.document.open();
744    w.document.write(html);
745    w.document.close();
746
747    const doPrint = () => {
748      try { w.focus(); } catch(e){}
749      w.print();
750      // w.close(); // enable to auto-close after print
751    };
752    w.addEventListener("DOMContentLoaded", doPrint);
753    setTimeout(doPrint, 400); // safety for images/styles
754  }
755
756  const modal = {
757    cfg: { openTrigger: "", container: "", closeTrigger: "", list: "", shareBtn: "" },
758    get els() {
759      return {
760        open: utils.qs(this.cfg.openTrigger),
761        root: utils.qs(this.cfg.container),
762        close: utils.qs(this.cfg.closeTrigger),
763        list: utils.qs(this.cfg.list),
764      };
765    },
766    async open() {
767      const { root, list } = this.els;
768      if (!root) return;
769      await renderer.renderListAsync(list, storage.read());
770      const details = root.querySelector("[data-details]");
771      if (details) details.setAttribute("href", sharing.buildShareURL());
772      root.classList.add("is-open");
773      root.setAttribute("aria-hidden", "false");
774    },
775    close() {
776      const { root } = this.els;
777      if (!root) return;
778      root.classList.remove("is-open");
779      root.setAttribute("aria-hidden", "true");
780    },
781    bind() {
782      const els = this.els;
783      if (els.open) utils.on(els.open, "click", () => this.open());
784      if (els.close) utils.on(els.close, "click", () => this.close());
785      // Dismiss on overlay click
786      if (els.root)
787        utils.delegate(els.root, "[data-dismiss-modal], .modal__overlay", "click", () =>
788          this.close()
789        );
790      // ESC to close
791      utils.on(document, "keydown", (e) => {
792        if (e.key === "Escape") this.close();
793      });
794      // Share button: allow dropdown UI in modal header to handle UX.
795      // Keep clipboard share on plan-your-day page only via page.shareBtn
796      // so we do not override the dropdown behavior here.
797      const details = els.root && els.root.querySelector("[data-detail
797s]");
798      if (details) details.setAttribute("href", sharing.buildShareURL());
799      // Print button (by selector or inside modal via [data-print])
800      const printEl = this.cfg.printBtn
801        ? utils.qs(this.cfg.printBtn)
802        : els.root && els.root.querySelector("[data-print]");
803      // NEW: open custom print window populated from hidden print area
804      if (printEl) {
805        utils.on(printEl, "click", async (e) => {
806          e.preventDefault();
807          e.stopPropagation();
808
809          // Optional: refresh the visible modal list first (keeps UI consistent)
810          const { list } = modal.els;
811          if (list) await renderer.renderListAsync(list, storage.read());
812
813          const printList = document.querySelector("#favorites-print-area .print-favorites-list");
814          if (printList) await printView.render(printList, storage.read());
815
816          await openFavoritesPrintWindow({
817            selector: "#favorites-print-area .print-favorites-list",
818            cssHref: "/wp-content/themes/navypier-2023/dist/print-favorites.css",
819            title: "My Plan at Navy Pier"
820          });
821        });
822      }
823
824    },
825  };
826
827  const page = {
828    cfg: { isPlanYourDay: () => false, container: "", shareBtn: "" },
829    hydrateFromURLIfNeeded() {
830      if (!this.cfg.isPlanYourDay()) return false;
831      const replaced = storage.replaceFromQuery(location.search);
832      return replaced;
833    },
834    async render() {
835      if (!this.cfg.isPlanYourDay()) return;
836      const list = utils.qs(this.cfg.container);
837      if (!list) return;
838      await renderer.renderListAsync(list, storage.read());
839      if (this.cfg.shareBtn) sharing.attach(this.cfg.shareBtn);
840    },
841  };
842
843  const PlanYourDay = {
844    init(options = {}) {
845      // Config
846      modal.cfg = Object.assign(modal.cfg, options.modal || {});
847      page.cfg = Object.assign(page.cfg, options.page || {});
848
849      // Optional dataset for richer cards
850      if (options.datasetSelector) dataset.load(options.datasetSelector);
851
852      // If we are on /plan-your-day/ with a query string, REPLACE local storage per spec
853      page.hydrateFromURLIfNeeded();
854
855      // Prevent link nav when hearts are inside <a>, and toggle on click/keyboard
856      utils.delegate(document, ".favorite-toggle", "pointerdown", (e, el) => {
857        const link = el.closest("a");
858        if (link) {
859          e.preventDefault();
860          e.stopPropagation();
861        }
862      });
863      utils.delegate(document, ".favorite-toggle", "click", (e, el) => {
864        e.preventDefault();
865        e.stopPropagation();
866        actions.toggleFromEl(el);
867      });
868      utils.delegate(document, ".favorite-toggle", "keydown", (e, el) => {
869        if (utils.isEnterOrSpace(e)) {
870          e.preventDefault();
871          e.stopPropagation();
872          actions.toggleFromEl(el);
873        }
874      });
875
876      // Initial paint
877      ui.refreshAllToggles();
878      ui.updateCounter();
879      ui.observeToggles();
880
881      // Bind modal + page
882      modal.bind();
883      page.render();
884    },
885
886    // Build share URL
887    buildShareURL: sharing.buildShareURL,
888
889    // Read state
890    getState: storage.read,
891
892    // Write state and refresh UI
893    setState(obj) {
894      storage.write(obj);
895      ui.refreshAllToggles();
896      ui.updateCounter();
897    },
898
899    // Force-update across the document
900    refreshUI() {
901      ui.refreshAllToggles();
902      ui.updateCounter();
903    },
904
905    // Refresh only within a specific container (after AJAX)
906    applyTo(root) {
907      ui.refreshTogglesIn(root);
908      ui.updateCounter();
909    },
910  };
911
912  // Expose globally
913  window.PlanYourDay = PlanYourDay;
914})();
915
916document.addEventListener("DOMContentLoaded", function () {
917  PlanYourDay.init({
918    modal: {
919      openTrigger: "#favorites-counter",
920      container: "#favorites-modal",
921      closeTrigger: "#favorites-modal .modal__close",
922      list: "#favorites-modal .favorites-list",
923      shareBtn: "#favorites-modal [data-share]",
924      printBtn: "#favorites-modal [data-print]",
925    },
926    page: {
927      isPlanYourDay: () => location.pathname.replace(/\/$/, "") === "/plan-your-day",
928      container: "#favorites-placeholder", // <= matches your template
929      shareBtn: "#share-button", // <= matches your template
930    },
931    datasetSelector: "#favorites-dataset", // optional
932  });
933});

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.