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(/ /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.