OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-booking / lib / routes / web.js

248 lines · javascript ref:
/**
 * Web routes — static files + public provider pages.
 */

import fs from 'bare-fs'
import path from 'bare-path'
import { html, notFound, json } from '../response.js'

const MIME_TYPES = {
  '.html': 'text/html',
  '.css': 'text/css',
  '.js': 'application/javascript',
  '.json': 'application/json',
  '.png': 'image/png',
  '.jpg': 'image/jpeg',
  '.jpeg': 'image/jpeg',
  '.svg': 'image/svg+xml',
  '.ico': 'image/x-icon',
  '.woff2': 'font/woff2',
  '.woff': 'font/woff'
}

export function registerWebRoutes (router, server) {
  // Root — serve index.html
  router.get('/', async (req, res) => {
    serveFile(res, path.join(server.appDir, 'web', 'index.html'))
  })

  // Static files — /web/*
  router.get('/web/*filepath', async (req, res) => {
    const filepath = req.params.filepath
    // Prevent directory traversal
    if (filepath.includes('..')) return notFound(res)
    serveFile(res, path.join(server.appDir, 'web', filepath))
  })

  // Health check
  router.get('/api/health', async (req, res) => {
    json(res, {
      status: 'ok',
      version: '0.1.0',
      uptime: process.uptime ? process.uptime() : 0
    })
  })
}

export function registerPublicRoutes (router, server) {
  // Booking page — /:slug/book/:packageId
  router.get('/:slug/book/:packageId', async (req, res) => {
    const { slug, packageId } = req.params

    const providerIdEntry = await server.db.get(`provider-by-slug:${slug}`)
    if (!providerIdEntry) return notFound(res, 'Provider not found')

    const providerEntry = await server.db.get(`provider:${providerIdEntry.value}`)
    if (!providerEntry) return notFound(res, 'Provider not found')

    const pkgEntry = await server.db.get(`package-detail:${packageId}`)
    if (!pkgEntry) return notFound(res, 'Package not found')

    const provider = providerEntry.value
    const pkg = pkgEntry.value
    const accent = provider.accentColor || '#c4a265'
    const esc = (s) => (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')

    const page = `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Book ${esc(pkg.name)} — ${esc(provider.displayName)}</title>
  <link rel="stylesheet" href="${server.pathPrefix}/web/css/app.css">
  <style>:root { --accent: ${accent}; }</style>
</head>
<body>
  <div class="container">
    <nav class="nav-bar">
      <a href="${server.pathPrefix}/${esc(provider.slug)}" style="text-decoration:none;color:inherit;">&larr; ${esc(provider.displayName)}</a>
      <div class="nav-right">
        <a href="${server.pathPrefix}/dashboard" class="btn btn-secondary" id="dashboard-link" style="display:none">Dashboard</a>
        <button class="btn btn-secondary" id="sign-in-btn">Sign In</button>
      </div>
    </nav>

    <section style="padding: 1rem 0;">
      <h2>${esc(pkg.name)}</h2>
      <p class="package-meta">${formatPrice(pkg.price)} · ${pkg.sessionDuration} min${pkg.sessionCount > 1 ? ' · ' + pkg.sessionCount + ' sessions' : ''}</p>
      ${pkg.description ? `<p style="color:var(--text-secondary);margin:1rem 0;">${esc(pkg.description)}</p>` : ''}
    </section>

    <section>
      <h2 class="section-title">Select a Time</h2>
      <div id="booking-container"><p class="empty">Loading slots...</p></div>
    </section>

    <footer class="footer">
      <p>Powered by <strong>hyper-booking</strong></p>
    </footer>
  </div>

  <script src="${server.pathPrefix}/web/js/identity.js"></script>
  <script src="${server.pathPrefix}/web/js/app.js"></script>
  <script>
    initBookingPage('${esc(provider.id)}', '${esc(provider.slug)}', '${esc(pkg.id)}')
  </script>
</body>
</html>`
    html(res, page)
  })

  // Public provider page — /:slug (MUST be last route)
  router.get('/:slug', async (req, res) => {
    const { slug } = req.params

    // Skip API and web paths
    if (slug.startsWith('api') || slug.startsWith('web') || slug === 'favicon.ico') {
      return false
    }

    const providerIdEntry = await server.db.get(`provider-by-slug:${slug}`)
    if (!providerIdEntry) return false  // fall through to 404

    const providerEntry = await server.db.get(`provider:${providerIdEntry.value}`)
    if (!providerEntry) return notFound(res, 'Provider not found')

    const provider = providerEntry.value

    // Get packages
    const packages = []
    for await (const entry of server.db.createReadStream({
      gte: `package:${provider.id}:`,
      lte: `package:${provider.id}:~`
    })) {
      if (entry.value.active !== false) {
        packages.push(entry.value)
      }
    }

    packages.sort((a, b) => {
      if (a.type === 'discovery' && b.type !== 'discovery') return -1
      if (b.type === 'discovery' && a.type !== 'discovery') return 1
      return a.price - b.price
    })

    // Render provider landing page
    const page = renderProviderPage(provider, packages, server.pathPrefix)
    html(res, page)
  })
}

function serveFile (res, filePath) {
  try {
    const content = fs.readFileSync(filePath)
    const ext = path.extname(filePath).toLowerCase()
    const contentType = MIME_TYPES[ext] || 'application/octet-stream'

    res.statusCode = 200
    res.setHeader('Content-Type', contentType)
    res.setHeader('X-Content-Type-Options', 'nosniff')
    res.setHeader('Cache-Control', 'public, max-age=3600')
    res.end(content)
  } catch {
    notFound(res)
  }
}

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

function renderProviderPage (provider, packages, prefix) {
  const accent = provider.accentColor || '#c4a265'
  const esc = (s) => (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')

  const packageCards = packages.map(pkg => {
    const meta = []
    if (pkg.price > 0) meta.push(formatPrice(pkg.price))
    if (pkg.sessionCount > 1) meta.push(`${pkg.sessionCount} sessions`)
    if (pkg.sessionDuration) meta.push(`${pkg.sessionDuration} min`)
    if (pkg.discountFullPay) meta.push(`Save ${pkg.discountFullPay}%`)

    const typeLabel = pkg.type === 'discovery' ? ' (Start Here)' : ''

    return `
      <div class="package-card">
        <div class="package-info">
          <h3 class="package-title">${esc(pkg.name)}${typeLabel}</h3>
          <div class="package-meta">${meta.join(' · ')}</div>
          <p class="package-desc">${esc(pkg.description)}</p>
          <a href="${prefix}/${esc(provider.slug)}/book/${esc(pkg.id)}" class="btn btn-accent">View Details →</a>
        </div>
      </div>`
  }).join('\n')

  return `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>${esc(provider.displayName)} — hyper-booking</title>
  <link rel="stylesheet" href="${prefix}/web/css/app.css">
  <style>:root { --accent: ${accent}; --accent-hover: ${accent}dd; }</style>
</head>
<body>
  <div class="container">
    <nav class="nav-bar">
      <span class="nav-logo">hyper-booking</span>
      <div class="nav-right">
        <a href="${prefix}/dashboard" class="btn btn-secondary" id="dashboard-link" style="display:none">Dashboard</a>
        <button class="btn btn-secondary" id="sign-in-btn">Sign In</button>
      </div>
    </nav>

    <section class="hero">
      <div class="hero-profile">
        <div class="avatar">${esc(provider.displayName).charAt(0)}</div>
        <div>
          <h1 class="provider-name">${esc(provider.displayName)}</h1>
          ${provider.specialties && provider.specialties.length
            ? `<p class="provider-specialties">${provider.specialties.map(esc).join(' · ')}</p>`
            : ''}
        </div>
      </div>
    </section>

    ${provider.bio ? `
    <section class="about">
      <h2>About</h2>
      <div class="about-text">${esc(provider.bio).replace(/\n/g, '<br>')}</div>
    </section>` : ''}

    <section class="packages">
      <h2 class="section-title">Packages</h2>
      ${packageCards || '<p class="empty">No packages available yet.</p>'}
    </section>

    <footer class="footer">
      <p>Powered by <strong>hyper-booking</strong></p>
    </footer>
  </div>

  <script src="${prefix}/web/js/identity.js"></script>
  <script src="${prefix}/web/js/app.js"></script>
</body>
</html>`
}