/**
* 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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;">← ${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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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>`
}