OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-booking / web / js / app.js

216 lines · javascript ref:
/**
 * hyper-booking — client-side SPA logic.
 * Minimal vanilla JS, no framework.
 */

/* global HyperBookingIdentity */

// ---- Path Prefix ----
// Detect prefix from URL so /api/* calls work behind reverse proxies like 7rnx.net/hyper-booking/
const PATH_PREFIX = (function () {
  const path = window.location.pathname
  const idx = path.indexOf('/web/')
  if (idx > 0) return path.substring(0, idx)
  const dashIdx = path.indexOf('/dashboard')
  if (dashIdx > 0) return path.substring(0, dashIdx)
  // Check for known prefix patterns
  const match = path.match(/^(\/[^/]+)\//)
  if (match && match[1] !== '/api' && match[1] !== '/web') return match[1]
  return ''
})()

// ---- Utilities ----

function showToast (message, type = 'info') {
  const toast = document.createElement('div')
  toast.className = 'toast' + (type !== 'info' ? ' ' + type : '')
  toast.textContent = message
  document.body.appendChild(toast)
  setTimeout(() => toast.remove(), 3500)
}

async function api (method, path, body) {
  const opts = {
    method,
    headers: { 'Content-Type': 'application/json' }
  }
  if (body) opts.body = JSON.stringify(body)

  const res = await fetch(PATH_PREFIX + path, opts)
  const data = await res.json()
  if (!res.ok) throw new Error(data.error || 'Request failed')
  return data
}

function formatTime (time24) {
  const [h, m] = time24.split(':').map(Number)
  const ampm = h >= 12 ? 'PM' : 'AM'
  const h12 = h % 12 || 12
  return `${h12}:${String(m).padStart(2, '0')} ${ampm}`
}

function formatDate (dateStr) {
  const d = new Date(dateStr + 'T12:00:00Z')
  return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
}

function formatPrice (cents) {
  if (cents === 0) return 'Free'
  return '$' + (cents / 100).toFixed(cents % 100 === 0 ? 0 : 2)
}

// ---- Sign In ----

const signInBtn = document.getElementById('sign-in-btn')
if (signInBtn) {
  signInBtn.addEventListener('click', async () => {
    try {
      if (typeof HyperBookingIdentity === 'undefined') {
        showToast('Identity module not loaded', 'error')
        return
      }
      if (HyperBookingIdentity.hasKeypair()) {
        showToast('Already signed in')
        return
      }
      signInBtn.textContent = 'Signing in...'
      await HyperBookingIdentity.generate()
      showToast('Identity created', 'success')
      signInBtn.textContent = 'Signed In'
      showDashboardLink()
    } catch (err) {
      showToast('Sign-in failed: ' + err.message, 'error')
      signInBtn.textContent = 'Sign In'
      console.error('[Sign-in error]', err)
    }
  })

  // Update button if already signed in
  if (typeof HyperBookingIdentity !== 'undefined' && HyperBookingIdentity.hasKeypair()) {
    signInBtn.textContent = 'Signed In'
    showDashboardLink()
  }
}

function showDashboardLink () {
  const link = document.getElementById('dashboard-link')
  if (link) link.style.display = ''
}

// ---- Booking Page ----
// When a booking page loads, this handles the calendar + slot selection

async function initBookingPage (providerId, providerSlug, packageId) {
  const pkg = await api('GET', `/api/packages/${packageId}`)
  const today = new Date()
  let weekStart = new Date(today)
  weekStart.setDate(weekStart.getDate() - weekStart.getDay() + 1) // Monday

  const container = document.getElementById('booking-container')
  if (!container) return

  let selectedSlot = null

  async function loadWeek () {
    const weekStr = weekStart.toISOString().split('T')[0]
    const data = await api('GET', `/api/providers/${providerSlug}/slots?weekOf=${weekStr}&duration=${pkg.sessionDuration}`)

    const weekEnd = new Date(weekStart)
    weekEnd.setDate(weekEnd.getDate() + 6)

    let html = `
      <div class="calendar-nav">
        <button id="prev-week">&larr;</button>
        <span>${formatDate(weekStr)} &ndash; ${formatDate(weekEnd.toISOString().split('T')[0])}</span>
        <button id="next-week">&rarr;</button>
      </div>
      <div class="calendar-grid">
    `

    for (const [date, slots] of Object.entries(data.days)) {
      html += `<div class="day-column"><h4>${formatDate(date)}</h4>`
      if (slots.length === 0) {
        html += '<p class="no-slots">No availability</p>'
      } else {
        for (const slot of slots) {
          html += `<button class="slot-btn" data-date="${date}" data-time="${slot.startTime}">${formatTime(slot.startTime)}</button>`
        }
      }
      html += '</div>'
    }

    html += '</div>'
    html += '<div id="selected-summary"></div>'
    html += '<button class="btn btn-primary" id="confirm-booking" disabled>Select a time to continue</button>'

    container.innerHTML = html

    // Event listeners
    document.getElementById('prev-week').addEventListener('click', () => {
      weekStart.setDate(weekStart.getDate() - 7)
      loadWeek()
    })
    document.getElementById('next-week').addEventListener('click', () => {
      weekStart.setDate(weekStart.getDate() + 7)
      loadWeek()
    })

    container.querySelectorAll('.slot-btn').forEach(btn => {
      btn.addEventListener('click', () => {
        container.querySelectorAll('.slot-btn').forEach(b => b.classList.remove('selected'))
        btn.classList.add('selected')
        selectedSlot = { date: btn.dataset.date, time: btn.dataset.time }

        document.getElementById('selected-summary').innerHTML = `
          <div class="selected-slot">
            <strong>${formatDate(selectedSlot.date)}</strong> at <strong>${formatTime(selectedSlot.time)}</strong>
          </div>
        `
        const confirmBtn = document.getElementById('confirm-booking')
        confirmBtn.disabled = false
        confirmBtn.textContent = pkg.price === 0 ? 'Book Free Session' : `Book — ${formatPrice(pkg.price)}`
      })
    })

    document.getElementById('confirm-booking').addEventListener('click', async () => {
      if (!selectedSlot) return

      // Ensure identity exists
      if (typeof HyperBookingIdentity !== 'undefined' && !HyperBookingIdentity.hasKeypair()) {
        await HyperBookingIdentity.generate()
      }

      try {
        const booking = await api('POST', '/api/bookings', {
          providerId,
          packageId,
          date: selectedSlot.date,
          startTime: selectedSlot.time
        })

        container.innerHTML = `
          <div style="text-align: center; padding: 3rem 0;">
            <h2 style="color: var(--success);">Booking Confirmed!</h2>
            <p style="margin: 1rem 0;">
              <strong>${formatDate(booking.date)}</strong> at <strong>${formatTime(booking.startTimeLocal)}</strong>
            </p>
            <p style="color: var(--text-secondary);">${pkg.name}</p>
            <p style="margin-top: 2rem;">
              <a href="/${providerSlug}" class="btn btn-secondary">Back to Provider</a>
            </p>
          </div>
        `
        showToast('Booking confirmed!', 'success')
      } catch (err) {
        showToast(err.message, 'error')
      }
    })
  }

  await loadWeek()
}

// Expose for inline use
window.initBookingPage = initBookingPage
window.showToast = showToast