PageSourceSearch

https://www.massaieservices.com/src/main.js?v=9

js massaieservices.com collected 2026-10-05 19:02:11 UTC 22,253 bytes, 526 lines download raw bytes

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.