1<!DOCTYPE html> 2<html lang="en"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Venue - GrassGigs</title> 7 <meta name="description" content="View upcoming bluegrass and grassroots music events at this venue"> 8 <meta name="robots" content="noindex, follow" id="robots-meta"> 9 10 <!-- Canonical URL - will be set dynamically --> 11 <link rel="canonical" href="https://grassgigs.com/venue/" id="canonical-url"> 12 13 <!-- Icons --> 14 <link rel="icon" type="image/png" href="pwa-base-bg-transparent-192.png"> 15 16 <!-- Fonts --> 17 <link rel="preconnect" href="https://fonts.googleapis.com"> 18 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 19 <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Lora:wght@500;600;700&display=swap" media="print" onload="this.media='all'"> 20 <noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Lora:wght@500;600;700&display=swap"></noscript> 21 22 <!-- Leaflet CSS is loaded lazily via ensureLeaflet() when a map is needed --> 23 24 <!-- Main Styles --> 25 <link rel="stylesheet" href="/style.css"> 26</head> 27<body> 28 <!-- Background art (loaded from shared file) --> 29 <div id="bg-art-placeholder"></div> 30
30<script src="/bg-art-loader.js"></script>
30 31 32 <!-- Shared Header --> 33 <div id="header-placeholder"></div> 34
34<script src="/header-loader.js"></script>
34 35 36 <main class="how-it-works-page"> 37 <!-- Venue Details Section --> 38 <section style="text-align: center; padding: 2rem 2rem 1rem 2rem;"> 39 <h2 style="font-size: 1.5rem; color: #cbd5e1; margin-bottom: 2rem;">Venue Details</h2> 40 </section> 41 42 <!-- Hero Section with Venue Info --> 43 <section class="hero-section-hiw"> 44 <h1 id="venue-name"><span class="location-pulse"></span> Loading...</h1> 45 <p class="subtitle"> 46 <span id="venue-city-state"></span> 47 </p> 48 <p class="subtitle" id="venue-address" style="font-size: 1rem; margin-top: 0.5rem;"></p> 49 <p class="subtitle" id="venue-festival-link" style="display: none;"></p> 50 51 <!-- Venue Profile Image --> 52 <div id="venue-profile-image-section" style="display: none; margin-top: 1.5rem;"> 53 <img id="venue-profile-image" src="" alt="Venue" style="max-width: 600px; width: 100%; height: auto; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.3); border: 2px solid rgba(5, 150, 105, 0.3); cursor: pointer;" onclick="openLightbox(this.src)"> 54 </div> 55 56 <!-- Venue Stats --> 57 <div id="venue-stats" style="display: none; gap: 2rem; justify-content: center; margin-top: 1.5rem; flex-wrap: wrap;"> 58 <div style="text-align: center;"> 59 <div id="stat-upcoming" style="font-size: 1.5rem; font-weight: 700; color: #10b981;">-</div> 60 <div style="font-size: 0.85rem; color: #94a3b8;">Upcoming Shows</div> 61 </div> 62 <div style="text-align: center;"> 63 <div id="stat-bands" style="font-size: 1.5rem; font-weight: 700; color: #10b981;">-</div> 64 <div style="font-size: 0.85rem; color: #94a3b8;">Artists</div> 65 </div> 66 </div> 67 68 <!-- Venue Description --> 69 <div id="venue-description-section" style="display: none; margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid rgba(5, 150, 105, 0.2);"> 70 <p id="venue-description" style="color: #cbd5e1; line-height: 1.6; max-width: 600px; margin: 0 auto;"></p> 71 </div> 72 73 <!-- Venue Gallery Images --> 74 <div id="venue-gallery-section" style="display: none; margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid rgba(5, 150, 105, 0.2);"> 75 <h3 style="color: #10b981; margin-bottom: 1rem; font-size: 1.2rem;">Gallery</h3> 76 <div id="venue-gallery" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; max-width: 800px; margin: 0 auto;"></div> 77 </div> 78 79 <!-- Contact & Social Links --> 80 <div id="venue-links-section" style="display: none; margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid rgba(5, 150, 105, 0.2);"> 81 <div style="display: flex; flex-wrap: wrap; gap: 1rem; justify-content: center; align-items: center;"> 82 <a id="venue-website" href="#" target="_blank" style="display: none; color: #10b981; text-decoration: none; font-weight: 500;"> 83 <img src="/custom-link.png" alt="" class="custom-emoji"> Website 84 </a> 85 <a id="venue-email" href="#" style="display: none; color: #10b981; text-decoration: none; font-weight: 500;"> 86 âï¸ Email 87 </a> 88 <a id="venue-phone" href="#" style="display: none; color: #10b981; text-decoration: none; font-weight: 500;"> 89 ð Phone 90 </a> 91 <a id="venue-facebook" href="#" target="_blank" style="display: none; color: #10b981; text-decoration: none; font-weight: 500;"> 92 Facebook 93 </a> 94 <a id="venue-instagram" href="#" target="_blank" style="display: none; color: #10b981; text-decoration: none; font-weight: 500;"> 95 Instagram 96 </a> 97 <a id="venue-youtube" href="#" target="_blank" style="display: none; color: #10b981; text-decoration: none; font-weight: 500;"> 98 YouTube 99 </a> 100 <a id="venue-twitter" href="#" target="_blank" style="display: none; color: #10b981; text-decoration: none; font-weight: 500;"> 101 Twitter/X 102 </a> 103 <a id="venue-tiktok" href="#" target="_blank" style="display: none; color: #10b981; text-decoration: none; font-weight: 500;"> 104 TikTok 105 </a> 106 <a id="venue-bandcamp" href="#" target="_blank" style="display: none; color: #10b981; text-decoration: none; font-weight: 500;"> 107 Bandcamp 108 </a> 109 </div> 110 </div> 111 112 <!-- Manage Venue CTA (for logged-in owners) --> 113 <div id="manage-venue-section" style="display: none; margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid rgba(5, 150, 105, 0.2);"> 114 <a href="/venue-manage.html" style="display: inline-block; padding: 0.75rem 1.5rem; background: linear-gradient(135deg, #059669 0%, #0ea5e9 100%); color: #ffffff !important; text-decoration: none; border-radius: 8px; font-weight: 600; transition: transform 0.2s, box-shadow 0.2s; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);"> 115 Manage This Venue â 116 </a> 117 </div> 118 119 <!-- Request Login Link (for claimed venues) --> 120 <div id="request-login-section" style="display: none; margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid rgba(5, 150, 105, 0.2);"> 121 <p style="font-size: 0.95rem; color: #cbd5e1; margin-bottom: 0.75rem;"> 122 ð Venue Owner? Manage your listing on GrassGigs 123 </p> 124 <a href="#" onclick="requestLoginLink(); return false;" style="display: inline-block; padding: 0.6rem 1.25rem; background: linear-gradient(135deg, #059669 0%, #0ea5e9 100%); color: #ffffff !important; -webkit-text-fill-color: #ffffff !important; text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 0.9rem; transition: transform 0.2s;"> 125 Get Your Login Link â 126 </a> 127 <div id="login-link-message" style="display: none; margin-top: 0.5rem; padding: 0.75rem; border-radius: 8px;"></div> 128 </div> 129
130 <!-- Claim Venue CTA --> 131 <div id="claim-venue-section" style="margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid rgba(5, 150, 105, 0.2);"> 132 <p style="font-size: 0.9rem; color: #94a3b8; margin-bottom: 0.5rem;"> 133 Are you affiliated with this venue? 134 </p> 135 <a href="#" onclick="showClaimModal(); return false;" style="color: #10b981; text-decoration: none; font-weight: 500; font-size: 0.95rem; transition: color 0.2s;"> 136 Claim this page â 137 </a> 138 </div> 139 </section> 140 141 <!-- Map Section --> 142 <section class="content-section"> 143 <div id="venue-map" style="width: 100%; height: 400px; border-radius: 20px; margin-bottom: 3rem; box-shadow: 0 20px 40px rgba(0,0,0,0.2); border: 1px solid rgba(5, 150, 105, 0.2);"></div> 144 </section> 145 146 <!-- Events Section --> 147 <section class="content-section"> 148 <div style="background: rgba(15, 23, 42, 0.8); backdrop-filter: blur(10px); border-radius: 20px; padding: 2rem; box-shadow: 0 20px 40px rgba(0,0,0,0.2); border: 1px solid rgba(5, 150, 105, 0.1);"> 149 <h2 style="color: #10b981; margin-bottom: 1.5rem; font-size: 1.5rem;">Upcoming Events</h2> 150 <div id="events-list"> 151 <div class="skeleton-container"> 152 <div class="skeleton-card"><div class="skeleton-line skeleton-line-band"></div><div class="skeleton-line skeleton-line-venue"></div><div class="skeleton-line skeleton-line-date"></div></div> 153 <div class="skeleton-card"><div class="skeleton-line skeleton-line-band"></div><div class="skeleton-line skeleton-line-venue"></div><div class="skeleton-line skeleton-line-date"></div></div> 154 <div class="skeleton-card"><div class="skeleton-line skeleton-line-band"></div><div class="skeleton-line skeleton-line-venue"></div><div class="skeleton-line skeleton-line-date"></div></div> 155 </div> 156 </div> 157 </div> 158 </section> 159 160 <!-- Past Events Section --> 161 <section class="content-section" id="past-events-section" style="display: none; margin-top: 1.5rem;"> 162 <div style="background: rgba(15, 23, 42, 0.8); backdrop-filter: blur(10px); border-radius: 20px; padding: 2rem; box-shadow: 0 20px 40px rgba(0,0,0,0.2); border: 1px solid rgba(5, 150, 105, 0.1);"> 163 <h2 style="color: #94a3b8; margin-bottom: 1rem; font-size: 1.25rem;">Recent Performers</h2> 164 <div id="past-events-list"></div> 165 </div> 166 </section> 167 168 <!-- Subscribe CTA --> 169 <section class="content-section" style="margin-top: 2rem;"> 170 <div style="background: rgba(15, 23, 42, 0.8); backdrop-filter: blur(10px); border-radius: 20px; padding: 2rem; box-shadow: 0 20px 40px rgba(0,0,0,0.2); border: 1px solid rgba(5, 150, 105, 0.1); text-align: center;"> 171 <h2 id="venue-subscribe-heading" style="color: #10b981; margin-bottom: 0.5rem; font-size: 1.3rem;">Get Notified About Shows Here</h2> 172 <p id="venue-subscribe-text" style="color: #cbd5e1; margin-bottom: 1rem;">Be the first to know when new events are announced at this venue</p> 173 <a id="venue-subscribe-link" href="#" style="display: inline-block; padding: 0.75rem 1.5rem; background: linear-gradient(135deg, #059669 0%, #0ea5e9 100%); color: #ffffff !important; text-decoration: none; border-radius: 8px; font-weight: 600; transition: transform 0.2s, box-shadow 0.2s; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);"> 174 ð§ Subscribe to Updates 175 </a> 176 <p style="color: #64748b; font-size: 0.85rem; margin-top: 0.75rem;">Free · Unsubscribe anytime</p> 177 </div> 178 </section> 179 </main> 180 181 <!-- Shared Footer --> 182 <div id="footer-placeholder"></div> 183 184 <!-- Claim Venue Modal --> 185 <div id="claimVenueModal" class="modal" style="display: none;"> 186 <div class="modal-content" style="max-width: 500px;">
187 <span class="close" onclick="closeClaimModal()">×</span> 188 189 <!-- Form View --> 190 <div id="claimFormView"> 191 <h2 style="color: #10b981; margin-bottom: 1rem;">Claim This Venue</h2> 192 <p style="color: #cbd5e1; line-height: 1.6; margin-bottom: 1.5rem;"> 193 Submit a request to claim this venue page. We'll review your request and contact you via email. 194 </p> 195 196 <form id="claimVenueForm"> 197 <input type="hidden" id="claimVenueId" name="venue_id"> 198 <input type="hidden" id="claimVenueName" name="venue_name"> 199 200 <label for="claimName" style="color: #cbd5e1; display: block; margin-bottom: 0.5rem; font-weight: 500;">Your Name *</label> 201 <input type="text" id="claimName" name="name" required style="width: 100%; padding: 0.75rem; margin-bottom: 1rem; border: 1px solid rgba(5, 150, 105, 0.3); border-radius: 8px; background: rgba(15, 23, 42, 0.6); color: #f8fafc; font-size: 1rem;"> 202 203 <label for="claimEmail" style="color: #cbd5e1; display: block; margin-bottom: 0.5rem; font-weight: 500;">Email Address *</label> 204 <input type="email" id="claimEmail" name="email" required style="width: 100%; padding: 0.75rem; margin-bottom: 1rem; border: 1px solid rgba(5, 150, 105, 0.3); border-radius: 8px; background: rgba(15, 23, 42, 0.6); color: #f8fafc; font-size: 1rem;"> 205 206 <label for="claimRole" style="color: #cbd5e1; display: block; margin-bottom: 0.5rem; font-weight: 500;">Your Role *</label> 207 <select id="claimRole" name="role" required style="width: 100%; padding: 0.75rem; margin-bottom: 1.5rem; border: 1px solid rgba(5, 150, 105, 0.3); border-radius: 8px; background: rgba(15, 23, 42, 0.6); color: #f8fafc; font-size: 1rem;"> 208 <option value="">Select your role...</option> 209 <option value="owner">Owner</option> 210 <option value="manager">Manager</option> 211 <option value="booker">Booker</option> 212 </select> 213 214 <div id="claimError" style="display: none; color: #ef4444; margin-bottom: 1rem; padding: 0.75rem; background: rgba(239, 68, 68, 0.1); border-radius: 8px; border: 1px solid rgba(239, 68, 68, 0.3);"></div> 215 216 <button type="submit" style="width: 100%; padding: 0.75rem; background: linear-gradient(135deg, #059669 0%, #0ea5e9 100%); color: white; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; font-size: 1rem;"> 217 Submit Claim Request 218 </button> 219 </form> 220 </div> 221 222 <!-- Success View --> 223 <div id="claimSuccessView" style="display: none;"> 224 <h2 style="color: #10b981; margin-bottom: 1rem;">â Claim Submitted!</h2> 225 226 <div style="background: rgba(16, 185, 129, 0.1); border: 1px solid rgba(16, 185, 129, 0.3); border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem;"> 227 <h3 style="color: #10b981; font-size: 1rem; margin-bottom: 0.5rem;">Next Steps:</h3> 228 <ol style="color: #cbd5e1; line-height: 1.8; margin: 0; padding-left: 1.5rem;"> 229 <li><strong>Check your email</strong> for a verification link</li> 230 <li><strong>Click the link</strong> to verify your email address</li> 231 <li><strong>We'll review</strong> your claim (typically 1-3 business days)</li> 232 <li><strong>Get your magic link</strong> in the approval email to manage your venue</li> 233 </ol> 234 </div> 235 236 <p style="color: #94a3b8; font-size: 0.9rem; margin-bottom: 1rem;"> 237 ð¡ <strong>Tip:</strong> Check your spam folder if you don't see the verification email within a few minutes. 238 </p> 239 240 <p style="color: #94a3b8; font-size: 0.9rem; margin-bottom: 1.5rem;"> 241 Questions? Email us at <a href="mailto:[email protected]" style="color: #10b981;">[email protected]</a> 242 </p> 243 244 <button onclick="closeClaimModal()" style="width: 100%; padding: 0.75rem; background: linear-gradient(135deg, #059669 0%, #0ea5e9 100%); color: white; border: none; border-radius: 8px; font-weight: 600; cursor: pointer;"> 245 Got It! 246 </button> 247 </div> 248 </div> 249 </div> 250 251 <!-- Image Lightbox Modal --> 252 <div id="imageLightbox" class="modal" style="display: none; z-index: 2000; background: rgba(15, 23, 42, 0.95);"> 253 <div class="modal-content" style="max-width: 90vw; max-height: 90vh; padding: 1rem; background: rgba(15, 23, 42, 0.95); border: 2px solid rgba(5, 150, 105, 0.3); box-shadow: 0 20px 60px rgba(0,0,0,0.5);">
254 <span class="close" onclick="closeLightbox()" style="position: absolute; top: 1rem; right: 1rem; font-size: 2rem; z-index: 2001; background: linear-gradient(135deg, rgba(5, 150, 105, 0.2) 0%, rgba(14, 165, 233, 0.2) 100%); width: 45px; height: 45px; border-radius: 50%; display: flex; align-items: center; justify-content: center; border: 2px solid rgba(5, 150, 105, 0.4); color: #10b981; cursor: pointer; transition: all 0.2s;" onmouseover="this.style.background='linear-gradient(135deg, rgba(5, 150, 105, 0.4) 0%, rgba(14, 165, 233, 0.4) 100%)'" onmouseout="this.style.background='linear-gradient(135deg, rgba(5, 150, 105, 0.2) 0%, rgba(14, 165, 233, 0.2) 100%)'">×</span> 255 <img id="lightboxImage" src="" alt="Gallery image" style="width: 100%; height: auto; max-height: 85vh; object-fit: contain; border-radius: 12px; border: 2px solid rgba(5, 150, 105, 0.3);"> 256 </div> 257 </div> 258 259
259<script> 260 const API_BASE_URL = 'https://bmbn5a7v4c.execute-api.us-east-1.amazonaws.com/Prod'; 261 const IMAGES_CDN = 'https://d3t15gta06qa0f.cloudfront.net'; 262 263 let map; 264 let allVenueEvents = []; // Store events for modal lookup 265 266 // Lazily load Leaflet (CSS + JS) only when a map is actually needed. 267 // Keeps ~150KB of render-blocking assets off the critical path. 268 let _leafletPromise = null; 269 function ensureLeaflet() { 270 if (window.L) return Promise.resolve(); 271 if (_leafletPromise) return _leafletPromise; 272 _leafletPromise = new Promise((resolve, reject) => { 273 const css = document.createElement('link'); 274 css.rel = 'stylesheet'; 275 css.href = 'https://unpkg.com/[email protected]/dist/leaflet.css'; 276 document.head.appendChild(css); 277 278 const js = document.createElement('script'); 279 js.src = 'https://unpkg.com/[email protected]/dist/leaflet.js'; 280 js.onload = () => resolve(); 281 js.onerror = reject; 282 document.head.appendChild(js); 283 }); 284 return _leafletPromise; 285 } 286 287 // Image helper functions 288 function getImageUrl(s3Key, size = 'large') { 289 if (!s3Key) return null; 290 if (s3Key.startsWith('http')) return s3Key; 291 292 // Encode each path segment to handle spaces in venue names 293 const encodedKey = s3Key.split('/').map(segment => encodeURIComponent(segment)).join('/'); 294 295 if (size === 'original') { 296 return `${IMAGES_CDN}/${encodedKey}`; 297 } 298 299 const base = s3Key.substring(0, s3Key.lastIndexOf('.')); 300 const encodedBase = base.split('/').map(segment => encodeURIComponent(segment)).join('/'); 301 302 return `${IMAGES_CDN}/${encodedBase}-${size}.jpg`; 303 } 304 305 // Helper to handle image load errors - fallback to original 306 function handleImageError(img, s3Key) { 307 if (!img.dataset.fallbackAttempted) { 308 img.dataset.fallbackAttempted = 'true'; 309 if (s3Key.startsWith('http')) { 310 img.src = s3Key; 311 } else { 312 const encodedKey = s3Key.split('/').map(segment => encodeURIComponent(segment)).join('/'); 313 img.src = `${IMAGES_CDN}/${encodedKey}`; 314 } 315 } 316 } 317 318 // Get venue slug from URL 319 const pathParts = window.location.pathname.split('/').filter(p => p); 320 321 // Check if this is actually a venue URL 322 if (pathParts[0] !== 'venue' || !pathParts[1]) { 323 // Not a venue URL, redirect to home 324 window.location.href = '/'; 325 } 326 327 const venueSlug = pathParts[1]; 328 329 // Set canonical URL to the static SEO page 330 const canonicalUrl = `https://grassgigs.com/venues/${venueSlug}.html`; 331 document.getElementById('canonical-url').setAttribute('href', canonicalUrl); 332 333 function toTitleCase(str) { 334 if (!str) return ''; 335 return str.replace(/\b\w+/g, word => { 336 const lower = word.toLowerCase(); 337 if (['and', 'or', 'the', 'in', 'at', 'of', 'for', 'on', 'to', 'a'].includes(lower) && str.indexOf(word) !== 0) { 338 return lower; 339 } 340 return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); 341 }); 342 } 343 344 // Display a venue name with proper capitalization. Only reformats names 345 // that are entirely lowercase so intentional casing (e.g. "NOLA", "AJ's") 346 // is preserved. 347 function displayVenueName(name) { 348 if (!name) return ''; 349 if (/[A-Z]/.test(name)) return name; 350 return toTitleCase(name); 351 } 352 353 // Helper: slugify text (matches backend logic) 354 function slugify(text) { 355 if (!text) return ''; 356 text = text.toLowerCase(); 357 text = text.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); 358 text = text.replace(/&/g, 'and'); 359 text = text.replace(/\s+/g, '-'); 360 text = text.replace(/[^a-z0-9-]/g, ''); 361 text = text.replace(/-+/g, '-'); 362 text = text.replace(/^-+|-+$/g, ''); 363 return text; 364 } 365 366 // Format date for display 367 function formatDate(dateStr, endDateStr) { 368 const date = new Date(dateStr + 'T00:00:00'); 369 370 // If there's an end date (multi-day event), format as range 371 if (endDateStr && endDateStr !== dateStr) { 372 const endDate = new Date(endDateStr + 'T00:00:00'); 373 374 // Same month and year - show "15-17 Aug, 2026" 375 if (date.getMonth() === endDate.getMonth() && date.getFullYear() === endDate.getFullYear()) { 376 const monthYear = date.toLocaleDateString('en-US', { 377 month: 'short', 378 year: 'numeric' 379 }); 380 return `${date.getDate()}-${endDate.getDate()} ${monthYear}`; 381 }
382 // Different months - show "Aug 30 - Sep 2, 2026" 383 else if (date.getFullYear() === endDate.getFullYear()) { 384 const startMonth = date.toLocaleDateString('en-US', { month: 'short' }); 385 const endMonth = endDate.toLocaleDateString('en-US', { month: 'short' }); 386 const year = date.getFullYear(); 387 return `${startMonth} ${date.getDate()} - ${endMonth} ${endDate.getDate()}, ${year}`; 388 } 389 // Different years - show full dates 390 else { 391 const startFormatted = date.toLocaleDateString('en-US', { 392 month: 'short', 393 day: 'numeric', 394 year: 'numeric' 395 }); 396 const endFormatted = endDate.toLocaleDateString('en-US', { 397 month: 'short', 398 day: 'numeric', 399 year: 'numeric' 400 }); 401 return `${startFormatted} - ${endFormatted}`; 402 } 403 } 404 405 // Single day event 406 return date.toLocaleDateString('en-US', { 407 weekday: 'long', 408 year: 'numeric', 409 month: 'long', 410 day: 'numeric' 411 }); 412 } 413 414 // Format time for display 415 function formatTime(timeStr) { 416 if (!timeStr || timeStr === '00:00') return ''; 417 const [hours, minutes] = timeStr.split(':'); 418 const hour = parseInt(hours); 419 const ampm = hour >= 12 ? 'PM' : 'AM'; 420 const displayHour = hour % 12 || 12; 421 return `${displayHour}:${minutes} ${ampm}`; 422 } 423 424 // Initialize map 425 async function initMap(lat, lng, venueName, isPremium = false) { 426 await ensureLeaflet(); 427 map = L.map('venue-map').setView([lat, lng], 15); 428 429 L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { 430 attribution: '© OpenStreetMap contributors' 431 }).addTo(map); 432 433 // Create custom marker icon matching main map style 434 let markerIcon = undefined; 435 436 if (isPremium) { 437 markerIcon = L.divIcon({ 438 className: 'premium-marker-icon', 439 html: `<div class="premium-marker"> 440 <svg width="30" height="40" viewBox="0 0 30 40" xmlns="http://www.w3.org/2000/svg"> 441 <defs><linearGradient id="premiumGrad" x1="0%" y1="0%" x2="0%" y2="100%"><stop offset="0%" style="stop-color:#fbbf24;stop-opacity:1" /><stop offset="100%" style="stop-color:#f59e0b;stop-opacity:1" /></linearGradient></defs> 442 <path d="M15 0 C6.716 0 0 6.716 0 15 C0 23.284 15 40 15 40 S30 23.284 30 15 C30 6.716 23.284 0 15 0 Z" fill="url(#premiumGrad)" stroke="#d97706" stroke-width="1.5"/> 443 </svg> 444 </div>`, 445 iconSize: [30, 40], 446 iconAnchor: [15, 40], 447 popupAnchor: [0, -40] 448 }); 449 } 450 451 // Build styled popup content matching main map style 452 let popupContent = '<div style="width: 100%; max-height: 300px; overflow-y: auto; box-sizing: border-box; padding-top: 35px; padding-left: 12px; padding-right: 12px;">'; 453 454 // Venue name 455 popupContent += '<h4 style="margin: 5px 0; text-align: center;">' + venueName; 456 if (isPremium) { 457 popupContent += ' <span style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); color: white; padding: 2px 6px; border-radius: 10px; font-size: 10px; font-weight: bold; margin-left: 4px; display: inline-block;">â FEATURED</span>'; 458 } 459 popupContent += '</h4>'; 460 461 // Get venue data from the page 462 const cityState = document.getElementById('venue-city-state').textContent; 463 const address = document.getElementById('venue-address').textContent; 464 465 if (cityState) { 466 popupContent += '<p style="margin: 3px 0; text-align: center;">' + cityState + '</p>'; 467 } 468 469 if (address && address.trim()) { 470 popupContent += '<p style="margin: 8px 0 3px 0; font-size: 0.85em; text-align: center; color: #64748b; line-height: 1.4;"><img src="/custom-map-pin.png" alt="" style="height:1.1em;width:auto;vertical-align:middle;"> ' + address + '</p>'; 471 } 472 473 popupContent += '</div>'; 474 475 L.marker([lat, lng], markerIcon ? { icon: markerIcon } : {}) 476 .addTo(map) 477 .bindPopup(popupContent, { 478 maxWidth: 300, 479 className: 'custom-popup' 480 }) 481 .openPopup(); 482 } 483 484 // Load venue details 485 async function loadVenueDetails() { 486 try { 487 const response = await fetch(`${API_BASE_URL}/venue/${venueSlug}`); 488 489 if (!response.ok) { 490 throw new Error('Venue not found'); 491 } 492 493 const data = await response.json(); 494 495 // Debug: Log the full API response 496 console.log('Full API response:', data); 497 console.log('Events array:', data.events); 498 console.log('Events count:', data.events ? data.events.length : 0); 499 500 const { venue, events } = data; 501 allVenueEvents = events || []; // Store for modal lookup 502 503 // If this venue has been migrated to a festival, redirect there 504 if (venue.festival_redirect) { 505 const festivalSlug = venue.festival_redirect 506 .toLowerCase() 507 .replace(/[^a-z0-9\s-]/g, '') 508 .replace(/\s+/g, '-') 509 .replace(/-+/g, '-') 510 .replace(/^-+|-+$/g, ''); 511 window.location.replace('/festival/' + festivalSlug); 512 return; 513 } 514 515 // Debug: Log the venue data to console 516 console.log('Venue data received:', venue); 517 518 // Store venue data for claim form 519 // Generate location-aware venue_id if we have city/state. 520 // Use the shared slugify() so the suffix matches the backend 521 // canonical key, and guard against double-suffixing when the slug 522 // already ends with -city-state OR -state (location-qualified keys 523 // produced by canonical_venue_key). 524 let claimVenueId = venueSlug; 525 if (venue.city && venue.state) { 526 const citySlug = slugify(venue.city); 527 const stateSlug = slugify(venue.state); 528 const endsWithLocation = 529 (citySlug && stateSlug && venueSlug.endsWith(`-${citySlug}-${stateSlug}`)) || 530 (stateSlug && venueSlug.endsWith(`-${stateSlug}`)); 531 if (!endsWithLocation) { 532 const suffix = [citySlug, stateSlug].filter(
532Boolean).join('-'); 533 if (suffix) { 534 claimVenueId = `${venueSlug}-${suffix}`; 535 } 536 } 537 } 538 539 // Normalize the display name (fixes all-lowercase scraped names) 540 venue.name = displayVenueName(venue.name); 541 542 currentVenueData = { 543 venue_id: claimVenueId, 544 name: venue.name 545 }; 546 547 // Update page title and meta 548 document.title = `${venue.name} - ${venue.city}, ${venue.state} | GrassGigs`; 549 550 // Update venue info 551 document.getElementById('venue-name').textContent = venue.name; 552 553 // Add verified badge for claimed venues 554 if (venue.claimed || venue.user_submitted) { 555 const badge = document.createElement('span'); 556 badge.className = 'verified-badge-venue'; 557 badge.innerHTML = 'â Official'; 558 badge.style.cssText = 'display: inline-flex; align-items: center; background: #10b981; color: white; -webkit-text-fill-color: white; padding: 4px 12px; border-radius: 16px; font-size: 14px; font-weight: 600; margin-left: 12px; vertical-align: middle;'; 559 document.getElementById('venue-name').appendChild(badge); 560 } 561 562 // Update page title and meta description 563 const locationParts = []; 564 if (venue.city) locationParts.push(venue.city); 565 if (venue.state) locationParts.push(venue.state); 566 const locationText = locationParts.length > 0 ? locationParts.join(', ') : ''; 567 568 document.title = `${venue.name}${locationText ? ' - ' + locationText : ''} | Live Bluegrass Events | GrassGigs`; 569 document.querySelector('meta[name="description"]').setAttribute('content', 570 `View upcoming bluegrass and live music events at ${venue.name}${locationText ? ' in ' + locationText : ''}. Find show dates, artists, and tickets.`); 571 572 document.getElementById('venue-city-state').innerHTML = locationText ? '<img src="/custom-map-pin.png" alt="" style="height:1.1em;width:auto;vertical-align:middle;"> ' + locationText : ''; 573 574 if (venue.address) { 575 document.getElementById('venue-address').textContent = venue.address; 576 } 577 578 // Display description if available, or generate a default 579 if (venue.description && venue.description.trim()) { 580 // Decode any HTML entities (e.g. ' -> ') for display 581 const descEl = document.getElementById('venue-description'); 582 const tmp = document.createElement('textarea'); 583 tmp.innerHTML = venue.description; 584 descEl.textContent = tmp.value; 585 document.getElementById('venue-description-section').style.display = 'block'; 586 } else if (events.length > 0) { 587 // Generate a default description from event data 588 const descEl = document.getElementById('venue-description'); 589 const venueName = venue.name || toTitleCase(venue.venue_name || venueSlug.replace(/-/g, ' ')); 590 const locationParts = []; 591 if (venue.city) locationParts.push(venue.city); 592 if (venue.state) locationParts.push(venue.state); 593 const locationStr = locationParts.length > 0 ? ` in ${locationParts.join(', ')}` : ''; 594 595 let descParts = []; 596 descParts.push(`${venueName}${locationStr} is a live music venue featuring bluegrass and grassroots music.`); 597 descParts.push(`There ${events.length === 1 ? 'is' : 'are'} ${events.length} upcoming show${events.length !== 1 ? 's' : ''} scheduled.`); 598 599 // Mention some bands playing there 600 const upcomingBands = events.slice(0, 3).map(e =>
600 e.band).filter(Boolean); 601 if (upcomingBands.length > 0) { 602 descParts.push(`Upcoming artists include ${upcomingBands.join(', ')}.`); 603 } 604 605 descEl.textContent = descParts.join(' '); 606 document.getElementById('venue-description-section').style.display = 'block'; 607 } 608 609 // Display contact and social links if available 610 let hasLinks = false; 611 const isPremium = venue.subscription_tier === 'premium'; 612 613 if (venue.website && venue.website.trim()) { 614 const websiteLink = document.getElementById('venue-website'); 615 websiteLink.href = venue.website; 616 websiteLink.onclick = () => trackVenueConversion(venueSlug, 'website', null, isPremium); 617 websiteLink.style.display = 'inline-block'; 618 hasLinks = true; 619 } 620 621 if (venue.contact_email && venue.contact_email.trim()) { 622 const emailLink = document.getElementById('venue-email'); 623 emailLink.href = `mailto:${venue.contact_email}`; 624 emailLink.onclick = () => trackVenueConversion(venueSlug, 'email', null, isPremium); 625 emailLink.style.display = 'inline-block'; 626 hasLinks = true; 627 } 628 629 if (venue.contact_phone && venue.contact_phone.trim()) { 630 const phoneLink = document.getElementById('venue-phone'); 631 phoneLink.href = `tel:${venue.contact_phone}`; 632 phoneLink.textContent = `ð ${venue.contact_phone}`; 633 phoneLink.onclick = () => trackVenueConversion(venueSlug, 'phone', null, isPremium); 634 phoneLink.style.display = 'inline-block'; 635 hasLinks = true; 636 } 637 638 if (venue.facebook && venue.facebook.trim()) { 639 const fbLink = document.getElementById('venue-facebook'); 640 fbLink.href = venue.facebook; 641 fbLink.onclick = () => trackVenueConversion(venueSlug, 'social_facebook', null, isPremium); 642 fbLink.style.display = 'inline-block'; 643 hasLinks = true; 644 } 645 646 if (venue.instagram && venue.instagram.trim()) { 647 const igLink = document.getElementById('venue-instagram'); 648 igLink.href = venue.instagram; 649 igLink.onclick = () => trackVenueConversion(venueSlug, 'social_instagram', null, isPremium); 650 igLink.style.display = 'inline-block'; 651 hasLinks = true; 652 } 653 654 if (venue.youtube && venue.youtube.trim()) { 655 const ytLink = document.getElementById('venue-youtube'); 656 ytLink.href = venue.youtube; 657 ytLink.onclick = () => trackVenueConversion(venueSlug, 'social_youtube', null, isPremium); 658 ytLink.style.display = 'inline-block'; 659 hasLinks = true; 660 } 661 662 if (venue.twitter && venue.twitter.trim()) { 663 const twLink = document.getElementById('venue-twitter'); 664 twLink.href = venue.twitter; 665 twLink.onclick = () => trackVenueConversion(venueSlug, 'social_twitter', null, isPremium); 666 twLink.style.display = 'inline-block'; 667 hasLinks = true; 668 } 669 670 if (venue.tiktok && venue.tiktok.trim()) { 671 const ttLink = document.getElementById('venue-tiktok'); 672 ttLink.href = venue.tiktok; 673 ttLink.onclick = () => trackVenueConversion(venueSlug, 'social_tiktok', null, isPremium); 674 ttLink.style.display = 'inline-block'; 675 hasLinks = true; 676 } 677 678 if (venue.bandcamp && venue.bandcamp.trim()) { 679 const bcLink = document.getElementById('venue-bandcamp'); 680 bcLink.href = venue.bandcamp; 681 bcLink.onclick = () => trackVenueConversion(venueSlug, 'social_bandcamp', null, isPremium); 682 bcLink.style.display = 'inline-block'; 683 hasLinks = true; 684 } 685 686 if (hasLinks) { 687 document.getElementById('venue-links-section').style.display = 'block'; 688 } 689 690 // Display profile image if available 691 if (venue.profile_image) { 692 const profileImg = document.getElementById('venue-profile-image'); 693 profileImg.src = getImageUrl(venue.profile_image, 'large'); 694 profileImg.alt = venue.name; 695 profileImg.onerror = function() { handleImageError(this, venue.profile_image); }; 696 document.getElementById('venue-profile-image-section').style.display = 'block'; 697 } 698 699 // Display gallery images if available 700 if (venue.gallery_images && venue.gallery_images.length > 0) { 701 const gallery = document.getElementById('venue-gallery'); 702 gallery.innerHTML = venue.gallery_images.map(s3Key => { 703 const imageUrl = getImageUrl(s3Key, 'thumbnail'); 704 const largeUrl = getImageUrl(s3Key, 'original'); 705 const escapedKey = s3Key.replace(/'/g, "\\'"); 706 const escapedUrl = largeUrl.replace(/'/g, "\\'"); 707 return `<div onclick="openLightbox('${escapedUrl}')" style="cursor: pointer;"> 708 <img src="${imageUrl}" alt="${venue.name} gallery" 709 onerror="handleImageError(this, '${escapedKey}')" 710 style="width: 100%; height: 200px; object-fit: cover; border-radius: 8px; transition: transform 0.2s;" 711 onmouseover="this.style.transform='scale(1.05)'" 712 onmouseout="this.style.transform='scale(1)'"> 713 </div>`; 714 }).join(''); 715 document.getElementById('venue-gallery-section').style.display = 'block'; 716 } 717 718 // Hide claim section if venue is already claimed or user-submitted 719 if (venue.claimed || venue.user_submitted) { 720 document.getElementById('claim-venue-section').style.display = 'none'; 721 // Show request login link for claimed/submitted venues 722 document.getElementById('request-login-section').style.display = 'block'; 723 } 724 725 // Check if current user is the owner of this venue 726 checkVenueOwnership(venueSlug); 727 728 // Track venue page view for premium venues 729 if (venue.subscription_tier === 'premium') { 730 trackVenueView(venueSlug, true); 731 } 732 733 // Initialize map 734 if (venue.latitude && venue.longitude) { 735 const isPremium = venue.subscription_tier === 'premium'; 736 initMap(venue.latitude, venue.longitude, venue.name, isPremium); 737 } 738 739 // Allow indexing if page has sufficient content 740 // (has events, or has description, or has contact info) 741 const hasEvents = events.length > 0;
742 const hasDescription = venue.description && venue.description.trim().length > 0; 743 const hasContactInfo = venue.website || venue.contact_email || venue.facebook || venue.instagram; 744 if (hasEvents || hasDescription || hasContactInfo) { 745 document.getElementById('robots-meta').setAttribute('content', 'index, follow'); 746 } 747 748 // Check if any event is linked to a festival â show link in hero 749 const festivalEvent = events.find(e => e.festival_id); 750 if (festivalEvent) { 751 const festEl = document.getElementById('venue-festival-link'); 752 const festName = festivalEvent.festival_name ? toTitleCase(festivalEvent.festival_name) : 'View Festival'; 753 festEl.innerHTML = `ðª <a href="/festival/${festivalEvent.festival_id}" style="color: #a855f7; text-decoration: none; font-weight: 500;">${festName} â</a>`; 754 festEl.style.display = 'block'; 755 } 756 757 // Display venue stats 758 if (events.length > 0) { 759 const uniqueBands = new Set(events.map(e => e.band).filter(Boolean)); 760 document.getElementById('stat-upcoming').textContent = events.length; 761 document.getElementById('stat-bands').textContent = uniqueBands.size; 762 document.getElementById('venue-stats').style.display = 'flex'; 763 } 764 765 // Update subscribe link 766 document.getElementById('venue-subscribe-heading').textContent = `Get Notified About Shows at ${venue.name}`; 767 document.getElementById('venue-subscribe-text').textContent = `Be the first to know when new events are announced`; 768 document.getElementById('venue-subscribe-link').href = `/?subscribe=venue&location=${encodeURIComponent(venueSlug)}#subscribe`; 769 770 // Display events using step-like cards 771 const eventsList = document.getElementById('events-list'); 772 773 if (events.length === 0) { 774 eventsList.innerHTML = '<p style="text-align: center; color: #cbd5e1; padding: 2rem;">No upcoming events at this venue</p>'; 775 } else { 776 // Group events by month for section headers 777 let currentMonth = ''; 778 eventsList.innerHTML = events.map((event) => { 779 const time = formatTime(event.time); 780 const isPremium = venue.subscription_tier === 'premium'; 781 const isJam = event.event_type === 'jam'; 782 783 // Date parts for calendar chip 784 const dateParts = event.date.split('-'); 785 const eventDate = new Date(parseInt(dateParts[0]), parseInt(dateParts[1]) - 1, parseInt(dateParts[2])); 786 const monthShort = eventDate.toLocaleDateString('en-US', { month: 'short' }).toUpperCase(); 787 const dayNum = eventDate.getDate(); 788 const dayOfWeek = eventDate.toLocaleDateString('en-US', { weekday: 'short' }); 789 790 // Month group header 791 const monthKey = eventDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); 792 let monthHeader = ''; 793 if (monthKey !== currentMonth) { 794 currentMonth = monthKey; 795 monthHeader = `<div style="padding: 0.75rem 0 0.5rem; margin-top: 0.5rem; border-bottom: 1px solid rgba(5, 150, 105, 0.15); margin-bottom: 0.75rem;"><span style="color: #94a3b8; font-size: 0.8rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em;">${monthKey}</span></div>`; 796 } 797 798 // Create band link with tracking 799 const bandDisplay = toTitleCase(event.band || ''); 800 const bandSlug = slugify(event.band || ''); 801 let bandLink; 802 if (bandSlug) { 803 const onclickAttr = isPremium ? 804 `onclick="trackVenueEventClick('${venueSlug}', '${event.event_id}', true); return true;"` : 805 ''; 806 bandLink = `<a href="/band/${bandSlug}" ${onclickAttr} style="color: #e2e8f0; text-decoration: none; transition: color 0.2s;" onmouseover="this.style.color='#10b981'" onmouseout="this.style.color='#e2e8f0'">${bandDisplay}</a>`; 807 } else { 808 bandLink = `<span style="color: #e2e8f0;">${bandDisplay}</span>`; 809 } 810 811 // Create ticket link with tracking if available 812 let ticketLink = ''; 813 if (event.ticket_url) { 814 const ticketOnclick = isPremium ? 815 `onclick="event.stopPropagation(); trackVenueConversion('${venueSlug}', 'ticket', '${event.event_id}', true); return true;"` : 816 'onclick="event.stopPropagation();"'; 817 ticketLink = `<a href="${event.ticket_url}" target="_blank" ${ticketOnclick} style="display: inline-flex; align-items: center; gap: 4px; margin-top: 0.5rem; padding: 0.3rem 0.75rem; background: rgba(251, 191, 36, 0.1); border: 1px solid rgba(251, 191, 36, 0.3); border-radius: 6px; color: #fbbf24; text-decoration: none; font-size: 0.8rem; font-weight: 600; transition: background 0.2s;" onmouseover="this.style.background='rgba(251, 191, 36, 0.2)'" onmouseout="this.style.background='rgba(251, 191, 36, 0.1)'">ðï¸ Tickets</a>`; 818 } 819 820 // Add jam description if available 821 const jamDescription = isJam && event.description ? 822 `<p style="color: #94a3b8; font-size: 0.85rem; margin: 0.3rem 0 0; font-style: italic;">${event.description}</p>` : ''; 823 824 // Add jam page link if this is a jam 825 let jamPageLink = ''; 826 if (isJam && event.jam_id) { 827 jamPageLink = `<a href="/jam.html?id=${encodeURIComponent(event.jam_id)}" onclick="event.stopPropagation();" style="display: inline-flex; align-items: center; gap: 4px; margin-top: 0.5rem; margin-right: 0.5rem; padding: 0.3rem 0.75rem; background: rgba(245, 158, 11, 0.1); border: 1px solid rgba(245, 158, 11, 0.3); border-radius: 6px; color: #f59e0b; text-decoration: none; font-size: 0.8rem; font-weight: 500;">ðµ Jam Details</a>`; 828 } else if (isJam && event.venue_name && event.venue_city && event.venue_state) { 829 const jamSlug = `${event.venue_name}-${event.venue_city}-${event.venue_state}`.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/&/g, 'and').replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-+|-+$/g, ''); 830 jamPageLink = `<a href="/jams/${jamSlug}.html" onclick="event.stopPropagation();" style="display: inline-flex; align-items: center; gap: 4px; margin-top: 0.5rem; margin-right: 0.5rem; padding: 0.3rem 0.75rem; background: rgba(245, 158, 11, 0.1); border: 1px solid rgba(245, 158, 11, 0.3); border-radius: 6px; color: #f59e0b; text-decoration: none; font-size: 0.8rem; font-weight: 500;">ðµ Jam Details</a>`; 831 } 832 833 // Add festival link if event is part of a festival 834 let festivalLink = ''; 835 if (event.festival_id) { 836 const festName = event.festival_name ? toTitleCase(event.festival_name) : 'View Festival'; 837 festivalLink = `<a href="/festival/${event.festival_id}" onclick="event.stopPropagation();" style="display: inline-flex; align-items: center; gap: 4px; margin-top: 0.5rem; margin-right: 0.5rem; padding: 0.3rem 0.75rem; background: rgba(168, 85, 247, 0.1); border: 1px solid rgba(168, 85, 247, 0.3); border-radius: 6px; color: #a855f7; text-decoration: none; font-size: 0.8rem; font-weight: 500;">ðª ${festName}</a>`; 838 } 839 840 // Multi-day badge 841 let multiDayBadge = ''; 842 if (event.end_date && event.end_date !== event.date) { 843 const endParts = event.end_date.split('-'); 844 const endDate = new Date(parseInt(endParts[0]), parseInt(endParts[1]) - 1, parseInt(endParts[2])); 845 const diffDays = Math.ceil((endDate - eventDate) / (1000 * 60 * 60 * 24)) + 1; 846 multiDayBadge = `<span style="display: inline-block; margin-left: 0.5rem; padding: 2px 8px; background: rgba(14, 165, 233, 0.15); border: 1px solid rgba(14, 165, 233, 0.3); border-radius: 4px; color: #7dd3fc; font-size: 0.7rem; font-weight: 600; vertical-align: middle;">${diffDays} days</span>`; 847 } 848 849 return `${monthHeader} 850 <div style="display: flex; gap: 1rem; padding: 1rem; margin-bottom: 0.5rem; border-radius: 12px; cursor: pointer; transition: background 0.2s ease, transform 0.15s ease; border: 1px solid transparent;" onclick="openEventModal('${event.event_id}')" onmouseover="this.style.background='rgba(5, 150, 105, 0.06)'; this.style.borderColor='rgba(5, 150, 105, 0.15)'; this.style.transform='translateX(4px)'" onmouseout="this.style.background='transparent'; this.style.borderColor='transparent'; this.style.transform='none'"> 851 <!-- Date chip --> 852 <div style="flex-shrink: 0; width: 56px; text-align: center;"> 853 <div style="background: linear-gradient(135deg, rgba(5, 150, 105, 0.2), rgba(14, 165, 233, 0.15)
853); border: 1px solid rgba(5, 150, 105, 0.25); border-radius: 10px; padding: 0.5rem 0.25rem; min-height: 62px; display: flex; flex-direction: column; align-items: center; justify-content: center;"> 854 <div style="font-size: 0.65rem; font-weight: 700; color: #10b981; letter-spacing: 0.05em;">${monthShort}</div> 855 <div style="font-size: 1.4rem; font-weight: 800; color: #f1f5f9; line-height: 1.1;">${dayNum}</div> 856 <div style="font-size: 0.65rem; color: #94a3b8; font-weight: 500;">${dayOfWeek}</div> 857 </div> 858 </div> 859 <!-- Event details --> 860 <div style="flex: 1; min-width: 0;"> 861 <h3 style="color: #e2e8f0; font-size: 1.1rem; font-weight: 600; margin: 0 0 0.3rem; line-height: 1.3;">${bandLink}${multiDayBadge}</h3> 862 ${time ? `<p style="color: #94a3b8; margin: 0 0 0.2rem; font-size: 0.85rem;">ð ${time}</p>` : ''} 863 ${jamDescription} 864 <div style="display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;"> 865 ${festivalLink}${jamPageLink}${ticketLink} 866 </div> 867 </div> 868 </div> 869 `; 870 }).join(''); 871 } 872 873 // Load past events for this venue (last 12 months) 874 try { 875 const today = new Date(); 876 const oneYearAgo = new Date(today); 877 oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); 878 const startDate = oneYearAgo.toISOString().split('T')[0]; 879 const endDate = new Date(today.getTime() - 86400000).toISOString().split('T')[0]; // yesterday 880 881 const venueName = venue.name || toTitleCase(venueSlug.replace(/-/g, ' ')); 882 const pastResp = await fetch(`${API_BASE_URL}/events?venue=${encodeURIComponent(venueName)}&start_date=${startDate}&end_date=${endDate}`); 883 if (pastResp.ok) { 884 const pastData = await pastResp.json(); 885 let pastEvents = pastData.events || pastData || []; 886 887 // Sort most recent first, deduplicate by band, limit to 15 888 pastEvents.sort((a, b) => (b.date || '').localeCompare(a.date || '')); 889 const seenBands = new Set(); 890 const dedupedPastEvents = []; 891 for (const event of pastEvents) { 892 const bandKey = (event.band || '').toLowerCase(); 893 if (bandKey && !seenBands.has(bandKey)) { 894 seenBands.add(bandKey); 895 dedupedPastEvents.push(event); 896 } 897 if (dedupedPastEvents.length >= 15) break; 898 } 899 900 if (dedupedPastEvents.length > 0) { 901 const pastSection = document.getElementById('past-events-section'); 902 const pastList = document.getElementById('past-events-list'); 903 904 pastList.innerHTML = dedupedPastEvents.map(event => { 905 const bandName = toTitleCase(event.band || ''); 906 const bandSlug = slugify(event.band || ''); 907 const dateStr = formatDate(event.date, event.end_date); 908 909 let bandLink = bandName; 910 if (bandSlug) { 911 bandLink = `<a href="/band/${bandSlug}" style="color: #94a3b8; text-decoration: none;">${bandName}</a>`; 912 } 913 914 return ` 915 <div style="display: flex; align-items: center; gap: 0.5rem; padding: 0.6rem 0; border-bottom: 1px solid rgba(5, 150, 105, 0.08); flex-wrap: wrap; opacity: 0.7;">
916 <span style="color: #94a3b8; font-size: 0.875rem; font-weight: 500; white-space: nowrap;">${dateStr}</span> 917 <span style="color: #475569; font-size: 0.75rem;">â¢</span> 918 <span style="color: #cbd5e1; font-size: 0.9rem; font-weight: 500;">${bandLink}</span> 919 </div> 920 `; 921 }).join(''); 922 923 pastSection.style.display = 'block'; 924 } 925 } 926 } catch (e) { 927 console.log('Could not load past events:', e); 928 } 929 930 // Check for deep-linked event modal 931 const urlParams = new URLSearchParams(window.location.search); 932 const eventId = urlParams.get('event'); 933 if (eventId) openEventModal(eventId); 934 935 } catch (error) { 936 console.error('Error loading venue:', error); 937 document.getElementById('venue-name').textContent = 'Venue Not Found'; 938 document.getElementById('venue-city-state').textContent = ''; 939 document.getElementById('events-list').innerHTML = 940 '<div style="text-align: center; padding: 2.5rem 1.5rem;">' + 941 '<p style="color: #94a3b8; margin-bottom: 1.5rem;">This venue could not be found. It may have been removed or the URL may be incorrect.</p>' + 942 '<div style="display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap;">' + 943 '<a href="/" style="padding: 0.6rem 1.5rem; background: linear-gradient(135deg, #059669 0%, #0ea5e9 100%); color: white !important; border-radius: 8px; text-decoration: none; font-weight: 600;">Browse Events</a>' + 944 '<a href="/venues/index.html" style="padding: 0.6rem 1.5rem; background: rgba(255,255,255,0.08); color: white !important; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; text-decoration: none; font-weight: 600;">All Venues</a>' + 945 '</div>' + 946 '</div>'; 947 } 948 } 949 950 // Load on page load 951 document.addEventListener('DOMContentLoaded', loadVenueDetails); 952 953 // Claim venue modal functions 954 let currentVenueData = null; 955 956 // Lightbox functions 957 function openLightbox(imageUrl) { 958 document.getElementById('lightboxImage').src = imageUrl; 959 document.getElementById('imageLightbox').style.display = 'block'; 960 } 961 962 function closeLightbox() { 963 document.getElementById('imageLightbox').style.display = 'none'; 964 } 965 966 // Close lightbox on escape key 967 document.addEventListener('keydown', function(e) { 968 if (e.key === 'Escape') { 969 closeLightbox(); 970 } 971 }); 972 973 // Close lightbox when clicking outside the image 974 document.getElementById('imageLightbox').addEventListener('click', function(e) { 975 if (e.target === this) { 976 closeLightbox(); 977 } 978 }); 979 980 function showClaimModal() { 981 // Populate hidden fields with venue data 982 if (currentVenueData) { 983 document.getElementById('claimVenueId').value = currentVenueData.venue_id; 984 document.getElementById('claimVenueName').value = currentVenueData.name; 985 } 986 987 // Reset form and show form view 988 document.getElementById('claimVenueForm').reset(); 989 document.getElementById('claimFormView').style.display = 'block'; 990 document.getElementById('claimSuccessView').style.display = 'none'; 991 document.getElementById('claimError').style.display = 'none'; 992 993 document.getElementById('claimVenueModal').style.display = 'block'; 994 } 995 996 function closeClaimModal() { 997 document.getElementById('claimVenueModal').style.display = 'none'; 998 } 999 1000 // Handle claim form submission 1001 document.getElementById('claimVenueForm').addEventListener('submit', async function(e) { 1002 e.preventDefault(); 1003 1004 const submitBtn = e.target.querySelector('button[type="submit"]'); 1005 const originalText = submitBtn.textContent; 1006 submitBtn.textContent = 'Submitting...'; 1007 submitBtn.disabled = true; 1008 1009 const formData = { 1010 venue_id: document.getElementById('claimVenueId').value, 1011 venue_name: document.getElementById('claimVenueName').value, 1012 name: document.getElementById('claimName').value, 1013 email: document.getElementById('claimEmail').value, 1014 role: document.getElementById('claimRole').value, 1015 website: document.getElementById('claimWebsite')?.value || null 1016 }; 1017 1018 try { 1019 const response = await fetch(`${API_BASE_URL}/venue/claim`, { 1020 method: 'POST', 1021 headers: { 1022 'Content-Type': 'application/json' 1023 }, 1024 body: JSON.stringify(formData) 1025 }); 1026 1027 if (!response.ok) { 1028 const error = await response.json(); 1029 throw new Error(error.error || 'Failed to submit claim request'); 1030 } 1031 1032 // Show success view 1033 document.getElementById('claimFormView').style.display = 'none'; 1034 document.getElementById('claimSuccessView').style.display = 'block'; 1035 1036 } catch (error) { 1037 console.error('Error submitting claim:', error); 1038 const errorDiv = document.getElementById('claimError'); 1039 errorDiv.textContent = error.message; 1040 errorDiv.style.display = 'block'; 1041 1042 submitBtn.textContent = originalText; 1043 submitBtn.disabled = false; 1044 } 1045 }); 1046 1047 // Close modal when clicking outside 1048 window.onclick = function(event) { 1049 const modal = document.getElementById('claimVenueModal'); 1050 if (event.target === modal) { 1051 closeClaimModal(); 1052 } 1053 } 1054 1055 // Check if current user owns this venue 1056 function checkVenueOwnership(venueSlug) { 1057 const sessionToken = localStorage.getItem('venue_session_token'); 1058 const storedVenueId = localStorage.getItem('venue_id'); 1059 1060 // If user is logged in and this is their venue, show manage button 1061 if (sessionToken && storedVenueId === venueSlug) { 1062 document.getElementById('manage-venue-section').style.display = 'block'; 1063 document.getElementById('claim-venue-section').style.display = 'none'; 1064 document.getElementById('request-login-section').style.display = 'none'; 1065 } 1066 } 1067 1068 // Request new login link 1069 async function requestLoginLink() { 1070 const messageDiv = document.getElementById('login-link-message'); 1071 1072 try { 1073 messageDiv.style.display = 'block'; 1074 messageDiv.style.background = 'rgba(16, 185, 129, 0.1)'; 1075 messageDiv.style.border = '1px solid rgba(16, 185, 129, 0.3)'; 1076 messageDiv.style.color = '#10b981'; 1077 messageDiv.textContent = 'Sending login link...'; 1078 1079 // Use location-aware venue_id (matches what was used during claim) 1080 const slugToSend = (currentVenueData && currentVenueData.venue_id) ? currentVenueData.venue_id : venueSlug; 1081 1082 const response = await fetch(`${API_BASE_URL}/venue/request-login`, { 1083 method: 'POST', 1084 headers: { 1085 'Content-Type': 'application/json' 1086 }, 1087 body: JSON.stringify({ 1088 venue_slug: slugToSend, 1089 base_slug: venueSlug 1090 }) 1091 }); 1092 1093 const data = await response.json(); 1094 1095 if (response.ok) { 1096 messageDiv.textContent = 'â Login link sent! Check your email (including spam folder).'; 1097 } else { 1098 throw new Error(data.error || 'Failed to send login link'); 1099 } 1100 1101 } catch (error) { 1102 console.error('Error requesting login link:', error); 1103 messageDiv.style.background = 'rgba(239, 68, 68, 0.1)'; 1104 messageDiv.style.border = '1px solid rgba(239, 68, 68, 0.3)'; 1105 messageDiv.style.color = '#ef4444'; 1106 messageDiv.textContent = error.message || 'Failed to send login link. Please try again.'; 1107 } 1108 } 1109 1110 // ============================================================================ 1111 // EVENT DETAIL MODAL 1112 // ============================================================================ 1113 1114 function openEventModal(eventId) { 1115 const event = allVenueEvents.find(e => e.event_id === eventId); 1116 if (!event) return; 1117 1118 // Create modal if it doesn't exist 1119 let modal = document.getElementById('eventDetailsModal'); 1120 if (!modal) { 1121 modal = document.createElement('div'); 1122 modal.id = 'eventDetailsModal'; 1123 modal.className = 'modal'; 1124 modal.innerHTML = '<div class="modal-content"><span class="close" onclick="closeEventModal()">×</span>
1124<div id="modalEventContent"></div></div>'; 1125 document.body.appendChild(modal); 1126 1127 modal.addEventListener('click', function(e) { 1128 if (e.target === modal) closeEventModal(); 1129 }); 1130 } 1131 1132 const content = document.getElementById('modalEventContent'); 1133 content.innerHTML = buildEventModalContent(event); 1134 modal.style.display = 'block'; 1135 document.body.style.overflow = 'hidden'; 1136 1137 if (event.latitude && event.longitude) { 1138 ensureLeaflet().then(() => { 1139 setTimeout(() => { 1140 const modalMap = L.map('modalEventMap').setView([event.latitude, event.longitude], 13); 1141 L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors' }).addTo(modalMap); 1142 L.marker([event.latitude, event.longitude]).addTo(modalMap) 1143 .bindPopup(`<strong>${toTitleCase(event.venue || '')}</strong><br>${event.city || ''}, ${event.state || ''}`) 1144 .openPopup(); 1145 }, 100); 1146 }); 1147 } 1148 1149 history.pushState({ eventId }, '', `?event=${eventId}`); 1150 } 1151 1152 function closeEventModal() { 1153 const modal = document.getElementById('eventDetailsModal'); 1154 if (modal) { 1155 modal.style.display = 'none'; 1156 document.body.style.overflow = 'auto'; 1157 const url = new URL(window.location); 1158 url.searchParams.delete('event'); 1159 history.pushState({}, '', url); 1160 } 1161 } 1162 1163 function buildEventModalContent(event) { 1164 const bandName = event.band || 'Unknown Band'; 1165 const bandSlugLocal = slugify(bandName); 1166 const venueName = toTitleCase(event.venue || 'Unknown Venue'); 1167 const eventUrl = `https://grassgigs.com/venue/${venueSlug}?event=${event.event_id}`; 1168 const eventText = `${bandName} at ${venueName} - ${formatDate(event.date, event.end_date)}`; 1169 1170 let html = '<div class="modal-event-details">'; 1171 1172 // Band name + venue 1173 html += `<h2 style="color: #10b981; margin: 0 0 0.5rem 0; font-size: 1.75rem; text-align: center;">${bandSlugLocal ? `<a href="/band/${bandSlugLocal}" style="color: #10b981; text-decoration: none;">${bandName}</a>` : bandName}</h2>`; 1174 html += `<h3 style="color: #38bdf8; text-align: center; margin: 0 0 1.5rem 0; font-size: 1.25rem; font-weight: normal;">at ${venueName}</h3>`; 1175 1176 // Info panel 1177 html += '<div style="background: rgba(56, 189, 248, 0.1); border-radius: 8px; padding: 1.5rem; margin: 1.5rem 0;">'; 1178 html += '<div style="display: grid; gap: 1rem;">'; 1179 1180 // Date & Time 1181 if (event.date) { 1182 html += `<div style="display: flex; align-items: start; gap: 0.75rem;"> 1183 <span style="font-size: 1.5rem;"><img src="/custom-calendar.png" alt="" style="height:1.2em;width:auto;vertical-align:middle;"></span> 1184 <div> 1185 <div style="color: #94a3b8; font-size: 0.875rem;">Date & Time</div> 1186 <div style="color: #f1f5f9; font-weight: 600;">${formatDate(event.date, event.end_date)}${event.time && event.time !== '00:00' ? ' at ' + formatTime(event.time) : ''}</div> 1187 </div> 1188 </div>`; 1189 } 1190 1191 // Venue 1192 html += `<div style="display: flex; align-items: start; gap: 0.75rem;"> 1193 <span style="font-size: 1.5rem;"><img src="/custom-map-pin.png" alt="" style="height:1.2em;width:auto;vertical-align:middle;"></span> 1194 <div> 1195 <div style="color: #94a3b8; font-size: 0.875rem;">Venue</div> 1196 <div style="color: #f1f5f9; font-weight: 600;">${venueName}</div> 1197 </div> 1198 </div>`; 1199 1200 // Location 1201 if (event.city && event.state) { 1202 const fullAddress = [event.address, event.city, event.state].filter(
1202Boolean).join(', '); 1203 html += `<div style="display: flex; align-items: start; gap: 0.75rem;"> 1204 <span style="font-size: 1.5rem;">ð</span> 1205 <div> 1206 <div style="color: #94a3b8; font-size: 0.875rem;">Location</div> 1207 <div style="color: #f1f5f9; font-weight: 600;">${event.city}, ${event.state}</div> 1208 <div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem;"> 1209 <button onclick="navigator.clipboard.writeText('${fullAddress.replace(/'/g, "\\'")}'); this.textContent='â Copied!'; setTimeout(() => { this.textContent='Copy Address'; }, 1500);" style="background: rgba(56, 189, 248, 0.2); color: #38bdf8; border: none; padding: 0.4rem 0.75rem; border-radius: 6px; cursor: pointer; font-size: 0.8rem;">Copy Address</button> 1210 <a href="https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(fullAddress)}" target="_blank" rel="noopener noreferrer" style="background: rgba(16, 185, 129, 0.2); color: #10b981; border: none; padding: 0.4rem 0.75rem; border-radius: 6px; text-decoration: none; font-size: 0.8rem; font-weight: 500;">Get Directions</a> 1211 </div> 1212 </div> 1213 </div>`; 1214 } 1215 1216 html += '</div></div>'; 1217 1218 // Map 1219 if (event.latitude && event.longitude) { 1220 html += '<div id="modalEventMap" style="height: 250px; border-radius: 8px; margin-bottom: 1.5rem; border: 1px solid rgba(56, 189, 248, 0.2);"></div>'; 1221 } 1222 1223 // Action buttons 1224 html += '<div style="display: flex; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 1.5rem;">'; 1225 if (bandSlugLocal) html += `<a href="/band/${bandSlugLocal}" style="flex: 1; min-width: 140px; text-align: center; background: rgba(16, 185, 129, 0.1); color: #10b981; padding: 0.75rem; border-radius: 8px; text-decoration: none; font-weight: 600;">View Band Page</a>`; 1226 if (event.source_url) html += `<a href="${event.source_url}" target="_blank" rel="noopener noreferrer" style="flex: 1; min-width: 140px; text-align: center; background: rgba(56, 189, 248, 0.1); color: #38bdf8; padding: 0.75rem; border-radius: 8px; text-decoration: none; font-weight: 600;">Band Website</a>`; 1227 if (event.ticket_url) html += `<a href="${event.ticket_url}" target="_blank" rel="noopener" style="flex: 1; min-width: 140px; text-align: center; background: rgba(251, 191, 36, 0.1); color: #fbbf24; padding: 0.75rem; border-radius: 8px; text-decoration: none; font-weight: 600;">ð« Get Tickets</a>`; 1228 html += '</div>'; 1229 1230 // Share buttons 1231 html += '<div style="margin-top: 1.5rem; text-align: center; padding-top: 1rem; border-top: 1px solid rgba(148, 163, 184, 0.2);">'; 1232 html += '<span style="display: block; font-size: 0.75rem; color: rgba(255, 255, 255, 0.5); margin-bottom: 0.5rem; font-weight: 500;">Share this event:</span>'; 1233 html += '<div style="display: flex; gap: 0.5rem; justify-content: center; align-items: center;">'; 1234 html += `<button onclick="window.open('https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(eventUrl)}', '_blank')" title="Share on Facebook" aria-label="Share on Facebook" style="padding: 0.5rem; border: none; border-radius: 6px; cursor: pointer; display: flex; align-items: center; justify-content: center; background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.7); transition: all 0.2s;" onmouseover="this.style.background='rgba(24, 119, 242, 0.2)'; this.style.color='#1877f2'" onmouseout="this.style.background='rgba(255, 255, 255, 0.1)'; this.style.color='rgba(255, 255, 255, 0.7)'"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg></button>`; 1235 html += `<button onclick="window.open('https://twitter.com/intent/tweet?text=${encodeURIComponent(eventText)}&url=${encodeURIComponent(eventUrl)}', '_blank')" title="Share on X/Twitter" aria-label="Share on X/Twitter" style="padding: 0.5rem; border: none; border-radius: 6px; cursor: pointer; display: flex; align-items: center; justify-content: center; background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.7); transition: all 0.2s;" onmouseover="this.style.background='rgba(255, 255, 255, 0.2)'; this.style.color='#ffffff'" onmouseout="this.style.background='rgba(255, 255, 255, 0.1)'; this.style.color='rgba(255, 255, 255, 0.7)'"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg></button>`; 1236 html += `<button onclick="window.open('https://wa.me/?text=${encodeURIComponent(eventText + ' ' + eventUrl)}', '_blank')" title="Share on WhatsApp" aria-label="Share on WhatsApp" style="padding: 0.5rem; border: none; border-radius: 6px; cursor: pointer; display: flex; align-items: center; justify-content: center; background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.7); transition: all 0.2s;" onmouseover="this.style.background='rgba(37, 211, 102, 0.2)'; this.style.color='#25d366'" onmouseout="this.style.background='rgba(255, 255, 255, 0.1)'; this.style.color='rgba(255, 255, 255, 0.7)'"><svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.
1236198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/></svg></button>`; 1237 html += '</div></div>'; 1238 1239 // Calendar export 1240 html += '<div style="text-align: center; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid rgba(148, 163, 184, 0.2);">'; 1241 html += '<p style="color: #94a3b8; font-size: 0.875rem; margin-bottom: 0.75rem;">Add to your calendar:</p>'; 1242 html += '<div style="display: flex; gap: 0.75rem; justify-content: center; flex-wrap: wrap;">'; 1243 html += `<button onclick="downloadEventICS('${event.event_id}')" style="display: inline-flex; align-items: center; gap: 0.5rem; background: rgba(16, 185, 129, 0.1); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.3); padding: 0.625rem 1.25rem; border-radius: 8px; cursor: pointer; font-weight: 600; font-size: 0.875rem;" onmouseover="this.style.background='rgba(16, 185, 129, 0.2)'" onmouseout="this.style.background='rgba(16, 185, 129, 0.1)'">ð Download .ics</button>`; 1244 html += `<button onclick="addEventToGoogleCalendar('${event.event_id}')" style="display: inline-flex; align-items: center; gap: 0.5rem; background: rgba(56, 189, 248, 0.1); color: #38bdf8; border: 1px solid rgba(56, 189, 248, 0.3); padding: 0.625rem 1.25rem; border-radius: 8px; cursor: pointer; font-weight: 600; font-size: 0.875rem;" onmouseover="this.style.background='rgba(56, 189, 248, 0.2)'" onmouseout="this.style.background='rgba(56, 189, 248, 0.1)'"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M19.5 3h-1V1.5h-2V3h-9V1.5h-2V3h-1C3.12 3 2 4.12 2 5.5v14C2 20.88 3.12 22 4.5 22h15c1.38 0 2.5-1.12 2.5-2.5v-14C22 4.12 20.88 3 19.5 3zm0 16.5h-15V8h15v11.5z"/></svg> Google Calendar</button>`; 1245 html += '</div></div>'; 1246 1247 // Subscribe CTA 1248 html += `<div style="text-align: center; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid rgba(148, 163, 184, 0.2);"> 1249 <a href="/?subscribe=venue&location=${encodeURIComponent(venueSlug)}#subscribe" style="display: inline-block; background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); color: white !important; padding: 0.75rem 1.5rem; border-radius: 8px; text-decoration: none; font-weight: 600; box-shadow: 0 4px 6px rgba(0,0,0,0.2);">ð§ Get Notified of New Shows</a> 1250 </div>`; 1251 1252 html += '</div>'; 1253 return html; 1254 } 1255 1256 // Calendar export helpers 1257 function formatICSDate(date) { 1258 return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); 1259 } 1260 1261 function formatICSDateOnly(date) { 1262 return date.getFullYear().toString() + 1263 String(date.getMonth() + 1).padStart(2, '0') + 1264 String(date.getDate()).padStart(2, '0'); 1265 } 1266 1267 function downloadEventICS(eventId) { 1268 const event = allVenueEvents.find(e => e.event_id === eventId); 1269 if (!event) return; 1270 1271 // On Android, open Google Calendar directly 1272 if (/Android/i.test(navigator.userAgent)) { 1273 addEventToGoogleCalendar(eventId); 1274 return; 1275 } 1276 1277 const parts = event.date.split('-'); 1278 const startDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2])); 1279 const bandName = event.band || 'Live Music'; 1280 const venueName = toTitleCase(event.venue || 'Venue TBA'); 1281 const location = [event.venue, event.city, event.state].filter(
1281Boolean).join(', '); 1282 1283 let dtstart, dtend; 1284 if (event.time && event.time !== '00:00') { 1285 const timeParts = event.time.split(':'); 1286 startDate.setHours(parseInt(timeParts[0]), parseInt(timeParts[1]), 0); 1287 dtstart = 'DTSTART:' + formatICSDate(startDate); 1288 const endDate = new Date(startDate.getTime() + 3 * 60 * 60 * 1000); 1289 dtend = 'DTEND:' + formatICSDate(endDate); 1290 } else { 1291 dtstart = 'DTSTART;VALUE=DATE:' + formatICSDateOnly(startDate); 1292 if (event.end_date && event.end_date !== event.date) { 1293 const endParts = event.end_date.split('-'); 1294 const endDate = new Date(parseInt(endParts[0]), parseInt(endParts[1]) - 1, parseInt(endParts[2]) + 1); 1295 dtend = 'DTEND;VALUE=DATE:' + formatICSDateOnly(endDate); 1296 } else { 1297 const endDate = new Date(startDate.getTime() + 24 * 60 * 60 * 1000); 1298 dtend = 'DTEND;VALUE=DATE:' + formatICSDateOnly(endDate); 1299 } 1300 } 1301 1302 const ics = [ 1303 'BEGIN:VCALENDAR', 1304 'VERSION:2.0', 1305 'PRODID:-//GrassGigs//Events//EN', 1306 'BEGIN:VEVENT', 1307 'UID:' + eventId + '@grassgigs.com', 1308 dtstart, 1309 dtend, 1310 'SUMMARY:' + bandName + ' at ' + venueName, 1311 'LOCATION:' + location, 1312 'URL:https://grassgigs.com/venue/' + venueSlug + '?event=' + eventId, 1313 'END:VEVENT', 1314 'END:VCALENDAR' 1315 ].join('\r\n'); 1316 1317 const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' }); 1318 const url = URL.createObjectURL(blob); 1319 const a = document.createElement('a'); 1320 a.href = url; 1321 a.download = slugify(bandName) + '-' + event.date + '.ics'; 1322 document.body.appendChild(a); 1323 a.click(); 1324 document.body.removeChild(a); 1325 URL.revokeObjectURL(url); 1326 } 1327 1328 function addEventToGoogleCalendar(eventId) { 1329 const event = allVenueEvents.find(e => e.event_id === eventId); 1330 if (!event) return; 1331 1332 const bandName = event.band || 'Live Music'; 1333 const venueName = toTitleCase(event.venue || 'Venue TBA'); 1334 const location = [event.venue, event.city, event.state].filter(Boolean).join(', '); 1335 const title = bandName + ' at ' + venueName; 1336 1337 const parts = event.date.split('-'); 1338 let dates; 1339 if (event.time && event.time !== '00:00') { 1340 const startDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2])); 1341 const timeParts = event.time.split(':'); 1342 startDate.setHours(parseInt(timeParts[0]), parseInt(timeParts[1]), 0); 1343 const endDate = new Date(startDate.getTime() + 3 * 60 * 60 * 1000); 1344 dates = formatICSDate(startDate) + '/' + formatICSDate(endDate); 1345 } else { 1346 const startStr = formatICSDateOnly(new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]))); 1347 if (event.end_date && event.end_date !== event.date) { 1348 const endParts = event.end_date.split('-'); 1349 const endStr = formatICSDateOnly(new Date(parseInt(endParts[0]), parseInt(endParts[1]) - 1, parseInt(endParts[2]) + 1)); 1350 dates = startStr + '/' + endStr; 1351 } else { 1352 const endDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]) + 1); 1353 dates = startStr + '/' + formatICSDateOnly(endDate); 1354 } 1355 } 1356 1357 const url = `https://www.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent(title)}&dates=${dates}&location=${encodeURIComponent(location)}&details=${encodeURIComponent('Event found on GrassGigs: https://grassgigs.com/venue/' + venueSlug)}`; 1358 window.open(url, '_blank'); 1359 } 1360 1361 // Handle browser back/forward for event modal 1362 window.addEventListener('popstate', function(e) { 1363 if (e.state && e.state.eventId) { 1364 openEventModal(e.state.eventId); 1365 } else { 1366 closeEventModal(); 1367 } 1368 }); 1369 1370 // Close modal on Escape 1371 document.addEventListener('keydown', function(e) { 1372 if (e.key === 'Escape') closeEventModal(); 1373 }); 1374 1375 // ============================================================================ 1376 // VENUE ANALYTICS TRACKING 1377 // ============================================================================ 1378 1379 // Track venue profile page view (ONLY for premium venues) 1380 function trackVenueView(venueSlug, isPremium) { 1381 if (!venueSlug || !isPremium) return; 1382 1383 const deviceType = /Mobile|Android|iPhone/i.test(navigator.userAgent) ? 'mobile' : 1384 /Tablet|iPad/i.test(navigator.userAgent) ? 'tablet' : 'desktop'; 1385 1386 fetch(`${API_BASE_URL}/venue/analytics/track-view`, { 1387 method: 'POST', 1388 headers: {'Content-Type': 'application/json'}, 1389 body: JSON.stringify({ 1390 venue_id: venueSlug, 1391 referrer: document.referrer || 'direct', 1392 device_type: deviceType 1393 }) 1394 }).catch(err => console.log('Analytics tracking failed:', err));
1395 } 1396 1397 // Track event click from venue context (ONLY for premium venues) 1398 function trackVenueEventClick(venueSlug, eventId, isPremium) { 1399 if (!venueSlug || !eventId || !isPremium) return; 1400 1401 fetch(`${API_BASE_URL}/venue/analytics/track-click`, { 1402 method: 'POST', 1403 headers: {'Content-Type': 'application/json'}, 1404 body: JSON.stringify({ 1405 venue_id: venueSlug, 1406 event_id: eventId 1407 }) 1408 }).catch(err => console.log('Analytics tracking failed:', err)); 1409 } 1410 1411 // Track conversion actions (ONLY for premium venues) 1412 function trackVenueConversion(venueSlug, conversionType, eventId, isPremium) { 1413 if (!venueSlug || !conversionType || !isPremium) return; 1414 1415 const payload = { 1416 venue_id: venueSlug, 1417 conversion_type: conversionType 1418 }; 1419 1420 if (eventId) { 1421 payload.event_id = eventId; 1422 } 1423 1424 fetch(`${API_BASE_URL}/venue/analytics/track-conversion`, { 1425 method: 'POST', 1426 headers: {'Content-Type': 'application/json'}, 1427 body: JSON.stringify(payload) 1428 }).catch(err => console.log('Conversion tracking failed:', err)); 1429 } 1430 </script>
1430 1431 1432 <!-- Load Shared Footer --> 1433
1433<script src="/footer-loader.js"></script>
1433 1434
1434<script src="/mobile-nav-loader.js"></script>
1434 1435</body> 1436</html>
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.