/**
* Dashboard client-side logic.
* Each view has its own init function.
*/
/* global HyperBookingIdentity, api, showToast, formatTime, formatDate, formatPrice */
const content = document.getElementById('content')
async function initDashboard (view, params) {
// Check auth first
try {
const me = await api('GET', '/api/auth/me')
const nameEl = document.getElementById('user-name')
if (nameEl) nameEl.textContent = me.username || me.userId || ''
// Show admin link if admin
if (me.role === 'admin') {
const adminLink = document.getElementById('admin-link')
if (adminLink) adminLink.style.display = ''
}
if (!me.authenticated && view !== 'profile') {
content.innerHTML = '<p class="empty">Please <a href="javascript:void(0)" onclick="signIn()">sign in</a> to access the dashboard.</p>'
return
}
} catch (err) {
content.innerHTML = '<p class="empty">Please sign in to access the dashboard.</p>'
return
}
// Auto-claim unclaimed provider on first dashboard visit
if (view === 'home' || view === 'clients' || view === 'packages' || view === 'availability' || view === 'analytics' || view === 'settings') {
try {
const provData = await api('GET', '/api/dashboard/provider')
if (!provData.provider) {
renderCreateProvider()
return
}
} catch (err) {
// Non-blocking — continue anyway
}
}
switch (view) {
case 'home': return renderCalendar()
case 'clients': return renderClients()
case 'client-detail': return renderClientDetail(params.clientId)
case 'packages': return renderPackages()
case 'package-edit': return renderPackageEdit(params.id)
case 'availability': return renderAvailability()
case 'analytics': return renderAnalytics()
case 'settings': return renderSettings()
case 'profile': return renderProfile()
case 'portal': return renderPortal()
case 'admin': return renderAdmin()
case 'admin-providers': return renderAdminProviders()
case 'admin-users': return renderAdminUsers()
default: content.innerHTML = '<p class="empty">Unknown view</p>'
}
}
async function signIn () {
if (typeof HyperBookingIdentity !== 'undefined') {
await HyperBookingIdentity.generate()
showToast('Identity created', 'success')
location.reload()
}
}
// ---- Calendar (Home) ----
async function renderCalendar () {
try {
const [today, week] = await Promise.all([
api('GET', '/api/dashboard/today'),
api('GET', '/api/dashboard/week')
])
const todayFmt = new Date(today.date + 'T12:00:00Z').toLocaleDateString('en-US', {
weekday: 'long', month: 'long', day: 'numeric'
})
let html = `
<div class="summary-grid">
<div class="summary-card">
<div class="summary-number">${today.summary.sessions}</div>
<div class="summary-label">Sessions Today</div>
</div>
<div class="summary-card">
<div class="summary-number">${today.summary.clients}</div>
<div class="summary-label">Clients Today</div>
</div>
</div>
<h3 style="margin-bottom: 1rem;">Today — ${todayFmt}</h3>
`
if (today.bookings.length === 0) {
html += '<p class="empty">No sessions scheduled today.</p>'
} else {
for (const b of today.bookings) {
html += renderSessionCard(b)
}
}
// Week overview
const todayStr = new Date().toISOString().split('T')[0]
html += '<h3 style="margin: 2rem 0 1rem;">This Week</h3><div class="week-grid">'
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
let i = 0
for (const [date, info] of Object.entries(week.days)) {
const isToday = date === todayStr
html += `
<div class="week-day ${isToday ? 'today' : ''}">
<div class="week-day-name">${dayNames[i++] || ''}</div>
<div class="week-day-count">${info.count}</div>
<div class="week-day-date">${date.slice(5)}</div>
</div>`
}
html += '</div>'
content.innerHTML = html
attachSessionActions()
} catch (err) {
content.innerHTML = `<p class="empty">Error loading calendar: ${err.message}</p>`
}
}
function renderSessionCard (b) {
const statusBadge = b.status === 'confirmed'
? '<span style="color:var(--success)">Confirmed</span>'
: b.status === 'completed'
? '<span style="color:var(--text-secondary)">Completed</span>'
: `<span>${b.status}</span>`
return `
<div class="session-card" data-booking-id="${b.id}">
<div class="session-time">${formatTime(b.startTimeLocal)}</div>
<div class="session-info">
<div class="session-client">${esc(b.clientName)}</div>
<div class="session-meta">${esc(b.packageName)} · ${b.sessionDuration}min · ${statusBadge}</div>
<div class="session-actions">
${b.status === 'confirmed' ? `
<button class="btn btn-secondary" onclick="completeSession('${b.id}')">Complete</button>
<button class="btn btn-secondary" onclick="markNoShow('${b.id}')">No-Show</button>
` : ''}
<button class="btn btn-secondary" onclick="cancelSession('${b.id}')">Cancel</button>
</div>
</div>
</div>`
}
function attachSessionActions () {
// Actions are inline onclick — nothing to attach
}
// ---- Clients ----
async function renderClients () {
try {
const clients = await api('GET', '/api/dashboard/clients')
let html = `<div style="margin-bottom: 1rem;">
<input type="text" id="client-search" placeholder="Search clients..." style="max-width:300px;" oninput="searchClients(this.value)">
</div>`
if (clients.length === 0) {
html += '<p class="empty">No clients yet. They appear here after their first booking.</p>'
} else {
html += '<div id="client-list">'
for (const c of clients) {
html += renderClientCard(c)
}
html += '</div>'
}
content.innerHTML = html
} catch (err) {
content.innerHTML = `<p class="empty">Error: ${err.message}</p>`
}
}
function renderClientCard (c) {
return `
<div class="client-card" onclick="location.href='/dashboard/clients/${c.id}'">
<div class="client-card-info">
<div class="client-card-name">${esc(c.displayName)}</div>
<div class="client-card-meta">
${c.email || 'No email'} · ${c.bookingCount || 0} bookings
${c.nextSession ? ' · Next: ' + c.nextSession : ''}
</div>
</div>
<span style="color:var(--text-secondary)">→</span>
</div>`
}
async function searchClients (q) {
const clients = await api('GET', '/api/dashboard/clients?q=' + encodeURIComponent(q))
const list = document.getElementById('client-list')
if (list) {
list.innerHTML = clients.length
? clients.map(renderClientCard).join('')
: '<p class="empty">No matches</p>'
}
}
// ---- Client Detail ----
async function renderClientDetail (clientId) {
try {
const data = await api('GET', '/api/dashboard/clients/' + clientId)
const { client, bookings } = data
const upcoming = bookings.filter(b => b.status === 'confirmed')
const past = bookings.filter(b => b.status === 'completed')
let html = `
<p><a href="/dashboard/clients">← Back to Clients</a></p>
<h2 style="margin: 1rem 0;">${esc(client.displayName)}</h2>
<div class="card">
<p><strong>Email:</strong> ${esc(client.email || 'Not provided')}</p>
<p><strong>Client since:</strong> ${new Date(client.createdAt).toLocaleDateString()}</p>
</div>
<h3 style="margin: 1.5rem 0 1rem;">Upcoming (${upcoming.length})</h3>
`
if (upcoming.length === 0) {
html += '<p class="empty">No upcoming sessions</p>'
} else {
for (const b of upcoming) {
html += `<div class="session-card">
<div class="session-time">${b.date}<br>${formatTime(b.startTimeLocal)}</div>
<div class="session-info">
<div class="session-meta">${esc(b.packageName)}</div>
</div>
</div>`
}
}
html += `<h3 style="margin: 1.5rem 0 1rem;">Past Sessions (${past.length})</h3>`
if (past.length === 0) {
html += '<p class="empty">No past sessions</p>'
} else {
for (const b of past) {
html += `<div class="session-card">
<div class="session-time">${b.date}</div>
<div class="session-info">
<div class="session-meta">${esc(b.packageName)}</div>
${b.notes ? `<p style="font-size:0.85rem;color:var(--text-secondary);margin-top:0.25rem;">"${esc(b.notes)}"</p>` : ''}
</div>
</div>`
}
}
content.innerHTML = html
} catch (err) {
content.innerHTML = `<p class="empty">Error: ${err.message}</p>`
}
}
// ---- Packages ----
async function renderPackages () {
try {
const provData = await api('GET', '/api/dashboard/provider')
if (!provData.provider) {
content.innerHTML = '<p class="empty">Create a provider profile first.</p>'
return
}
const provider = provData.provider
const packages = await api('GET', `/api/providers/${provider.slug}/packages`)
let html = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
<span>${packages.length} package${packages.length !== 1 ? 's' : ''}</span>
<a href="/dashboard/packages/new" class="btn btn-accent">+ New Package</a>
</div>
`
for (const pkg of packages) {
const meta = []
meta.push(pkg.type)
meta.push(pkg.sessionDuration + 'min')
if (pkg.sessionCount > 1) meta.push(pkg.sessionCount + ' sessions')
meta.push(formatPrice(pkg.price))
html += `
<div class="package-row">
<div class="package-row-info">
<div class="package-row-name">${esc(pkg.name)}</div>
<div class="package-row-meta">${meta.join(' · ')}</div>
</div>
<div class="package-row-actions">
<a href="/dashboard/packages/${pkg.id}/edit" class="btn btn-secondary">Edit</a>
<button class="btn btn-secondary" onclick="archivePackage('${pkg.id}')">Archive</button>
</div>
</div>`
}
content.innerHTML = html
} catch (err) {
content.innerHTML = `<p class="empty">Error: ${err.message}</p>`
}
}
// ---- Package Edit ----
async function renderPackageEdit (packageId) {
let pkg = null
let myProvider = null
try {
const provData = await api('GET', '/api/dashboard/provider')
myProvider = provData.provider
if (packageId) {
pkg = await api('GET', `/api/packages/${packageId}`)
}
} catch (err) {
content.innerHTML = `<p class="empty">Error: ${err.message}</p>`
return
}
const isNew = !pkg
const providerId = pkg ? pkg.providerId : (myProvider ? myProvider.id : '')
content.innerHTML = `
<p><a href="/dashboard/packages">← Back to Packages</a></p>
<h2 style="margin:1rem 0;">${isNew ? 'New Package' : 'Edit Package'}</h2>
<form id="package-form" class="card">
<input type="hidden" id="pkg-providerId" value="${providerId}">
<label>Package Type</label>
<select id="pkg-type">
<option value="discovery" ${pkg && pkg.type === 'discovery' ? 'selected' : ''}>Discovery (Free)</option>
<option value="single" ${pkg && pkg.type === 'single' ? 'selected' : ''}>Single Session</option>
<option value="bundle" ${pkg && pkg.type === 'bundle' ? 'selected' : ''}>Bundle (Multiple Sessions)</option>
</select>
<label>Name *</label>
<input type="text" id="pkg-name" value="${esc(pkg ? pkg.name : '')}" required>
<label>Description</label>
<textarea id="pkg-description" rows="3">${esc(pkg ? pkg.description : '')}</textarea>
<label>Session Duration (minutes)</label>
<input type="number" id="pkg-duration" value="${pkg ? pkg.sessionDuration : 60}" min="5" step="5">
<div id="price-section">
<label>Price (cents)</label>
<input type="number" id="pkg-price" value="${pkg ? pkg.price : 0}" min="0">
<p style="font-size:0.8rem;color:var(--text-secondary)">Enter in cents: 7500 = $75.00</p>
</div>
<div id="bundle-section" style="display:none;">
<label>Number of Sessions</label>
<input type="number" id="pkg-sessionCount" value="${pkg ? pkg.sessionCount : 6}" min="2">
<label>Pay-in-Full Discount (%)</label>
<input type="number" id="pkg-discount" value="${pkg ? pkg.discountFullPay || 0 : 0}" min="0" max="50">
<label>Installments</label>
<input type="number" id="pkg-installments" value="${pkg ? pkg.installments || 0 : 0}" min="0" max="12">
</div>
<div style="margin-top:1rem;display:flex;gap:0.5rem;">
<button type="submit" class="btn btn-accent">${isNew ? 'Create Package' : 'Save Changes'}</button>
<a href="/dashboard/packages" class="btn btn-secondary">Cancel</a>
</div>
</form>
<script>
// Show/hide fields based on type
const typeSelect = document.getElementById('pkg-type')
function updateTypeFields() {
const t = typeSelect.value
document.getElementById('price-section').style.display = t === 'discovery' ? 'none' : ''
document.getElementById('bundle-section').style.display = t === 'bundle' ? '' : 'none'
if (t === 'discovery') document.getElementById('pkg-price').value = '0'
}
typeSelect.addEventListener('change', updateTypeFields)
updateTypeFields()
document.getElementById('package-form').addEventListener('submit', async (e) => {
e.preventDefault()
const body = {
providerId: document.getElementById('pkg-providerId').value,
name: document.getElementById('pkg-name').value,
type: typeSelect.value,
description: document.getElementById('pkg-description').value,
sessionDuration: parseInt(document.getElementById('pkg-duration').value),
price: parseInt(document.getElementById('pkg-price').value),
sessionCount: parseInt(document.getElementById('pkg-sessionCount').value) || 1,
discountFullPay: parseInt(document.getElementById('pkg-discount').value) || 0,
installments: parseInt(document.getElementById('pkg-installments').value) || 0
}
try {
if (${packageId ? 'true' : 'false'}) {
await api('PUT', '/api/packages/${packageId || ''}', body)
showToast('Package updated', 'success')
} else {
await api('POST', '/api/packages', body)
showToast('Package created', 'success')
}
location.href = '/dashboard/packages'
} catch (err) {
showToast(err.message, 'error')
}
})
</script>
`
}
// ---- Availability ----
async function renderAvailability () {
try {
const provData = await api('GET', '/api/dashboard/provider')
if (!provData.provider) {
content.innerHTML = '<p class="empty">Create a provider profile first.</p>'
return
}
const provider = provData.provider
const avail = await api('GET', `/api/providers/${provider.slug}/availability`)
const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
const timeOpts = generateTimeOptions()
let html = '<form id="avail-form">'
for (const day of days) {
const windows = (avail.weekly && avail.weekly[day]) || []
const checked = windows.length > 0
html += `
<div class="day-editor" data-day="${day}">
<div class="day-editor-header">
<input type="checkbox" id="day-${day}" ${checked ? 'checked' : ''} onchange="toggleDay('${day}', this.checked)">
<label for="day-${day}">${day.charAt(0).toUpperCase() + day.slice(1)}</label>
</div>
<div id="windows-${day}" style="${checked ? '' : 'display:none'}">
${windows.map((w, i) => renderTimeWindow(day, i, w, timeOpts)).join('')}
<button type="button" class="add-window" onclick="addWindow('${day}')">+ Add window</button>
</div>
</div>`
}
html += `
<div style="margin-top:1.5rem; padding-top:1rem; border-top:1px solid var(--border);">
<label>Buffer between sessions</label>
<select id="avail-buffer" style="width:auto;display:inline-block;margin-left:0.5rem;">
${[0, 5, 10, 15, 20, 30].map(m => `<option value="${m}" ${avail.bufferMinutes === m ? 'selected' : ''}>${m} min</option>`).join('')}
</select>
<div style="margin-top:1rem;">
<label>Min advance booking</label>
<select id="avail-minHours" style="width:auto;display:inline-block;margin-left:0.5rem;">
${[0, 1, 2, 4, 8, 12, 24, 48].map(h => `<option value="${h}" ${avail.advanceBooking && avail.advanceBooking.minHours === h ? 'selected' : ''}>${h}h</option>`).join('')}
</select>
</div>
<div style="margin-top:1rem;">
<label>Max advance booking</label>
<select id="avail-maxDays" style="width:auto;display:inline-block;margin-left:0.5rem;">
${[7, 14, 30, 60, 90].map(d => `<option value="${d}" ${avail.advanceBooking && avail.advanceBooking.maxDays === d ? 'selected' : ''}>${d} days</option>`).join('')}
</select>
</div>
<button type="submit" class="btn btn-accent" style="margin-top:1.5rem;">Save Schedule</button>
</div>
</form>`
content.innerHTML = html
document.getElementById('avail-form').addEventListener('submit', async (e) => {
e.preventDefault()
const weekly = {}
for (const day of days) {
const cb = document.getElementById('day-' + day)
if (!cb.checked) { weekly[day] = []; continue }
const windowEls = document.querySelectorAll('#windows-' + day + ' .time-window')
weekly[day] = Array.from(windowEls).map(el => ({
start: el.querySelector('.tw-start').value,
end: el.querySelector('.tw-end').value
}))
}
try {
await api('PUT', '/api/availability', {
providerId: provider.id,
timezone: provider.timezone,
weekly,
bufferMinutes: parseInt(document.getElementById('avail-buffer').value),
advanceBooking: {
minHours: parseInt(document.getElementById('avail-minHours').value),
maxDays: parseInt(document.getElementById('avail-maxDays').value)
}
})
showToast('Schedule saved', 'success')
} catch (err) {
showToast(err.message, 'error')
}
})
} catch (err) {
content.innerHTML = `<p class="empty">Error: ${err.message}</p>`
}
}
function renderTimeWindow (day, index, window, timeOpts) {
return `
<div class="time-window">
<select class="tw-start">${timeOpts.map(t => `<option value="${t}" ${window.start === t ? 'selected' : ''}>${t}</option>`).join('')}</select>
<span>to</span>
<select class="tw-end">${timeOpts.map(t => `<option value="${t}" ${window.end === t ? 'selected' : ''}>${t}</option>`).join('')}</select>
<button type="button" class="remove-window" onclick="this.parentElement.remove()">×</button>
</div>`
}
function generateTimeOptions () {
const opts = []
for (let h = 0; h < 24; h++) {
for (let m = 0; m < 60; m += 15) {
opts.push(String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0'))
}
}
return opts
}
// ---- Analytics ----
async function renderAnalytics () {
try {
const data = await api('GET', '/api/dashboard/analytics')
let html = `
<div class="summary-grid">
<div class="summary-card">
<div class="summary-number">${data.sessions.completed}</div>
<div class="summary-label">Completed</div>
</div>
<div class="summary-card">
<div class="summary-number">${data.sessions.confirmed}</div>
<div class="summary-label">Upcoming</div>
</div>
<div class="summary-card">
<div class="summary-number">${data.sessions.cancelled}</div>
<div class="summary-label">Cancelled</div>
</div>
<div class="summary-card">
<div class="summary-number">${data.clients}</div>
<div class="summary-label">Clients</div>
</div>
</div>
<h3 style="margin:1.5rem 0 1rem;">By Package</h3>
`
for (const [pkgId, info] of Object.entries(data.packages)) {
html += `
<div class="package-row">
<div class="package-row-info">
<div class="package-row-name">${esc(info.name)}</div>
<div class="package-row-meta">${info.count} bookings · ${formatPrice(info.price)} each</div>
</div>
</div>`
}
content.innerHTML = html
} catch (err) {
content.innerHTML = `<p class="empty">Error: ${err.message}</p>`
}
}
// ---- Settings ----
async function renderSettings () {
try {
const provData = await api('GET', '/api/dashboard/provider')
if (!provData.provider) {
content.innerHTML = '<p class="empty">No provider profile found.</p>'
return
}
const p = provData.provider
const avail = await api('GET', `/api/providers/${p.slug}/availability`)
const pubPage = PATH_PREFIX + '/' + p.slug
content.innerHTML = `
<div class="settings-grid">
<div class="settings-section">
<h3>Profile</h3>
<form id="profile-form">
<label>Display Name</label>
<input type="text" id="set-name" value="${esc(p.displayName)}">
<label>Bio</label>
<textarea id="set-bio" rows="3">${esc(p.bio)}</textarea>
<label>Specialties (comma-separated)</label>
<input type="text" id="set-specialties" value="${(p.specialties || []).join(', ')}">
<div class="settings-row">
<div>
<label>Accent Color</label>
<input type="color" id="set-color" value="${p.accentColor || '#c4a265'}" style="width:50px;height:34px;padding:2px;">
</div>
<div style="flex:2;">
<label>Timezone</label>
<input type="text" value="${esc(p.timezone || 'America/New_York')}" disabled style="background:#f8f6f2;">
</div>
</div>
<button type="submit" class="btn btn-accent" style="margin-top:0.25rem;">Save Profile</button>
</form>
</div>
<div class="settings-section">
<h3>Booking Page</h3>
<div class="settings-info">
<p>Your public URL:</p>
<p style="margin:0.5rem 0;"><a href="${pubPage}" target="_blank" style="font-size:1rem;">${pubPage}</a></p>
<p>Status: <span class="settings-badge ${p.active ? 'success' : 'neutral'}">${p.active ? 'Published' : 'Unpublished'}</span></p>
</div>
<h3 style="margin-top:1.25rem;">Cancellation Policy</h3>
<form id="policy-form">
<div class="settings-row">
<div>
<label>Window</label>
<select id="set-cancelWindow" style="width:auto;">
${[0, 12, 24, 48, 72].map(h => `<option value="${h}" ${avail.cancellation && avail.cancellation.windowHours === h ? 'selected' : ''}>${h}h before</option>`).join('')}
</select>
</div>
<div>
<label>Refund</label>
<select id="set-refundPolicy" style="width:auto;">
<option value="full" ${avail.cancellation && avail.cancellation.refundPolicy === 'full' ? 'selected' : ''}>Full refund</option>
<option value="partial" ${avail.cancellation && avail.cancellation.refundPolicy === 'partial' ? 'selected' : ''}>50% refund</option>
<option value="none" ${avail.cancellation && avail.cancellation.refundPolicy === 'none' ? 'selected' : ''}>No refund</option>
</select>
</div>
</div>
<button type="submit" class="btn btn-accent">Save Policy</button>
</form>
</div>
<div class="settings-section">
<h3>Google Calendar</h3>
<div id="gcal-section"><p class="settings-info">Loading...</p></div>
</div>
<div class="settings-section">
<h3>Identity</h3>
<p class="settings-info" style="word-break:break-all;">
Public Key:<br>
<code style="font-size:0.75rem;">${HyperBookingIdentity.getPublicKey() || 'Not signed in'}</code>
</p>
<div style="display:flex;gap:0.5rem;margin-top:0.75rem;">
<button class="btn btn-secondary" onclick="exportKeypair()">Export Keypair</button>
<button class="btn btn-danger" onclick="signOut()">Sign Out</button>
</div>
</div>
</div>
`
document.getElementById('profile-form').addEventListener('submit', async (e) => {
e.preventDefault()
try {
await api('PUT', `/api/providers/${p.slug}`, {
displayName: document.getElementById('set-name').value,
bio: document.getElementById('set-bio').value,
specialties: document.getElementById('set-specialties').value.split(',').map(s => s.trim()).filter(Boolean),
accentColor: document.getElementById('set-color').value
})
showToast('Profile saved', 'success')
} catch (err) { showToast(err.message, 'error') }
})
document.getElementById('policy-form').addEventListener('submit', async (e) => {
e.preventDefault()
try {
await api('PUT', '/api/availability', {
providerId: p.id,
timezone: p.timezone,
weekly: avail.weekly,
bufferMinutes: avail.bufferMinutes,
advanceBooking: avail.advanceBooking,
cancellation: {
windowHours: parseInt(document.getElementById('set-cancelWindow').value),
refundPolicy: document.getElementById('set-refundPolicy').value
}
})
showToast('Policy saved', 'success')
} catch (err) { showToast(err.message, 'error') }
})
// Google Calendar section
loadGcalSection()
} catch (err) {
content.innerHTML = `<p class="empty">Error: ${err.message}</p>`
}
}
async function loadGcalSection () {
const gcalEl = document.getElementById('gcal-section')
if (!gcalEl) return
try {
const status = await api('GET', '/api/gcal/status')
if (!status.configured) {
// Server doesn't have Google credentials set up
gcalEl.innerHTML = `
<p style="color:var(--text-secondary);">
To enable Google Calendar sync, set <code>GCAL_CLIENT_ID</code> and
<code>GCAL_CLIENT_SECRET</code> environment variables when starting the server.
</p>`
return
}
if (status.connected) {
// Connected — show status, calendar picker, disconnect
gcalEl.innerHTML = `
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.5rem;">
<span class="settings-badge success">Connected</span>
<span class="settings-info" style="margin:0;">Events block booking slots automatically</span>
</div>
<p style="font-size:0.8rem;color:var(--text-secondary);margin-bottom:0.75rem;">
Only availability (busy/free) is read. Event details are never accessed.
</p>
<div id="gcal-calendars">
<p class="settings-info">Loading calendars...</p>
</div>
<div style="margin-top:0.75rem;">
<button class="btn btn-danger" id="gcal-disconnect-btn" style="font-size:0.8rem;padding:0.4rem 0.8rem;">Disconnect</button>
</div>
`
// Load calendar picker
try {
const calendars = await api('GET', '/api/gcal/calendars')
const selectedIds = status.calendarIds || ['primary']
const calDiv = document.getElementById('gcal-calendars')
if (calDiv && Array.isArray(calendars) && calendars.length > 0) {
calDiv.innerHTML = `
<p style="font-size:0.8rem;font-weight:500;margin-bottom:0.4rem;color:var(--text);">Monitor for conflicts:</p>
<div style="max-height:240px;overflow-y:auto;border:1px solid var(--border);border-radius:6px;padding:0.25rem 0;">
${calendars.map(cal => `
<label style="display:flex;align-items:center;gap:0.5rem;padding:0.35rem 0.75rem;cursor:pointer;font-size:0.85rem;border-bottom:1px solid #f0f0f0;">
<input type="checkbox" class="gcal-cal-check" value="${cal.id}"
${selectedIds.includes(cal.id) ? 'checked' : ''}
style="width:auto;margin:0;flex-shrink:0;">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(cal.summary)}${cal.primary ? ' <em style="color:var(--text-secondary);">(primary)</em>' : ''}</span>
</label>
`).join('')}
</div>
<button class="btn btn-accent" id="gcal-save-cals-btn" style="margin-top:0.5rem;font-size:0.8rem;padding:0.4rem 0.8rem;">Save Selection</button>
`
document.getElementById('gcal-save-cals-btn').addEventListener('click', async () => {
const checked = [...document.querySelectorAll('.gcal-cal-check:checked')].map(el => el.value)
if (checked.length === 0) { showToast('Select at least one calendar', 'error'); return }
try {
await api('PUT', '/api/gcal/calendars', { calendarIds: checked })
showToast('Calendar selection saved', 'success')
} catch (err) { showToast(err.message, 'error') }
})
}
} catch (err) {
const calDiv = document.getElementById('gcal-calendars')
if (calDiv) calDiv.innerHTML = '<p style="font-size:0.85rem;color:var(--text-secondary);">Could not load calendar list.</p>'
}
document.getElementById('gcal-disconnect-btn').addEventListener('click', async () => {
if (!confirm('Disconnect Google Calendar?')) return
try {
await api('DELETE', '/api/gcal/disconnect')
showToast('Disconnected', 'success')
loadGcalSection()
} catch (err) { showToast(err.message, 'error') }
})
} else {
// Not connected — one button to connect
gcalEl.innerHTML = `
<p style="color:var(--text-secondary);margin-bottom:1rem;">
Sync your Google Calendar so clients only see times when you're actually free.
</p>
<button class="btn btn-accent" id="gcal-connect-btn">Connect Google Calendar</button>
`
document.getElementById('gcal-connect-btn').addEventListener('click', async () => {
try {
const { url } = await api('GET', '/api/gcal/auth-url')
window.location.href = url
} catch (err) { showToast(err.message, 'error') }
})
}
} catch (err) {
gcalEl.innerHTML = '<p style="color:var(--text-secondary);">Could not load calendar status.</p>'
}
}
// ---- Profile ----
async function renderProfile () {
const hasPk = HyperBookingIdentity.hasKeypair()
if (!hasPk) {
content.innerHTML = `
<div class="card" style="text-align:center;padding:2rem;">
<p>You are not signed in.</p>
<button class="btn btn-accent" onclick="signIn()" style="margin-top:1rem;">Create Identity</button>
</div>`
return
}
let profile = {}
try {
profile = await api('GET', '/api/profile')
} catch {}
content.innerHTML = `
<div class="card">
<h3 style="margin-bottom:1rem;">Your Profile</h3>
<form id="profile-form">
<label>Display Name</label>
<input type="text" id="prof-name" value="${esc(profile.displayName || '')}" placeholder="Your name">
<label>Email (for notifications)</label>
<input type="email" id="prof-email" value="${esc(profile.email || '')}" placeholder="you@email.com">
<label>Phone (for SMS reminders, optional)</label>
<input type="tel" id="prof-phone" value="${esc(profile.phone || '')}" placeholder="+1 555 123 4567">
<button type="submit" class="btn btn-accent">Save Profile</button>
</form>
</div>
<div class="card" style="margin-top:1rem;">
<h3 style="margin-bottom:1rem;">Identity</h3>
<p style="font-size:0.85rem;color:var(--text-secondary);word-break:break-all;">
Public Key: ${HyperBookingIdentity.getPublicKey()}
</p>
<div style="margin-top:1rem;display:flex;gap:0.5rem;flex-wrap:wrap;">
<button class="btn btn-secondary" onclick="exportKeypair()">Export Keypair</button>
<button class="btn btn-secondary" onclick="importKeypairPrompt()">Import Keypair</button>
<button class="btn btn-danger" onclick="signOut()">Sign Out</button>
</div>
</div>
`
document.getElementById('profile-form').addEventListener('submit', async (e) => {
e.preventDefault()
try {
await api('PUT', '/api/profile', {
displayName: document.getElementById('prof-name').value,
email: document.getElementById('prof-email').value,
phone: document.getElementById('prof-phone').value
})
showToast('Profile saved', 'success')
// Update nav display name
const nameEl = document.getElementById('user-name')
if (nameEl) nameEl.textContent = document.getElementById('prof-name').value
} catch (err) {
showToast(err.message, 'error')
}
})
}
// ---- Portal ----
async function renderPortal () {
try {
const bookings = await api('GET', '/api/portal/bookings')
const today = new Date().toISOString().split('T')[0]
const upcoming = bookings.filter(b => b.status === 'confirmed' && b.date >= today)
const past = bookings.filter(b => b.status === 'completed' || b.date < today)
let html = '<h3 style="margin-bottom:1rem;">Upcoming Sessions</h3>'
if (upcoming.length === 0) {
html += '<p class="empty">No upcoming sessions.</p>'
} else {
for (const b of upcoming) {
html += `
<div class="session-card">
<div class="session-time">${b.date}<br>${formatTime(b.startTimeLocal)}</div>
<div class="session-info">
<div class="session-meta">${esc(b.packageId)}</div>
<div class="session-actions">
<button class="btn btn-secondary" onclick="cancelSession('${b.id}')">Cancel</button>
</div>
</div>
</div>`
}
}
html += `<h3 style="margin:2rem 0 1rem;">Past Sessions (${past.length})</h3>`
if (past.length === 0) {
html += '<p class="empty">No past sessions.</p>'
} else {
for (const b of past) {
html += `
<div class="session-card">
<div class="session-time">${b.date}</div>
<div class="session-info">
<div class="session-meta">${b.status}${b.notes ? ' — "' + esc(b.notes) + '"' : ''}</div>
</div>
</div>`
}
}
content.innerHTML = html
} catch (err) {
content.innerHTML = `<p class="empty">Error: ${err.message}</p>`
}
}
// ---- Admin ----
// ---- Create Provider ----
function renderCreateProvider () {
content.innerHTML = `
<div class="card" style="max-width:600px;margin:0 auto;padding:2rem;">
<h2 style="margin-bottom:0.5rem;">Create Your Coaching Page</h2>
<p style="color:var(--text-secondary);margin-bottom:1.5rem;">
Set up your provider profile so clients can discover and book sessions with you.
</p>
<form id="create-provider-form">
<label for="cp-name">Display Name *</label>
<input type="text" id="cp-name" placeholder="e.g. Fleeky Flanco" required>
<label for="cp-slug" style="margin-top:1rem;">URL Slug *</label>
<input type="text" id="cp-slug" placeholder="e.g. fleeky-flanco" required
pattern="[a-z0-9][a-z0-9\\-]*[a-z0-9]" title="Lowercase letters, numbers, and hyphens only">
<p style="font-size:0.8rem;color:var(--text-secondary);margin:0.25rem 0 0;">
Your public page will be at <strong>/${document.getElementById('cp-slug')?.value || 'your-slug'}</strong>
</p>
<label for="cp-bio" style="margin-top:1rem;">Bio</label>
<textarea id="cp-bio" rows="4" placeholder="Tell clients about yourself, your approach, and your experience..."></textarea>
<label for="cp-specialties" style="margin-top:1rem;">Specialties</label>
<input type="text" id="cp-specialties" placeholder="e.g. Handstands, Acrobatics, Movement (comma-separated)">
<label for="cp-timezone" style="margin-top:1rem;">Timezone</label>
<select id="cp-timezone">
<option value="America/New_York">Eastern (New York)</option>
<option value="America/Chicago">Central (Chicago)</option>
<option value="America/Denver">Mountain (Denver)</option>
<option value="America/Los_Angeles">Pacific (Los Angeles)</option>
<option value="UTC">UTC</option>
<option value="Europe/London">London</option>
<option value="Europe/Paris">Paris</option>
<option value="Asia/Tokyo">Tokyo</option>
</select>
<label for="cp-color" style="margin-top:1rem;">Accent Color</label>
<input type="color" id="cp-color" value="#c4a265" style="width:60px;height:36px;border:none;cursor:pointer;">
<div style="margin-top:1.5rem;">
<button type="submit" class="btn btn-accent" id="cp-submit">Create My Page</button>
</div>
<p id="cp-error" style="color:var(--error);margin-top:0.75rem;display:none;"></p>
</form>
</div>
`
// Auto-generate slug from name
const nameInput = document.getElementById('cp-name')
const slugInput = document.getElementById('cp-slug')
nameInput.addEventListener('input', () => {
if (!slugInput.dataset.manual) {
slugInput.value = nameInput.value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
})
slugInput.addEventListener('input', () => {
slugInput.dataset.manual = 'true'
})
document.getElementById('create-provider-form').addEventListener('submit', async (e) => {
e.preventDefault()
const errEl = document.getElementById('cp-error')
errEl.style.display = 'none'
const name = nameInput.value.trim()
const slug = slugInput.value.trim()
if (!name || !slug) {
errEl.textContent = 'Name and slug are required.'
errEl.style.display = ''
return
}
const submitBtn = document.getElementById('cp-submit')
submitBtn.disabled = true
submitBtn.textContent = 'Creating...'
try {
await api('POST', '/api/providers', {
displayName: name,
slug: slug,
bio: document.getElementById('cp-bio').value.trim(),
specialties: document.getElementById('cp-specialties').value
.split(',').map(s => s.trim()).filter(Boolean),
timezone: document.getElementById('cp-timezone').value,
accentColor: document.getElementById('cp-color').value
})
showToast('Your coaching page is live!', 'success')
// Reload dashboard — now they have a provider
setTimeout(() => location.reload(), 500)
} catch (err) {
errEl.textContent = err.message
errEl.style.display = ''
submitBtn.disabled = false
submitBtn.textContent = 'Create My Page'
}
})
}
// ---- Admin ----
async function renderAdmin () {
try {
const sys = await api('GET', '/api/admin/system')
content.innerHTML = `
<div class="summary-grid">
<div class="summary-card">
<div class="summary-number">${sys.users}</div>
<div class="summary-label">Users</div>
</div>
<div class="summary-card">
<div class="summary-number">${sys.providers}</div>
<div class="summary-label">Providers</div>
</div>
<div class="summary-card">
<div class="summary-number">${sys.bookings}</div>
<div class="summary-label">Bookings</div>
</div>
<div class="summary-card">
<div class="summary-number">${sys.gcalConfigured ? 'Yes' : 'No'}</div>
<div class="summary-label">Google Calendar</div>
</div>
</div>
<p style="font-size:0.85rem;color:var(--text-secondary);margin-top:0.5rem;">
v${sys.version} · uptime ${Math.floor(sys.uptime / 60)}m
</p>
<h3 style="margin:1.5rem 0 1rem;">Manage</h3>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;">
<a href="${PATH_PREFIX}/admin/providers" class="btn btn-secondary">Providers</a>
<a href="${PATH_PREFIX}/admin/users" class="btn btn-secondary">Users</a>
</div>
`
} catch (err) {
content.innerHTML = `<p class="empty">Error: ${err.message}</p>`
}
}
async function renderAdminProviders () {
try {
const [providers, users] = await Promise.all([
api('GET', '/api/admin/providers'),
api('GET', '/api/admin/users')
])
let html = `<p><a href="${PATH_PREFIX}/admin">← Back to Admin</a></p>`
html += `<h3 style="margin:1rem 0;">Providers (${providers.length})</h3>`
for (const p of providers) {
const ownerLabel = p.owner
? esc(p.owner.username || p.owner.name || p.owner.userId.substring(0, 12) + '...')
: '<em style="color:var(--text-secondary)">unassigned</em>'
html += `
<div class="package-row" style="flex-wrap:wrap;gap:0.5rem;">
<div class="package-row-info" style="min-width:200px;">
<div class="package-row-name">${esc(p.displayName)}</div>
<div class="package-row-meta">
/${esc(p.slug)} · ${p.active ? '<span style="color:var(--success)">Active</span>' : '<span style="color:var(--error)">Disabled</span>'}
· ${p.bookingCount} bookings
${p.gcalConnected ? ' · <span style="color:var(--success)">GCal</span>' : ''}
</div>
<div class="package-row-meta" style="margin-top:0.25rem;">
Owner: ${ownerLabel}
</div>
</div>
<div class="package-row-actions" style="display:flex;gap:0.25rem;flex-wrap:wrap;">
<a href="${PATH_PREFIX}/${p.slug}" class="btn btn-secondary" target="_blank" style="font-size:0.8rem;">View</a>
<button class="btn btn-secondary" style="font-size:0.8rem;" onclick="adminToggleProvider('${p.id}', ${p.active})">${p.active ? 'Disable' : 'Enable'}</button>
${p.userId ? `<button class="btn btn-secondary" style="font-size:0.8rem;" onclick="adminUnlinkProvider('${p.id}', '${esc(p.displayName)}')">Unlink Owner</button>` : ''}
${!p.userId ? `<button class="btn btn-accent" style="font-size:0.8rem;" onclick="adminShowAssign('${p.id}')">Assign</button>` : ''}
</div>
</div>`
// Hidden assign form
if (!p.userId) {
html += `
<div id="assign-${p.id}" style="display:none;padding:0.75rem 1rem;background:var(--surface);border-radius:8px;margin:-0.5rem 0 0.5rem;">
<label style="font-size:0.85rem;">Assign to user:</label>
<select id="assign-select-${p.id}" style="margin:0.25rem 0;">
${users.filter(u => !u.provider).map(u =>
`<option value="${u.userId}">${esc(u.username || u.name || u.userId.substring(0, 16))}</option>`
).join('')}
</select>
<button class="btn btn-accent" style="font-size:0.8rem;" onclick="adminAssignProvider('${p.id}')">Assign</button>
<button class="btn btn-secondary" style="font-size:0.8rem;" onclick="document.getElementById('assign-${p.id}').style.display='none'">Cancel</button>
</div>`
}
}
content.innerHTML = html
} catch (err) {
content.innerHTML = `<p class="empty">Error: ${err.message}</p>`
}
}
async function renderAdminUsers () {
try {
const users = await api('GET', '/api/admin/users')
let html = `<p><a href="${PATH_PREFIX}/admin">← Back to Admin</a></p>`
html += `<h3 style="margin:1rem 0;">Users (${users.length})</h3>`
for (const u of users) {
const perms = (u.globalPermissions || []).join(', ')
const provLabel = u.provider
? `<a href="${PATH_PREFIX}/${u.provider.slug}">${esc(u.provider.displayName)}</a>`
: '<em style="color:var(--text-secondary)">none</em>'
const pkSnippet = u.identities.length > 0
? u.identities[0].publicKey?.substring(0, 20) + '...'
: 'no identity'
html += `
<div class="package-row" style="flex-wrap:wrap;gap:0.5rem;">
<div class="package-row-info" style="min-width:200px;">
<div class="package-row-name">${esc(u.username || u.name || 'Anonymous')}</div>
<div class="package-row-meta">
${u.userId.substring(0, 12)}... · ${perms}
</div>
<div class="package-row-meta" style="margin-top:0.25rem;">
Provider: ${provLabel} · Key: <code style="font-size:0.75rem;">${esc(pkSnippet)}</code>
</div>
</div>
<div class="package-row-actions" style="display:flex;gap:0.25rem;flex-wrap:wrap;">
${!u.globalPermissions?.includes('admin')
? `<button class="btn btn-secondary" style="font-size:0.8rem;" onclick="adminPromote('${u.userId}')">Make Admin</button>`
: `<span style="font-size:0.8rem;color:var(--success);padding:0.25rem 0.5rem;">Admin</span>`
}
${u.globalPermissions?.includes('admin') && u.userId !== users[0]?.userId
? `<button class="btn btn-secondary" style="font-size:0.8rem;" onclick="adminDemote('${u.userId}')">Remove Admin</button>`
: ''
}
</div>
</div>`
}
content.innerHTML = html
} catch (err) {
content.innerHTML = `<p class="empty">Error: ${err.message}</p>`
}
}
// ---- Admin Actions ----
async function adminToggleProvider (providerId, currentlyActive) {
try {
const result = await api('PUT', `/api/admin/providers/${providerId}/toggle`)
showToast(result.active ? 'Provider enabled' : 'Provider disabled', 'success')
renderAdminProviders()
} catch (err) { showToast(err.message, 'error') }
}
async function adminUnlinkProvider (providerId, name) {
if (!confirm(`Unlink owner from "${name}"? The provider page stays but has no owner until reassigned.`)) return
try {
await api('PUT', `/api/admin/providers/${providerId}/unlink`)
showToast('Owner unlinked', 'success')
renderAdminProviders()
} catch (err) { showToast(err.message, 'error') }
}
function adminShowAssign (providerId) {
const el = document.getElementById('assign-' + providerId)
if (el) el.style.display = ''
}
async function adminAssignProvider (providerId) {
const select = document.getElementById('assign-select-' + providerId)
if (!select) return
try {
await api('PUT', `/api/admin/providers/${providerId}/assign`, { userId: select.value })
showToast('Provider assigned', 'success')
renderAdminProviders()
} catch (err) { showToast(err.message, 'error') }
}
async function adminPromote (userId) {
if (!confirm('Grant admin permissions?')) return
try {
await api('PUT', `/api/admin/users/${userId}/permissions`, { permissions: ['read', 'write', 'admin'] })
showToast('User promoted to admin', 'success')
renderAdminUsers()
} catch (err) { showToast(err.message, 'error') }
}
async function adminDemote (userId) {
if (!confirm('Remove admin permissions? User will keep read + write.')) return
try {
await api('PUT', `/api/admin/users/${userId}/permissions`, { permissions: ['read', 'write'] })
showToast('Admin removed', 'success')
renderAdminUsers()
} catch (err) { showToast(err.message, 'error') }
}
// ---- Global Actions ----
async function completeSession (bookingId) {
try {
await api('PUT', `/api/bookings/${bookingId}/complete`)
showToast('Session marked complete', 'success')
renderCalendar()
} catch (err) { showToast(err.message, 'error') }
}
async function markNoShow (bookingId) {
if (!confirm('Mark as no-show?')) return
try {
await api('PUT', `/api/bookings/${bookingId}/cancel`, { cancelledBy: 'provider' })
showToast('Marked as no-show', 'success')
renderCalendar()
} catch (err) { showToast(err.message, 'error') }
}
async function cancelSession (bookingId) {
if (!confirm('Cancel this session?')) return
try {
await api('PUT', `/api/bookings/${bookingId}/cancel`)
showToast('Session cancelled', 'success')
renderCalendar()
} catch (err) { showToast(err.message, 'error') }
}
async function archivePackage (packageId) {
if (!confirm('Archive this package?')) return
try {
await api('DELETE', `/api/packages/${packageId}`)
showToast('Package archived', 'success')
renderPackages()
} catch (err) { showToast(err.message, 'error') }
}
function exportKeypair () {
const data = HyperBookingIdentity.exportKeypair()
if (!data) return showToast('No keypair to export', 'error')
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = 'hyper-booking-keypair.json'
a.click()
showToast('Keypair exported', 'success')
}
function importKeypairPrompt () {
const input = prompt('Paste your 132-character keypair:')
if (input) {
try {
HyperBookingIdentity.importKeypair(input.trim())
showToast('Keypair imported', 'success')
location.reload()
} catch (err) { showToast(err.message, 'error') }
}
}
function signOut () {
if (!confirm('Sign out? Make sure you have exported your keypair first.')) return
HyperBookingIdentity.clear()
showToast('Signed out')
location.href = '/'
}
function esc (s) {
if (!s) return ''
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
}
function toggleDay (day, checked) {
document.getElementById('windows-' + day).style.display = checked ? '' : 'none'
}
function addWindow (day) {
const container = document.getElementById('windows-' + day)
const timeOpts = generateTimeOptions()
const div = document.createElement('div')
div.className = 'time-window'
div.innerHTML = `
<select class="tw-start">${timeOpts.map(t => `<option value="${t}">${t}</option>`).join('')}</select>
<span>to</span>
<select class="tw-end">${timeOpts.map(t => `<option value="${t}" ${t === '17:00' ? 'selected' : ''}>${t}</option>`).join('')}</select>
<button type="button" class="remove-window" onclick="this.parentElement.remove()">×</button>
`
container.insertBefore(div, container.querySelector('.add-window'))
}
window.initDashboard = initDashboard
window.signIn = signIn
window.completeSession = completeSession
window.markNoShow = markNoShow
window.cancelSession = cancelSession
window.archivePackage = archivePackage
window.exportKeypair = exportKeypair
window.importKeypairPrompt = importKeypairPrompt
window.signOut = signOut
window.toggleDay = toggleDay
window.addWindow = addWindow
window.searchClients = searchClients