1// ========================================== 2// MOBILE MENU TOGGLE 3// ========================================== 4const mobileMenuToggle = document.querySelector('.mobile-menu-toggle'); 5const navLinks = document.querySelector('.nav-links'); 6 7if (mobileMenuToggle) { 8 const toggleMenu = () => { 9 navLinks.classList.toggle('active'); 10 mobileMenuToggle.classList.toggle('active'); 11 12 // Animate hamburger icon 13 const spans = mobileMenuToggle.querySelectorAll('span'); 14 if (navLinks.classList.contains('active')) { 15 spans[0].style.transform = 'rotate(45deg) translateY(8px)'; 16 spans[1].style.opacity = '0'; 17 spans[2].style.transform = 'rotate(-45deg) translateY(-8px)'; 18 } else { 19 spans[0].style.transform = ''; 20 spans[1].style.opacity = '1'; 21 spans[2].style.transform = ''; 22 } 23 }; 24 25 // Add both click and touch events (touchend is more reliable than touchstart) 26 mobileMenuToggle.addEventListener('click', toggleMenu); 27 mobileMenuToggle.addEventListener('touchend', (e) => { 28 e.preventDefault(); 29 toggleMenu(); 30 }, { passive: false }); 31 32 // Close menu when clicking on a link 33 navLinks.querySelectorAll('a').forEach(link => { 34 link.addEventListener('click', () => { 35 navLinks.classList.remove('active'); 36 mobileMenuToggle.classList.remove('active'); 37 const spans = mobileMenuToggle.querySelectorAll('span'); 38 spans[0].style.transform = ''; 39 spans[1].style.opacity = '1'; 40 spans[2].style.transform = ''; 41 }); 42 }); 43} 44 45// ========================================== 46// PASSWORD VISIBILITY TOGGLE 47// ========================================== 48function togglePasswordVisibility(inputId) { 49 const input = document.getElementById(inputId); 50 const toggleButton = input.nextElementSibling; 51 const icon = toggleButton.querySelector('.password-toggle-icon'); 52 53 if (input.type === 'password') { 54 input.type = 'text'; 55 icon.textContent = 'ð'; // Eye with slash 56 toggleButton.setAttribute('aria-label', 'Hide password'); 57 } else { 58 input.type = 'password'; 59 icon.textContent = 'ðï¸'; // Eye 60 toggleButton.setAttribute('aria-label', 'Show password'); 61 } 62} 63 64// ========================================== 65// BUTTON LOADING STATE HELPERS 66// ========================================== 67function setButtonLoading(button, isLoading = true) { 68 if (isLoading) { 69 button.dataset.originalText = button.textContent; 70 button.classList.add('loading'); 71 button.disabled = true; 72 } else { 73 button.classList.remove('loading'); 74 button.disabled = false; 75 if (button.dataset.originalText) { 76 button.textContent = button.dataset.originalText; 77 delete button.dataset.originalText; 78 } 79 } 80} 81 82// ========================================== 83// FORM VALIDATION HELPERS 84// ========================================== 85function showFieldError(input, message) { 86 const formGroup = input.closest('.form-group'); 87 if (formGroup) { 88 formGroup.classList.add('error'); 89 formGroup.classList.remove('success'); 90 91 // Remove existing error message 92 const existingError = formGroup.querySelector('.error-message'); 93 if (existingError) existingError.remove(); 94 95 // Add new error message 96 const errorDiv = document.createElement('small'); 97 errorDiv.className = 'error-message'; 98 errorDiv.textContent = message; 99 input.parentNode.appendChild(errorDiv); 100 } else { 101 input.classList.add('error'); 102 } 103} 104 105function clearFieldError(input) { 106 const formGroup = input.closest('.form-group'); 107 if (formGroup) { 108 formGroup.classList.remove('error'); 109 const errorMessage = formGroup.querySelector('.error-message'); 110 if (errorMessage) errorMessage.remove(); 111 } else { 112 input.classList.remove('error'); 113 } 114} 115 116function showFieldSuccess(input) { 117 const formGroup = input.closest('.form-group'); 118 if (formGroup) { 119 formGroup.classList.add('success'); 120 formGroup.classList.remove('error'); 121 const errorMessage = formGroup.querySelector('.error-message'); 122 if (errorMessage) errorMessage.remove(); 123 } 124} 125 126function clearAllFieldErrors(form) { 127 const errorGroups = form.querySelectorAll('.form-group.error'); 128 errorGroups.forEach(group => { 129 group.classList.remove('error'); 130 const errorMessage = group.querySelector('.error-message'); 131 if (errorMessage) errorMessage.remove(); 132 }); 133 134 const errorInputs = form.querySelectorAll('input.error, select.error, textarea.error');
135 errorInputs.forEach(input => input.classList.remove('error')); 136} 137 138// ========================================== 139// SMOOTH SCROLLING 140// ========================================== 141document.querySelectorAll('a[href^="#"]').forEach(anchor => { 142 anchor.addEventListener('click', function (e) { 143 const href = this.getAttribute('href'); 144 145 // Only prevent default for anchor links that aren't just "#" 146 if (href && href !== '#') { 147 const target = document.querySelector(href); 148 if (target) { 149 e.preventDefault(); 150 const offsetTop = target.offsetTop - 80; // Account for fixed navbar 151 window.scrollTo({ 152 top: offsetTop, 153 behavior: 'smooth' 154 }); 155 } 156 } 157 }); 158}); 159 160// ========================================== 161// NAVBAR SCROLL EFFECT 162// ========================================== 163const navbar = document.querySelector('.navbar'); 164let lastScroll = 0; 165 166window.addEventListener('scroll', () => { 167 const currentScroll = window.pageYOffset; 168 169 // Add scrolled class for styling 170 if (currentScroll > 50) { 171 navbar.classList.add('scrolled'); 172 } else { 173 navbar.classList.remove('scrolled'); 174 } 175 176 lastScroll = currentScroll; 177}); 178 179// ========================================== 180// INTERSECTION OBSERVER FOR ANIMATIONS 181// ========================================== 182const observerOptions = { 183 threshold: 0.1, 184 rootMargin: '0px 0px -50px 0px' 185}; 186 187const observer = new IntersectionObserver((entries) => { 188 entries.forEach(entry => { 189 if (entry.isIntersecting) { 190 entry.target.classList.add('fade-in-up'); 191 observer.unobserve(entry.target); 192 } 193 }); 194}, observerOptions); 195 196// Observe elements 197document.addEventListener('DOMContentLoaded', () => { 198 const animateElements = document.querySelectorAll( 199 '.value-card, .timeline-item, .event-card, .testimonial-card, .contact-card, .topic-badge' 200 ); 201 202 animateElements.forEach(el => { 203 observer.observe(el); 204 }); 205}); 206 207// ========================================== 208// STATISTICS COUNTER ANIMATION 209// ========================================== 210const animateCounter = (element, target, duration = 2000) => { 211 const start = 0; 212 const increment = target / (duration / 16); // 60 FPS 213 let current = start; 214 215 const updateCounter = () => { 216 current += increment; 217 if (current < target) { 218 element.textContent = Math.floor(current).toLocaleString() + '+'; 219 requestAnimationFrame(updateCounter); 220 } else { 221 element.textContent = target.toLocaleString() + '+'; 222 } 223 }; 224 225 updateCounter(); 226}; 227 228// Observe stats section 229const statsObserver = new IntersectionObserver((entries) => { 230 entries.forEach(entry => { 231 if (entry.isIntersecting) { 232 const statNumbers = document.querySelectorAll('.stat-number'); 233 statNumbers.forEach(stat => { 234 const text = stat.textContent; 235 const number = parseInt(text.replace(/[^0-9]/g, '')); 236 animateCounter(stat, number); 237 }); 238 statsObserver.unobserve(entry.target); 239 } 240 }); 241}, { threshold: 0.5 }); 242 243const heroStats = document.querySelector('.hero-stats'); 244if (heroStats) { 245 statsObserver.observe(heroStats); 246} 247 248// ========================================== 249// ACTIVE NAVIGATION LINK 250// ========================================== 251const sections = document.querySelectorAll('section[id]'); 252 253const highlightNavigation = () => { 254 const scrollPosition = window.pageYOffset + 100; 255 256 sections.forEach(section => { 257 const sectionTop = section.offsetTop; 258 const sectionHeight = section.offsetHeight; 259 const sectionId = section.getAttribute('id'); 260 const navLink = document.querySelector(`.nav-links a[href="#${sectionId}"]`); 261 262 if (navLink) { 263 if (scrollPosition >= sectionTop && scrollPosition < sectionTop + sectionHeight) { 264 navLink.style.color = 'var(--primary-color)'; 265 } else { 266 navLink.style.color = ''; 267 } 268 } 269 }); 270}; 271 272window.addEventListener('scroll', highlightNavigation); 273 274// ========================================== 275// PARALLAX EFFECT 276// ========================================== 277window.addEventListener('scroll', () => { 278 const scrolled = window.pageYOffset; 279 const heroLogo = document.querySelector('.hero-logo'); 280 281 if (heroLogo && scrolled < window.innerHeight) { 282 heroLogo.style.transform = `translateY(${scrolled * 0.5}px)`; 283 heroLogo.style.opacity = 1 - (scrolled / window.innerHeight); 284 } 285}); 286 287// ========================================== 288// READING PROGRESS BAR 289// ========================================== 290const scrollProgressBar = document.getElementById('scroll-progress'); 291if (scrollProgressBar) { 292 let progressTicking = false;
293 const updateScrollProgress = () => { 294 const doc = document.documentElement; 295 const max = doc.scrollHeight - window.innerHeight; 296 const ratio = max > 0 ? Math.min(window.pageYOffset / max, 1) : 0; 297 scrollProgressBar.style.transform = `scaleX(${ratio})`; 298 progressTicking = false; 299 }; 300 window.addEventListener('scroll', () => { 301 if (!progressTicking) { 302 progressTicking = true; 303 requestAnimationFrame(updateScrollProgress); 304 } 305 }, { passive: true }); 306 window.addEventListener('resize', updateScrollProgress, { passive: true }); 307 updateScrollProgress(); 308} 309 310// ========================================== 311// HERO VIDEO: REDUCED MOTION + PAUSE CONTROL 312// ========================================== 313const heroVideo = document.getElementById('hero-video'); 314const heroVideoToggle = document.getElementById('hero-video-toggle'); 315if (heroVideo && heroVideoToggle) { 316 const iconPause = heroVideoToggle.querySelector('.icon-pause'); 317 const iconPlay = heroVideoToggle.querySelector('.icon-play'); 318 let heroVideoUserPaused = false; 319 320 const setVideoToggleState = (paused) => { 321 if (iconPause) iconPause.style.display = paused ? 'none' : ''; 322 if (iconPlay) iconPlay.style.display = paused ? '' : 'none'; 323 heroVideoToggle.setAttribute('aria-pressed', String(paused)); 324 heroVideoToggle.setAttribute('aria-label', paused ? 'Play background video' : 'Pause background video'); 325 }; 326 327 // Users who prefer reduced motion get a still frame, not an autoplaying loop 328 if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { 329 heroVideo.removeAttribute('autoplay'); 330 heroVideo.pause(); 331 heroVideoUserPaused = true; 332 setVideoToggleState(true); 333 } else { 334 setVideoToggleState(false); 335 } 336 337 heroVideoToggle.addEventListener('click', () => { 338 if (heroVideo.paused) { 339 heroVideoUserPaused = false; 340 heroVideo.play().catch(() => {}); 341 setVideoToggleState(false); 342 } else { 343 heroVideoUserPaused = true; 344 heroVideo.pause(); 345 setVideoToggleState(true); 346 } 347 }); 348 349 // Save bandwidth/battery: pause playback while the hero is off screen 350 const heroSection = document.getElementById('home'); 351 if (heroSection && 'IntersectionObserver' in window) { 352 const heroVisibility = new IntersectionObserver((entries) => { 353 entries.forEach(entry => { 354 if (!entry.isIntersecting) { 355 heroVideo.pause(); 356 } else if (!heroVideoUserPaused) { 357 heroVideo.play().catch(() => {}); 358 } 359 }); 360 }, { threshold: 0.05 }); 361 heroVisibility.observe(heroSection); 362 } 363} 364 365// Email buttons now use mailto: links directly 366// No need for copy functionality 367 368// ========================================== 369// RESPONSIVE VIDEO EMBEDS (if needed in future) 370// ========================================== 371const setVideoAspectRatio = () => { 372 const videos = document.querySelectorAll('iframe[src*="youtube"], iframe[src*="vimeo"]'); 373 videos.forEach(video => { 374 const width = video.offsetWidth; 375 video.style.height = (width * 9 / 16) + 'px'; 376 }); 377}; 378 379window.addEventListener('resize', setVideoAspectRatio); 380setVideoAspectRatio(); 381 382// ========================================== 383// FETCH AND DISPLAY EVENTS 384// ========================================== 385async function fetchAndDisplayEvents() { 386 try { 387 console.log('Fetching events from API...'); 388 const response = await fetch('/.netlify/functions/get-events'); 389 390 console.log('Response status:', response.status); 391 392 if (!response.ok) { 393 const errorText = await response.text(); 394 console.error('API error response:', errorText); 395 throw new Error(`Failed to fetch events: ${response.status}`); 396 } 397 398 const data = await response.json(); 399 console.log('Events data received:', data); 400 401 // Store the master list, build the search/filter toolbar, then render 402 allUpcomingEvents = data.events || []; 403 buildEventsToolbar(allUpcomingEvents); 404 renderEvents(); 405 406 // Populate the registration form dropdown 407 populateEventDropdown(data.events); 408 409 } catch (error) { 410 console.error('Error fetching events:', error); 411 console.error('Error details:', error.message); 412 413 const container = document.getElementById('events-container'); 414 if (container) { 415 container.setAttribute('aria-busy', 'false'); 416 container.innerHTML = '<div class="no-events">Unable to load events. Please try again later.</div>'; 417 } 418 419 const eventSelect = document.getElementById('event-select'); 420 if (eventSelect) { 421 eventSelect.innerHTML = '<option value="">Unable to load events</option>'; 422 } 423 } 424} 425 426// ========================================== 427// UTILITY FUNCTIONS FOR EVENT DISPLAY 428// ========================================== 429 430// Format time from 24-hour format (HH:MM:SS) to 12-hour format (h:MM AM/PM) 431function formatTime(timeString) { 432 if (!timeString) return '6:00 PM'; 433 434 // Handle both HH:MM:SS and HH:MM formats
435 const timeParts = timeString.split(':'); 436 let hours = parseInt(timeParts[0], 10); 437 const minutes = timeParts[1]; 438 439 const ampm = hours >= 12 ? 'PM' : 'AM'; 440 hours = hours % 12; 441 hours = hours ? hours : 12; // Convert 0 to 12 442 443 return `${hours}:${minutes} ${ampm}`; 444} 445 446// ========================================== 447// EVENT SEARCH & LOCATION FILTERS 448// ========================================== 449let allUpcomingEvents = []; 450// Event IDs the signed-in member is already booked on (see loadMemberRegistrations) 451const registeredEventIds = new Set(); 452let showingAllEvents = false; 453let eventSearchQuery = ''; 454let eventLocationFilter = 'all'; 455 456// Locations arrive as full venue addresses ("NQ64, 14-16 Baldwin St, Bristol, 457// BS1 1SE, England"). For filter chips we want just the city: drop postcodes, 458// countries and placeholders, then take the last remaining part. 459const LOCATION_NOISE_RE = /^(uk|united kingdom|england|scotland|wales|ireland|northern ireland|australia|tbc|tbd)$/i; 460const POSTCODE_RE = /^([A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}|\d{4,5})$/i; 461 462function getEventCity(event) { 463 const parts = String(event.location || '').split(',') 464 .map(part => part.trim()) 465 .filter(part => part && !LOCATION_NOISE_RE.test(part) && !POSTCODE_RE.test(part)); 466 return parts.length ? parts[parts.length - 1] : ''; 467} 468 469function getFilteredEvents() { 470 const query = eventSearchQuery.trim().toLowerCase(); 471 return allUpcomingEvents.filter(event => { 472 if (eventLocationFilter !== 'all' && getEventCity(event) !== eventLocationFilter) { 473 return false; 474 } 475 if (!query) return true; 476 return [event.eventName, event.location, event.country, event.description] 477 .some(field => (field || '').toLowerCase().includes(query)); 478 }); 479} 480 481function eventFiltersActive() { 482 return eventSearchQuery.trim() !== '' || eventLocationFilter !== 'all'; 483} 484 485function renderEvents() { 486 displayEventsGrid(getFilteredEvents(), eventFiltersActive()); 487} 488 489function clearEventFilters() { 490 eventSearchQuery = ''; 491 eventLocationFilter = 'all'; 492 const searchInput = document.getElementById('events-search'); 493 if (searchInput) searchInput.value = ''; 494 const chips = document.querySelectorAll('#events-filter-chips .filter-chip'); 495 chips.forEach(chip => { 496 const isAll = chip.dataset.location === 'all'; 497 chip.classList.toggle('active', isAll); 498 chip.setAttribute('aria-pressed', String(isAll)); 499 }); 500 renderEvents(); 501} 502 503function buildEventsToolbar(events) { 504 const toolbar = document.getElementById('events-toolbar'); 505 if (!toolbar) return; 506 507 // A toolbar is only useful when there is something to narrow down 508 if (!events || events.length < 2) { 509 toolbar.hidden = true; 510 return; 511 } 512 toolbar.hidden = false; 513 514 const chipsWrap = document.getElementById('events-filter-chips'); 515 if (chipsWrap) { 516 const locations = [...new Set( 517 events.map(getEventCity).filter(Boolean) 518 )].sort((a, b) => a.localeCompare(b)); 519 520 if (locations.length > 1) { 521 chipsWrap.innerHTML = 522 `<button type="button" class="filter-chip active" data-location="all" aria-pressed="true">All locations</button>` + 523 locations.map(loc => 524 `<button type="button" class="filter-chip" data-location="${escapeHtmlAttr(loc)}" aria-pressed="false">${escapeHtml(loc)}</button>` 525 ).join(''); 526 } else { 527 chipsWrap.innerHTML = ''; 528 } 529 } 530 531 if (toolbar.dataset.wired === 'true') return; 532 toolbar.dataset.wired = 'true'; 533 534 // Chip selection (delegated so rebuilt chips keep working) 535 if (chipsWrap) { 536 chipsWrap.addEventListener('click', (e) => { 537 const chip = e.target.closest('.filter-chip'); 538 if (!chip) return; 539 eventLocationFilter = chip.dataset.location || 'all'; 540 chipsWrap.querySelectorAll('.filter-chip').forEach(c => { 541 const active = c === chip; 542 c.classList.toggle('active', active); 543 c.setAttribute('aria-pressed', String(active)); 544 }); 545 renderEvents(); 546 }); 547 } 548 549 // Debounced text search 550 const searchInput = document.getElementById('events-search'); 551 if (searchInput) { 552 let searchTimer = null; 553 searchInput.addEventListener('input', () => { 554 clearTimeout(searchTimer); 555 searchTimer = setTimeout(() => { 556 eventSearchQuery = searchInput.value; 557 renderEvents(); 558 }, 150); 559 }); 560 561 // Press "/" anywhere to jump to the event search box 562 document.addEventListener('keydown', (e) => { 563 if (e.key !== '/' || e.ctrlKey || e.metaKey || e.altKey) return; 564 const target = e.target; 565 const tag = target.tagName; 566 if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable) return; 567 if (document.querySelector('.modal.active')) return; 568 e.preventDefault(); 569 searchInput.focus(); 570 searchInput.scrollIntoView({ behavior: 'smooth', block: 'center' }); 571 }); 572 } 573} 574 575// ========================================== 576// DISPLAY EVENTS IN GRID 577// ========================================== 578 579function displayEventsGrid(events, isFiltered = false) { 580 const container = document.getElementById('events-container'); 581 582 if (!container) { 583 console.log('Events container not found on this page'); 584 return; 585 } 586 587 container.setAttribute('aria-busy', 'false'); 588 const showMoreWrap = document.getElementById('show-more-events'); 589 590 if (!events || events.length === 0) { 591 if (isFiltered) { 592 container.innerHTML = ` 593 <div class="no-events no-events--filtered"> 594 <p>No events match your search.</p> 595 <button type="button" class="clear-filters-btn" onclick="clearEventFilters()">Clear filters</button> 596 </div> 597 `; 598 } else { 599 container.innerHTML = '<div class="no-events">No upcoming events at the moment. Check back soon!</div>'; 600 } 601 if (showMoreWrap) showMoreWrap.style.display = 'none'; 602 return; 603 } 604 605 // Unfiltered view shows the first 3 with a "Show More" toggle; 606 // an active search/filter always shows every match 607 const eventsToShow = (showingAllEvents || isFiltered) ? events : events.slice(0, 3); 608 609 if (showMoreWrap) { 610 showMoreWrap.style.display = (!isFiltered && events.length > 3) ? 'block' : 'none'; 611 const toggleBtn = document.getElementById('toggle-events-btn'); 612 if (toggleBtn) { 613 toggleBtn.textContent = showingAllEvents ? 'Show Less Events' : `Show More Events (${events.length - 3} more)`; 614 } 615 } 616 617 container.innerHTML = eventsToShow.map(event => { 618 const eventDate = new Date(event.eventDate); 619 const dateFormatted = eventDate.toLocaleDateString('en-GB', { 620 weekday: 'long', 621 day: 'numeric', 622 month: 'long', 623 year: 'numeric' 624 }); 625 626 // Check if registration is available 627 const hasValidDate = event.eventDate && event.eventDate.trim() !== ''; 628 const hasValidLocation = event.location && 629 event.location.trim() !== '' && 630 event.location.toUpperCase() !== 'TBC'; 631 const canRegister = hasValidDate && hasValidLocation; 632 633 // Sanitize all user-controlled data to prevent XSS 634 const safeName = escapeHtml(event.eventName); 635 const safeLocation = escapeHtml(event.location); 636 const safeCountry = escapeHtml(event.country); 637 const safeNotes = escapeHtml(event.notes); 638 const safeDescription = escapeHtml(event.description); 639 const safeId = escapeHtmlAttr(event.id); 640 const safeNameAttr = escapeHtmlAttr(event.eventName); 641 const safeDateAttr = escapeHtmlAttr(dateFormatted); 642 const safeLocationAttr = escapeHtmlAttr(event.location + ', ' + event.country); 643 const safeFeaturedImage = event.featuredImage ? sanitizeUrl(event.featuredImage) : null; 644 645 // Donation amount shown in the event's own currency (£10, A$15, ...) 646 const donationLabel = escapeHtml(window.PCGCurrency 647 ? window.PCGCurrency.getCurrencyForCountry(event.country).label 648 : '£10'); 649 650 // Display affiliates/sponsors if available 651 let partnersInfo = ''; 652 if (event.partners) { 653 const partnersList = Array.isArray(event.partners) ? event.partners.join(', ') : event.partners; 654 const safePartners = escapeHtml(partnersList); 655 partnersInfo = ` 656 <div class="event-detail"> 657 <strong>ð¤ Affiliates:</strong>
658 <span>${safePartners}</span> 659 </div> 660 `; 661 } 662 663 // Partner Event info - shown at bottom as collapsible 664 const hasPartnerEvent = event.partnerEvent && event.partnerEventLocation; 665 let partnerEventSection = ''; 666 if (hasPartnerEvent) { 667 const eventId = event.id.replace(/[^a-zA-Z0-9]/g, ''); 668 const safePartnerName = escapeHtml(event.partnerEventName); 669 const safePartnerLocation = escapeHtml(event.partnerEventLocation); 670 const safePartnerLink = sanitizeUrl(event.partnerEventLink); 671 const hasValidPartnerLocation = event.partnerEventLocation && 672 event.partnerEventLocation.trim() !== '' && 673 event.partnerEventLocation.toUpperCase() !== 'TBC'; 674 675 partnerEventSection = ` 676 <button class="partner-event-toggle" onclick="togglePartnerEvent('${eventId}')"> 677 ð¤ Partner Event Details 678 <span class="toggle-icon" id="toggle-${eventId}">â¼</span> 679 </button> 680 <div class="partner-event-info" id="partner-${eventId}" style="display: none;"> 681 ${event.partnerEventName ? ` 682 <div class="event-detail"> 683 <strong>Event Name:</strong> 684 <span>${safePartnerName}</span> 685 </div> 686 ` : ''} 687 <div class="event-detail"> 688 <div style="display: flex; flex-direction: column; gap: 0.35rem;"> 689 <strong>ð Location:</strong> 690 ${hasValidPartnerLocation ? ` 691 <a href="https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(safePartnerLocation)}" 692 target="_blank" 693 rel="noopener noreferrer" 694 class="map-link" 695 title="View on Google Maps" 696 style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.3rem 0.6rem; background: var(--primary-color); color: white; border-radius: 4px; text-decoration: none; font-size: 0.75rem; font-weight: 600; transition: all 0.3s ease; white-space: nowrap; width: fit-content;"> 697 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 698 <path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path> 699 <circle cx="12" cy="10" r="3"></circle> 700 </svg> 701 View Map 702 </a> 703 ` : ''} 704 </div> 705 <span>${safePartnerLocation}</span> 706 </div> 707 ${safePartnerLink ? ` 708 <div class="event-detail"> 709 <a href="${safePartnerLink}" target="_blank" rel="noopener noreferrer" style="color: var(--primary-color); text-decoration: underline;"> 710 View Partner Event Details â 711 </a> 712 </div> 713 ` : ''} 714 </div> 715 `; 716 } 717 718 return ` 719 <div class="event-card${safeFeaturedImage ? ' event-card--has-image' : ''}"> 720 ${safeFeaturedImage ? ` 721 <div class="event-card-image"> 722 <img src="${safeFeaturedImage}" alt="${safeName}" loading="lazy"> 723 </div> 724 ` : ''} 725 <h3>${safeName}</h3> 726 727 ${canRegister && !event.isSpecialEvent && registeredEventIds.has(String(event.id)) ? ` 728 <a href="dashboard.html" class="register-btn register-btn--done" aria-label="You're registered for ${safeNameAttr}. View it in your dashboard"> 729 â You're registered 730 </a> 731 ` : canRegister && event.isSpecialEvent ? ` 732 <a href="special-event.html?id=${safeId}" class="register-btn" style="display: inline-block; text-decoration: none; text-align: center;"> 733 ⨠Register â Special Event 734 </a> 735 ` : canRegister ? ` 736 <button class="register-btn js-register-btn" 737 data-event-id="${safeId}" 738 data-event-name="${safeNameAttr}" 739 data-event-date="${safeDateAttr}" 740 data-event-location="${safeLocationAttr}" 741 data-event-country="${escapeHtmlAttr(event.country || '')}"> 742 Register 743 </button> 744 ` : ` 745 <div style="padding: 0.75rem 2rem; border-radius: 50px; background: rgba(255, 255, 255, 0.1); color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 1.5rem; text-align: center;"> 746 Registration Coming Soon 747 </div> 748 `} 749 750 <div class="event-detail"> 751 ${hasValidDate ? ` 752 <div style="display: flex; flex-direction: column; gap: 0.35rem;"> 753 <strong>ð Date:</strong> 754 <button type="button" class="event-calendar-btn js-calendar-btn" data-event-id="${safeId}" aria-label="Add ${safeNameAttr} to your calendar"> 755 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> 756 <rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect> 757 <line x1="16" y1="2" x2="16" y2="6"></line> 758 <line x1="8" y1="2" x2="8" y2="6"></line> 759 <line x1="3" y1="10" x2="21" y2="10"></line> 760 <line x1="12" y1="14" x2="12" y2="18"></line> 761 <line x1="10" y1="16" x2="14" y2="16"></line> 762 </svg> 763 Add to Calendar 764 </button> 765 </div> 766 ` : ` 767 <strong>ð Date:</strong> 768 `}
769 <span>${dateFormatted}</span> 770 </div> 771 772 <div class="event-detail"> 773 <strong>ð Time:</strong> 774 <span>${event.startTime ? formatTime(event.startTime) : '6:00 PM'}</span> 775 </div> 776 777 <div class="event-detail"> 778 <div style="display: flex; flex-direction: column; gap: 0.35rem;"> 779 <strong>ð Location:</strong> 780 ${hasValidLocation ? ` 781 <a href="https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(safeLocation + ', ' + safeCountry)}" 782 target="_blank" 783 rel="noopener noreferrer" 784 class="map-link" 785 title="View on Google Maps" 786 style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.3rem 0.6rem; background: var(--primary-color); color: white; border-radius: 4px; text-decoration: none; font-size: 0.75rem; font-weight: 600; transition: all 0.3s ease; white-space: nowrap; width: fit-content;"> 787 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 788 <path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path> 789 <circle cx="12" cy="10" r="3"></circle> 790 </svg> 791 View Map 792 </a> 793 ` : ''} 794 </div> 795 <span>${safeLocation}, ${safeCountry}</span> 796 </div> 797 798 ${partnersInfo} 799 800 ${event.description ? ` 801 <div class="event-detail event-description"> 802 <p style="margin-top: 1rem; line-height: 1.6; white-space: pre-line;">${safeDescription}</p> 803 </div> 804 ` : ''} 805 806 ${event.notes ? ` 807 <div class="event-detail"> 808 <p style="margin-top: 1rem; line-height: 1.6;">${safeNotes}</p> 809 </div> 810 ` : ''} 811 812 ${partnerEventSection} 813 814 <div class="event-support"> 815 <p class="event-support-text">Attending? Help keep PCG free for the next person.</p> 816 <button type="button" class="event-donate-btn js-donate-btn" 817 data-event-id="${safeId}" 818 data-event-name="${safeNameAttr}" 819 data-event-country="${escapeHtmlAttr(event.country || '')}"> 820 ð Donate ${donationLabel} 821 </button> 822 </div> 823 </div> 824 `; 825 }).join(''); 826} 827 828// Toggle partner event details 829function togglePartnerEvent(eventId) { 830 const detailsDiv = document.getElementById(`partner-${eventId}`); 831 const toggleIcon = document.getElementById(`toggle-${eventId}`); 832 833 if (detailsDiv.style.display === 'none') { 834 detailsDiv.style.display = 'block'; 835 toggleIcon.textContent = 'â²'; 836 } else { 837 detailsDiv.style.display = 'none'; 838 toggleIcon.textContent = 'â¼'; 839 } 840} 841 842// Toggle showing all events vs first 3 843function toggleAllEvents() { 844 showingAllEvents = !showingAllEvents; 845 renderEvents(); 846 847 // Scroll to events section if expanding 848 if (showingAllEvents) { 849 document.getElementById('upcoming-events').scrollIntoView({ behavior: 'smooth', block: 'start' }); 850 } 851} 852 853// ========================================== 854// ADD TO CALENDAR (.ics download) 855// ========================================== 856function escapeIcsText(value) { 857 return String(value || '') 858 .replace(/\\/g, '\\\\') 859 .replace(/;/g, '\\;') 860 .replace(/,/g, '\\,') 861 .replace(/\r?\n/g, '\\n'); 862} 863 864function formatIcsLocalDate(date) { 865 const pad = (n) => String(n).padStart(2, '0'); 866 return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}T${pad(date.getHours())}${pad(date.getMinutes())}00`; 867} 868 869function downloadEventIcs(event) { 870 const datePart = String(event.eventDate || '').slice(0, 10); 871 const timePart = (event.startTime || '18:00').slice(0, 5); 872 const start = new Date(`${datePart}T${timePart}:00`); 873 if (isNaN(start.getTime())) { 874 showNotification('â This event has no confirmed date yet', 'error'); 875 return; 876 } 877 const end = new Date(start.getTime() + 2 * 60 * 60 * 1000); 878 879 const location = [event.location, event.country] 880 .filter(part => part && String(part).trim() && String(part).trim().toUpperCase() !== 'TBC') 881 .join(', '); 882 const description = (event.description || 'A free Project Connect Group networking event.') 883 + '\n\nDetails & registration: https://projectconnect.group/#upcoming-events'; 884 885 const ics = [ 886 'BEGIN:VCALENDAR', 887 'VERSION:2.0', 888 'PRODID:-//Project Connect Group//Events//EN', 889 'BEGIN:VEVENT', 890 `UID:pcg-${escapeIcsText(event.id)}@projectconnect.group`, 891 `DTSTAMP:${formatIcsLocalDate(new Date())}`, 892 `DTSTART:${formatIcsLocalDate(start)}`, 893 `DTEND:${formatIcsLocalDate(end)}`, 894 `SUMMARY:${escapeIcsText(event.eventName || 'Project Connect Group Event')}`, 895 location ? `LOCATION:${escapeIcsText(location)}` : null, 896 `DESCRIPTION:${escapeIcsText(description)}`, 897 'END:VEVENT', 898 'END:VCALENDAR'
899 ].filter(Boolean).join('\r\n'); 900 901 const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' }); 902 const url = URL.createObjectURL(blob); 903 const link = document.createElement('a'); 904 const slug = String(event.eventName || 'pcg-event').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'pcg-event'; 905 link.href = url; 906 link.download = `${slug}.ics`; 907 document.body.appendChild(link); 908 link.click(); 909 document.body.removeChild(link); 910 URL.revokeObjectURL(url); 911} 912 913// Delegated listeners so card buttons keep working across re-renders 914document.addEventListener('DOMContentLoaded', () => { 915 const eventsContainer = document.getElementById('events-container'); 916 if (!eventsContainer) return; 917 eventsContainer.addEventListener('click', (e) => { 918 const calendarBtn = e.target.closest('.js-calendar-btn'); 919 if (calendarBtn) { 920 const event = allUpcomingEvents.find(ev => String(ev.id) === calendarBtn.dataset.eventId); 921 if (event) downloadEventIcs(event); 922 return; 923 } 924 925 const donateBtn = e.target.closest('.js-donate-btn'); 926 if (donateBtn) { 927 openSupportDonation( 928 donateBtn.dataset.eventId, 929 donateBtn.dataset.eventName, 930 donateBtn.dataset.eventCountry 931 ); 932 } 933 }); 934}); 935 936// ========================================== 937// POPULATE EVENT DROPDOWN 938// ========================================== 939function populateEventDropdown(events) { 940 const eventSelect = document.getElementById('event-select'); 941 942 if (!eventSelect) { 943 console.log('Event select dropdown not found on this page'); 944 return; 945 } 946 947 if (!events || events.length === 0) { 948 eventSelect.innerHTML = '<option value="">No upcoming events</option>'; 949 return; 950 } 951 952 eventSelect.innerHTML = '<option value="">Choose an event...</option>' + 953 events.map(event => { 954 const eventDate = new Date(event.eventDate); 955 const dateFormatted = eventDate.toLocaleDateString('en-GB', { 956 day: 'numeric', 957 month: 'short', 958 year: 'numeric' 959 }); 960 961 const displayText = `${event.eventName} - ${event.location} (${dateFormatted})`; 962 return `<option value="${escapeHtmlAttr(event.id)}">${escapeHtml(displayText)}</option>`; 963 }).join(''); 964} 965 966// ========================================== 967// SCROLL TO REGISTRATION 968// ========================================== 969function scrollToRegistration(eventId) { 970 const registrationSection = document.getElementById('contact'); 971 const eventSelect = document.getElementById('event-select'); 972 973 if (eventSelect) { 974 eventSelect.value = eventId; 975 } 976 977 if (registrationSection) { 978 registrationSection.scrollIntoView({ behavior: 'smooth' }); 979 } 980} 981 982// ========================================== 983// FETCH GALLERY IMAGES FROM AIRTABLE 984// ========================================== 985async function fetchGalleryFromAirtable() { 986 try { 987 const response = await fetch( 988 `https://api.airtable.com/v0/${AIRTABLE_CONFIG.baseId}/${AIRTABLE_CONFIG.galleryTable}?sort[0][field]=Date&sort[0][direction]=desc`, 989 { 990 headers: { 991 'Authorization': `Bearer ${AIRTABLE_CONFIG.apiKey}`, 992 'Content-Type': 'application/json' 993 } 994 } 995 ); 996 997 if (!response.ok) { 998 throw new Error('Failed to fetch gallery from Airtable'); 999 } 1000 1001 const data = await response.json(); 1002 displayGallery(data.records); 1003 } catch (error) { 1004 console.error('Error fetching gallery:', error); 1005 } 1006} 1007 1008// ========================================== 1009// DISPLAY GALLERY IMAGES 1010// ========================================== 1011function displayGallery(records) { 1012 const galleryContainer = document.getElementById('gallery-container'); 1013 if (!galleryContainer || records.length === 0) return; 1014 1015 // Clear existing placeholder images 1016 galleryContainer.innerHTML = ''; 1017 1018 records.forEach(record => { 1019 const { Title, Date, Image } = record.fields; 1020 1021 if (Image && Image[0]) { 1022 const galleryItem = document.createElement('div'); 1023 galleryItem.className = 'gallery-item'; 1024 1025 galleryItem.innerHTML = ` 1026 <img src="${Image[0].url}" alt="${Title || 'Event photo'}"> 1027 <div class="gallery-overlay"> 1028 <h4>${Title || 'Event'}</h4> 1029 <p>${Date || ''}</p> 1030 </div> 1031 `; 1032 1033 galleryContainer.appendChild(galleryItem); 1034 } 1035 }); 1036} 1037 1038// ========================================== 1039// SUBMIT REGISTRATION VIA NETLIFY FUNCTION 1040// ========================================== 1041async function submitRegistration(formData) { 1042 try { 1043 const response = await fetch('/.netlify/functions/submit-registration', { 1044 method: 'POST', 1045 headers: { 1046 'Content-Type': 'application/json' 1047 }, 1048 body: JSON.stringify({ 1049 attendeeName: formData.Name, 1050 email: formData.Email, 1051 phone: formData.Phone, 1052 companyOrganisation: formData.Company, 1053 jobTitle: formData['Job Title'] || '', 1054 notes: formData.Notes || '', 1055 eventId: formData.Event 1056 }) 1057 }); 1058 1059 const data = await response.json(); 1060 1061 if (!response.ok) { 1062 throw new Error(data.error || 'Failed to submit registration'); 1063 } 1064 1065 return data; 1066 } catch (error) { 1067 console.error('Error submitting registration:', error); 1068 throw error; 1069 } 1070} 1071 1072// ========================================== 1073// HANDLE REGISTRATION FORM SUBMISSION 1074// ========================================== 1075function initializeRegistrationForm() { 1076 const registrationForm = document.getElementById('registration-form'); 1077 1078 if (registrationForm) { 1079 registrationForm.addEventListener('submit', async (e) => { 1080 e.preventDefault(); 1081 1082 const submitButton = registrationForm.querySelector('button[type="submit"]'); 1083 const originalButtonText = submitButton.textContent; 1084 submitButton.disabled = true; 1085 submitButton.textContent = 'Submitting...'; 1086 1087 const formData = { 1088 attendeeName: document.getElementById('attendee-name').value, 1089 email: document.getElementById('email').value, 1090 phone: document.getElementById('phone').value || '', 1091 companyOrganisation: document.getElementById('company').value || '', 1092 jobTitle: document.getElementById('job-title').value || '', 1093 notes: document.getElementById('notes').value || '', 1094 eventId: document.getElementById('event-select').value, 1095 }; 1096 1097 try { 1098 const response = await fetch('/.netlify/functions/submit-registration', { 1099 method: 'POST', 1100 headers: { 'Content-Type': 'application/json' }, 1101 body: JSON.stringify(formData), 1102 }); 1103 1104 const data = await response.json(); 1105 1106 if (response.status === 200) { 1107 showNotification('â Registration successful! We look forward to seeing you at the event.', 'success'); 1108 registrationForm.reset(); 1109 } else if (response.status === 409) { 1110 showNotification('â¹ï¸ You are already registered for this event.', 'error'); 1111 } else { 1112 showNotification('â Registration failed: ' + (data.error || 'Unknown error'), 'error'); 1113 } 1114 } catch (error) { 1115 console.error('Error:', error); 1116 showNotification('â An error occurred. Please try again or c
1116ontact us directly.', 'error'); 1117 } finally { 1118 submitButton.disabled = false; 1119 submitButton.textContent = originalButtonText; 1120 } 1121 }); 1122 } 1123} 1124 1125// ========================================== 1126// SHOW NOTIFICATION 1127// ========================================== 1128function showNotification(message, type = 'success') { 1129 const notification = document.createElement('div'); 1130 notification.textContent = message; 1131 notification.style.cssText = ` 1132 position: fixed; 1133 bottom: 20px; 1134 left: 50%; 1135 transform: translateX(-50%); 1136 background: ${type === 'success' ? 'var(--primary-color)' : '#f44336'}; 1137 color: white; 1138 padding: 1rem 2rem; 1139 border-radius: 50px; 1140 font-weight: 600; 1141 z-index: 10000; 1142 animation: fadeInUp 0.3s ease; 1143 max-width: 90%; 1144 text-align: center; 1145 `; 1146 document.body.appendChild(notification); 1147 1148 setTimeout(() => { 1149 notification.style.animation = 'fadeInUp 0.3s ease reverse'; 1150 setTimeout(() => notification.remove(), 300); 1151 }, 4000); 1152} 1153 1154// ========================================== 1155// INITIALIZE FEATURES 1156// ========================================== 1157document.addEventListener('DOMContentLoaded', () => { 1158 // Fetch and display events from Netlify function 1159 fetchAndDisplayEvents(); 1160 1161 // Delegated click handler for Register buttons. Reading event data from 1162 // data-* attributes avoids breaking the handler when fields contain 1163 // characters like apostrophes (e.g. "O'Connell Street"). 1164 const eventsContainer = document.getElementById('events-container'); 1165 if (eventsContainer) { 1166 eventsContainer.addEventListener('click', async (e) => { 1167 const btn = e.target.closest('.js-register-btn'); 1168 if (!btn || btn.disabled) return; 1169 1170 // Checking the session takes a moment on mobile data, so show 1171 // that the tap registered instead of appearing to do nothing. 1172 const label = btn.textContent; 1173 btn.disabled = true; 1174 btn.setAttribute('aria-busy', 'true'); 1175 btn.textContent = 'Openingâ¦'; 1176 try { 1177 await openRegistrationModal( 1178 btn.dataset.eventId, 1179 btn.dataset.eventName, 1180 btn.dataset.eventDate, 1181 btn.dataset.eventLocation, 1182 btn.dataset.eventCountry || '' 1183 ); 1184 } finally { 1185 btn.disabled = false; 1186 btn.removeAttribute('aria-busy'); 1187 btn.textContent = label; 1188 } 1189 }); 1190 } 1191 1192 // Initialize registration form 1193 initializeRegistrationForm(); 1194 1195 const params = new URLSearchParams(window.location.search); 1196 const isOn = (value) => value === '1' || value === 'true'; 1197 1198 // If we just came back from an OAuth/email-confirm flow with a pending 1199 // event registration, reopen that modal automatically. 1200 if (params.get('resume_registration') === '1') { 1201 // Strip the param so a refresh doesn't keep retriggering. 1202 const cleanUrl = window.location.pathname + window.location.hash; 1203 window.history.replaceState({}, document.title, cleanUrl); 1204 // Wait for auth state to settle, then resume. 1205 setTimeout(() => resumePendingRegistration(), 500); 1206 } else if (isOn(params.get('login')) || isOn(params.get('signup'))) { 1207 // Deep links such as /?login=1 (used by other pages and emails) 1208 const wantsSignup = isOn(params.get('signup')); 1209 const cleanUrl = window.location.pathname + window.location.hash; 1210 window.history.replaceState({}, document.title, cleanUrl); 1211 getCurrentSession().then(({ session, member }) => { 1212 if (session && member) return; // Already signed in, nothing to do 1213 if (wantsSignup) openSignupModal(); else openLoginModal(); 1214 }); 1215 } 1216 1217 // Note: Gallery still uses placeholder images 1218 // To load from Airtable, create a separate Netlify function 1219}); 1220 1221// ========================================== 1222// MODAL REGISTRATION FUNCTIONS 1223// ========================================== 1224 1225// The event shown in the registration modal, kept so it can be restored if 1226// the member has to log in again part-way through. 1227let currentRegistrationEvent = null; 1228 1229async function openRegistrationModal(eventId, eventName, eventDate, eventLocation, eventCountry) { 1230 // Check if user is logged in FIRST 1231 let { session, member } = await getCurrentSession(); 1232 1233 // Signed in but no profile row yet (e.g. straight back from an email 1234 // confirmation link): build it from the sign-up details rather than 1235 // treating the member as logged out. 1236 if (session && !member) { 1237 member = await ensureMemberProfile(session); 1238 } 1239 1240 if (!session || !member) { 1241 // Stash the event the user was trying to register for. After they 1242 // log in or create an account we'll auto-reopen this modal â see 1243 // resumePendingRegistration() below. 1244 setPendingRegistration({ 1245 eventId: eventId, 1246 eventName: eventName, 1247 eventDate: eventDate, 1248 eventLocation: eventLocation, 1249 eventCountry: eventCountry || '', 1250 }); 1251 1252 // Most people tapping Register are new, so lead with sign-up unless 1253 // this browser has signed in before. 1254 if (isReturningMember()) { 1255 openLoginModal(); 1256 } else { 1257 openSignupModal(); 1258 } 1259 return; 1260 } 1261 1262 markReturningMember(); 1263 currentRegistrationEvent = { eventId, eventName, eventDate, eventLocation, eventCountry: eventCountry || '' }; 1264 1265 const modal = document.getElementById('registration-modal'); 1266 const form = document.getElementById('modal-registration-form'); 1267 if (form) { 1268 form.reset(); 1269 clearAllFieldErrors(form); 1270 } 1271 setFormStatus('reg-status'); 1272 1273 // Populate event details, including the start time when we know it 1274 const eventRecord = allUpcomingEvents.find(ev => String(ev.id) === String(eventId)); 1275 const timeText = eventRecord && eventRecord.startTime ? ` · ${formatTime(eventRecord.startTime)}` : ''; 1276 document.getElementById('modal-event-name').textContent = eventName; 1277 document.getElementById('modal-event-date').textContent = `ð ${eventDate}${timeText}`; 1278 document.getElementById('modal-event-location').textContent = `ð ${eventLocation}`; 1279 1280 // Set Google Maps link 1281 const mapLink = document.getElementById('modal-map-link'); 1282 if (mapLink) { 1283 mapLink.href = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(eventLocation)}`; 1284 } 1285 1286 // Set hidden event ID field 1287 document.getElementById('modal-event-id').value = eventId; 1288 const countryEl = document.getElementById('modal-event-country'); 1289 if (countryEl) countryEl.value = eventCountry || ''; 1290 1291 // Auto-fill user details from member profile 1292 const nameField = document.getElementById('modal-attendee-name'); 1293 const emailField = document.getElementById('modal-email'); 1294 const phoneField = document.getElementById('modal-phone'); 1295 const companyField = document.getElementById('modal-company'); 1296 const jobTitleField = document.getElementById('modal-job-title'); 1297 1298 const memberEmail = member.email || (session.user && session.user.email) || ''; 1299 if (nameField) nameField.value = member.member_name || ''; 1300 if (emailField) emailField.value = memberEmail; 1301 if (phoneField) phoneField.value = member.phone || ''; 1302 if (companyField) companyField.value = member.company_organisation || ''; 1303 if (jobTitleField) jobTitleField.value = member.job_title || ''; 1304 1305 // Members with a complete profile get a one-tap confirmation; anyone 1306 // missing a required detail sees the form fields instead. 1307 const profileComplete = [member.member_name, memberEmail, member.company_organisation, member.job_title] 1308 .every(value => value && String(value).trim() !== ''); 1309 1310 document.getElementById('reg-summary-name').textContent = member.member_name || memberEmail; 1311 document.getElementById('reg-summary-email').textContent = memberEmail; 1312 const roleEl = document.getElementById('reg-summary-role'); 1313 const roleLine = [member.job_title, member.company_organisation].filter(
1313Boolean).join(' · '); 1314 roleEl.textContent = roleLine; 1315 roleEl.hidden = !roleLine; 1316 1317 // Google/LinkedIn sign-ups have no company or job title yet: ask only 1318 // for what's missing rather than the whole form. 1319 setRegistrationMode(profileComplete ? 'summary' : 'partial'); 1320 1321 // Show modal 1322 modal.classList.add('active'); 1323 1324 // Prevent body scroll when modal is open 1325 document.body.style.overflow = 'hidden'; 1326} 1327 1328// 'summary': the "Registering as" card only. 'partial': the card plus just the 1329// details still missing. 'fields': the full editable form. 1330function setRegistrationMode(mode) { 1331 const summary = document.getElementById('reg-summary'); 1332 const fields = document.getElementById('reg-fields'); 1333 if (summary) summary.hidden = mode === 'fields'; 1334 if (!fields) return; 1335 fields.hidden = mode === 'summary'; 1336 fields.querySelectorAll('.form-group').forEach(group => { 1337 const input = group.querySelector('input'); 1338 group.hidden = mode === 'partial' && !!input && (input.type === 'tel' || input.value.trim() !== ''); 1339 }); 1340} 1341 1342function showRegistrationFields() { 1343 setRegistrationMode('fields'); 1344 const nameField = document.getElementById('modal-attendee-name'); 1345 if (nameField) nameField.focus(); 1346} 1347 1348function closeRegistrationModal() { 1349 const modal = document.getElementById('registration-modal'); 1350 const form = document.getElementById('modal-registration-form'); 1351 1352 // Hide modal 1353 modal.classList.remove('active'); 1354 1355 // Re-enable body scroll 1356 document.body.style.overflow = ''; 1357 1358 // Reset form 1359 form.reset(); 1360 clearAllFieldErrors(form); 1361 setFormStatus('reg-status'); 1362} 1363 1364// Close modal when clicking overlay 1365document.addEventListener('DOMContentLoaded', () => { 1366 const modal = document.getElementById('registration-modal'); 1367 const overlay = modal?.querySelector('.modal-overlay'); 1368 1369 if (overlay) { 1370 overlay.addEventListener('click', closeRegistrationModal); 1371 } 1372 1373 // Global ESC key handler - closes any open modal 1374 document.addEventListener('keydown', (e) => { 1375 if (e.key === 'Escape') { 1376 // Find any active modal 1377 const activeModal = document.querySelector('.modal.active'); 1378 if (activeModal) { 1379 // Find the close button and click it to trigger the proper close function 1380 const closeButton = activeModal.querySelector('.modal-close'); 1381 if (closeButton) { 1382 closeButton.click(); 1383 } 1384 } 1385 } 1386 }); 1387 1388 // Handle modal form submission 1389 initializeModalRegistrationForm(); 1390}); 1391 1392// ========================================== 1393// HANDLE MODAL REGISTRATION FORM SUBMISSION 1394// ========================================== 1395function initializeModalRegistrationForm() { 1396 const modalForm = document.getElementById('modal-registration-form'); 1397 1398 if (!modalForm) return; 1399 1400 modalForm.addEventListener('submit', async (e) => { 1401 e.preventDefault(); 1402 1403 clearAllFieldErrors(modalForm); 1404 setFormStatus('reg-status'); 1405 1406 const nameInput = document.getElementById('modal-attendee-name'); 1407 const emailInput = document.getElementById('modal-email'); 1408 const companyInput = document.getElementById('modal-company'); 1409 const jobTitleInput = document.getElementById('modal-job-title'); 1410 const termsCheckbox = document.getElementById('modal-terms'); 1411 1412 // Validate required details; reveal the fields if one is missing 1413 const problems = []; 1414 if (!nameInput.value.trim()) problems.push([nameInput, 'Please enter your name']); 1415 if (!isValidEmail(emailInput.value.trim())) problems.push([emailInput, 'Please enter a valid email address']); 1416 if (!companyInput.value.trim()) problems.push([companyInput, 'Please enter your company or organisation']); 1417 if (!jobTitleInput.value.trim()) problems.push([jobTitleInput, 'Please enter your job title']); 1418 1419 if (problems.length > 0) { 1420 setRegistrationMode('fields'); 1421 problems.forEach(([input, message]) => showFieldError(input, message)); 1422 problems[0][0].focus(); 1423 return; 1424 } 1425 1426 if (termsCheckbox && !termsCheckbox.checked) { 1427 showFieldError(termsCheckbox, 'Please tick the box to accept the Terms & Conditions and Privacy Policy'); 1428 termsCheckbox.focus(); 1429 return; 1430 } 1431 1432 const submitButton = modalForm.querySelector('button[type="submit"]'); 1433 setButtonLoading(submitButton, true); 1434 1435 const formData = { 1436 attendeeName: nameInput.value.trim(), 1437 email: emailInput.value.trim(), 1438 phone: document.getElementById('modal-phone').value.trim() || '', 1439 companyOrganisation: companyInput.value.trim(), 1440 jobTitle: jobTitleInput.value.trim(), 1441 notes: document.getElementById('modal-notes')?.value || '', 1442 eventId: document.getElementById('modal-event-id').value, 1443 registrationDate: new Date().toISOString(), 1444 }; 1445 1446 const eventName = document.getElementById('modal-event-name').textContent; 1447 const eventCountry = document.getElementById('modal-event-country')?.value || ''; 1448 1449 // Session expired while the modal was open: keep their place and 1450 // ask them to log in again rather than failing. 1451 const askToLogInAgain = () => { 1452 if (currentRegistrationEvent) setPendingRegistration(currentRegistrationEvent); 1453 closeRegistrationModal(); 1454 openLoginModal(); 1455 setFormStatus('login-status', 'info', 'Your session has expired. Log in again and we\'ll take you straight back to your registration.'); 1456 }; 1457 1458 try { 1459 // Get Supabase session for authentication 1460 const { session } = await getCurrentSession(); 1461 if (!session) { 1462 askToLogInAgain(); 1463 return; 1464 } 1465 1466 const response = await fetch('/.netlify/functions/submit-registration', { 1467 method: 'POST', 1468 headers: { 1469 'Content-Type': 'application/json', 1470 'Authorization': `Bearer ${session.access_token}` 1471 }, 1472 body: JSON.stringify(formData), 1473 }); 1474 1475 const data = await response.json().catch(() => ({})); 1476 1477 if (response.status === 200) { 1478 markEventRegistered(formData.eventId); 1479 1480 // Close registration modal 1481 closeRegistrationModal(); 1482 1483 // Show donation modal (doubles as the "you're in" confirmation) 1484 showDonationModal(data.registrationId, eventName, eventCountry); 1485 } else if (response.status === 409) { 1486 // Already booked is good news, not an error 1487 markEventRegistered(formData.eventId); 1488 closeRegistrationModal(); 1489 showNotification('â You\'re already registered for this event. It\'s in your dashboard.', 'success'); 1490 } else if (response.status === 401) { 1491 askToLogInAgain(); 1492 } else { 1493 const reason = response.status >= 500 1494 ? 'something went wrong on our side' 1495 : (data.message || data.error || 'something went wrong'); 1496 setFormStatus('reg-status', 'error', `We couldn't complete your registration (${reason}). Please try again, or email [email protected] and we'll add you.`); 1497 console.error('Registration error details:', data); 1498 } 1499 } catch (error) { 1500 console.error('Error:', error); 1501 setFormStatus('reg-status', 'error', 'We couldn\'t reach the server. Check your connection and try again.'); 1502 } finally { 1503 setButtonLoading(submitButton, false); 1504 } 1505 }); 1506} 1507 1508// ========================================== 1509// DONATION MODAL FUNCTIONS 1510// ========================================== 1511 1512// Tracks how the donation modal was opened, so closing it doesn't announce a 1513// registration that never happened.
1514let donationModalMode = 'post-registration'; 1515 1516function showDonationModal(registrationId, eventName, eventCountry, options = {}) { 1517 const mode = options.mode || 'post-registration'; 1518 const eventId = options.eventId || ''; 1519 donationModalMode = mode; 1520 1521 const modal = document.getElementById('donation-modal'); 1522 // The subtitle is rewritten below, which can remove this span, so it may 1523 // legitimately be absent when reopening the modal in a different mode. 1524 const nameSpan = document.getElementById('donation-event-name'); 1525 if (nameSpan) nameSpan.textContent = eventName || ''; 1526 document.getElementById('donation-event-name-hidden').value = eventName || ''; 1527 document.getElementById('donation-registration-id').value = registrationId || ''; 1528 1529 const eventIdInput = document.getElementById('donation-event-id'); 1530 if (eventIdInput) eventIdInput.value = eventId; 1531 1532 const countryInput = document.getElementById('donation-country-hidden'); 1533 if (countryInput) countryInput.value = eventCountry || ''; 1534 1535 // The modal doubles as a post-registration prompt and a standalone 1536 // "support us" prompt, so swap the header copy to match. 1537 const steps = document.getElementById('donation-steps'); 1538 const title = document.getElementById('donation-title'); 1539 const subtitle = document.getElementById('donation-subtitle'); 1540 const isSupport = mode === 'support'; 1541
1542 // Set 'flex' explicitly: clearing the property would drop the inline 1543 // display:flex and stack the steps vertically. 1544 if (steps) steps.style.display = isSupport ? 'none' : 'flex'; 1545 if (title) { 1546 title.textContent = isSupport 1547 ? 'Support Project Connect Group' 1548 : "You're in â and it's FREE"; 1549 } 1550 if (subtitle) { 1551 if (isSupport && eventName) { 1552 subtitle.innerHTML = 'Chipping in towards <span style="font-weight: 700;"></span> ð'; 1553 subtitle.querySelector('span').textContent = eventName; 1554 } else if (isSupport) { 1555 subtitle.textContent = 'Help us keep every event free ð'; 1556 } else { 1557 subtitle.innerHTML = 'See you at <span id="donation-event-name" style="font-weight: 700;"></span> ð'; 1558 subtitle.querySelector('#donation-event-name').textContent = eventName || ''; 1559 } 1560 } 1561 1562 // Update displayed currency to match the event's country (e.g. £ for UK, 1563 // A$ for Australia). Falls back to GBP â see js/currency.js. 1564 if (window.PCGCurrency) { 1565 const currency = window.PCGCurrency.getCurrencyForCountry(eventCountry); 1566 document.querySelectorAll('#donation-modal [data-currency-amount]').forEach(node => { 1567 node.textContent = currency.label; 1568 }); 1569 } 1570 1571 setDonationStatus('', ''); 1572 modal.classList.add('active'); 1573 document.body.style.overflow = 'hidden'; 1574} 1575 1576// Open the donation prompt on its own - used by the "Donate" button on event 1577// cards and the support band, so members who registered days ago (or guests who 1578// never registered) can still chip in. 1579function openSupportDonation(eventId, eventName, eventCountry) { 1580 showDonationModal('', eventName || '', eventCountry || '', { mode: 'support', eventId: eventId || '' }); 1581} 1582 1583function closeDonationModal() { 1584 const modal = document.getElementById('donation-modal'); 1585 modal.classList.remove('active'); 1586 document.body.style.overflow = ''; 1587 setDonationStatus('', ''); 1588 1589 // Only confirm a registration when the modal actually followed one. 1590 const isHomepage = window.location.pathname.includes('index.html') || window.location.pathname === '/'; 1591 if (donationModalMode === 'post-registration' && isHomepage) { 1592 showNotification('â Registration successful! We look forward to seeing you at the event.', 'success'); 1593 } 1594} 1595 1596// Persistent status message inside the donation modal, so failures can't be 1597// missed the way a transient toast can (type: info | success | error) 1598function setDonationStatus(type, message) { 1599 const el = document.getElementById('donation-status'); 1600 if (!el) return; 1601 if (!message) { 1602 el.className = 'upload-status'; 1603 el.textContent = ''; 1604 el.style.display = 'none'; 1605 return; 1606 } 1607 el.className = 'upload-status upload-status--' + type; 1608 el.textContent = message; 1609 el.style.display = 'block'; 1610} 1611 1612async function processDonation() { 1613 const registrationId = document.getElementById('donation-registration-id').value; 1614 const eventName = document.getElementById('donation-event-name-hidden').value; 1615 const eventCountry = document.getElementById('donation-country-hidden')?.value || ''; 1616 const donateBtn = document.getElementById('donate-btn'); 1617 1618 // Disable button and show loading state 1619 donateBtn.disabled = true; 1620 const originalText = donateBtn.textContent; 1621 donateBtn.textContent = 'Processing...'; 1622 1623 const failDonation = (message) => { 1624 setDonationStatus('error', message); 1625 showNotification('â ' + message, 'error'); 1626 donateBtn.disabled = false; 1627 donateBtn.textContent = originalText; 1628 }; 1629 1630 try { 1631 setDonationStatus('info', 'Preparing your secure checkoutâ¦'); 1632 1633 // A login isn't required to donate. When the donor IS signed in we send 1634 // their token so the donation links to their registration and earns the 1635 // donor badge; otherwise Stripe Checkout collects their email. 1636 const eventId = document.getElementById('donation-event-id')?.value || ''; 1637 let session = null; 1638 try { 1639 ({ session } = await getCurrentSession()); 1640 } catch (sessionError) { 1641 console.warn('No active session for donation, continuing as guest:', sessionError); 1642 } 1643 1644 const response = await fetch('/.netlify/functions/create-donation-checkout', { 1645 method: 'POST', 1646 headers: { 1647 'Content-Type': 'application/json', 1648 ...(session ? { 'Authorization': `Bearer ${session.access_token}` } : {}) 1649 }, 1650 body: JSON.stringify({ 1651 registrationId: registrationId, 1652 eventId: eventId, 1653 eventCountry: eventCountry, 1654 }) 1655 }); 1656 1657 const data = await response.json().catch(() => ({})); 1658 1659 if (response.ok && data.url) { 1660 setDonationStatus('success', 'Taking you to the secure Stripe checkoutâ¦'); 1661 donateBtn.textContent = 'Redirecting...'; 1662 // Redirect to Stripe Checkout 1663 window.location.href = data.url; 1664 } else if (response.status === 401) { 1665 failDonation('Your session has expired - please log in again, then donate from your dashboard.'); 1666 } else if (response.status === 403 || response.status === 404) { 1667 failDonation('We could not match this donation to your event registration. Please email [email protected] and we will sort it out.'); 1668 } else if (response.status === 429) { 1669 failDonation('Too many attempts - please wait a minute and try again.'); 1670 } else { 1671 console.error('Donation checkout error response:', response.status, data); 1672 failDonation('Donations are temporarily unavailable - please try again later or email [email protected].'); 1673 } 1674 } catch (error) { 1675 console.error('Error creating donation checkout:', error); 1676 failDonation(error instanceof TypeError 1677 ? 'Could not reach the server - please check your connection and try again.' 1678 : 'Unable to process the donation right now - please try again later.'); 1679 } 1680} 1681 1682function skipDonation() { 1683 closeDonationModal(); 1684} 1685 1686// ========================================== 1687// MEMBER AUTHENTICATION 1688// ========================================== 1689 1690// Session management 1691function getSessionToken() { 1692 return localStorage.getItem('pcg_session_token'); 1693} 1694 1695function setSessionToken(token) { 1696 localStorage.setItem('pcg_session_token', token); 1697} 1698 1699function clearSession() { 1700 localStorage.removeItem('pcg_session_token'); 1701 localStorage.removeItem('pcg_member_data'); 1702} 1703 1704function getMemberData() { 1705 const data = localStorage.getItem('pcg_member_data'); 1706 return data ? JSON.parse(data) : null; 1707} 1708 1709function setMemberData(memberData) { 1710 localStorage.setItem('pcg_member_data', JSON.stringify(memberData)); 1711} 1712 1713// Check authentication state on page load 1714async function checkAuthState() { 1715 const { session, member } = await getCurrentSession(); 1716 1717 const authNavItem = document.getElementById('auth-nav-item'); 1718 const joinUsNavItem = document.getElementById('join-us-nav-item'); 1719 const memberNavItem = document.getElementById('member-nav-item'); 1720 const hostNavItem = document.getElementById('host-nav-item'); 1721 const adminNavItem = document.getElementById('admin-nav-item'); 1722 const mobileAuthBtn = document.getElementById('mobile-auth-btn'); 1723 1724 if (session && member) { 1725 // User is logged in 1726 markReturningMember(); 1727 1728 if (authNavItem) authNavItem.style.display = 'none'; 1729 if (joinUsNavItem) joinUsNavItem.style.display = 'none'; 1730 1731 if (memberNavItem) { 1732 memberNavItem.style.display = 'block'; 1733 1734 // Everyone goes to member dashboard first 1735 // Admin/Host buttons are shown within the dashboard 1736 const dashboardLink = memberNavItem.querySelector('a'); 1737 if (dashboardLink) { 1738 dashboardLink.href = 'dashboard.html'; 1739 dashboardLink.textContent = 'My Dashboard'; 1740 } 1741 } 1742 1743 if (mobileAuthBtn) { 1744 mobileAuthBtn.href = 'dashboard.html'; 1745 mobileAuthBtn.textContent = 'My Dashboard'; 1746 mobileAuthBtn.dataset.mode = 'dashboard'; 1747 } 1748 1749 // Show host/admin links based on role 1750 const role = member.member_role || 'member'; 1751 if (role === 'admin' && adminNavItem) { 1752 adminNavItem.style.display = 'block'; 1753 // Admins don't need host link since admin console has all host features 1754 if (hostNavItem) hostNavItem.style.display = 'none'; 1755 } else if (role === 'host' && hostNavItem) { 1756 hostNavItem.style.display = 'block'; 1757 if (adminNavItem) adminNavItem.style.display = 'none'; 1758 } else { 1759 // Regular members see neither 1760 if (hostNavItem) hostNavItem.style.display = 'none'; 1761 if (adminNavItem) adminNavItem.style.display = 'none'; 1762 } 1763 1764 loadMemberRegistrations(session); 1765 } else { 1766 // User is not logged in 1767 if (authNavItem) authNavItem.style.display = 'block'; 1768 if (joinUsNavItem) joinUsNavItem.style.display = 'block'; 1769 if (memberNavItem) memberNavItem.style.display = 'none'; 1770 if (hostNavItem) hostNavItem.style.display = 'none'; 1771 if (adminNavItem) adminNavItem.style.display = 'none'; 1772 1773 if (mobileAuthBtn) { 1774 mobileAuthBtn.href = '/?login=1'; 1775 mobileAuthBtn.textContent = 'Log in'; 1776 mobileAuthBtn.dataset.mode = 'login'; 1777 } 1778 1779 if (registeredEventIds.size > 0) { 1780 registeredEventIds.clear(); 1781 if (allUpcomingEvents.length) renderEvents(); 1782 } 1783 } 1784} 1785 1786// Call on page load 1787document.addEventListener('DOMContentLoaded', checkAuthState); 1788 1789// Mobile header shortcut: open the login modal in place when there is one 1790document.addEventListener('DOMContentLoaded', () => { 1791 const mobileAuthBtn = document.getElementById('mobile-auth-btn'); 1792 if (!mobileAuthBtn) return; 1793 mobileAuthBtn.addEventListener('click', (e) => { 1794 if (mobileAuthBtn.dataset.mode === 'login' && document.getElementById('login-modal')) { 1795 e.preventDefault(); 1796 openLoginModal(); 1797 } 1798 }); 1799}); 1800 1801/** 1802 * Fetch the signed-in member's upcoming registrations so event cards can say 1803 * "You're registered" instead of offering a duplicate booking. 1804 */ 1805let memberRegistrationsLoaded = false;
1806async function loadMemberRegistrations(session) { 1807 if (memberRegistrationsLoaded || !session || !document.getElementById('events-container')) return; 1808 memberRegistrationsLoaded = true; 1809 try { 1810 const response = await fetch('/.netlify/functions/get-member-registrations', { 1811 method: 'GET', 1812 headers: { 'Authorization': `Bearer ${session.access_token}` }, 1813 }); 1814 if (!response.ok) return; 1815 const data = await response.json(); 1816 (data.upcomingEvents || []).forEach(reg => { 1817 if (reg && reg.eventId) registeredEventIds.add(String(reg.eventId)); 1818 }); 1819 if (registeredEventIds.size > 0 && allUpcomingEvents.length) renderEvents(); 1820 } catch (error) { 1821 // Non-essential: cards simply keep their Register buttons 1822 console.warn('Could not load member registrations:', error); 1823 } 1824} 1825 1826function markEventRegistered(eventId) { 1827 if (!eventId) return; 1828 registeredEventIds.add(String(eventId)); 1829 if (allUpcomingEvents.length) renderEvents(); 1830} 1831 1832// ========================================== 1833// SCROLL TO TOP BUTTON 1834// ========================================== 1835 1836const scrollToTopBtn = document.getElementById('scroll-to-top'); 1837 1838// Show/hide button based on scroll position 1839window.addEventListener('scroll', () => { 1840 if (scrollToTopBtn) { 1841 if (window.pageYOffset > 300) { 1842 scrollToTopBtn.classList.add('visible'); 1843 } else { 1844 scrollToTopBtn.classList.remove('visible'); 1845 } 1846 } 1847}); 1848 1849// Scroll to top when button is clicked 1850if (scrollToTopBtn) { 1851 scrollToTopBtn.addEventListener('click', () => { 1852 window.scrollTo({ 1853 top: 0, 1854 behavior: 'smooth' 1855 }); 1856 }); 1857} 1858 1859// ========================================== 1860// PENDING REGISTRATION & RETURNING MEMBERS 1861// ========================================== 1862const PENDING_REGISTRATION_KEY = 'pcg_pending_registration'; 1863const PENDING_REGISTRATION_TTL_MS = 24 * 60 * 60 * 1000; // allows time to confirm an email 1864const RETURNING_MEMBER_KEY = 'pcg_returning_member'; 1865 1866function setPendingRegistration(pending) { 1867 try { 1868 localStorage.setItem(PENDING_REGISTRATION_KEY, JSON.stringify({ ...pending, savedAt: Date.now() })); 1869 } catch (_) { 1870 // localStorage may be unavailable (private mode) â proceed anyway. 1871 } 1872} 1873 1874function getPendingRegistration() { 1875 try { 1876 const raw = localStorage.getItem(PENDING_REGISTRATION_KEY); 1877 if (!raw) return null; 1878 const pending = JSON.parse(raw); 1879 if (!pending || !pending.eventId) return null; 1880 if (pending.savedAt && Date.now() - pending.savedAt > PENDING_REGISTRATION_TTL_MS) { 1881 localStorage.removeItem(PENDING_REGISTRATION_KEY); 1882 return null; 1883 } 1884 return pending; 1885 } catch (_) { 1886 return null; 1887 } 1888} 1889 1890function clearPendingRegistration() { 1891 try { 1892 localStorage.removeItem(PENDING_REGISTRATION_KEY); 1893 } catch (_) { /* ignore */ } 1894} 1895 1896// Remember that this browser has signed in before, so the next Register tap 1897// opens "Log in" rather than "Create account". 1898function markReturningMember() { 1899 try { 1900 localStorage.setItem(RETURNING_MEMBER_KEY, '1'); 1901 } catch (_) { /* ignore */ } 1902} 1903 1904function isReturningMember() { 1905 try { 1906 return localStorage.getItem(RETURNING_MEMBER_KEY) === '1'; 1907 } catch (_) { 1908 return false; 1909 } 1910} 1911 1912/** 1913 * If the user clicked "Register" on an event before authenticating, re-open 1914 * the registration modal automatically once they're signed in. Returns true 1915 * if a pending registration was resumed, false otherwise. 1916 */ 1917function resumePendingRegistration(statusMessage) { 1918 const pending = getPendingRegistration(); 1919 if (!pending) return false; 1920 clearPendingRegistration(); 1921 1922 // Give the auth state a tick to settle, then open the modal. 1923 setTimeout(() => { 1924 openRegistrationModal( 1925 pending.eventId, 1926 pending.eventName, 1927 pending.eventDate, 1928 pending.eventLocation, 1929 pending.eventCountry 1930 ).then(() => { 1931 // Confirm inside the modal: a toast here would cover the button 1932 const modal = document.getElementById('registration-modal'); 1933 const status = document.getElementById('reg-status'); 1934 if (statusMessage && modal && modal.classList.contains('active') && status && status.hidden) { 1935 setFormStatus('reg-status', 'success', statusMessage); 1936 } 1937 }).catch(err => console.error('Failed to resume pending registration:', err)); 1938 }, 200); 1939 1940 return true; 1941} 1942 1943// ========================================== 1944// FORM HELPERS (auth + registration modals) 1945// ========================================== 1946function isValidEmail(email) { 1947 return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(email || '').trim()); 1948} 1949 1950/** 1951 * Show (or clear, when called with no message) an inline status message. 1952 * Text is always set with textContent. An optional action renders as a 1953 * button after the message, e.g. "Resend email". 1954 */ 1955function setFormStatus(elementId, type, message, action) { 1956 const el = document.getElementById(elementId); 1957 if (!el) return; 1958 el.replaceChildren(); 1959 if (!message) { 1960 el.hidden = true; 1961 el.className = 'form-status'; 1962 return; 1963 } 1964 el.className = `form-status form-status--${type || 'info'}`; 1965 const text = document.createElement('span'); 1966 text.textContent = message; 1967 el.appendChild(text); 1968 if (action && action.label && typeof action.onClick === 'function') { 1969 const btn = document.createElement('button'); 1970 btn.type = 'button'; 1971 btn.className = 'link-btn'; 1972 btn.textContent = action.label; 1973 btn.addEventListener('click', action.onClick); 1974 el.appendChild(document.createTextNode(' ')); 1975 el.appendChild(btn); 1976 } 1977 el.hidden = false;
1978} 1979 1980// Show which event the member is signing in for, so the detour to create an 1981// account doesn't feel like losing their place. 1982function renderAuthEventContext() { 1983 const pending = getPendingRegistration(); 1984 document.querySelectorAll('.auth-event-context').forEach(el => { 1985 el.replaceChildren(); 1986 if (!pending || !pending.eventName) { 1987 el.hidden = true; 1988 return; 1989 } 1990 const label = document.createElement('span'); 1991 label.className = 'auth-event-context__label'; 1992 label.textContent = 'Almost there! Your place at'; 1993 const name = document.createElement('strong'); 1994 name.className = 'auth-event-context__name'; 1995 name.textContent = pending.eventName; 1996 // Keep it to one short line: date, time and city rather than the 1997 // full venue address 1998 const record = allUpcomingEvents.find(ev => String(ev.id) === String(pending.eventId)); 1999 const time = record && record.startTime ? formatTime(record.startTime) : ''; 2000 const place = (record && getEventCity(record)) || pending.eventLocation; 2001 const meta = document.createElement('span'); 2002 meta.className = 'auth-event-context__meta'; 2003 meta.textContent = [pending.eventDate, time, place].filter(Boolean).join(' · '); 2004 el.append(label, name, meta); 2005 el.hidden = false; 2006 }); 2007 2008 const signupSubmit = document.getElementById('signup-submit'); 2009 if (signupSubmit && !signupSubmit.classList.contains('loading')) { 2010 signupSubmit.textContent = pending ? 'Create account & continue' : 'Create account'; 2011 } 2012 const signupTitle = document.getElementById('signup-title'); 2013 if (signupTitle) { 2014 signupTitle.textContent = pending ? 'Create a free account to book' : 'Create your free account'; 2015 } 2016 const loginTitle = document.getElementById('login-title'); 2017 if (loginTitle) { 2018 loginTitle.textContent = pending ? 'Log in to book your place' : 'Welcome back'; 2019 } 2020} 2021 2022// Focus the first field on desktop only: on phones it would pop the keyboard 2023// over the event summary before the member has read it. 2024function focusFirstField(modal) { 2025 if (!window.matchMedia || !window.matchMedia('(pointer: fine)').matches) return; 2026 const field = modal.querySelector('input:not([type="hidden"]):not([type="checkbox"])'); 2027 if (field) setTimeout(() => field.focus(), 50); 2028} 2029 2030// ========================================== 2031// LOGIN / SIGNUP MODALS 2032// ========================================== 2033function openLoginModal() { 2034 const modal = document.getElementById('login-modal'); 2035 if (!modal) { 2036 // Pages without the modal (e.g. thank-you.html) send people home to log in 2037 window.location.href = '/?login=1'; 2038 return; 2039 } 2040 renderAuthEventContext(); 2041 modal.classList.add('active'); 2042 document.body.style.overflow = 'hidden'; 2043 focusFirstField(modal); 2044} 2045 2046function closeLoginModal() { 2047 const modal = document.getElementById('login-modal'); 2048 if (!modal) return; 2049 modal.classList.remove('active'); 2050 document.body.style.overflow = ''; 2051 const form = document.getElementById('login-form'); 2052 if (form) { 2053 form.reset(); 2054 clearAllFieldErrors(form); 2055 } 2056 setFormStatus('login-status'); 2057} 2058 2059// Signup modal functions 2060function openSignupModal() { 2061 const modal = document.getElementById('signup-modal'); 2062 if (!modal) { 2063 window.location.href = '/?signup=1'; 2064 return; 2065 } 2066 renderAuthEventContext(); 2067 modal.classList.add('active'); 2068 document.body.style.overflow = 'hidden'; 2069 focusFirstField(modal); 2070} 2071 2072function closeSignupModal() { 2073 const modal = document.getElementById('signup-modal'); 2074 if (!modal) return; 2075 modal.classList.remove('active'); 2076 document.body.style.overflow = ''; 2077 resetSignupForm(); 2078} 2079 2080// Back to a blank sign-up form (also used by "Use a different email") 2081function resetSignupForm() { 2082 const form = document.getElementById('signup-form'); 2083 if (form) { 2084 form.reset(); 2085 clearAllFieldErrors(form); 2086 } 2087 setFormStatus('signup-status'); 2088 setFormStatus('signup-check-status'); 2089 const main = document.getElementById('signup-main'); 2090 const checkEmail = document.getElementById('signup-check-email'); 2091 if (main) main.hidden = false;
2092 if (checkEmail) checkEmail.hidden = true; 2093} 2094 2095// The X button / backdrop: the member is abandoning the booking, so forget it 2096function dismissAuthModal(which) { 2097 clearPendingRegistration(); 2098 if (which === 'signup') { 2099 closeSignupModal(); 2100 } else { 2101 closeLoginModal(); 2102 } 2103} 2104 2105// Move between "Log in" and "Create account", carrying the email across 2106function switchAuthModal(target) { 2107 const loginEmail = document.getElementById('login-email'); 2108 const signupEmail = document.getElementById('signup-email'); 2109 if (target === 'signup') { 2110 const email = loginEmail ? loginEmail.value.trim() : ''; 2111 closeLoginModal(); 2112 openSignupModal(); 2113 if (signupEmail && email) signupEmail.value = email; 2114 } else { 2115 const email = signupEmail ? signupEmail.value.trim() : ''; 2116 closeSignupModal(); 2117 openLoginModal(); 2118 if (loginEmail && email) loginEmail.value = email; 2119 } 2120} 2121 2122// Kept for older links: sign-up is now a single step 2123function goToSignupStep() { 2124 return true; 2125} 2126 2127// OAuth button handlers 2128async function loginWithGoogle() { 2129 try { 2130 const result = await signInWithGoogle(); 2131 if (!result.success && result.error) { 2132 showNotification('â ' + result.error, 'error'); 2133 } 2134 // Success will trigger OAuth redirect, no notification needed 2135 } catch (error) { 2136 console.error('Error:', error); 2137 showNotification('â An error occurred. Please try again.', 'error'); 2138 } 2139} 2140 2141async function loginWithLinkedIn() { 2142 try { 2143 const result = await signInWithLinkedIn(); 2144 if (!result.success && result.error) { 2145 showNotification('â ' + result.error, 'error'); 2146 } 2147 // Success will trigger OAuth redirect, no notification needed 2148 } catch (error) { 2149 console.error('Error:', error); 2150 showNotification('â An error occurred. Please try again.', 'error'); 2151 } 2152} 2153 2154async function signupWithGoogle() { 2155 return loginWithGoogle(); 2156} 2157 2158async function signupWithLinkedIn() { 2159 return loginWithLinkedIn(); 2160} 2161 2162// After any successful sign-in: continue the booking, or go to the dashboard 2163function finishSignIn(successMessage) { 2164 if (resumePendingRegistration(`${successMessage} Just confirm your place below.`)) { 2165 return; 2166 } else { 2167 showNotification(`â ${successMessage} Taking you to your dashboardâ¦`, 'success'); 2168 setTimeout(() => { 2169 window.location.href = 'dashboard.html'; 2170 }, 1000); 2171 } 2172} 2173 2174// "Email me a sign-in link" â passwordless login for existing members 2175async function handleSendLoginLink() { 2176 const emailInput = document.getElementById('login-email'); 2177 const linkBtn = document.getElementById('login-link-btn'); 2178 const loginForm = document.getElementById('login-form'); 2179 if (!emailInput) return; 2180 2181 clearAllFieldErrors(loginForm); 2182 setFormStatus('login-status'); 2183 2184 const email = emailInput.value.trim(); 2185 if (!isValidEmail(email)) { 2186 showFieldError(emailInput, 'Enter your email address and we\'ll send you a link'); 2187 emailInput.focus(); 2188 return; 2189 } 2190 2191 if (linkBtn) linkBtn.disabled = true; 2192 try { 2193 const result = await sendLoginLink(email); 2194 if (result.success) { 2195 setFormStatus('login-status', 'success', `Check your inbox: we've emailed a sign-in link to ${email}. Open it on this device to continue.`); 2196 } else if (result.code === 'no_account') { 2197 setFormStatus('login-status', 'error', result.error, { 2198 label: 'Create a free account', 2199 onClick: () => switchAuthModal('signup') 2200 }); 2201 } else { 2202 setFormStatus('login-status', 'error', result.error); 2203 } 2204 } finally { 2205 if (linkBtn) linkBtn.disabled = false; 2206 } 2207} 2208 2209async function handleResendConfirmation(emailOverride) { 2210 const email = emailOverride 2211 || document.getElementById('signup-check-email-address')?.textContent 2212 || ''; 2213 if (!email) return; 2214 const statusId = emailOverride ? 'login-status' : 'signup-check-status';
2215 setFormStatus(statusId, 'info', 'Sendingâ¦'); 2216 const result = await resendConfirmationEmail(email); 2217 if (result.success) { 2218 setFormStatus(statusId, 'success', `Sent! Check ${email} for the confirmation link.`); 2219 } else { 2220 setFormStatus(statusId, 'error', result.error); 2221 } 2222} 2223 2224function showSignupCheckEmail(email) { 2225 const main = document.getElementById('signup-main'); 2226 const checkEmail = document.getElementById('signup-check-email'); 2227 const address = document.getElementById('signup-check-email-address'); 2228 const next = document.getElementById('signup-check-email-next'); 2229 if (address) address.textContent = email; 2230 if (next) { 2231 next.textContent = getPendingRegistration() 2232 ? 'Tap the link to activate your account and we\'ll bring you straight back to finish your booking.' 2233 : 'Tap the link to activate your account and you\'re all set.'; 2234 } 2235 if (main) main.hidden = true; 2236 if (checkEmail) checkEmail.hidden = false; 2237 const content = document.querySelector('#signup-modal .modal-content'); 2238 if (content) content.scrollTop = 0; 2239} 2240 2241// Handle login form submission 2242document.addEventListener('DOMContentLoaded', () => { 2243 const loginForm = document.getElementById('login-form'); 2244 2245 if (loginForm) { 2246 loginForm.addEventListener('submit', async (e) => { 2247 e.preventDefault(); 2248 2249 clearAllFieldErrors(loginForm); 2250 setFormStatus('login-status'); 2251 2252 const emailInput = document.getElementById('login-email'); 2253 const passwordInput = document.getElementById('login-password'); 2254 const email = emailInput.value.trim(); 2255 const password = passwordInput.value; 2256 2257 if (!isValidEmail(email)) { 2258 showFieldError(emailInput, 'Please enter a valid email address'); 2259 emailInput.focus(); 2260 return; 2261 } 2262 if (!password) { 2263 showFieldError(passwordInput, 'Please enter your password'); 2264 passwordInput.focus(); 2265 return; 2266 } 2267 2268 const submitButton = loginForm.querySelector('button[type="submit"]'); 2269 setButtonLoading(submitButton, true); 2270 2271 try { 2272 const result = await signInWithEmail(email, password); 2273 2274 if (result.success) { 2275 markReturningMember(); 2276 closeLoginModal(); 2277 finishSignIn('Logged in!'); 2278 } else if (result.code === 'email_not_confirmed') { 2279 setFormStatus('login-status', 'error', result.error, { 2280 label: 'Resend confirmation email', 2281 onClick: () => handleResendConfirmation(email) 2282 }); 2283 } else if (result.code === 'invalid_credentials') { 2284 setFormStatus('login-status', 'error', `${result.error} Check for typos, reset your password, or use "Email me a sign-in link" below.`, { 2285 label: 'New here? Create an account', 2286 onClick: () => switchAuthModal('signup') 2287 }); 2288 } else { 2289 setFormStatus('login-status', 'error', result.error || 'Could not sign you in. Please try again.'); 2290 } 2291 } catch (error) { 2292 console.error('Error:', error); 2293 setFormStatus('login-status', 'error', 'Something went wrong. Please try again.'); 2294 } finally { 2295 setButtonLoading(submitButton, false); 2296 } 2297 }); 2298 } 2299}); 2300 2301// Handle signup form submission 2302document.addEventListener('DOMContentLoaded', () => { 2303 const signupForm = document.getElementById('signup-form'); 2304 2305 if (signupForm) { 2306 signupForm.addEventListener('submit', async (e) => { 2307 e.preventDefault(); 2308 2309 clearAllFieldErrors(signupForm); 2310 setFormStatus('signup-status'); 2311 2312 const nameInput = document.getElementById('signup-name'); 2313 const emailInput = document.getElementById('signup-email'); 2314 const passwordInput = document.getElementById('signup-password'); 2315 const companyInput = document.getElementById('signup-company'); 2316 const jobTitleInput = document.getElementById('signup-job-title'); 2317 const termsCheckbox = document.getElementById('signup-terms'); 2318 2319 const memberName = nameInput.value.trim(); 2320 const email = emailInput.value.trim(); 2321 const password = passwordInput.value; 2322 const companyOrganisation = companyInput.value.trim(); 2323 const jobTitle = jobTitleInput.value.trim(); 2324 2325 const problems = [];
2326 if (!memberName) problems.push([nameInput, 'Please enter your name']); 2327 if (!isValidEmail(email)) problems.push([emailInput, 'Please enter a valid email address']); 2328 if (password.length < 6) problems.push([passwordInput, 'Use at least 6 characters']); 2329 if (!companyOrganisation) problems.push([companyInput, 'Please enter your company or organisation']); 2330 if (!jobTitle) problems.push([jobTitleInput, 'Please enter your job title']); 2331 if (termsCheckbox && !termsCheckbox.checked) problems.push([termsCheckbox, 'Please accept the Terms & Conditions and Privacy Policy']); 2332 2333 if (problems.length > 0) { 2334 problems.forEach(([input, message]) => showFieldError(input, message)); 2335 problems[0][0].focus(); 2336 return; 2337 } 2338 2339 const submitButton = document.getElementById('signup-submit') || signupForm.querySelector('button[type="submit"]'); 2340 setButtonLoading(submitButton, true); 2341 2342 try { 2343 const result = await signUpWithEmail(email, password, { 2344 memberName, 2345 companyOrganisation, 2346 jobTitle, 2347 termsAccepted: true 2348 }); 2349 2350 if (!result.success) { 2351 if (result.alreadyRegistered) { 2352 // Send them to log in with the email already filled in 2353 switchAuthModal('login'); 2354 setFormStatus('login-status', 'info', 'You already have an account with that email. Log in below, or get a sign-in link by email if you\'ve forgotten your password.'); 2355 } else { 2356 setFormStatus('signup-status', 'error', result.error || 'Could not create your account. Please try again.'); 2357 } 2358 return; 2359 } 2360 2361 markReturningMember(); 2362 2363 // Email confirmation required: no session yet. The profile is 2364 // built from these details when they follow the link. 2365 if (result.needsConfirmation) { 2366 showSignupCheckEmail(email); 2367 return; 2368 } 2369 2370 const member = await ensureMemberProfile(result.session); 2371 closeSignupModal(); 2372 2373 if (!member) { 2374 showNotification('â ï¸ Your account is ready, but we couldn\'t save your details. You can add them from your dashboard.', 'error'); 2375 } 2376 finishSignIn('Account created!'); 2377 } catch (error) { 2378 console.error('Error:', error); 2379 setFormStatus('signup-status', 'error', 'Something went wrong. Please try again.'); 2380 } finally { 2381 setButtonLoading(submitButton, false); 2382 renderAuthEventContext(); 2383 } 2384 }); 2385 } 2386}); 2387 2388 2389// Handle password reset form submission 2390document.addEventListener('DOMContentLoaded', () => { 2391 const passwordResetForm = document.getElementById('password-reset-form'); 2392 2393 if (passwordResetForm) { 2394 passwordResetForm.addEventListener('submit', async (e) => { 2395 e.preventDefault(); 2396 2397 clearAllFieldErrors(passwordResetForm); 2398 setFormStatus('reset-status'); 2399 2400 const emailInput = document.getElementById('reset-email'); 2401 const email = emailInput.value.trim(); 2402 if (!isValidEmail(email)) { 2403 showFieldError(emailInput, 'Please enter a valid email address'); 2404 emailInput.focus(); 2405 return; 2406 } 2407 2408 const submitButton = passwordResetForm.querySelector('button[type="submit"]'); 2409 setButtonLoading(submitButton, true); 2410 2411 try { 2412 const result = await resetPassword(email); 2413 2414 if (result.success) { 2415 setFormStatus('reset-status', 'success', `If there's an account for ${email}, a reset link is on its way. Check your inbox.`); 2416 } else { 2417 setFormStatus('reset-status', 'error', result.error || 'Failed to send reset link'); 2418 } 2419 } catch (error) { 2420 console.error('Error:', error); 2421 setFormStatus('reset-status', 'error', 'Something went wrong. Please try again.'); 2422 } finally { 2423 setButtonLoading(submitButton, false); 2424 } 2425 }); 2426 } 2427}); 2428 2429// ========================================== 2430// MEMBER PROFILE CREATION 2431// ========================================== 2432 2433// Shared so the auth listener and the registration flow don't race each other 2434// into creating the profile (and sending the welcome email) twice. 2435let memberProfilePromise = null; 2436 2437/** 2438 * Make sure a signed-in user has a row in `members`, building it from the 2439 * details captured at sign-up (email) or supplied by Google/LinkedIn. 2440 * Returns the member, or null if it couldn't be created. 2441 */ 2442async function ensureMemberProfile(session) { 2443 if (!session || !session.user) return null; 2444 if (memberProfilePromise) return memberProfilePromise; 2445 2446 memberProfilePromise = (async () => { 2447 const { member } = await getCurrentSession(); 2448 if (member) return member; 2449 2450 const metadata = session.user.user_metadata || {}; 2451 2452 // Profile photo - LinkedIn OIDC uses 'picture', Google uses 'avatar_url' or 'picture' 2453 const profilePhoto = metadata.picture || 2454 metadata.avatar_url || 2455 metadata.profile_image_url || 2456 null; 2457 2458 // Full name - LinkedIn OIDC uses 'name', can also construct from given_name + family_name 2459 const memberName = metadata.full_name || 2460 metadata.name || 2461 (metadata.given_name && metadata.family_name ? 2462 `${metadata.given_name} ${metadata.family_name}` : null) || 2463 session.user.email?.split('@')[0] || 2464 'User'; 2465 2466 // Company and job title come from our own sign-up form. LinkedIn OIDC 2467 // doesn't provide them, so OAuth members add them when they register. 2468 const profileResult = await saveMemberProfile({ 2469 memberName: memberName, 2470 phone: null, 2471 companyOrganisation: metadata.company || null, 2472 jobTitle: metadata.job_title || null, 2473 linkedinProfile: null, 2474 profilePhoto: profilePhoto 2475 }); 2476 2477 if (!profileResult.success) { 2478 console.error('Failed to create member profile:', profileResult.error); 2479 return null; 2480 } 2481 2482 // Send welcome email (don't fail the sign-up if it doesn't send) 2483 // keepalive lets the request finish if we redirect straight away. 2484 // Recipient and name are taken server-side from the verified session 2485 // and member row. 2486 fetch('/.netlify/functions/send-welcome-email', { 2487 method: 'POST', 2488 keepalive: true, 2489 headers: { 2490 'Authorization': `Bearer ${session.access_token}`, 2491 }, 2492 }).catch(emailError => console.error('Error sending welcome email:', emailError)); 2493 2494 return profileResult.member; 2495 })(); 2496 2497 try { 2498 return await memberProfilePromise; 2499 } finally { 2500 memberProfilePromise = null; 2501 } 2502} 2503 2504// Handle Supabase auth state changes 2505document.addEventListener('DOMContentLoaded', () => { 2506 onAuthStateChange(async (event, session) => { 2507 if (event === 'SIGNED_IN' && session) { 2508 // User logged in (password, email link or OAuth). Create their 2509 // profile if this is their first sign-in. 2510 await ensureMemberProfile(session); 2511
2512 // Update UI to show dashboard links 2513 checkAuthState(); 2514 } else if (event === 'SIGNED_OUT') { 2515 // User signed out 2516 checkAuthState(); // Update UI 2517 } 2518 }); 2519}); 2520 2521// ========================================== 2522// GALLERY - LOAD AND DISPLAY EVENT ALBUMS 2523// ========================================== 2524 2525// Global variable to store events data 2526let eventsData = []; 2527 2528async function loadGallery() { 2529 const galleryLoading = document.getElementById('gallery-loading'); 2530 const galleryAlbumsContainer = document.getElementById('gallery-albums-container'); 2531 const galleryEmpty = document.getElementById('gallery-empty'); 2532 const galleryUploadSection = document.getElementById('gallery-upload-section'); 2533 2534 if (!galleryAlbumsContainer) return; 2535 2536 try { 2537 // Show upload button if logged in 2538 const sessionToken = getSessionToken(); 2539 if (sessionToken) { 2540 if (galleryUploadSection) galleryUploadSection.style.display = 'block'; 2541 } 2542 2543 // Fetch past events with photos 2544 const response = await fetch('/.netlify/functions/get-past-events'); 2545 const data = await response.json(); 2546 2547 if (galleryLoading) galleryLoading.style.display = 'none'; 2548 2549 if (data.success && data.events.length > 0) { 2550 eventsData = data.events; // Store for album viewer 2551 displayEventAlbums(data.events); 2552 galleryAlbumsContainer.style.display = 'grid'; 2553 if (galleryEmpty) galleryEmpty.style.display = 'none'; 2554 } else { 2555 if (galleryEmpty) galleryEmpty.style.display = 'block'; 2556 } 2557 } catch (error) { 2558 console.error('Error loading gallery:', error); 2559 if (galleryLoading) galleryLoading.style.display = 'none'; 2560 if (galleryEmpty) { 2561 galleryEmpty.innerHTML = '<p>Failed to load gallery. Please try again later.</p>'; 2562 galleryEmpty.style.display = 'block'; 2563 } 2564 } 2565} 2566 2567function displayEventAlbums(events) { 2568 const galleryAlbumsContainer = document.getElementById('gallery-albums-container'); 2569 if (!galleryAlbumsContainer) return; 2570 2571 // Filter to only show events that have photos 2572 const eventsWithPhotos = events.filter(event => event.photos && event.photos.length > 0); 2573 2574 if (eventsWithPhotos.length === 0) { 2575 // Show empty state if no events have photos 2576 const galleryEmpty = document.getElementById('gallery-empty'); 2577 if (galleryEmpty) galleryEmpty.style.display = 'block'; 2578 galleryAlbumsContainer.style.display = 'none'; 2579 return; 2580 } 2581 2582 let albumsHTML = ''; 2583 2584 eventsWithPhotos.forEach(event => { 2585 const eventDate = new Date(event.eventDate); 2586 const dateFormatted = eventDate.toLocaleDateString('en-US', { 2587 day: 'numeric', 2588 month: 'short', 2589 year: 'numeric' 2590 }); 2591 2592 const photoCount = event.photos.length; 2593 const coverPhoto = event.photos[0]; // First photo as album cover 2594 2595 albumsHTML += ` 2596 <div class="gallery-item album-cover" onclick="openAlbumViewer('${event.id}', '${event.eventName.replace(/'/g, "\\'")}', '${event.location}', '${event.country}', '${dateFormatted}')"> 2597 <img src="${coverPhoto.url}" alt="${event.eventName}" loading="lazy"> 2598 <div class="gallery-overlay"> 2599 <h4>${event.location}</h4> 2600 <p>${dateFormatted}</p> 2601 </div> 2602 </div> 2603 `; 2604 }); 2605 2606 galleryAlbumsContainer.innerHTML = albumsHTML; 2607} 2608 2609// ========================================== 2610// ALBUM VIEWER MODAL 2611// ========================================== 2612 2613async function openAlbumViewer(eventId, eventName, location, country, date) { 2614 const modal = document.getElementById('album-viewer-modal'); 2615 if (!modal) return; 2616 2617 // Set event details 2618 document.getElementById('album-event-name').textContent = eventName; 2619 document.getElementById('album-event-details').textContent = `${location}, ${country} ⢠${date}`; 2620 2621 // Find event data 2622 const event = eventsData.find(e => e.id === eventId); 2623 if (!event) return; 2624 2625 // Display photos with comment sections 2626 await displayAlbumPhotos(event); 2627 2628 modal.classList.add('active'); 2629 document.body.style.overflow = 'hidden'; 2630} 2631 2632function closeAlbumViewer() { 2633 const modal = document.getElementById('album-viewer-modal'); 2634 if (!modal) return; 2635 2636 modal.classList.remove('active'); 2637 document.body.style.overflow = ''; 2638} 2639 2640async function displayAlbumPhotos(event) { 2641 const container = document.getElementById('album-photos-container'); 2642 if (!container) return; 2643 2644 // Check if user is logged in using Supabase 2645 const { data: { session } } = await supabaseClient.auth.getSession(); 2646 const isLoggedIn = !!session; 2647 2648 let photosHTML = ''; 2649 2650 event.photos.forEach(photo => { 2651 photosHTML += ` 2652 <div class="album-photo-item" data-photo-url="${photo.url}"> 2653 <img src="${photo.url}" alt="${event.eventName}" loading="lazy"> 2654 2655 <div class="photo-comments-section"> 2656 <h4>Comments</h4> 2657 <div class="comments-list" id="comments-${photo.id}"> 2658 <!-- Comments will load here --> 2659 </div> 2660 ${isLoggedIn ? ` 2661 <div class="add-comment-form"> 2662 <textarea class="comment-input" placeholder="Add a comment..." rows="2"></textarea> 2663 <button class="btn-primary btn-sm" onclick="addPhotoComment('${event.id}', '${photo.url}', this)">Post Comment</button> 2664 </div> 2665 ` : ` 2666 <p style="color: var(--text-secondary); font-size: 14px; margin-top: 1rem;"> 2667 <a href="javascript:void(0)" onclick="closeAlbumViewer(); openLoginModal();">Login</a> to comment on photos 2668 </p> 2669 `} 2670 </div> 2671 </div> 2672 `; 2673 }); 2674 2675 container.innerHTML = photosHTML; 2676 2677 // Load comments for each photo 2678 event.photos.forEach(photo => { 2679 loadPhotoComments(event.id, photo); 2680 }); 2681} 2682 2683async function loadPhotoComments(eventId, photo) { 2684 const commentsList = document.getElementById(`comments-${photo.id}`); 2685 if (!commentsList) return; 2686 2687 try { 2688 const response = await fetch(`/.netlify/functions/get-photo-comments?eventId=${eventId}`); 2689 const data = await response.json(); 2690 2691 if (data.success) { 2692 // Filter comments for this specific photo 2693 const photoComments = data.comments.filter(c => c.photoUrl === photo.url); 2694 2695 if (photoComments.length === 0) { 2696 commentsList.innerHTML = '<p style="color: var(--text-secondary); font-size: 14px;">No comments yet. Be the first to comment!</p>'; 2697 } else { 2698 let commentsHTML = '';
2699 photoComments.forEach(comment => { 2700 const commentDate = new Date(comment.createdDate); 2701 const dateStr = commentDate.toLocaleDateString('en-US', { 2702 month: 'short', 2703 day: 'numeric', 2704 year: 'numeric', 2705 hour: '2-digit', 2706 minute: '2-digit' 2707 }); 2708 2709 commentsHTML += ` 2710 <div class="comment-item"> 2711 <strong>${escapeHtml(comment.memberName)}</strong> 2712 <span class="comment-date">${dateStr}</span> 2713 <p>${escapeHtml(comment.commentText)}</p> 2714 </div> 2715 `; 2716 }); 2717 commentsList.innerHTML = commentsHTML; 2718 } 2719 } 2720 } catch (error) { 2721 console.error('Error loading comments:', error); 2722 commentsList.innerHTML = '<p style="color: red; font-size: 14px;">Failed to load comments</p>'; 2723 } 2724} 2725 2726async function addPhotoComment(eventId, photoUrl, button) { 2727 const commentInput = button.previousElementSibling; 2728 const commentText = commentInput.value.trim(); 2729 2730 if (!commentText) { 2731 showNotification('â Please enter a comment', 'error'); 2732 return; 2733 } 2734 2735 const sessionToken = getSessionToken(); 2736 if (!sessionToken) { 2737 showNotification('â Please log in to comment', 'error'); 2738 return; 2739 } 2740 2741 const originalButtonText = button.textContent; 2742 button.disabled = true; 2743 button.textContent = 'Posting...'; 2744 2745 try { 2746 const response = await fetch('/.netlify/functions/add-photo-comment', { 2747 method: 'POST', 2748 headers: { 2749 'Authorization': `Bearer ${sessionToken}`, 2750 'Content-Type': 'application/json', 2751 }, 2752 body: JSON.stringify({ 2753 eventId: eventId, 2754 photoUrl: photoUrl, 2755 commentText: commentText, 2756 }), 2757 }); 2758 2759 const data = await response.json(); 2760 2761 if (response.status === 200) { 2762 showNotification('â Comment added!', 'success'); 2763 commentInput.value = ''; 2764 2765 // Reload comments for this photo 2766 const event = eventsData.find(e => e.id === eventId); 2767 const photo = event.photos.find(p => p.url === photoUrl); 2768 if (photo) { 2769 await loadPhotoComments(eventId, photo); 2770 } 2771 } else { 2772 showNotification('â ' + (data.message || data.error || 'Failed to add comment'), 'error'); 2773 } 2774 } catch (error) { 2775 console.error('Error:', error); 2776 showNotification('â An error occurred. Please try again.', 'error'); 2777 } finally { 2778 button.disabled = false; 2779 button.textContent = originalButtonText; 2780 } 2781} 2782 2783// ========================================== 2784// PHOTO UPLOAD MODAL 2785// ========================================== 2786 2787function openPhotoUploadModal() { 2788 const modal = document.getElementById('photo-upload-modal'); 2789 if (!modal) return; 2790 2791 modal.classList.add('active'); 2792 document.body.style.overflow = 'hidden'; 2793 2794 // Load past events for selection 2795 loadPastEventsForUpload(); 2796} 2797 2798function closePhotoUploadModal() { 2799 const modal = document.getElementById('photo-upload-modal'); 2800 if (!modal) return; 2801 2802 modal.classList.remove('active'); 2803 document.body.style.overflow = ''; 2804 2805 // Reset form 2806 const form = document.getElementById('photo-upload-form'); 2807 if (form) form.reset(); 2808 2809 // Hide preview 2810 const previewContainer = document.getElementById('photo-preview-container'); 2811 if (previewContainer) previewContainer.style.display = 'none'; 2812} 2813 2814async function loadPastEventsForUpload() { 2815 const select = document.getElementById('photo-event-select'); 2816 if (!select) return; 2817 2818 try { 2819 // Include events without photos for upload dropdown 2820 const response = await fetch('/.netlify/functions/get-past-events?includeEmpty=true'); 2821 const data = await response.json(); 2822 2823 if (data.success && data.events.length > 0) { 2824 select.innerHTML = '<option value="">Select an event...</option>' + 2825 data.events.map(event => { 2826 const eventDate = new Date(event.eventDate); 2827 const dateFormatted = eventDate.toLocaleDateString('en-US', { 2828 day: 'numeric', 2829 month: 'short', 2830 year: 'numeric' 2831 }); 2832 return `<option value="${event.id}">${event.eventName} - ${dateFormatted}</option>`; 2833 }).join(''); 2834 } else { 2835 select.innerHTML = '<option value="">No past events available</option>'; 2836 } 2837 } catch (error) { 2838 console.error('Error loading events:', error); 2839 select.innerHTML = '<option value="">Failed to load events</option>'; 2840 } 2841} 2842 2843// Handle photo file selection and preview 2844document.addEventListener('DOMContentLoaded', () => { 2845 const photoFileInput = document.getElementById('photo-file'); 2846 const photoPreview = document.getElementById('photo-preview'); 2847 const photoPreviewContainer = document.getElementById('photo-preview-container'); 2848 2849 if (photoFileInput) { 2850 photoFileInput.addEventListener('change', (e) => { 2851 const file = e.target.files[0]; 2852 if (file && photoPreview && photoPreviewContainer) { 2853 const reader = new FileReader(); 2854 reader.onload = (event) => { 2855 photoPreview.src = event.target.result; 2856 photoPreviewContainer.style.display = 'block'; 2857 };
2858 reader.readAsDataURL(file); 2859 } 2860 }); 2861 } 2862 2863 // Handle photo upload form submission 2864 const photoUploadForm = document.getElementById('photo-upload-form'); 2865 if (photoUploadForm) { 2866 photoUploadForm.addEventListener('submit', async (e) => { 2867 e.preventDefault(); 2868 2869 const submitButton = photoUploadForm.querySelector('button[type="submit"]'); 2870 const originalButtonText = submitButton.textContent; 2871 submitButton.disabled = true; 2872 submitButton.textContent = 'Uploading...'; 2873 2874 const eventId = document.getElementById('photo-event-select').value; 2875 const photoFile = document.getElementById('photo-file').files[0]; 2876 2877 if (!eventId || !photoFile) { 2878 showNotification('â Please select an event and a photo', 'error'); 2879 submitButton.disabled = false; 2880 submitButton.textContent = originalButtonText; 2881 return; 2882 } 2883 2884 // Check file size (max 5MB) 2885 if (photoFile.size > 5 * 1024 * 1024) { 2886 showNotification('â Photo must be less than 5MB', 'error'); 2887 submitButton.disabled = false; 2888 submitButton.textContent = originalButtonText; 2889 return; 2890 } 2891 2892 // Convert to base64 2893 const reader = new FileReader(); 2894 reader.readAsDataURL(photoFile); 2895 2896 reader.onload = async () => { 2897 const photoBase64 = reader.result; 2898 const sessionToken = getSessionToken(); 2899 2900 try { 2901 const response = await fetch('/.netlify/functions/upload-event-photo', { 2902 method: 'POST', 2903 headers: { 2904 'Authorization': `Bearer ${sessionToken}`, 2905 'Content-Type': 'application/json', 2906 }, 2907 body: JSON.stringify({ 2908 eventId: eventId, 2909 photoBase64: photoBase64, 2910 }), 2911 }); 2912 2913 const data = await response.json(); 2914 2915 if (response.status === 200) { 2916 showNotification('â Photo uploaded successfully! Your name has been added to the photo.', 'success'); 2917 closePhotoUploadModal(); 2918 2919 // Reload gallery to show new photo 2920 setTimeout(() => { 2921 loadGallery(); 2922 }, 1000); 2923 } else if (response.status === 500 && data.error === 'Image hosting not configured') { 2924 showNotification('â Image hosting not configured. Please contact the administrator.', 'error'); 2925 console.error('IMGBB_API_KEY not set in environment variables'); 2926 } else { 2927 showNotification('â ' + (data.message || data.error || 'Failed to upload photo'), 'error'); 2928 } 2929 } catch (error) { 2930 console.error('Error:', error); 2931 showNotification('â An error occurred. Please try again.', 'error'); 2932 } finally { 2933 submitButton.disabled = false; 2934 submitButton.textContent = originalButtonText; 2935 } 2936 }; 2937 2938 reader.onerror = () => { 2939 showNotification('â Failed to read photo file', 'error'); 2940 submitButton.disabled = false; 2941 submitButton.textContent = originalButtonText; 2942 }; 2943 }); 2944 } 2945 2946 // Load gallery on page load 2947 loadGallery(); 2948}); 2949 2950// ========================================== 2951// MEMBER SEARCH & MESSAGING 2952// ========================================== 2953// Search and the send-message form are handled in dashboard.js (via the 2954// search-members and send-member-message Netlify functions). Don't bind 2955// them here as well: dashboard.html loads both scripts. 2956 2957function closeSendMessageModal() { 2958 const modal = document.getElementById('send-message-modal'); 2959 if (!modal) return; 2960 2961 modal.classList.remove('active'); 2962 document.body.style.overflow = ''; 2963 2964 // Reset form 2965 const form = document.getElementById('send-message-form'); 2966 if (form) form.reset(); 2967} 2968 2969// ========================================== 2970// TERMS & CONDITIONS AND PRIVACY MODALS 2971// ========================================== 2972function openTermsModal() { 2973 const modal = document.getElementById('terms-modal'); 2974 modal.classList.add('active'); 2975 document.body.style.overflow = 'hidden'; 2976} 2977 2978function closeTermsModal() { 2979 const modal = document.getElementById('terms-modal'); 2980 modal.classList.remove('active'); 2981 document.body.style.overflow = ''; 2982} 2983 2984function openPrivacyModal() { 2985 const modal = document.getElementById('privacy-modal'); 2986 modal.classList.add('active'); 2987 document.body.style.overflow = 'hidden'; 2988} 2989 2990function closePrivacyModal() { 2991 const modal = document.getElementById('privacy-modal'); 2992 modal.classList.remove('active'); 2993 document.body.style.overflow = ''; 2994} 2995 2996function openPasswordResetModal() { 2997 const modal = document.getElementById('password-reset-modal'); 2998 modal.classList.add('active'); 2999 document.body.style.overflow = 'hidden'; 3000 // Close login modal if it's open 3001 closeLoginModal(); 3002} 3003 3004function closePasswordResetModal() { 3005 const modal = document.getElementById('password-reset-modal'); 3006 modal.classList.remove('active'); 3007 document.body.style.overflow = ''; 3008 document.getElementById('password-reset-form').reset(); 3009} 3010 3011// ========================================== 3012// TIMELINE COLLAPSE/EXPAND 3013// ========================================== 3014const timelineToggle = document.getElementById('timeline-toggle'); 3015const milestonesContainer = document.getElementById('milestones-container'); 3016 3017if (timelineToggle && milestonesContainer) { 3018 timelineToggle.addEventListener('click', () => { 3019 const isExpanded = timelineToggle.getAttribute('aria-expanded') === 'true'; 3020 3021 if (isExpanded) { 3022 // Collapse 3023 milestonesContainer.classList.add('collapsed'); 3024 timelineToggle.setAttribute('aria-expanded', 'false'); 3025 timelineToggle.querySelector('.toggle-text').textContent = 'Show Full Timeline'; 3026 } else { 3027 // Expand 3028 milestonesContainer.classList.remove('collapsed'); 3029 timelineToggle.setAttribute('aria-expanded', 'true'); 3030 timelineToggle.querySelector('.toggle-text').textContent = 'Hide Timeline'; 3031 } 3032 }); 3033} 3034 3035// ========================================== 3036// CONSOLE MESSAGE 3037// ========================================== 3038console.log('%cð Project Connect Group', 'font-size: 24px; font-weight: bold; color: #00bcd4;'); 3039console.log('%c#PayItForward #FindAMentor #BreakingPaywalls', 'font-size: 14px; color: #00bcd4;'); 3040console.log('Interested in how this site was built? Get in touch with us!');
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.