1// ââ Helpers ââââââââââââââââââââââââââââââââââââââââââââââââââââ 2function formatDate(dateStr) { 3 if (!dateStr) return '' 4 const d = new Date(dateStr) 5 return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) 6} 7 8function parseFrontmatter(raw) { 9 const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/) 10 if (!match) return { data: {}, content: raw } 11 const data = {} 12 match[1].split('\n').forEach(line => { 13 const colon = line.indexOf(':') 14 if (colon === -1) return 15 const key = line.slice(0, colon).trim() 16 const val = line.slice(colon + 1).trim().replace(/^["']|["']$/g, '') 17 data[key] = val 18 }) 19 return { data, content: match[2] } 20} 21 22function setMeta(title, description) { 23 document.title = title 24 let desc = document.querySelector('meta[name="description"]') 25 if (!desc) { desc = document.createElement('meta'); desc.name = 'description'; document.head.appendChild(desc) } 26 desc.content = description 27} 28 29// ââ Nav scroll effect ââââââââââââââââââââââââââââââââââââââââââ 30const nav = document.getElementById('nav') 31window.addEventListener('scroll', () => { 32 nav.classList.toggle('scrolled', window.scrollY > 40) 33}, { passive: true }) 34 35// ââ Mobile burger ââââââââââââââââââââââââââââââââââââââââââââââ 36const burger = document.getElementById('burger') 37const mobileMenu = document.getElementById('mobileMenu') 38 39burger.addEventListener('click', () => { 40 const open = mobileMenu.classList.toggle('open') 41 burger.setAttribute('aria-expanded', open) 42 const spans = burger.querySelectorAll('span') 43 if (open) { 44 spans[0].style.transform = 'rotate(45deg) translate(5px, 5px)' 45 spans[1].style.opacity = '0' 46 spans[2].style.transform = 'rotate(-45deg) translate(5px, -5px)' 47 } else { 48 spans[0].style.transform = '' 49 spans[1].style.opacity = '' 50 spans[2].style.transform = '' 51 } 52}) 53 54mobileMenu.querySelectorAll('a').forEach(a => { 55 a.addEventListener('click', () => { 56 mobileMenu.classList.remove('open') 57 burger.setAttribute('aria-expanded', 'false') 58 burger.querySelectorAll('span').forEach(s => { s.style.transform = ''; s.style.opacity = '' }) 59 }) 60}) 61 62// ââ SPA Router âââââââââââââââââââââââââââââââââââââââââââââââââ 63const mainSections = () => document.querySelectorAll('.main-section') 64const articlesListPage = () => document.getElementById('articles-list-page') 65const articleSinglePage = () => document.getElementById('article-single-page') 66 67function showMain(hash) { 68 mainSections().forEach(s => s.style.display = '') 69 articlesListPage().style.display = 'none' 70 articleSinglePage().style.display = 'none' 71 nav.querySelectorAll('.main-nav-link').forEach(l => l.style.opacity = '') 72 setMeta('Massaie Services | World-Class Concierge', 'Over a decade of VIP/VVIP concierge, medical tourism, and luxury transportation in Rochester, Minnesota.') 73 if (hash) { 74 setTimeout(() => { 75 const el = document.querySelector(hash) 76 if (el) el.scrollIntoView({ behavior: 'smooth' }) 77 }, 50) 78 } else { 79 window.scrollTo({ top: 0, behavior: 'smooth' }) 80 } 81 initReveal() 82} 83 84function showArticlesList() { 85 mainSections().forEach(s => s.style.display = 'none') 86 articlesListPage().style.display = 'block' 87 articleSinglePage().style.display = 'none' 88 setMeta('Articles & Guides | Massaie Services', 'Expert guidance on medical tourism, VIP travel, and concierge living in Rochester, Minnesota.') 89 window.scrollTo({ top: 0 }) 90 loadArticlesList() 91} 92 93function showSingleArticle(slug) { 94 mainSections().forEach(s => s.style.display = 'none') 95 articlesListPage().style.display = 'none' 96 articleSinglePage().style.display = 'block' 97 window.scrollTo({ top: 0 }) 98 loadArticle(slug) 99} 100 101function route(path, push = true) { 102 if (push) history.pushState({}, '', path) 103 104 // Strip trailing slash, handle home 105 const clean = path.replace(/\/$/, '') || '/' 106 107 if (clean === '/articles') { 108 showArticlesList() 109 } else if (clean.startsWith('/articles/')) { 110 const slug = clean.replace('/articles/', '') 111 showSingleArticle(slug) 112 } else if (clean === '/' || clean === '') { 113 const hash = window.location.hash 114 showMain(hash || null) 115 } else if (clean.startsWith('/#') || window.location.hash) { 116 showMain(window.location.hash) 117 } else { 118 showMain(null) 119 } 120} 121 122// Handle all [data-spa] link clicks 123document.addEventListener('click', e => { 124 const link = e.target.closest('[data-spa]') 125 if (!link) return 126 e.preventDefault() 127 const href = link.getAttribute('href') 128 // If link is /#contact etc, go home first then scroll 129 if (href.startsWith('/#')) { 130 history.pushState({}, '', href) 131 showMain(href.slice(1)) // pass #contact 132 } else { 133 route(href) 134 } 135}) 136 137// Browser back/forward 138window.addEventListener('popstate', () => route(window.location.pathname, false)) 139
140// ââ Article Loading âââââââââââââââââââââââââââââââââââââââââââââ 141let manifestCache = null 142 143async function getManifest() { 144 if (manifestCache) return manifestCache 145 try { 146 const res = await fetch('/content/articles-manifest.json') 147 if (!res.ok) throw new Error('No manifest') 148 manifestCache = await res.json() 149 } catch { 150 manifestCache = [] 151 } 152 return manifestCache 153} 154 155async function loadArticlesList() { 156 const grid = document.getElementById('articlesGrid') 157 grid.innerHTML = '<div class="articles-loading">Loading articlesâ¦</div>' 158 const articles = await getManifest() 159 160 if (!articles.length) { 161 grid.innerHTML = '<div class="articles-loading">No articles yet â check back soon.</div>' 162 return 163 } 164 165 grid.innerHTML = articles.map(a => ` 166 <article class="article-card reveal" onclick="route('/articles/${a.slug}')"> 167 <div class="article-card__img"> 168 ${a.image 169 ? `<img src="${a.image}" alt="${a.title}" loading="lazy" />` 170 : `<span class="article-card__img-placeholder">${a.title.charAt(0)}</span>`} 171 </div> 172 <div class="article-card__body"> 173 <p class="article-card__date">${formatDate(a.date)}</p> 174 <h2 class="article-card__title">${a.title}</h2> 175 <p class="article-card__desc">${a.description}</p> 176 <span class="article-card__link"> 177 Read Article 178 <svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M3 7h8M7 3l4 4-4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg> 179 </span> 180 </div> 181 </article> 182 `).join('') 183 184 // Animate cards in 185 grid.querySelectorAll('.article-card').forEach((card, i) => { 186 card.style.transitionDelay = `${i * 0.08}s` 187 setTimeout(() => card.classList.add('visible'), 50) 188 }) 189} 190 191async function loadArticle(slug) { 192 const titleEl = document.getElementById('articleTitle') 193 const metaEl = document.getElementById('articleMeta') 194 const infoEl = document.getElementById('articleInfo') 195 const contentEl = document.getElementById('articleContent') 196 const relatedEl = document.getElementById('relatedArticles') 197 198 titleEl.textContent = 'Loadingâ¦' 199 contentEl.innerHTML = '<p style="color:var(--gray-400)">Loading articleâ¦</p>' 200 201 try { 202 const res = await fetch(`/content/articles/${slug}.md`) 203 if (!res.ok) throw new Error('Not found') 204 const raw = await res.text() 205 const { data, content } = parseFrontmatter(raw) 206 207 setMeta( 208 `${data.title || slug} | Massaie Services`, 209 data.description || 'Read this article from Massaie Services.' 210 ) 211 212 metaEl.textContent = data.date ? formatDate(data.date) : '' 213 titleEl.textContent = data.title || slug 214 infoEl.innerHTML = ` 215 <span>By ${data.author || 'Massaie Services'}</span> 216 ${data.readTime ? `<span>·</span><span>${data.readTime} min read</span>` : ''} 217 ` 218 contentEl.innerHTML = marked.parse(content) 219 220 // Related articles 221 const manifest = await getManifest() 222 const related = manifest.filter(a => a.slug !== slug).slice(0, 4) 223 relatedEl.innerHTML = related.map(a => ` 224 <a class="related-link" href="/articles/${a.slug}" data-spa>${a.title}</a> 225 `).join('') 226 227 } catch { 228 titleEl.textContent = 'Article not found' 229 contentEl.innerHTML = `<p>Sorry, this article could not be loaded. <a href="/articles" data-spa>Browse all articles â</a></p>` 230 } 231} 232 233// ââ Scroll Reveal âââââââââââââââââââââââââââââââââââââââââââââââ 234function initReveal() { 235 const observer = new IntersectionObserver(entries => { 236 entries.forEach(entry => { 237 if (entry.isIntersecting) { 238 entry.target.classList.add('visible') 239 observer.unobserve(entry.target) 240 } 241 }) 242 }, { threshold: 0.12, rootMargin: '0px 0px -40px 0px' }) 243 244 const targets = [ 245 '.service-card', '.about__text', '.about__visual', 246 '.contact__info', '.contact__form', '.section-header', '.process__step' 247 ]
248 targets.forEach(sel => { 249 document.querySelectorAll(sel).forEach((el, i) => { 250 el.classList.add('reveal') 251 el.style.transitionDelay = `${i * 0.07}s` 252 observer.observe(el) 253 }) 254 }) 255} 256 257// ââ Contact form (2-step) âââââââââââââââââââââââââââââââââââââââ 258const FORMSPREE_ENDPOINT = 'https://formspree.io/f/maqlzzpk' 259 260// Per-service follow-up question definitions 261const SERVICE_QUESTIONS = { 262 'Airport Transportation (MSP or RST)': { 263 label: 'Airport Transportation Details', 264 hasSubTypes: true, 265 subTypes: [ 266 { 267 label: 'Airport Pickup / Drop-off', 268 fields: [ 269 { label: 'Airport', name: 'trans_airport', type: 'select', options: ['MSP â Minneapolis-Saint Paul', 'RST â Rochester International'] }, 270 { label: 'Service Type', name: 'trans_type', type: 'select', options: ['Pickup (arriving)', 'Drop-off (departing)', 'Both'] }, 271 { label: 'No. of Passengers', name: 'trans_passengers', type: 'number' }, 272 { label: 'No. of Bags', name: 'trans_bags', type: 'number' }, 273 { label: 'Vehicle Preference', name: 'trans_vehicle', type: 'select', options: ['Sedan', 'SUV', 'Van', 'Wheelchair Accessible'] }, 274 { label: 'Arrival Date & Time', name: 'trans_datetime', type: 'datetime-local' }, 275 ] 276 }, 277 { 278 label: 'Round Trip (arrive + depart)', 279 fields: [ 280 { label: 'Airport', name: 'rt_airport', type: 'select', options: ['MSP â Minneapolis-Saint Paul', 'RST â Rochester International'] }, 281 { label: 'Arrival Date & Time', name: 'rt_arrive', type: 'datetime-local' }, 282 { label: 'Departure Date & Time', name: 'rt_depart', type: 'datetime-local' }, 283 { label: 'No. of Passengers', name: 'rt_passengers', type: 'number' }, 284 { label: 'Vehicle Preference', name: 'rt_vehicle', type: 'select', options: ['Sedan', 'SUV', 'Van', 'Wheelchair Accessible'] }, 285 ] 286 }, 287 ] 288 }, 289 'Daily Rides to Appointments': { 290 label: 'Daily Appointment Transportation', 291 fields: [ 292 { label: 'Stay Start Date', name: 'daily_start', type: 'date' }, 293 { label: 'Number of Days', name: 'daily_days', type: 'number' }, 294 { label: 'Vehicle Preference', name: 'daily_vehicle', type: 'select', options: ['Sedan', 'SUV', 'Van', 'Wheelchair Accessible'] }, 295 { label: 'Pickup Address / Hotel', name: 'daily_from', type: 'text', placeholder: 'Hotel name or address' }, 296 { label: 'Destination (clinic/facility)', name: 'daily_to', type: 'text', placeholder: 'Clinic or facility name' }, 297 ] 298 }, 299 'Accommodation Coordination': { 300 label: 'Accommodation Preferences', 301 fields: [ 302 { label: 'Arrival Date', name: 'lodge_arrival', type: 'date' }, 303 { label: 'Duration of Stay', name: 'lodge_duration', type: 'select', options: ['1â3 days', '4â7 days', '1â2 weeks', '3â4 weeks', '1â3 months', '3+ months'] }, 304 { label: 'Number of Guests', name: 'lodge_guests', type: 'number' }, 305 { label: 'Budget Range', name: 'lodge_budget', type: 'select', options: ['Economy ($100â150/night)', 'Standard ($150â250/night)', 'Luxury ($250â500/night)'] }, 306 { label: 'Special Requirements', name: 'lodge_notes', type: 'textarea', placeholder: 'Accessibility needs, proximity to specific facilities, kitchen requiredâ¦' }, 307 ] 308 }, 309 'Grocery & Errand Support': { 310 label: 'Grocery & Errand Details', 311 fields: [ 312 { label: 'Support Needed', name: 'errand_type', type: 'checkgroup', options: ['Grocery shopping', 'Pharmacy runs', 'Laundry coordination', 'Meal preparation assistance', 'Other errands'] }, 313 { label: 'Frequency', name: 'errand_freq', type: 'select', options: ['Once', 'A few times per week', 'Daily', 'As needed'] }, 314 { label: 'Dietary Restrictions', name: 'errand_diet', type: 'text', placeholder: 'e.g. Halal, gluten-free, no porkâ¦' }, 315 { label: 'Additional Notes', name: 'errand_notes', type: 'textarea', placeholder: 'Specific stores, brands, or requirementsâ¦' }, 316 ] 317 }, 318 'Full-Stay Concierge Package': { 319 label: 'Full-Stay Concierge Details', 320 fields: [ 321 { label: 'Arrival Date', name: 'pkg_arrival', type: 'date' }, 322 { label: 'Duration of Stay', name: 'pkg_duration', type: 'select', options: ['Less than 1 week', '1â2 weeks', '3â4 weeks', '1â3 months', '3+ months'] }, 323 { label: 'Number of People', name: 'pkg_people', type: 'number' }, 324 { label: 'Airport', name: 'pkg_airport', type: 'select', options: ['MSP â Minneapolis-Saint Paul', 'RST â Rochester International', 'Driving in'] }, 325 { label: 'Services Included', name: 'pkg_services', type: 'checkgroup', options: ['Airport transportation', 'Daily appointment rides', 'Accommodation coordination', 'Grocery & errands', 'Meals support', 'Family/child support'] }, 326 { label: 'Tell us more', name: 'pkg_notes', type: 'textarea', placeholder: 'Any specific needs, medical situation, or preferences we should know aboutâ¦' }, 327 ] 328 }, 329 'Other / Not Sure Yet': { 330 label: 'Tell Us What You Need', 331 fields: [ 332 { label: 'Describe what you need', name: 'other_notes', type: 'textarea', placeholder: 'Tell us about your trip and we\'ll figure out the best way to help youâ¦' }, 333 { label: 'Arrival Date (if known)', name: 'other_arrival', type: 'date' },
334 { label: 'Best way to reach you', name: 'other_contact', type: 'select', options: ['Email', 'Phone call', 'Text / WhatsApp'] }, 335 ] 336 }, 337} 338 339function buildField(field) { 340 const wrap = `<div class="form-group">` 341 const label = `<label>${field.label}</label>` 342 let input = '' 343 if (field.type === 'select') { 344 input = `<select name="${field.name}"><option value="">Selectâ¦</option>${field.options.map(o => `<option>${o}</option>`).join('')}</select>` 345 } else if (field.type === 'textarea') { 346 input = `<textarea name="${field.name}" rows="3" placeholder="${field.placeholder || ''}"></textarea>` 347 } else if (field.type === 'checkgroup') { 348 input = `<div class="checkgroup">${field.options.map(o => `<label class="checkgroup-item"><input type="checkbox" name="${field.name}[]" value="${o}"><span>${o}</span></label>`).join('')}</div>` 349 } else { 350 input = `<input type="${field.type}" name="${field.name}" placeholder="${field.placeholder || ''}" />` 351 } 352 return wrap + label + input + `</div>` 353} 354 355function buildStep2Form(selectedServices, clientName) { 356 const container = document.getElementById('contactStep2') 357 358 const sections = selectedServices.map(svcName => { 359 const svc = SERVICE_QUESTIONS[svcName] 360 if (!svc) return '' 361 362 if (svc.hasSubTypes) { 363 const tabs = svc.subTypes.map((st, i) => 364 `<button type="button" class="step2-subtype-btn${i === 0 ? ' active' : ''}" data-subtype="${i}">${st.label}</button>` 365 ).join('') 366 367 const panels = svc.subTypes.map((st, i) => 368 `<div class="step2-subfields${i === 0 ? ' active' : ''}" data-panel="${i}">${st.fields.map(buildField).join('')}</div>` 369 ).join('') 370 371 return `<div class="step2-section"> 372 <div class="step2-section-title">${svc.label}</div> 373 <div class="step2-subtypes">${tabs}</div> 374 ${panels} 375 </div>` 376 } 377 378 return `<div class="step2-section"> 379 <div class="step2-section-title">${svc.label}</div> 380 ${svc.fields.map(buildField).join('')} 381 </div>` 382 }).join('') 383 384 container.innerHTML = ` 385 <form class="step2-form" id="step2Form"> 386 <h3>Message sent â one more step</h3> 387 <p>To prepare your quote, we just need a bit more information about the services you selected.</p> 388 <input type="hidden" name="_subject" value="Follow-up details from ${clientName}" /> 389 ${sections} 390 <div style="margin-top:28px; display:flex; gap:12px; flex-wrap:wrap;"> 391 <button type="submit" class="btn btn--primary">Submit Details</button> 392 <button type="button" class="btn btn--ghost" id="skipStep2">Skip for Now</button> 393 </div> 394 <p class="form-note" style="margin-top:14px">This is optional â we'll follow up by email with any missing information.</p> 395 </form> 396 ` 397 398 // Sub-type tab switching (for Transportation) 399 container.querySelectorAll('.step2-subtype-btn').forEach(btn => { 400 btn.addEventListener('click', () => { 401 const section = btn.closest('.step2-section') 402 const idx = btn.dataset.subtype 403 section.querySelectorAll('.step2-subtype-btn').forEach(b => b.classList.remove('active')) 404 section.querySelectorAll('.step2-subfields').forEach(p => p.classList.remove('active')) 405 btn.classList.add('active') 406 section.querySelector(`.step2-subfields[data-panel="${idx}"]`).classList.add('active') 407 }) 408 }) 409 410 document.getElementById('skipStep2').addEventListener('click', () => { 411 container.innerHTML = `<div class="step2-submitted"><h3>All Set</h3><p>We received your message and will be in touch shortly.</p></div>` 412 }) 413 414 document.getElementById('step2Form').addEventListener('submit', async e => { 415 e.preventDefault() 416 const btn = e.target.querySelector('[type="submit"]') 417 btn.textContent = 'Sendingâ¦' 418 btn.disabled = true 419 try { 420 const res = await fetch(FORMSPREE_ENDPOINT, { 421 method: 'POST', 422 headers: { 'Accept': 'application/json' }, 423 body: new FormData(e.target) 424 }) 425 if (!res.ok) throw new Error() 426 } catch { /* fail silently â main form already sent */ } 427 container.innerHTML = `<div class="step2-submitted"><h3>
427Details Received</h3><p>Thank you â we'll prepare your quote and be in touch soon.</p></div>` 428 }) 429 430 container.scrollIntoView({ behavior: 'smooth', block: 'start' }) 431} 432 433const form = document.getElementById('contactForm') 434form.addEventListener('submit', async e => { 435 e.preventDefault() 436 const btn = form.querySelector('button[type="submit"]') 437 const original = btn.textContent 438 const data = Object.fromEntries(new FormData(form)) 439 const missing = ['firstName', 'lastName', 'email'].filter(k => !data[k]?.trim()) 440 if (missing.length) { 441 missing.forEach(k => { 442 const input = form.querySelector(`#${k}`) 443 input.style.borderColor = '#ef4444' 444 input.addEventListener('input', () => { input.style.borderColor = '' }, { once: true }) 445 }) 446 return 447 } 448 449 // Collect selected services 450 const selectedServices = [...form.querySelectorAll('input[name="services"]:checked')].map(cb => cb.value) 451 452 btn.textContent = 'Sendingâ¦' 453 btn.disabled = true 454 455 try { 456 const res = await fetch(FORMSPREE_ENDPOINT, { 457 method: 'POST', 458 headers: { 'Accept': 'application/json' }, 459 body: new FormData(form) 460 }) 461 if (!res.ok) throw new Error('Server error') 462 } catch { 463 btn.textContent = 'Failed â please email us directly' 464 btn.style.background = '#ef4444' 465 btn.style.borderColor = '#ef4444' 466 setTimeout(() => { 467 btn.textContent = original 468 btn.style.background = '' 469 btn.style.borderColor = '' 470 btn.disabled = false 471 }, 4000) 472 return 473 } 474 475 btn.textContent = 'Sent!' 476 btn.style.background = '#16a34a' 477 btn.style.borderColor = '#16a34a' 478 form.reset() 479 480 // GA4 event via GTM dataLayer 481 window.dataLayer = window.dataLayer || [] 482 window.dataLayer.push({ 483 event: 'form_submit', 484 form_id: 'contactForm', 485 form_name: 'Homepage Contact Form' 486 }) 487 488 const clientName = `${data.firstName} ${data.lastName}` 489 if (selectedServices.length) { 490 buildStep2Form(selectedServices, clientName) 491 } 492 493 setTimeout(() => { 494 btn.textContent = original 495 btn.style.background = '' 496 btn.style.borderColor = '' 497 btn.disabled = false 498 }, 3500) 499}) 500 501// ââ Active nav highlight ââââââââââââââââââââââââââââââââââââââââ 502const sections = document.querySelectorAll('section[id]') 503const navLinks = document.querySelectorAll('.nav__links a[href^="#"]') 504const sectionObserver = new IntersectionObserver(entries => { 505 entries.forEach(entry => { 506 if (entry.isIntersecting) { 507 const id = entry.target.id 508 navLinks.forEach(link => { 509 link.style.color = link.getAttribute('href') === `#${id}` ? 'var(--white)' : '' 510 }) 511 } 512 }) 513}, { threshold: 0.4 }) 514sections.forEach(s => sectionObserver.observe(s)) 515 516// ââ Init ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 517// Expose route globally so dynamically-injected onclick attributes can call it 518console.log('[massaie] module init, route type:', typeof route) 519window.route = route 520console.log('[massaie] window.route set:', typeof window.route) 521 522route(window.location.pathname, false) 523 524// Auto-update copyright year 525const yearEl = document.getElementById('copyright-year') 526if (yearEl) yearEl.textContent = new Date().getFullYear()
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.