1// In alpineFunctions.js 2/** 3 * Alpine Functions 4 * This file contains the Alpine functions for the Prep Network Content theme 5 */ 6document.addEventListener('alpine:init', () => { 7 window.alpineFunctions = window.alpineFunctions || {}; 8 /** 9 * Background Image Component 10 * This component is used to load the background image 11 * It uses the srcset to load the image and the fallback to load the image if the srcset is not available 12 * It also uses the debouncedUpdate to update the image when the window is resized 13 */ 14 window.alpineFunctions.backgroundImageComponent = function (srcset = '', fallback = '') { 15 return { 16 srcset, 17 fallback, 18 currentSrc: fallback, 19 _debounceTimeout: null, 20 21 init() { 22 this.update(); 23 window.addEventListener('resize', this.debouncedUpdate.bind(this)); 24 }, 25 update() { 26 if (!this.srcset) { 27 return; 28 } 29 const sources = Array.from(this.srcset.matchAll(/(https?:\/\/.+?)(?=\s+\d+w)/g)).map((url, i, arr) => { 30 const widths = [...this.srcset.matchAll(/(\d+)w/g)]; 31 return { 32 url: url[1], 33 width: parseInt(widths[i][1]), 34 }; 35 }).filter(source => source.width !== source.height); 36 sources.sort((a, b) => a.width - b.width); 37 const viewport = window.innerWidth; 38 let selected = sources[sources.length - 1]?.url || this.fallback; 39 for (const source of sources) { 40 if (viewport <= source.width) { 41 selected = source.url; 42 break; 43 } 44 } 45 this.currentSrc = selected; 46 }, 47 debouncedUpdate() { 48 clearTimeout(this._debounceTimeout); 49 this._debounceTimeout = setTimeout(() => { 50 this.update(); 51 }, 250); 52 } 53 }; 54 }; 55 56 /** 57 * Lazy Background Component 58 * This component is used to lazy load the background image 59 * It uses the Intersection Observer API to load the image when it is in view 60 * It also uses the backgroundImageComponent to handle the image loading and resizing 61 */ 62 Alpine.data('lazyBackgroundComponent', (srcset, fallback) => ({ 63 observer: null, 64 loaded: false, 65 child: null, // this will hold the backgroundImageComponent instance 66 resizeHandler: null, // store handler reference for cleanup 67 syncTimeout: null, // store timeout reference for cleanup 68 isSliderSlide: false, // track if this is a slider slide 69 70 currentSrc: '', // exposed for Blade x-bind 71 72 init() { 73 // Check if element has data-slide-index attribute (slider slides) 74 this.isSliderSlide = this.$el.hasAttribute('data-slide-index'); 75 76 if (this.isSliderSlide) { 77 // For slider slides, set currentSrc to empty initially 78 // They will be loaded manually via Slick events 79 this.currentSrc = ''; 80 // Don't create IntersectionObserver - will be loaded manually 81 return; 82 } 83 84 // For non-slider elements, use fallback and IntersectionObserver 85 this.currentSrc = fallback; 86 87 this.observer = new IntersectionObserver((entries) => { 88 if (entries[0].isIntersecting) { 89 this.loadBackground(); 90 this.observer.disconnect(); 91 } 92 }, { 93 rootMargin: '200px', // preload slightly before visibility 94 }); 95 96 this.observer.observe(this.$el); 97 }, 98 99 loadBackground() { 100 if (this.loaded) return; 101 this.loaded = true; 102 103 // initialize the responsive component dynamically 104 this.child = window.alpineFunctions.backgroundImageComponent(srcset, fallback); 105 106 // Manually run its init + update routine 107 this.child.init(); 108 this.child.update(); 109 110 // Sync the background to the wrapper value 111 this.currentSrc = this.child.currentSrc; 112 113 // Preload the image to improve perceived load time 114 if (this.currentSrc) { 115 const img = new Image(); 116 img.src = this.currentSrc; 117 } 118 119 // Keep them in sync on resize 120 // Store handler reference for cleanup 121 this.resizeHandler = () => { 122 if (!this.child) return; // safety check 123 this.child.debouncedUpdate();
124 // Clear any pending sync timeout 125 if (this.syncTimeout) { 126 clearTimeout(this.syncTimeout); 127 } 128 // Sync after debounce completes (250ms delay) 129 this.syncTimeout = setTimeout(() => { 130 if (this.child) { 131 this.currentSrc = this.child.currentSrc; 132 } 133 this.syncTimeout = null; 134 }, 250); 135 }; 136 window.addEventListener('resize', this.resizeHandler); 137 }, 138 139 destroy() { 140 // Cleanup: remove resize listener, clear timeouts, and disconnect observer 141 if (this.resizeHandler) { 142 window.removeEventListener('resize', this.resizeHandler); 143 this.resizeHandler = null; 144 } 145 if (this.syncTimeout) { 146 clearTimeout(this.syncTimeout); 147 this.syncTimeout = null; 148 } 149 if (this.observer) { 150 this.observer.disconnect(); 151 this.observer = null; 152 } 153 } 154 })); 155 156 /** 157 * Color Utils 158 * Global helper functions for working with colors 159 * - createGradientClass: Creates a gradient class for an element 160 * - clearGradientClass: Removes a dynamic gradient from an element 161 * - rotateHueAndLighten: Rotates a hue and lightens a color 162 * - darken: Darkens a color 163 * 164 * Gradient usage: 165 * <div x-data x-init=" 166 * $el.style.setProperty('--primary', $colorUtils.getVar('--site-primary')); 167 * $colorUtils.createGradientClass($el, 350, 44); 168 * "> 169 * $el is the element that the gradient class is being applied to 170 * --primary is the variable that is being set to the site primary color 171 * 350 is the hue offset 172 * 44 is the lighten amount 173 * 174 */ 175 Alpine.magic('colorUtils', () => ({ 176 getVar(name) { 177 return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); 178 }, 179 /** 180 * hexToHsl: Converts a hex color to an hsl color 181 */ 182 hexToHsl(hex) { 183 hex = hex.replace('#', ''); 184 let r = parseInt(hex.slice(0, 2), 16) / 255; 185 let g = parseInt(hex.slice(2, 4), 16) / 255;
186 let b = parseInt(hex.slice(4, 6), 16) / 255; 187 188 let max = Math.max(r, g, b), min = Math.min(r, g, b); 189 let h, s, l = (max + min) / 2; 190 191 if (max === min) { 192 h = s = 0; 193 } else { 194 let d = max - min; 195 s = l > 0.5 ? d / (2 - max - min) : d / (max + min); 196 switch (max) { 197 case r: h = (g - b) / d + (g < b ? 6 : 0); break; 198 case g: h = (b - r) / d + 2; break; 199 case b: h = (r - g) / d + 4; break; 200 } 201 h /= 6; 202 } 203 204 return { 205 h: Math.round(h * 360), 206 s: Math.round(s * 100), 207 l: Math.round(l * 100), 208 }; 209 }, 210 211 /** 212 * hslToHex: Converts an hsl color to a hex color 213 */ 214 hslToHex(h, s, l) { 215 s /= 100; 216 l /= 100; 217 218 const c = (1 - Math.abs(2 * l - 1)) * s; 219 const x = c * (1 - Math.abs((h / 60) % 2 - 1)); 220 const m = l - c / 2; 221 222 let r, g, b; 223 if (h < 60) [r, g, b] = [c, x, 0]; 224 else if (h < 120) [r, g, b] = [x, c, 0]; 225 else if (h < 180) [r, g, b] = [0, c, x]; 226 else if (h < 240) [r, g, b] = [0, x, c]; 227 else if (h < 300) [r, g, b] = [x, 0, c]; 228 else [r, g, b] = [c, 0, x]; 229 230 r = Math.round((r + m) * 255); 231 g = Math.round((g + m) * 255); 232 b = Math.round((b + m) * 255); 233 234 return `#${[r, g, b].map(x => x.toString(16).padStart(2, '0')).join('')}`; 235 }, 236 /** 237 * rotateHueAndLighten: Rotates a hue and lightens a color 238 * this is based on wheel of colors so we match the right contrast for the color 239 * https://www.rapidtables.com/web/color/RGB_Color.html 240 * 0 is the starting point of the wheel of colors based on the color passed in 241 * 360 is the ending point of the wheel of colors 242 */ 243 rotateHueAndLighten(hex, hueOffset = 30, lightenAmount = 30) { 244 const hsl = this.hexToHsl(hex); 245 const newHue = (hsl.h + hueOffset) % 360; 246 /**Exeptions prep dig / redzone **/ 247 /** those colors were too dark so we lighten them up a bit manually */ 248 const exceptions = {213: 57, 357: 54}; 249 if(Object.keys(exceptions).includes(hsl.h.toString())) { 250 lightenAmount = exceptions[hsl.h.toString()]; 251 } 252 const newLightness = Math.min(100, hsl.l + lightenAmount); 253 return this.hslToHex(newHue, hsl.s, newLightness); 254 }, 255 /**
256 * darken: Darkens a color 257 */ 258 darken(hex, percent) { 259 hex = hex.replace('#', ''); 260 let r = parseInt(hex.substring(0, 2), 16); 261 let g = parseInt(hex.substring(2, 4), 16); 262 let b = parseInt(hex.substring(4, 6), 16); 263 264 r = Math.max(0, Math.floor(r * (1 - percent / 100))); 265 g = Math.max(0, Math.floor(g * (1 - percent / 100))); 266 b = Math.max(0, Math.floor(b * (1 - percent / 100))); 267 268 return `#${[r, g, b].map(x => x.toString(16).padStart(2, '0')).join('')}`; 269 }, 270 /** 271 * hexToRgba: Converts a hex color to rgba 272 */ 273 hexToRgba(hex, alpha) { 274 hex = hex.replace('#', ''); 275 if (hex.length === 3) { 276 hex = hex.split('').map((char) => char + char).join(''); 277 } 278 279 const r = parseInt(hex.slice(0, 2), 16); 280 const g = parseInt(hex.slice(2, 4), 16); 281 const b = parseInt(hex.slice(4, 6), 16); 282 283 return `rgba(${r}, ${g}, ${b}, ${alpha})`; 284 }, 285 /** 286 * createGradientClass: Creates a gradient class for an element 287 * 288 * @param {HTMLElement} el 289 * @param {number} hueOffset 290 * @param {number} lightenAmount 291 * @param {boolean} border 292 * @param {string} direction CSS linear-gradient direction (e.g. 'to right', 'to top') 293 * @param {number|null} opacity Optional alpha for a lighter transparent wash (0â1) 294 */ 295 createGradientClass(el, hueOffset = 30, lightenAmount = 30, border = true, direction = 'to right', opacity = null) { 296 const primary = this.getVar('--site-primary'); 297 const gradientDark = this.rotateHueAndLighten(primary, hueOffset+30, lightenAmount); 298 const gradientLight = this.rotateHueAndLighten(primary, hueOffset, lightenAmount-5); 299 const borderColor = this.rotateHueAndLighten(primary, 0, lightenAmount-3); 300 301 el.classList.add('dynamic-gradient'); 302 303 if (opacity !== null) { 304 el.style.backgroundImage = `linear-gradient(${direction}, ${this.hexToRgba(gradientDark, opacity)}, ${this.hexToRgba(gradientLight, opacity * 0.25)})`; 305 if (border) { 306 el.style.borderColor = this.hexToRgba(borderColor, Math.min(opacity + 0.15, 0.45)); 307 } 308 } else { 309 el.style.backgroundImage = `linear-gradient(${direction}, ${gradientDark}, ${gradientLight})`; 310 if (border) { 311 el.style.borderColor = borderColor; 312 } 313 } 314 }, 315 clearGradientClass(el) { 316 el.classList.remove('dynamic-gradient'); 317 el.style.backgroundImage = ''; 318 el.style.borderColor = ''; 319 } 320 })); 321 322 window.alpineFunctions.scrollOnClick = function () { 323 return { 324 scrollToId(id, offset = 0) { 325 const el = document.getElementById(id); 326 if (!el) return; 327 const y = el.getBoundingClientRect().top + window.scrollY - offset; 328 window.scrollTo({ top: y, behavior: 'smooth' }); 329 }, 330 init() { 331 const raw = window.location.search; 332 const safeSearch = encodeURI(raw); 333 const params = new URLSearchParams(safeSearch); 334 if (params.has('prompt_claim')) { 335 setTimeout(() => { 336 this.scrollToId('verify-player', 300); 337 }, 500); 338 } 339 } 340 } 341 }; 342 343 /** 344 * My Events page â subscriber benefit banner and upcoming events list. 345 * 346 * @param {{ apiUrl?: string, browseAllUrl?: string, admissionCode?: string }} config 347 */ 348 window.alpineFunctions.myEventsPage = function (config = {}) { 349 return { 350 apiUrl: config.apiUrl || '', 351 browseAllUrl: config.browseAllUrl || '#', 352 loading: false, 353 error: null, 354 events: [], 355 admissionCode: config.admissionCode || '', 356 copyLabel: 'Copy', 357 copyTimeout: null, 358 359 init() { 360 this.fetchEvents(); 361 }, 362 363 mapApiEvent(event) { 364 const start = new Date(event.start_date); 365 const end = new Date(event.end_date); 366 367 return { 368 id: event.id, 369 month: start.toLocaleString('en-US', { month: 'short' }), 370 days: this.formatEventDays(start, end), 371 name: event.title, 372 location: event.location || '', 373 badge: this.getEventBadge(start), 374 ticketUrl: this.browseAllUrl + '&event_id=' + event.id + '&coupon=' + this.admissionCode, 375 }; 376 }, 377 378 formatEventDays(start, end) { 379 const startDay = start.getDate(); 380 const endDay = end.getDate(); 381 const sameMonth = 382 start.getMonth() === end.getMonth() && start.getFullYear() === end.getFullYear(); 383 384 if (sameMonth && startDay !== endDay) { 385 return `${startDay}-${endDay}`; 386 } 387 388 if (startDay === endDay && sameMonth) { 389 return String(startDay); 390 } 391
392 const startLabel = start.toLocaleString('en-US', { month: 'short', day: 'numeric' }); 393 const endLabel = end.toLocaleString('en-US', { month: 'short', day: 'numeric' }); 394 return `${startLabel}-${endLabel}`; 395 }, 396 397 getEventBadge(start) { 398 const now = new Date(); 399 now.setHours(0, 0, 0, 0); 400 401 const monday = new Date(now); 402 const day = now.getDay(); 403 monday.setDate(now.getDate() - ((day + 6) % 7)); 404 405 const friday = new Date(monday); 406 friday.setDate(monday.getDate() + 4); 407 friday.setHours(0, 0, 0, 0); 408 409 const sunday = new Date(friday); 410 sunday.setDate(friday.getDate() + 2); 411 sunday.setHours(23, 59, 59, 999); 412 413 const startDay = new Date(start); 414 startDay.setHours(0, 0, 0, 0); 415 416 if (startDay >= friday && startDay <= sunday) { 417 return 'This weekend'; 418 } 419 420 return null; 421 }, 422 423 async fetchEvents() { 424 if (!this.apiUrl) { 425 this.error = 'Events API is not configured.'; 426 return; 427 } 428 429 this.loading = true; 430 this.error = null; 431 432 try { 433 const response = await fetch(this.apiUrl); 434 435 if (!response.ok) { 436 throw new Error('Failed to load events'); 437 } 438 439 const data = await response.json(); 440 this.events = Array.isArray(data) ? data.map((event) => this.mapApiEvent(event)) : []; 441 } catch (err) { 442 this.error = err?.message || 'Unable to load events. Please try again.'; 443 this.events = []; 444 } finally { 445 this.loading = false; 446 } 447 }, 448 449 async copyToClipboard(text = null) { 450 if (!text) { 451 return; 452 } 453 454 var success = false; 455 456 try { 457 await navigator.clipboard.writeText(text); 458 success = true; 459 } catch { 460 // Fallback for browsers without clipboard API 461 const input = document.createElement('textarea'); 462 input.value = text; 463 input.setAttribute('readonly', ''); 464 input.style.position = 'absolute'; 465 input.style.left = '-9999px'; 466 document.body.appendChild(input); 467 input.select(); 468 document.execCommand('copy'); 469 document.body.removeChild(input); 470 } 471 if (success) { 472 const el = this.$el; 473 const svgNs = 'http://www.w3.org/2000/svg'; 474 const iconEl = document.createElementNS(svgNs, 'svg'); 475 476 iconEl.setAttribute('class', 'w-4 h-4 text-green-500'); 477 iconEl.setAttribute('fill', 'currentColor'); 478 iconEl.setAttribute('viewBox', '0 0 20 20'); 479 480 const path = document.createElementNS(svgNs, 'path'); 481 482 path.setAttribute('fill-rule', 'evenodd'); 483 path.setAttribute( 484 'd', 485 'M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z', 486 ); 487 path.setAttribute('clip-rule', 'evenodd'); 488 iconEl.appendChild(path); 489 490 el.classList.add('hidden'); 491 el.insertAdjacentElement('afterend', iconEl); 492 493 setTimeout(() => { 494 iconEl.remove(); 495 el.classList.remove('hidden'); 496 }, 2000); 497 } 498 }, 499 }; 500 }; 501 502 // Example: Add more components here 503 // window.alpineFunctions.anotherComponent = function () { 504 // return { /* ... */ }; 505 // }; 506}); 507; 508// Global Alpine.js functions 509(function() { 510 const registerGlobalFunctions = () => { 511 window.alpineFunctions = window.alpineFunctions || {}; 512 window.alpineFunctions.backgroundImageComponent = function (srcset = '', fallback = '') { 513 return { 514 srcset, 515 fallback, 516 currentSrc: fallback, 517 _debounceTimeout: null, 518 519 init() { 520 this.update(); 521 window.addEventListener('resize', this.debouncedUpdate.bind(this)); 522 }, 523 update() { 524 if (!this.srcset) { 525 return; 526 } 527 const sources = Array.from(this.srcset.matchAll(/(https?:\/\/.+?)(?=\s+\d+w)/g)).map((url, i, arr) => { 528 const widths = [...this.srcset.matchAll(/(\d+)w/g)]; 529 return { 530 url: url[1], 531 width: parseInt(widths[i][1]), 532 }; 533 }).filter(source => source.width !== source.height); 534 sources.sort((a, b) => a.width - b.width); 535 const viewport = window.innerWidth; 536 let selected = sources[sources.length - 1]?.url || this.fallback; 537 for (const source of sources) { 538 if (viewport <= source.width) { 539 selected = source.url; 540 break; 541 } 542 } 543 this.currentSrc = selected; 544 }, 545 debouncedUpdate() { 546 clearTimeout(this._debounceTimeout); 547 this._debounceTimeout = setTimeout(() => { 548 this.update(); 549 }, 250); 550 } 551 }; 552 }; 553 554 /** 555 * Color Utils 556 * Global helper functions for working with colors 557 * - createGradientClass: Creates a gradient class for an element 558 * - rotateHueAndLighten: Rotates a hue and lightens a color 559 * - darken: Darkens a color 560 * 561 * Gradient usage: 562 * <div x-data x-init=" 563 * $el.style.setProperty('--primary', $colorUtils.getVar('--site-primary')); 564 * $colorUtils.createGradientClass($el, 350, 44); 565 * "> 566 * $el is the element that the gradient class is being applied to 567 * --primary is the variable that is being set to the site primary color 568 * 350 is the hue offset 569 * 44 is the lighten amount 570 * 571 */ 572 Alpine.magic('colorUtils', () => ({ 573 getVar(name) { 574 return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); 575 }, 576 /** 577 * hexToHsl: Converts a hex color to an hsl color 578 */ 579 hexToHsl(hex) { 580 hex = hex.replace('#', '');
581 let r = parseInt(hex.slice(0, 2), 16) / 255; 582 let g = parseInt(hex.slice(2, 4), 16) / 255; 583 let b = parseInt(hex.slice(4, 6), 16) / 255; 584 585 let max = Math.max(r, g, b), min = Math.min(r, g, b); 586 let h, s, l = (max + min) / 2; 587 588 if (max === min) { 589 h = s = 0; 590 } else { 591 let d = max - min; 592 s = l > 0.5 ? d / (2 - max - min) : d / (max + min); 593 switch (max) { 594 case r: h = (g - b) / d + (g < b ? 6 : 0); break; 595 case g: h = (b - r) / d + 2; break; 596 case b: h = (r - g) / d + 4; break; 597 } 598 h /= 6; 599 } 600 601 return { 602 h: Math.round(h * 360), 603 s: Math.round(s * 100), 604 l: Math.round(l * 100), 605 }; 606 }, 607 608 /** 609 * hslToHex: Converts an hsl color to a hex color 610 */ 611 hslToHex(h, s, l) { 612 s /= 100; 613 l /= 100; 614 615 const c = (1 - Math.abs(2 * l - 1)) * s; 616 const x = c * (1 - Math.abs((h / 60) % 2 - 1)); 617 const m = l - c / 2; 618 619 let r, g, b; 620 if (h < 60) [r, g, b] = [c, x, 0]; 621 else if (h < 120) [r, g, b] = [x, c, 0]; 622 else if (h < 180) [r, g, b] = [0, c, x]; 623 else if (h < 240) [r, g, b] = [0, x, c]; 624 else if (h < 300) [r, g, b] = [x, 0, c]; 625 else [r, g, b] = [c, 0, x]; 626 627 r = Math.round((r + m) * 255); 628 g = Math.round((g + m) * 255); 629 b = Math.round((b + m) * 255); 630 631 return `#${[r, g, b].map(x => x.toString(16).padStart(2, '0')).join('')}`; 632 }, 633 /** 634 * rotateHueAndLighten: Rotates a hue and lightens a color 635 * this is based on wheel of colors so we match the right contrast for the color 636 * https://www.rapidtables.com/web/color/RGB_Color.html 637 * 0 is the starting point of the wheel of colors based on the color passed in 638 * 360 is the ending point of the wheel of colors 639 */ 640 rotateHueAndLighten(hex, hueOffset = 30, lightenAmount = 30) { 641 const hsl = this.hexToHsl(hex); 642 const newHue = (hsl.h + hueOffset) % 360; 643 /**Exeptions prep dig / redzone **/ 644 /** those colors were too dark so we lighten them up a bit manually */ 645 const exceptions = {213: 57, 357: 54}; 646 if(Object.keys(exceptions).includes(hsl.h.toString())) { 647 lightenAmount = exceptions[hsl.h.toString()]; 648 } 649 const newLightness = Math.min(100, hsl.l + lightenAmount); 650 return this.hslToHex(newHue, hsl.s, newLightness); 651 }, 652 /**
653 * darken: Darkens a color 654 */ 655 darken(hex, percent) { 656 hex = hex.replace('#', ''); 657 let r = parseInt(hex.substring(0, 2), 16); 658 let g = parseInt(hex.substring(2, 4), 16); 659 let b = parseInt(hex.substring(4, 6), 16); 660 661 r = Math.max(0, Math.floor(r * (1 - percent / 100))); 662 g = Math.max(0, Math.floor(g * (1 - percent / 100))); 663 b = Math.max(0, Math.floor(b * (1 - percent / 100))); 664 665 return `#${[r, g, b].map(x => x.toString(16).padStart(2, '0')).join('')}`; 666 }, 667 /** 668 * hexToRgba: Converts a hex color to rgba 669 */ 670 hexToRgba(hex, alpha) { 671 hex = hex.replace('#', ''); 672 if (hex.length === 3) { 673 hex = hex.split('').map((char) => char + char).join(''); 674 } 675 676 const r = parseInt(hex.slice(0, 2), 16); 677 const g = parseInt(hex.slice(2, 4), 16); 678 const b = parseInt(hex.slice(4, 6), 16); 679 680 return `rgba(${r}, ${g}, ${b}, ${alpha})`; 681 }, 682 /** 683 * createGradientClass: Creates a gradient class for an element 684 * 685 * @param {HTMLElement} el 686 * @param {number} hueOffset 687 * @param {number} lightenAmount 688 * @param {boolean} border 689 * @param {string} direction CSS linear-gradient direction (e.g. 'to right', 'to top') 690 * @param {number|null} opacity Optional alpha for a lighter transparent wash (0â1) 691 */ 692 createGradientClass(el, hueOffset = 30, lightenAmount = 30, border = true, direction = 'to right', opacity = null) { 693 const primary = this.getVar('--site-primary'); 694 const gradientDark = this.rotateHueAndLighten(primary, hueOffset+30, lightenAmount); 695 const gradientLight = this.rotateHueAndLighten(primary, hueOffset, lightenAmount-5); 696 const borderColor = this.rotateHueAndLighten(primary, 0, lightenAmount-3); 697 698 el.classList.add('dynamic-gradient'); 699 700 if (opacity !== null) { 701 el.style.backgroundImage = `linear-gradient(${direction}, ${this.hexToRgba(gradientDark, opacity)}, ${this.hexToRgba(gradientLight, opacity * 0.25)})`; 702 if (border) { 703 el.style.borderColor = this.hexToRgba(borderColor, Math.min(opacity + 0.15, 0.45)); 704 } 705 } else { 706 el.style.backgroundImage = `linear-gradient(${direction}, ${gradientDark}, ${gradientLight})`; 707 if (border) { 708 el.style.borderColor = borderColor; 709 } 710 } 711 }, 712 clearGradientClass(el) { 713 el.classList.remove('dynamic-gradient'); 714 el.style.backgroundImage = ''; 715 el.style.borderColor = ''; 716 } 717 })); 718 719 /** 720 * Autocomplete Component - Initialization wrapper 721 * Reads searchTypes from data attribute to avoid JSON encoding issues 722 */ 723 Alpine.data('autocompleteDataInit', (elementId, selectType, fieldToApply, inputId) => { 724 // Create a completely fresh instance each time 725 // We'll read searchTypes in init(), so start with empty array 726 return autocompleteDataFunction([], selectType, fieldToApply, elementId, inputId); 727 }); 728 729 /** 730 * Autocomplete Component - Core function 731 * Global Alpine.js data function for autocomplete search inputs 732 */ 733 function autocompleteDataFunction(searchTypes, selectType, fieldToApply, elementId, inputId) { 734 // Ensure searchTypes is a new array to avoid reference sharing 735 const searchTypesArray = Array.isArray(searchTypes) ? [...searchTypes] : []; 736 737 return { 738 searchText: '', 739 searchTypes: searchTypesArray, 740 get isPlayerSearch() { 741 return Array.isArray(this.searchTypes) && this.searchTypes.includes('players'); 742 }, 743 errorMessage: '', 744 isLoading: false, 745 showResults: false, 746 resultsHtml: '', 747 selectedItemId: null, 748 timer: null, 749 clickHandlerAdded: false, // Track if click handler has been added 750 get url() { 751 return (typeof ajax_object !== 'undefined' && ajax_object?.ajax_url) 752 ? ajax_object.ajax_url 753 : '/wp-admin/admin-ajax.php'; 754 }, 755 fieldToApply: fieldToApply || '', 756 selectType: selectType || 'id', 757 elementId: elementId || '', 758 inputId: inputId || '', 759 760 init() { 761 // Always read searchTypes from data attribute (it's the source of truth) 762 const element = document.getElementById(this.elementId); 763 if (element) { 764 try { 765 const dataAttr = element.getAttribute('data-search-types'); 766 if (dataAttr) { 767 const parsed = JSON.parse(dataAttr); 768 if (Array.isArray(parsed)) { 769 this.searchTypes = parsed; 770 } 771 } 772 } catch (e) { 773 console.error('Error parsing searchTypes from data attribute:', e); 774 } 775 } 776 777 // Set initial value from input if it exists (but don't use value attribute to avoid conflicts with x-model) 778 this.$nextTick(() => { 779 const initialInput = document.getElementById(this.inputId); 780 if (initialInput && initialInput.hasAttribute('value') && initialInput.getAttribute('value')) { 781 this.searchText = initialInput.getAttribute('value'); 782 } 783 }); 784 785 if (this.selectType === 'id') { 786 // Use event delegation on the results list to handle clicks 787 this.$watch('resultsHtml', () => { 788 this.$nextTick(() => { 789 setTimeout(() => { 790 const resultsList = document.querySelector(`#${this.elementId} ul#options`); 791 if (resultsList) { 792 // Initialize Alpine.js on the results list 793 if (typeof window.Alpine !== 'undefined' && window.Alpine.initTree) { 794 window.Alpine.initTree(resultsList); 795 } 796 797 // Add click handler using event delegation (only once per component instance) 798 if (!this.clickHandlerAdded) { 799 resultsList.addEventListener('click', (e) => { 800 const li = e.target.closest('li'); 801 if (li) { 802 e.preventDefault(); 803 e.stopPropagation();
804 this.handleItemClick(li); 805 } 806 }); 807 this.clickHandlerAdded = true; 808 } 809 } 810 }, 50); 811 }); 812 }); 813 } 814 }, 815 816 handleInput() { 817 if (this.isPlayerSearch || this.searchText.length === 0) { 818 this.errorMessage = ''; 819 } 820 this.performSearch(); 821 }, 822 823 handleFocus() { 824 if (this.isPlayerSearch) { 825 this.errorMessage = ''; 826 } 827 if (this.resultsHtml && this.resultsHtml.trim() !== '') { 828 this.showResults = true; 829 } 830 }, 831 832 handleKeyup() { 833 if (this.isPlayerSearch) { 834 this.errorMessage = ''; 835 } 836 }, 837 838 handlePaste() { 839 if (this.isPlayerSearch) { 840 this.errorMessage = ''; 841 } 842 }, 843 844 handleBlur() { 845 if (this.isPlayerSearch && this.searchText.length === 0) { 846 this.errorMessage = ''; 847 } 848 // Hide results when input loses focus (with a small delay to allow clicks on results) 849 setTimeout(() => { 850 // Only hide if focus hasn't moved to the results list 851 const resultsList = document.querySelector(`#${this.elementId} ul#options`); 852 const activeElement = document.activeElement; 853 if (resultsList && !resultsList.contains(activeElement)) { 854 this.showResults = false; 855 } 856 }, 200); 857 }, 858 859 handleClick() { 860 if (this.resultsHtml && this.resultsHtml.trim() !== '') { 861 this.showResults = true; 862 } 863 }, 864 865 performSearch() { 866 if (this.isPlayerSearch) { 867 this.errorMessage = ''; 868 } 869 870 const searchLength = this.searchText.length; 871 872 // For player searches, require at least 2 words (first and last name) 873 if (this.isPlayerSearch) { 874 const words = this.searchText.trim().split(/\s+/).filter(word => word.length > 0); 875 if (words.length < 2 && this.searchText.trim().length > 0) { 876 // Show error message if user has typed something but not enough words 877 this.errorMessage = 'Please enter both first and last name to search for players.'; 878 this.showResults = false; 879 this.resultsHtml = ''; 880 this.isLoading = false; 881 if (this.fieldToApply && this.$refs.fieldToApply) { 882 this.$refs.fieldToApply.value = ''; 883 } 884 return; 885 } else if (words.length < 2) { 886 // Field is empty or only whitespace - clear error and don't search 887 this.showResults = false; 888 this.resultsHtml = ''; 889 this.errorMessage = ''; 890 this.isLoading = false; 891 if (this.fieldToApply && this.$refs.fieldToApply) { 892 this.$refs.fieldToApply.value = ''; 893 } 894 return; 895 } 896 } 897 898 // For non-player searches, require at least 3 characters 899 if (!this.isPlayerSearch && searchLength < 3) { 900 this.errorMessage = 'Please enter at least three letters when searching'; 901 this.showResults = false; 902 if (this.fieldToApply && this.$refs.fieldToApply) { 903 this.$refs.fieldToApply.value = ''; 904 } 905 return; 906 } 907 908 this.errorMessage = ''; 909 this.isLoading = true; 910 this.resultsHtml = ''; 911 912 // Ensure URL is set correctly 913 const ajaxUrl = (typeof ajax_object !== 'undefined' && ajax_object?.ajax_url) 914 ? ajax_object.ajax_url 915 : '/wp-admin/admin-ajax.php'; 916 917 fetch(ajaxUrl, { 918 method: 'POST', 919 headers: { 920 'Content-Type': 'application/x-www-form-urlencoded', 921 }, 922 credentials: 'same-origin', 923 body: new URLSearchParams({ 924 itemName: this.searchText, 925 postTypes: JSON.stringify(this.searchTypes), 926 selectType: this.selectType, 927 action: 'get_items_for_autocomplete' 928 }) 929 }) 930 .then(response => { 931 if (!response.ok) { 932 throw new Error(`HTTP error! status: ${response.status}`); 933 } 934 return response.text(); 935 }) 936 .then(html => { 937 this.isLoading = false; 938 const hasResults = html.trim() !== ''; 939 940 if (hasResults) { 941 // Set HTML first 942 this.resultsHtml = html; 943 944 // Wait for DOM to update, then show and initialize Alpine 945 this.$nextTick(() => { 946 // Set showResults after DOM is updated 947 this.showResults = true; 948 949 // Explicitly remove hidden class if it exists 950 setTimeout(() => { 951 const resultsList = document.querySelector(`#${this.elementId} ul#options`); 952 if (resultsList) { 953 resultsList.classList.remove('hidden'); 954 955 // Initialize Alpine.js on the inserted HTML 956 if (typeof window.Alpine !== 'undefined' && window.Alpine.initTree) { 957 try { 958 window.Alpine.initTree(resultsList); 959 } catch (e) { 960 console.error('Error initializing Alpine on results:', e); 961 } 962 } 963 } 964 }, 50); 965 }); 966 } else { 967 this.resultsHtml = ''; 968 this.showResults = false;
969 } 970 }) 971 .catch(error => { 972 this.isLoading = false; 973 this.errorMessage = 'An error occurred while searching. Please try again.'; 974 this.showResults = false; 975 console.error('Autocomplete error:', error); 976 console.error('Search text:', this.searchText); 977 console.error('Search types:', this.searchTypes); 978 console.error('AJAX URL:', ajaxUrl); 979 }); 980 }, 981 982 handleItemClick(li) { 983 const isChecked = li.classList.contains('checked'); 984 const allItems = document.querySelectorAll(`#${this.elementId} li`); 985 986 if (isChecked) { 987 li.classList.remove('checked'); 988 const checkIcon = li.querySelector('.item-check'); 989 if (checkIcon) checkIcon.classList.add('hidden'); 990 this.searchText = ''; 991 if (this.fieldToApply && this.$refs.fieldToApply) { 992 this.$refs.fieldToApply.value = ''; 993 } 994 } else { 995 allItems.forEach(item => { 996 item.classList.remove('checked'); 997 const checkIcon = item.querySelector('.item-check'); 998 if (checkIcon) checkIcon.classList.add('hidden'); 999 }); 1000 1001 li.classList.add('checked'); 1002 const checkIcon = li.querySelector('.item-check'); 1003 if (checkIcon) checkIcon.classList.remove('hidden'); 1004 1005 const itemName = li.querySelector('.item-name'); 1006 if (itemName) { 1007 this.searchText = itemName.textContent.trim(); 1008 } 1009 1010 this.showResults = false; 1011 1012 if (this.fieldToApply && this.$refs.fieldToApply) { 1013 const itemWrapper = li.querySelector('.item-wrapper'); 1014 if (itemWrapper && itemWrapper.dataset && itemWrapper.dataset.id) { 1015 this.$refs.fieldToApply.value = itemWrapper.dataset.id; 1016 } else if (itemWrapper && itemWrapper.getAttribute('data-id')) { 1017 this.$refs.fieldToApply.value = itemWrapper.getAttribute('data-id'); 1018 } 1019 } 1020 } 1021 } 1022 }; 1023 } 1024 1025 // Also register as a direct function for backwards compatibility 1026 Alpine.data('autocompleteData', (searchTypes, selectType, fieldToApply, elementId, inputId) => { 1027 return autocompleteDataFunction(searchTypes, selectType, fieldToApply, elementId, inputId); 1028 }); 1029 }; 1030 1031 // Register immediately if Alpine is available, otherwise wait for alpine:init 1032 if (typeof window.Alpine !== 'undefined') { 1033 registerGlobalFunctions(); 1034 } else { 1035 document.addEventListener('alpine:init', registerGlobalFunctions); 1036 } 1037})(); 1038 1039;
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.