OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-booking commits / 63e8348

Phase 2a-2h: Dashboard, admin, portal, profile, package manager
Full management UI with sidebar navigation: - Dashboard home: today's sessions + week overview - Client list with search + client detail page - Package manager: create/edit/archive with dynamic form - Availability editor: visual weekly schedule + buffer/advance settings - Analytics: session counts, client count, per-package breakdown - Settings: profile editing, cancellation policy, identity export - Client portal: upcoming/past sessions - Admin panel: provider listing, platform overview - Profile page: identity management, keypair export/import Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
63e83482b8d5e2037f0eb3fb5b634f830ee0f602
by plankatron <fleeky@gmail.com> · 2026-03-22T18:24:11.000Z

Changed files (4) +1604 -0

lib/routes/dashboard.js +397 -0
……@@ -0,0 +1,397 @@
1/**
2 * Dashboard routes — provider management pages + API.
3 */
4
5import { json, html, readJson, notFound } from '../response.js'
6import { requireAuth } from './auth-helpers.js'
7
8export function registerDashboardRoutes (router, server) {
9 // ---- API Endpoints ----
10
11 // Today's schedule + summary
12 router.get('/api/dashboard/today', async (req, res) => {
13 const auth = await requireAuth(req, server, 'write')
14 const providerId = await getProviderIdForUser(server, auth.userId)
15 if (!providerId) return json(res, { error: 'No provider profile' }, 403)
16
17 const today = new Date().toISOString().split('T')[0]
18 const bookings = await getBookingsForDate(server.db, providerId, today)
19
20 // Enrich with client + package info
21 for (const b of bookings) {
22 const clientEntry = await server.db.get(`client:${b.clientId}`)
23 b.clientName = clientEntry ? clientEntry.value.displayName : 'Unknown'
24 const pkgEntry = await server.db.get(`package-detail:${b.packageId}`)
25 b.packageName = pkgEntry ? pkgEntry.value.name : 'Unknown'
26 b.sessionDuration = pkgEntry ? pkgEntry.value.sessionDuration : 60
27 }
28
29 // Sort by start time
30 bookings.sort((a, b) => (a.startTimeLocal || '').localeCompare(b.startTimeLocal || ''))
31
32 // Summary
33 const active = bookings.filter(b => b.status !== 'cancelled')
34 const revenue = 0 // Placeholder until payments wired
35
36 json(res, {
37 date: today,
38 bookings,
39 summary: {
40 sessions: active.length,
41 clients: new Set(active.map(b => b.clientId)).size,
42 revenue
43 }
44 })
45 })
46
47 // Week overview (session counts per day)
48 router.get('/api/dashboard/week', async (req, res) => {
49 const auth = await requireAuth(req, server, 'write')
50 const providerId = await getProviderIdForUser(server, auth.userId)
51 if (!providerId) return json(res, { error: 'No provider profile' }, 403)
52
53 const url = new URL('http://x' + req.url)
54 const weekOf = url.searchParams.get('weekOf') || getMonday(new Date())
55
56 const days = {}
57 for (let i = 0; i < 7; i++) {
58 const date = addDays(weekOf, i)
59 const bookings = await getBookingsForDate(server.db, providerId, date)
60 const active = bookings.filter(b => b.status !== 'cancelled')
61 days[date] = { count: active.length, bookings: active }
62 }
63
64 json(res, { weekOf, days })
65 })
66
67 // Client list
68 router.get('/api/dashboard/clients', async (req, res) => {
69 const auth = await requireAuth(req, server, 'write')
70 const providerId = await getProviderIdForUser(server, auth.userId)
71 if (!providerId) return json(res, { error: 'No provider profile' }, 403)
72
73 const url = new URL('http://x' + req.url)
74 const search = (url.searchParams.get('q') || '').toLowerCase()
75
76 const clients = []
77 for await (const entry of server.db.createReadStream({
78 gte: `clients-by-provider:${providerId}:`,
79 lte: `clients-by-provider:${providerId}:~`
80 })) {
81 const clientEntry = await server.db.get(`client:${entry.value}`)
82 if (!clientEntry) continue
83 const client = clientEntry.value
84
85 if (search && !(client.displayName || '').toLowerCase().includes(search) &&
86 !(client.email || '').toLowerCase().includes(search)) continue
87
88 // Get their bookings with this provider
89 const bookings = []
90 for await (const bEntry of server.db.createReadStream({
91 gte: `bookings-by-client:${client.id}:`,
92 lte: `bookings-by-client:${client.id}:~`
93 })) {
94 const booking = await server.db.get(`booking:${bEntry.value}`)
95 if (booking && booking.value.providerId === providerId) {
96 bookings.push(booking.value)
97 }
98 }
99
100 client.bookingCount = bookings.length
101 client.lastSession = bookings
102 .filter(b => b.status === 'completed')
103 .sort((a, b) => (b.date || '').localeCompare(a.date || ''))[0]?.date || null
104 client.nextSession = bookings
105 .filter(b => b.status === 'confirmed' && b.date >= new Date().toISOString().split('T')[0])
106 .sort((a, b) => (a.date || '').localeCompare(b.date || ''))[0]?.date || null
107
108 clients.push(client)
109 }
110
111 json(res, clients)
112 })
113
114 // Client detail
115 router.get('/api/dashboard/clients/:clientId', async (req, res) => {
116 const auth = await requireAuth(req, server, 'write')
117 const providerId = await getProviderIdForUser(server, auth.userId)
118 if (!providerId) return json(res, { error: 'No provider profile' }, 403)
119
120 const clientEntry = await server.db.get(`client:${req.params.clientId}`)
121 if (!clientEntry) return notFound(res, 'Client not found')
122
123 const client = clientEntry.value
124
125 // All bookings for this client with this provider
126 const bookings = []
127 for await (const bEntry of server.db.createReadStream({
128 gte: `bookings-by-client:${client.id}:`,
129 lte: `bookings-by-client:${client.id}:~`
130 })) {
131 const booking = await server.db.get(`booking:${bEntry.value}`)
132 if (booking && booking.value.providerId === providerId) {
133 const b = booking.value
134 const pkgEntry = await server.db.get(`package-detail:${b.packageId}`)
135 b.packageName = pkgEntry ? pkgEntry.value.name : 'Unknown'
136 bookings.push(b)
137 }
138 }
139
140 bookings.sort((a, b) => (b.date || '').localeCompare(a.date || ''))
141
142 json(res, { client, bookings })
143 })
144
145 // Analytics
146 router.get('/api/dashboard/analytics', async (req, res) => {
147 const auth = await requireAuth(req, server, 'write')
148 const providerId = await getProviderIdForUser(server, auth.userId)
149 if (!providerId) return json(res, { error: 'No provider profile' }, 403)
150
151 // Count all bookings by status
152 let completed = 0
153 let cancelled = 0
154 let noShow = 0
155 let confirmed = 0
156 const packageCounts = {}
157
158 for await (const entry of server.db.createReadStream({
159 gte: `bookings-by-provider:${providerId}:`,
160 lte: `bookings-by-provider:${providerId}:~`
161 })) {
162 const bEntry = await server.db.get(`booking:${entry.value}`)
163 if (!bEntry) continue
164 const b = bEntry.value
165
166 if (b.status === 'completed') completed++
167 else if (b.status === 'cancelled') cancelled++
168 else if (b.status === 'no-show') noShow++
169 else if (b.status === 'confirmed') confirmed++
170
171 if (!packageCounts[b.packageId]) packageCounts[b.packageId] = { count: 0 }
172 packageCounts[b.packageId].count++
173 }
174
175 // Enrich package counts with names
176 for (const [pkgId, data] of Object.entries(packageCounts)) {
177 const pkgEntry = await server.db.get(`package-detail:${pkgId}`)
178 data.name = pkgEntry ? pkgEntry.value.name : 'Unknown'
179 data.price = pkgEntry ? pkgEntry.value.price : 0
180 }
181
182 // Count clients
183 let clientCount = 0
184 for await (const _ of server.db.createReadStream({
185 gte: `clients-by-provider:${providerId}:`,
186 lte: `clients-by-provider:${providerId}:~`
187 })) {
188 clientCount++
189 }
190
191 json(res, {
192 sessions: { completed, cancelled, noShow, confirmed },
193 clients: clientCount,
194 packages: packageCounts
195 })
196 })
197
198 // ---- HTML Pages ----
199
200 // Dashboard home
201 router.get('/dashboard', async (req, res) => {
202 serveDashboardPage(res, server, 'home')
203 })
204
205 router.get('/dashboard/clients', async (req, res) => {
206 serveDashboardPage(res, server, 'clients')
207 })
208
209 router.get('/dashboard/clients/:clientId', async (req, res) => {
210 serveDashboardPage(res, server, 'client-detail', req.params)
211 })
212
213 router.get('/dashboard/packages', async (req, res) => {
214 serveDashboardPage(res, server, 'packages')
215 })
216
217 router.get('/dashboard/packages/new', async (req, res) => {
218 serveDashboardPage(res, server, 'package-edit')
219 })
220
221 router.get('/dashboard/packages/:id/edit', async (req, res) => {
222 serveDashboardPage(res, server, 'package-edit', req.params)
223 })
224
225 router.get('/dashboard/availability', async (req, res) => {
226 serveDashboardPage(res, server, 'availability')
227 })
228
229 router.get('/dashboard/analytics', async (req, res) => {
230 serveDashboardPage(res, server, 'analytics')
231 })
232
233 router.get('/dashboard/settings', async (req, res) => {
234 serveDashboardPage(res, server, 'settings')
235 })
236
237 // Profile page (any authenticated user)
238 router.get('/profile', async (req, res) => {
239 serveDashboardPage(res, server, 'profile')
240 })
241
242 // Client portal
243 router.get('/portal', async (req, res) => {
244 serveDashboardPage(res, server, 'portal')
245 })
246
247 // Admin pages
248 router.get('/admin', async (req, res) => {
249 serveDashboardPage(res, server, 'admin')
250 })
251
252 router.get('/admin/providers', async (req, res) => {
253 serveDashboardPage(res, server, 'admin-providers')
254 })
255
256 router.get('/admin/users', async (req, res) => {
257 serveDashboardPage(res, server, 'admin-users')
258 })
259}
260
261// ---- Helpers ----
262
263async function getProviderIdForUser (server, userId) {
264 for await (const entry of server.db.createReadStream({
265 gte: 'provider:',
266 lte: 'provider:~'
267 })) {
268 if (entry.value.userId === userId) return entry.value.id
269 }
270 return null
271}
272
273async function getBookingsForDate (db, providerId, date) {
274 const bookings = []
275 for await (const entry of db.createReadStream({
276 gte: `bookings-by-date:${providerId}:${date}:`,
277 lte: `bookings-by-date:${providerId}:${date}:~`
278 })) {
279 const bookingEntry = await db.get(`booking:${entry.value}`)
280 if (bookingEntry) bookings.push(bookingEntry.value)
281 }
282 return bookings
283}
284
285function getMonday (d) {
286 const date = new Date(d)
287 const day = date.getDay()
288 const diff = date.getDate() - day + (day === 0 ? -6 : 1)
289 date.setDate(diff)
290 return date.toISOString().split('T')[0]
291}
292
293function addDays (dateStr, days) {
294 const d = new Date(dateStr + 'T12:00:00Z')
295 d.setUTCDate(d.getUTCDate() + days)
296 return d.toISOString().split('T')[0]
297}
298
299// ---- Page Renderer ----
300
301function serveDashboardPage (res, server, view, params = {}) {
302 const prefix = server.pathPrefix
303
304 const page = `<!DOCTYPE html>
305<html lang="en">
306<head>
307 <meta charset="UTF-8">
308 <meta name="viewport" content="width=device-width, initial-scale=1.0">
309 <title>Dashboard — hyper-booking</title>
310 <link rel="stylesheet" href="${prefix}/web/css/app.css">
311 <link rel="stylesheet" href="${prefix}/web/css/dashboard.css">
312</head>
313<body>
314 <div class="dashboard-layout">
315 <nav class="dashboard-sidebar" id="sidebar">
316 <div class="sidebar-header">
317 <a href="${prefix}/" class="sidebar-logo">hyper-booking</a>
318 </div>
319 <div class="sidebar-nav">
320 <a href="${prefix}/dashboard" class="nav-item ${view === 'home' ? 'active' : ''}">
321 <span class="nav-icon">&#128197;</span> Calendar
322 </a>
323 <a href="${prefix}/dashboard/clients" class="nav-item ${view === 'clients' || view === 'client-detail' ? 'active' : ''}">
324 <span class="nav-icon">&#128101;</span> Clients
325 </a>
326 <a href="${prefix}/dashboard/packages" class="nav-item ${view === 'packages' || view === 'package-edit' ? 'active' : ''}">
327 <span class="nav-icon">&#128230;</span> Packages
328 </a>
329 <a href="${prefix}/dashboard/availability" class="nav-item ${view === 'availability' ? 'active' : ''}">
330 <span class="nav-icon">&#128340;</span> Availability
331 </a>
332 <a href="${prefix}/dashboard/analytics" class="nav-item ${view === 'analytics' ? 'active' : ''}">
333 <span class="nav-icon">&#128200;</span> Analytics
334 </a>
335 <a href="${prefix}/dashboard/settings" class="nav-item ${view === 'settings' ? 'active' : ''}">
336 <span class="nav-icon">&#9881;</span> Settings
337 </a>
338 <div class="sidebar-divider"></div>
339 <a href="${prefix}/portal" class="nav-item ${view === 'portal' ? 'active' : ''}">
340 <span class="nav-icon">&#128100;</span> Client Portal
341 </a>
342 <a href="${prefix}/profile" class="nav-item ${view === 'profile' ? 'active' : ''}">
343 <span class="nav-icon">&#128272;</span> Profile
344 </a>
345 <a href="${prefix}/admin" class="nav-item ${view === 'admin' || view.startsWith('admin') ? 'active' : ''}" id="admin-link" style="display:none">
346 <span class="nav-icon">&#128737;</span> Admin
347 </a>
348 </div>
349 </nav>
350
351 <main class="dashboard-main">
352 <header class="dashboard-header">
353 <button class="sidebar-toggle" onclick="document.getElementById('sidebar').classList.toggle('open')">&#9776;</button>
354 <span id="header-title">${viewTitle(view)}</span>
355 <div class="header-right">
356 <span id="user-name"></span>
357 </div>
358 </header>
359
360 <div class="dashboard-content" id="content">
361 <p class="empty">Loading...</p>
362 </div>
363 </main>
364 </div>
365
366 <script src="${prefix}/web/vendor/noble-ed25519.js"></script>
367 <script src="${prefix}/web/vendor/noble-sha512.js"></script>
368 <script src="${prefix}/web/js/identity.js"></script>
369 <script src="${prefix}/web/js/app.js"></script>
370 <script src="${prefix}/web/js/dashboard.js"></script>
371 <script>
372 initDashboard('${view}', ${JSON.stringify(params)})
373 </script>
374</body>
375</html>`
376
377 html(res, page)
378}
379
380function viewTitle (view) {
381 const titles = {
382 home: 'Calendar',
383 clients: 'Clients',
384 'client-detail': 'Client Detail',
385 packages: 'Packages',
386 'package-edit': 'Edit Package',
387 availability: 'Availability',
388 analytics: 'Analytics',
389 settings: 'Settings',
390 profile: 'Profile',
391 portal: 'Client Portal',
392 admin: 'Admin',
393 'admin-providers': 'Manage Providers',
394 'admin-users': 'Manage Users'
395 }
396 return titles[view] || 'Dashboard'
397}
lib/server.js +2 -0
……@@ -25,6 +25,7 @@ import { registerAvailabilityRoutes } from './routes/availability.js'
2525import { registerBookingRoutes } from './routes/bookings.js'
2626import { registerAuthRoutes } from './routes/auth.js'
2727import { registerWebRoutes, registerPublicRoutes } from './routes/web.js'
28import { registerDashboardRoutes } from './routes/dashboard.js'
2829
2930export class HyperBookingServer {
3031 constructor (options = {}) {
……@@ -122,6 +123,7 @@ export class HyperBookingServer {
122123 registerPackageRoutes(this.router, this)
123124 registerAvailabilityRoutes(this.router, this)
124125 registerBookingRoutes(this.router, this)
126 registerDashboardRoutes(this.router, this)
125127 // Public slug routes MUST be last (catch-all /:slug)
126128 registerPublicRoutes(this.router, this)
127129 }
web/css/dashboard.css +333 -0
……@@ -0,0 +1,333 @@
1/* Dashboard layout — sidebar + main content */
2
3.dashboard-layout {
4 display: flex;
5 min-height: 100vh;
6}
7
8/* Sidebar */
9.dashboard-sidebar {
10 width: 220px;
11 background: #fafaf8;
12 border-right: 1px solid var(--border);
13 display: flex;
14 flex-direction: column;
15 flex-shrink: 0;
16}
17
18.sidebar-header {
19 padding: 1.25rem;
20 border-bottom: 1px solid var(--border);
21}
22
23.sidebar-logo {
24 font-weight: 600;
25 font-size: 1.1rem;
26 color: var(--text);
27 text-decoration: none;
28}
29
30.sidebar-nav {
31 padding: 0.5rem 0;
32 flex: 1;
33}
34
35.nav-item {
36 display: flex;
37 align-items: center;
38 gap: 0.6rem;
39 padding: 0.6rem 1.25rem;
40 color: var(--text-secondary);
41 text-decoration: none;
42 font-size: 0.9rem;
43 transition: all 0.1s;
44}
45.nav-item:hover { color: var(--text); background: #f0eeea; }
46.nav-item.active { color: var(--accent); font-weight: 500; background: #f5f0e8; border-right: 3px solid var(--accent); }
47.nav-icon { font-size: 1.1rem; width: 1.4rem; text-align: center; }
48
49.sidebar-divider {
50 height: 1px;
51 background: var(--border);
52 margin: 0.5rem 1.25rem;
53}
54
55.sidebar-toggle { display: none; }
56
57/* Main content */
58.dashboard-main {
59 flex: 1;
60 display: flex;
61 flex-direction: column;
62 min-width: 0;
63}
64
65.dashboard-header {
66 display: flex;
67 align-items: center;
68 justify-content: space-between;
69 padding: 0.75rem 1.5rem;
70 border-bottom: 1px solid var(--border);
71 background: white;
72}
73
74.dashboard-header #header-title {
75 font-weight: 600;
76 font-size: 1.1rem;
77}
78
79.header-right {
80 display: flex;
81 align-items: center;
82 gap: 1rem;
83 color: var(--text-secondary);
84 font-size: 0.9rem;
85}
86
87.dashboard-content {
88 padding: 1.5rem;
89 flex: 1;
90 overflow-y: auto;
91}
92
93/* Session card (calendar view) */
94.session-card {
95 display: flex;
96 align-items: flex-start;
97 gap: 1rem;
98 padding: 1rem 1.25rem;
99 border: 1px solid var(--border);
100 border-radius: var(--radius);
101 margin-bottom: 0.75rem;
102 background: white;
103}
104
105.session-time {
106 font-weight: 600;
107 font-size: 0.95rem;
108 min-width: 80px;
109 color: var(--accent);
110}
111
112.session-info { flex: 1; }
113
114.session-client {
115 font-weight: 500;
116 margin-bottom: 0.15rem;
117}
118
119.session-meta {
120 font-size: 0.85rem;
121 color: var(--text-secondary);
122 margin-bottom: 0.5rem;
123}
124
125.session-actions {
126 display: flex;
127 gap: 0.4rem;
128 flex-wrap: wrap;
129}
130
131.session-actions .btn {
132 padding: 0.3rem 0.7rem;
133 font-size: 0.8rem;
134 border-radius: 4px;
135}
136
137/* Summary cards */
138.summary-grid {
139 display: grid;
140 grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
141 gap: 1rem;
142 margin-bottom: 1.5rem;
143}
144
145.summary-card {
146 padding: 1rem;
147 border: 1px solid var(--border);
148 border-radius: var(--radius);
149 text-align: center;
150 background: white;
151}
152
153.summary-number {
154 font-size: 2rem;
155 font-weight: 600;
156 color: var(--accent);
157}
158
159.summary-label {
160 font-size: 0.8rem;
161 color: var(--text-secondary);
162 margin-top: 0.25rem;
163}
164
165/* Week overview */
166.week-grid {
167 display: grid;
168 grid-template-columns: repeat(7, 1fr);
169 gap: 0.5rem;
170 margin: 1rem 0;
171}
172
173.week-day {
174 text-align: center;
175 padding: 0.75rem 0.5rem;
176 border: 1px solid var(--border);
177 border-radius: var(--radius);
178 background: white;
179}
180
181.week-day.today { border-color: var(--accent); background: #faf8f4; }
182
183.week-day-name {
184 font-size: 0.75rem;
185 color: var(--text-secondary);
186 text-transform: uppercase;
187}
188
189.week-day-count {
190 font-size: 1.4rem;
191 font-weight: 600;
192 margin: 0.25rem 0;
193}
194
195.week-day-date {
196 font-size: 0.75rem;
197 color: var(--text-secondary);
198}
199
200/* Client list */
201.client-card {
202 display: flex;
203 justify-content: space-between;
204 align-items: center;
205 padding: 1rem 1.25rem;
206 border: 1px solid var(--border);
207 border-radius: var(--radius);
208 margin-bottom: 0.5rem;
209 background: white;
210 cursor: pointer;
211 transition: border-color 0.1s;
212}
213.client-card:hover { border-color: var(--accent); }
214
215.client-card-info { flex: 1; }
216.client-card-name { font-weight: 500; margin-bottom: 0.15rem; }
217.client-card-meta { font-size: 0.85rem; color: var(--text-secondary); }
218
219/* Package list */
220.package-row {
221 display: flex;
222 justify-content: space-between;
223 align-items: center;
224 padding: 1rem 1.25rem;
225 border: 1px solid var(--border);
226 border-radius: var(--radius);
227 margin-bottom: 0.5rem;
228 background: white;
229}
230
231.package-row-info { flex: 1; }
232.package-row-name { font-weight: 500; margin-bottom: 0.15rem; }
233.package-row-meta { font-size: 0.85rem; color: var(--text-secondary); }
234
235.package-row-actions {
236 display: flex;
237 gap: 0.4rem;
238}
239.package-row-actions .btn { padding: 0.3rem 0.7rem; font-size: 0.8rem; }
240
241/* Availability editor */
242.day-editor {
243 padding: 1rem 0;
244 border-bottom: 1px solid var(--border);
245}
246
247.day-editor-header {
248 display: flex;
249 align-items: center;
250 gap: 0.75rem;
251 margin-bottom: 0.5rem;
252}
253
254.day-editor-header label { font-weight: 500; margin-bottom: 0; flex: 1; }
255
256.time-window {
257 display: flex;
258 align-items: center;
259 gap: 0.5rem;
260 margin-bottom: 0.4rem;
261 margin-left: 1.5rem;
262}
263
264.time-window select {
265 width: auto;
266 margin-bottom: 0;
267 padding: 0.4rem;
268 font-size: 0.9rem;
269}
270
271.time-window .remove-window {
272 background: none;
273 border: none;
274 color: var(--error);
275 cursor: pointer;
276 font-size: 1.2rem;
277 padding: 0.2rem;
278}
279
280.add-window {
281 margin-left: 1.5rem;
282 background: none;
283 border: none;
284 color: var(--accent);
285 cursor: pointer;
286 font-size: 0.85rem;
287 padding: 0.3rem 0;
288}
289.add-window:hover { text-decoration: underline; }
290
291/* Settings form sections */
292.settings-section {
293 margin-bottom: 2rem;
294}
295
296.settings-section h3 {
297 font-size: 1.1rem;
298 margin-bottom: 1rem;
299 padding-bottom: 0.5rem;
300 border-bottom: 1px solid var(--border);
301}
302
303/* Progress bar */
304.progress {
305 height: 8px;
306 background: var(--border);
307 border-radius: 4px;
308 overflow: hidden;
309 margin: 0.5rem 0;
310}
311
312.progress-fill {
313 height: 100%;
314 background: var(--accent);
315 border-radius: 4px;
316 transition: width 0.3s;
317}
318
319/* Responsive */
320@media (max-width: 768px) {
321 .dashboard-sidebar {
322 position: fixed;
323 left: -220px;
324 top: 0;
325 bottom: 0;
326 z-index: 100;
327 transition: left 0.2s;
328 }
329 .dashboard-sidebar.open { left: 0; box-shadow: 2px 0 8px rgba(0,0,0,0.1); }
330 .sidebar-toggle { display: block; background: none; border: none; font-size: 1.4rem; cursor: pointer; }
331 .summary-grid { grid-template-columns: repeat(2, 1fr); }
332 .week-grid { grid-template-columns: repeat(4, 1fr); }
333}
web/js/dashboard.js +872 -0
……@@ -0,0 +1,872 @@
1/**
2 * Dashboard client-side logic.
3 * Each view has its own init function.
4 */
5
6/* global HyperBookingIdentity, api, showToast, formatTime, formatDate, formatPrice */
7
8const content = document.getElementById('content')
9
10async function initDashboard (view, params) {
11 // Check auth first
12 try {
13 const me = await api('GET', '/api/auth/me')
14 const nameEl = document.getElementById('user-name')
15 if (nameEl) nameEl.textContent = me.username || me.userId || ''
16
17 // Show admin link if admin
18 if (me.role === 'admin') {
19 const adminLink = document.getElementById('admin-link')
20 if (adminLink) adminLink.style.display = ''
21 }
22
23 if (!me.authenticated && view !== 'profile') {
24 content.innerHTML = '<p class="empty">Please <a href="javascript:void(0)" onclick="signIn()">sign in</a> to access the dashboard.</p>'
25 return
26 }
27 } catch (err) {
28 content.innerHTML = '<p class="empty">Please sign in to access the dashboard.</p>'
29 return
30 }
31
32 switch (view) {
33 case 'home': return renderCalendar()
34 case 'clients': return renderClients()
35 case 'client-detail': return renderClientDetail(params.clientId)
36 case 'packages': return renderPackages()
37 case 'package-edit': return renderPackageEdit(params.id)
38 case 'availability': return renderAvailability()
39 case 'analytics': return renderAnalytics()
40 case 'settings': return renderSettings()
41 case 'profile': return renderProfile()
42 case 'portal': return renderPortal()
43 case 'admin': return renderAdmin()
44 case 'admin-providers': return renderAdminProviders()
45 case 'admin-users': return renderAdminUsers()
46 default: content.innerHTML = '<p class="empty">Unknown view</p>'
47 }
48}
49
50async function signIn () {
51 if (typeof HyperBookingIdentity !== 'undefined') {
52 await HyperBookingIdentity.generate()
53 showToast('Identity created', 'success')
54 location.reload()
55 }
56}
57
58// ---- Calendar (Home) ----
59
60async function renderCalendar () {
61 try {
62 const [today, week] = await Promise.all([
63 api('GET', '/api/dashboard/today'),
64 api('GET', '/api/dashboard/week')
65 ])
66
67 const todayFmt = new Date(today.date + 'T12:00:00Z').toLocaleDateString('en-US', {
68 weekday: 'long', month: 'long', day: 'numeric'
69 })
70
71 let html = `
72 <div class="summary-grid">
73 <div class="summary-card">
74 <div class="summary-number">${today.summary.sessions}</div>
75 <div class="summary-label">Sessions Today</div>
76 </div>
77 <div class="summary-card">
78 <div class="summary-number">${today.summary.clients}</div>
79 <div class="summary-label">Clients Today</div>
80 </div>
81 </div>
82
83 <h3 style="margin-bottom: 1rem;">Today — ${todayFmt}</h3>
84 `
85
86 if (today.bookings.length === 0) {
87 html += '<p class="empty">No sessions scheduled today.</p>'
88 } else {
89 for (const b of today.bookings) {
90 html += renderSessionCard(b)
91 }
92 }
93
94 // Week overview
95 const todayStr = new Date().toISOString().split('T')[0]
96 html += '<h3 style="margin: 2rem 0 1rem;">This Week</h3><div class="week-grid">'
97 const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
98 let i = 0
99 for (const [date, info] of Object.entries(week.days)) {
100 const isToday = date === todayStr
101 html += `
102 <div class="week-day ${isToday ? 'today' : ''}">
103 <div class="week-day-name">${dayNames[i++] || ''}</div>
104 <div class="week-day-count">${info.count}</div>
105 <div class="week-day-date">${date.slice(5)}</div>
106 </div>`
107 }
108 html += '</div>'
109
110 content.innerHTML = html
111 attachSessionActions()
112 } catch (err) {
113 content.innerHTML = `<p class="empty">Error loading calendar: ${err.message}</p>`
114 }
115}
116
117function renderSessionCard (b) {
118 const statusBadge = b.status === 'confirmed'
119 ? '<span style="color:var(--success)">Confirmed</span>'
120 : b.status === 'completed'
121 ? '<span style="color:var(--text-secondary)">Completed</span>'
122 : `<span>${b.status}</span>`
123
124 return `
125 <div class="session-card" data-booking-id="${b.id}">
126 <div class="session-time">${formatTime(b.startTimeLocal)}</div>
127 <div class="session-info">
128 <div class="session-client">${esc(b.clientName)}</div>
129 <div class="session-meta">${esc(b.packageName)} · ${b.sessionDuration}min · ${statusBadge}</div>
130 <div class="session-actions">
131 ${b.status === 'confirmed' ? `
132 <button class="btn btn-secondary" onclick="completeSession('${b.id}')">Complete</button>
133 <button class="btn btn-secondary" onclick="markNoShow('${b.id}')">No-Show</button>
134 ` : ''}
135 <button class="btn btn-secondary" onclick="cancelSession('${b.id}')">Cancel</button>
136 </div>
137 </div>
138 </div>`
139}
140
141function attachSessionActions () {
142 // Actions are inline onclick — nothing to attach
143}
144
145// ---- Clients ----
146
147async function renderClients () {
148 try {
149 const clients = await api('GET', '/api/dashboard/clients')
150
151 let html = `<div style="margin-bottom: 1rem;">
152 <input type="text" id="client-search" placeholder="Search clients..." style="max-width:300px;" oninput="searchClients(this.value)">
153 </div>`
154
155 if (clients.length === 0) {
156 html += '<p class="empty">No clients yet. They appear here after their first booking.</p>'
157 } else {
158 html += '<div id="client-list">'
159 for (const c of clients) {
160 html += renderClientCard(c)
161 }
162 html += '</div>'
163 }
164
165 content.innerHTML = html
166 } catch (err) {
167 content.innerHTML = `<p class="empty">Error: ${err.message}</p>`
168 }
169}
170
171function renderClientCard (c) {
172 return `
173 <div class="client-card" onclick="location.href='/dashboard/clients/${c.id}'">
174 <div class="client-card-info">
175 <div class="client-card-name">${esc(c.displayName)}</div>
176 <div class="client-card-meta">
177 ${c.email || 'No email'} · ${c.bookingCount || 0} bookings
178 ${c.nextSession ? ' · Next: ' + c.nextSession : ''}
179 </div>
180 </div>
181 <span style="color:var(--text-secondary)">&rarr;</span>
182 </div>`
183}
184
185async function searchClients (q) {
186 const clients = await api('GET', '/api/dashboard/clients?q=' + encodeURIComponent(q))
187 const list = document.getElementById('client-list')
188 if (list) {
189 list.innerHTML = clients.length
190 ? clients.map(renderClientCard).join('')
191 : '<p class="empty">No matches</p>'
192 }
193}
194
195// ---- Client Detail ----
196
197async function renderClientDetail (clientId) {
198 try {
199 const data = await api('GET', '/api/dashboard/clients/' + clientId)
200 const { client, bookings } = data
201
202 const upcoming = bookings.filter(b => b.status === 'confirmed')
203 const past = bookings.filter(b => b.status === 'completed')
204
205 let html = `
206 <p><a href="/dashboard/clients">&larr; Back to Clients</a></p>
207 <h2 style="margin: 1rem 0;">${esc(client.displayName)}</h2>
208 <div class="card">
209 <p><strong>Email:</strong> ${esc(client.email || 'Not provided')}</p>
210 <p><strong>Client since:</strong> ${new Date(client.createdAt).toLocaleDateString()}</p>
211 </div>
212
213 <h3 style="margin: 1.5rem 0 1rem;">Upcoming (${upcoming.length})</h3>
214 `
215 if (upcoming.length === 0) {
216 html += '<p class="empty">No upcoming sessions</p>'
217 } else {
218 for (const b of upcoming) {
219 html += `<div class="session-card">
220 <div class="session-time">${b.date}<br>${formatTime(b.startTimeLocal)}</div>
221 <div class="session-info">
222 <div class="session-meta">${esc(b.packageName)}</div>
223 </div>
224 </div>`
225 }
226 }
227
228 html += `<h3 style="margin: 1.5rem 0 1rem;">Past Sessions (${past.length})</h3>`
229 if (past.length === 0) {
230 html += '<p class="empty">No past sessions</p>'
231 } else {
232 for (const b of past) {
233 html += `<div class="session-card">
234 <div class="session-time">${b.date}</div>
235 <div class="session-info">
236 <div class="session-meta">${esc(b.packageName)}</div>
237 ${b.notes ? `<p style="font-size:0.85rem;color:var(--text-secondary);margin-top:0.25rem;">"${esc(b.notes)}"</p>` : ''}
238 </div>
239 </div>`
240 }
241 }
242
243 content.innerHTML = html
244 } catch (err) {
245 content.innerHTML = `<p class="empty">Error: ${err.message}</p>`
246 }
247}
248
249// ---- Packages ----
250
251async function renderPackages () {
252 try {
253 const providers = await api('GET', '/api/providers')
254 if (providers.length === 0) {
255 content.innerHTML = '<p class="empty">Create a provider profile first.</p>'
256 return
257 }
258 const provider = providers[0] // First provider for this user
259 const packages = await api('GET', `/api/providers/${provider.slug}/packages`)
260
261 let html = `
262 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
263 <span>${packages.length} package${packages.length !== 1 ? 's' : ''}</span>
264 <a href="/dashboard/packages/new" class="btn btn-accent">+ New Package</a>
265 </div>
266 `
267
268 for (const pkg of packages) {
269 const meta = []
270 meta.push(pkg.type)
271 meta.push(pkg.sessionDuration + 'min')
272 if (pkg.sessionCount > 1) meta.push(pkg.sessionCount + ' sessions')
273 meta.push(formatPrice(pkg.price))
274
275 html += `
276 <div class="package-row">
277 <div class="package-row-info">
278 <div class="package-row-name">${esc(pkg.name)}</div>
279 <div class="package-row-meta">${meta.join(' · ')}</div>
280 </div>
281 <div class="package-row-actions">
282 <a href="/dashboard/packages/${pkg.id}/edit" class="btn btn-secondary">Edit</a>
283 <button class="btn btn-secondary" onclick="archivePackage('${pkg.id}')">Archive</button>
284 </div>
285 </div>`
286 }
287
288 content.innerHTML = html
289 } catch (err) {
290 content.innerHTML = `<p class="empty">Error: ${err.message}</p>`
291 }
292}
293
294// ---- Package Edit ----
295
296async function renderPackageEdit (packageId) {
297 let pkg = null
298 let providers = []
299
300 try {
301 providers = await api('GET', '/api/providers')
302 if (packageId) {
303 pkg = await api('GET', `/api/packages/${packageId}`)
304 }
305 } catch (err) {
306 content.innerHTML = `<p class="empty">Error: ${err.message}</p>`
307 return
308 }
309
310 const isNew = !pkg
311 const providerId = pkg ? pkg.providerId : (providers[0] ? providers[0].id : '')
312
313 content.innerHTML = `
314 <p><a href="/dashboard/packages">&larr; Back to Packages</a></p>
315 <h2 style="margin:1rem 0;">${isNew ? 'New Package' : 'Edit Package'}</h2>
316 <form id="package-form" class="card">
317 <input type="hidden" id="pkg-providerId" value="${providerId}">
318 <label>Package Type</label>
319 <select id="pkg-type">
320 <option value="discovery" ${pkg && pkg.type === 'discovery' ? 'selected' : ''}>Discovery (Free)</option>
321 <option value="single" ${pkg && pkg.type === 'single' ? 'selected' : ''}>Single Session</option>
322 <option value="bundle" ${pkg && pkg.type === 'bundle' ? 'selected' : ''}>Bundle (Multiple Sessions)</option>
323 </select>
324 <label>Name *</label>
325 <input type="text" id="pkg-name" value="${esc(pkg ? pkg.name : '')}" required>
326 <label>Description</label>
327 <textarea id="pkg-description" rows="3">${esc(pkg ? pkg.description : '')}</textarea>
328 <label>Session Duration (minutes)</label>
329 <input type="number" id="pkg-duration" value="${pkg ? pkg.sessionDuration : 60}" min="5" step="5">
330 <div id="price-section">
331 <label>Price (cents)</label>
332 <input type="number" id="pkg-price" value="${pkg ? pkg.price : 0}" min="0">
333 <p style="font-size:0.8rem;color:var(--text-secondary)">Enter in cents: 7500 = $75.00</p>
334 </div>
335 <div id="bundle-section" style="display:none;">
336 <label>Number of Sessions</label>
337 <input type="number" id="pkg-sessionCount" value="${pkg ? pkg.sessionCount : 6}" min="2">
338 <label>Pay-in-Full Discount (%)</label>
339 <input type="number" id="pkg-discount" value="${pkg ? pkg.discountFullPay || 0 : 0}" min="0" max="50">
340 <label>Installments</label>
341 <input type="number" id="pkg-installments" value="${pkg ? pkg.installments || 0 : 0}" min="0" max="12">
342 </div>
343 <div style="margin-top:1rem;display:flex;gap:0.5rem;">
344 <button type="submit" class="btn btn-accent">${isNew ? 'Create Package' : 'Save Changes'}</button>
345 <a href="/dashboard/packages" class="btn btn-secondary">Cancel</a>
346 </div>
347 </form>
348 <script>
349 // Show/hide fields based on type
350 const typeSelect = document.getElementById('pkg-type')
351 function updateTypeFields() {
352 const t = typeSelect.value
353 document.getElementById('price-section').style.display = t === 'discovery' ? 'none' : ''
354 document.getElementById('bundle-section').style.display = t === 'bundle' ? '' : 'none'
355 if (t === 'discovery') document.getElementById('pkg-price').value = '0'
356 }
357 typeSelect.addEventListener('change', updateTypeFields)
358 updateTypeFields()
359
360 document.getElementById('package-form').addEventListener('submit', async (e) => {
361 e.preventDefault()
362 const body = {
363 providerId: document.getElementById('pkg-providerId').value,
364 name: document.getElementById('pkg-name').value,
365 type: typeSelect.value,
366 description: document.getElementById('pkg-description').value,
367 sessionDuration: parseInt(document.getElementById('pkg-duration').value),
368 price: parseInt(document.getElementById('pkg-price').value),
369 sessionCount: parseInt(document.getElementById('pkg-sessionCount').value) || 1,
370 discountFullPay: parseInt(document.getElementById('pkg-discount').value) || 0,
371 installments: parseInt(document.getElementById('pkg-installments').value) || 0
372 }
373 try {
374 if (${packageId ? 'true' : 'false'}) {
375 await api('PUT', '/api/packages/${packageId || ''}', body)
376 showToast('Package updated', 'success')
377 } else {
378 await api('POST', '/api/packages', body)
379 showToast('Package created', 'success')
380 }
381 location.href = '/dashboard/packages'
382 } catch (err) {
383 showToast(err.message, 'error')
384 }
385 })
386 </script>
387 `
388}
389
390// ---- Availability ----
391
392async function renderAvailability () {
393 try {
394 const providers = await api('GET', '/api/providers')
395 if (providers.length === 0) {
396 content.innerHTML = '<p class="empty">Create a provider profile first.</p>'
397 return
398 }
399 const provider = providers[0]
400 const avail = await api('GET', `/api/providers/${provider.slug}/availability`)
401
402 const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
403 const timeOpts = generateTimeOptions()
404
405 let html = '<form id="avail-form">'
406
407 for (const day of days) {
408 const windows = (avail.weekly && avail.weekly[day]) || []
409 const checked = windows.length > 0
410
411 html += `
412 <div class="day-editor" data-day="${day}">
413 <div class="day-editor-header">
414 <input type="checkbox" id="day-${day}" ${checked ? 'checked' : ''} onchange="toggleDay('${day}', this.checked)">
415 <label for="day-${day}">${day.charAt(0).toUpperCase() + day.slice(1)}</label>
416 </div>
417 <div id="windows-${day}" style="${checked ? '' : 'display:none'}">
418 ${windows.map((w, i) => renderTimeWindow(day, i, w, timeOpts)).join('')}
419 <button type="button" class="add-window" onclick="addWindow('${day}')">+ Add window</button>
420 </div>
421 </div>`
422 }
423
424 html += `
425 <div style="margin-top:1.5rem; padding-top:1rem; border-top:1px solid var(--border);">
426 <label>Buffer between sessions</label>
427 <select id="avail-buffer" style="width:auto;display:inline-block;margin-left:0.5rem;">
428 ${[0, 5, 10, 15, 20, 30].map(m => `<option value="${m}" ${avail.bufferMinutes === m ? 'selected' : ''}>${m} min</option>`).join('')}
429 </select>
430 <div style="margin-top:1rem;">
431 <label>Min advance booking</label>
432 <select id="avail-minHours" style="width:auto;display:inline-block;margin-left:0.5rem;">
433 ${[0, 1, 2, 4, 8, 12, 24, 48].map(h => `<option value="${h}" ${avail.advanceBooking && avail.advanceBooking.minHours === h ? 'selected' : ''}>${h}h</option>`).join('')}
434 </select>
435 </div>
436 <div style="margin-top:1rem;">
437 <label>Max advance booking</label>
438 <select id="avail-maxDays" style="width:auto;display:inline-block;margin-left:0.5rem;">
439 ${[7, 14, 30, 60, 90].map(d => `<option value="${d}" ${avail.advanceBooking && avail.advanceBooking.maxDays === d ? 'selected' : ''}>${d} days</option>`).join('')}
440 </select>
441 </div>
442 <button type="submit" class="btn btn-accent" style="margin-top:1.5rem;">Save Schedule</button>
443 </div>
444 </form>`
445
446 content.innerHTML = html
447
448 document.getElementById('avail-form').addEventListener('submit', async (e) => {
449 e.preventDefault()
450 const weekly = {}
451 for (const day of days) {
452 const cb = document.getElementById('day-' + day)
453 if (!cb.checked) { weekly[day] = []; continue }
454 const windowEls = document.querySelectorAll('#windows-' + day + ' .time-window')
455 weekly[day] = Array.from(windowEls).map(el => ({
456 start: el.querySelector('.tw-start').value,
457 end: el.querySelector('.tw-end').value
458 }))
459 }
460
461 try {
462 await api('PUT', '/api/availability', {
463 providerId: provider.id,
464 timezone: provider.timezone,
465 weekly,
466 bufferMinutes: parseInt(document.getElementById('avail-buffer').value),
467 advanceBooking: {
468 minHours: parseInt(document.getElementById('avail-minHours').value),
469 maxDays: parseInt(document.getElementById('avail-maxDays').value)
470 }
471 })
472 showToast('Schedule saved', 'success')
473 } catch (err) {
474 showToast(err.message, 'error')
475 }
476 })
477 } catch (err) {
478 content.innerHTML = `<p class="empty">Error: ${err.message}</p>`
479 }
480}
481
482function renderTimeWindow (day, index, window, timeOpts) {
483 return `
484 <div class="time-window">
485 <select class="tw-start">${timeOpts.map(t => `<option value="${t}" ${window.start === t ? 'selected' : ''}>${t}</option>`).join('')}</select>
486 <span>to</span>
487 <select class="tw-end">${timeOpts.map(t => `<option value="${t}" ${window.end === t ? 'selected' : ''}>${t}</option>`).join('')}</select>
488 <button type="button" class="remove-window" onclick="this.parentElement.remove()">&times;</button>
489 </div>`
490}
491
492function generateTimeOptions () {
493 const opts = []
494 for (let h = 0; h < 24; h++) {
495 for (let m = 0; m < 60; m += 15) {
496 opts.push(String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0'))
497 }
498 }
499 return opts
500}
501
502// ---- Analytics ----
503
504async function renderAnalytics () {
505 try {
506 const data = await api('GET', '/api/dashboard/analytics')
507
508 let html = `
509 <div class="summary-grid">
510 <div class="summary-card">
511 <div class="summary-number">${data.sessions.completed}</div>
512 <div class="summary-label">Completed</div>
513 </div>
514 <div class="summary-card">
515 <div class="summary-number">${data.sessions.confirmed}</div>
516 <div class="summary-label">Upcoming</div>
517 </div>
518 <div class="summary-card">
519 <div class="summary-number">${data.sessions.cancelled}</div>
520 <div class="summary-label">Cancelled</div>
521 </div>
522 <div class="summary-card">
523 <div class="summary-number">${data.clients}</div>
524 <div class="summary-label">Clients</div>
525 </div>
526 </div>
527
528 <h3 style="margin:1.5rem 0 1rem;">By Package</h3>
529 `
530
531 for (const [pkgId, info] of Object.entries(data.packages)) {
532 html += `
533 <div class="package-row">
534 <div class="package-row-info">
535 <div class="package-row-name">${esc(info.name)}</div>
536 <div class="package-row-meta">${info.count} bookings · ${formatPrice(info.price)} each</div>
537 </div>
538 </div>`
539 }
540
541 content.innerHTML = html
542 } catch (err) {
543 content.innerHTML = `<p class="empty">Error: ${err.message}</p>`
544 }
545}
546
547// ---- Settings ----
548
549async function renderSettings () {
550 try {
551 const providers = await api('GET', '/api/providers')
552 if (providers.length === 0) {
553 content.innerHTML = '<p class="empty">No provider profile found.</p>'
554 return
555 }
556 const p = providers[0]
557 const avail = await api('GET', `/api/providers/${p.slug}/availability`)
558
559 content.innerHTML = `
560 <div class="settings-section">
561 <h3>Profile</h3>
562 <form id="profile-form">
563 <label>Display Name</label>
564 <input type="text" id="set-name" value="${esc(p.displayName)}">
565 <label>Bio</label>
566 <textarea id="set-bio" rows="4">${esc(p.bio)}</textarea>
567 <label>Specialties (comma-separated)</label>
568 <input type="text" id="set-specialties" value="${(p.specialties || []).join(', ')}">
569 <label>Accent Color</label>
570 <input type="color" id="set-color" value="${p.accentColor || '#c4a265'}" style="width:60px;height:40px;padding:2px;">
571 <div style="margin-top:1rem;">
572 <button type="submit" class="btn btn-accent">Save Profile</button>
573 </div>
574 </form>
575 </div>
576
577 <div class="settings-section">
578 <h3>Booking Page</h3>
579 <p>Your URL: <a href="/${p.slug}" target="_blank">/${p.slug}</a></p>
580 <p>Status: ${p.active ? '<span style="color:var(--success)">Published</span>' : '<span style="color:var(--error)">Unpublished</span>'}</p>
581 </div>
582
583 <div class="settings-section">
584 <h3>Cancellation Policy</h3>
585 <form id="policy-form">
586 <label>Cancellation window</label>
587 <select id="set-cancelWindow" style="width:auto;">
588 ${[0, 12, 24, 48, 72].map(h => `<option value="${h}" ${avail.cancellation && avail.cancellation.windowHours === h ? 'selected' : ''}>${h} hours</option>`).join('')}
589 </select>
590 <label style="margin-top:0.5rem;">Refund policy</label>
591 <select id="set-refundPolicy" style="width:auto;">
592 <option value="full" ${avail.cancellation && avail.cancellation.refundPolicy === 'full' ? 'selected' : ''}>Full refund</option>
593 <option value="partial" ${avail.cancellation && avail.cancellation.refundPolicy === 'partial' ? 'selected' : ''}>50% refund</option>
594 <option value="none" ${avail.cancellation && avail.cancellation.refundPolicy === 'none' ? 'selected' : ''}>No refund</option>
595 </select>
596 <div style="margin-top:1rem;">
597 <button type="submit" class="btn btn-accent">Save Policy</button>
598 </div>
599 </form>
600 </div>
601
602 <div class="settings-section">
603 <h3>Identity</h3>
604 <p style="font-size:0.85rem;color:var(--text-secondary);word-break:break-all;">
605 Public Key: ${HyperBookingIdentity.getPublicKey() || 'Not signed in'}
606 </p>
607 <button class="btn btn-secondary" onclick="exportKeypair()">Export Keypair</button>
608 <button class="btn btn-danger" style="margin-left:0.5rem;" onclick="signOut()">Sign Out</button>
609 </div>
610 `
611
612 document.getElementById('profile-form').addEventListener('submit', async (e) => {
613 e.preventDefault()
614 try {
615 await api('PUT', `/api/providers/${p.slug}`, {
616 displayName: document.getElementById('set-name').value,
617 bio: document.getElementById('set-bio').value,
618 specialties: document.getElementById('set-specialties').value.split(',').map(s => s.trim()).filter(Boolean),
619 accentColor: document.getElementById('set-color').value
620 })
621 showToast('Profile saved', 'success')
622 } catch (err) { showToast(err.message, 'error') }
623 })
624
625 document.getElementById('policy-form').addEventListener('submit', async (e) => {
626 e.preventDefault()
627 try {
628 await api('PUT', '/api/availability', {
629 providerId: p.id,
630 timezone: p.timezone,
631 weekly: avail.weekly,
632 bufferMinutes: avail.bufferMinutes,
633 advanceBooking: avail.advanceBooking,
634 cancellation: {
635 windowHours: parseInt(document.getElementById('set-cancelWindow').value),
636 refundPolicy: document.getElementById('set-refundPolicy').value
637 }
638 })
639 showToast('Policy saved', 'success')
640 } catch (err) { showToast(err.message, 'error') }
641 })
642 } catch (err) {
643 content.innerHTML = `<p class="empty">Error: ${err.message}</p>`
644 }
645}
646
647// ---- Profile ----
648
649async function renderProfile () {
650 const hasPk = HyperBookingIdentity.hasKeypair()
651 content.innerHTML = `
652 <div class="card">
653 <h3 style="margin-bottom:1rem;">Identity</h3>
654 ${hasPk ? `
655 <p style="font-size:0.85rem;color:var(--text-secondary);word-break:break-all;">
656 Public Key: ${HyperBookingIdentity.getPublicKey()}
657 </p>
658 <div style="margin-top:1rem;display:flex;gap:0.5rem;">
659 <button class="btn btn-secondary" onclick="exportKeypair()">Export Keypair</button>
660 <button class="btn btn-secondary" onclick="importKeypairPrompt()">Import Keypair</button>
661 <button class="btn btn-danger" onclick="signOut()">Sign Out</button>
662 </div>
663 ` : `
664 <p>You are not signed in.</p>
665 <button class="btn btn-accent" onclick="signIn()">Create Identity</button>
666 `}
667 </div>
668 `
669}
670
671// ---- Portal ----
672
673async function renderPortal () {
674 try {
675 const bookings = await api('GET', '/api/portal/bookings')
676 const today = new Date().toISOString().split('T')[0]
677 const upcoming = bookings.filter(b => b.status === 'confirmed' && b.date >= today)
678 const past = bookings.filter(b => b.status === 'completed' || b.date < today)
679
680 let html = '<h3 style="margin-bottom:1rem;">Upcoming Sessions</h3>'
681
682 if (upcoming.length === 0) {
683 html += '<p class="empty">No upcoming sessions.</p>'
684 } else {
685 for (const b of upcoming) {
686 html += `
687 <div class="session-card">
688 <div class="session-time">${b.date}<br>${formatTime(b.startTimeLocal)}</div>
689 <div class="session-info">
690 <div class="session-meta">${esc(b.packageId)}</div>
691 <div class="session-actions">
692 <button class="btn btn-secondary" onclick="cancelSession('${b.id}')">Cancel</button>
693 </div>
694 </div>
695 </div>`
696 }
697 }
698
699 html += `<h3 style="margin:2rem 0 1rem;">Past Sessions (${past.length})</h3>`
700 if (past.length === 0) {
701 html += '<p class="empty">No past sessions.</p>'
702 } else {
703 for (const b of past) {
704 html += `
705 <div class="session-card">
706 <div class="session-time">${b.date}</div>
707 <div class="session-info">
708 <div class="session-meta">${b.status}${b.notes ? ' — "' + esc(b.notes) + '"' : ''}</div>
709 </div>
710 </div>`
711 }
712 }
713
714 content.innerHTML = html
715 } catch (err) {
716 content.innerHTML = `<p class="empty">Error: ${err.message}</p>`
717 }
718}
719
720// ---- Admin ----
721
722async function renderAdmin () {
723 try {
724 const providers = await api('GET', '/api/providers')
725 content.innerHTML = `
726 <div class="summary-grid">
727 <div class="summary-card">
728 <div class="summary-number">${providers.length}</div>
729 <div class="summary-label">Providers</div>
730 </div>
731 </div>
732 <h3 style="margin:1.5rem 0 1rem;">Quick Links</h3>
733 <div style="display:flex;gap:0.5rem;">
734 <a href="/admin/providers" class="btn btn-secondary">Manage Providers</a>
735 </div>
736 `
737 } catch (err) {
738 content.innerHTML = `<p class="empty">Error: ${err.message}</p>`
739 }
740}
741
742async function renderAdminProviders () {
743 try {
744 const providers = await api('GET', '/api/providers')
745
746 let html = '<p><a href="/admin">&larr; Back to Admin</a></p>'
747 html += `<h3 style="margin:1rem 0;">Providers (${providers.length})</h3>`
748
749 for (const p of providers) {
750 html += `
751 <div class="package-row">
752 <div class="package-row-info">
753 <div class="package-row-name">${esc(p.displayName)}</div>
754 <div class="package-row-meta">/${esc(p.slug)} · ${p.active ? 'Active' : 'Disabled'}</div>
755 </div>
756 <div class="package-row-actions">
757 <a href="/${p.slug}" class="btn btn-secondary" target="_blank">View Page</a>
758 </div>
759 </div>`
760 }
761
762 content.innerHTML = html
763 } catch (err) {
764 content.innerHTML = `<p class="empty">Error: ${err.message}</p>`
765 }
766}
767
768async function renderAdminUsers () {
769 content.innerHTML = '<p class="empty">User management coming soon.</p>'
770}
771
772// ---- Global Actions ----
773
774async function completeSession (bookingId) {
775 try {
776 await api('PUT', `/api/bookings/${bookingId}/complete`)
777 showToast('Session marked complete', 'success')
778 renderCalendar()
779 } catch (err) { showToast(err.message, 'error') }
780}
781
782async function markNoShow (bookingId) {
783 if (!confirm('Mark as no-show?')) return
784 try {
785 await api('PUT', `/api/bookings/${bookingId}/cancel`, { cancelledBy: 'provider' })
786 showToast('Marked as no-show', 'success')
787 renderCalendar()
788 } catch (err) { showToast(err.message, 'error') }
789}
790
791async function cancelSession (bookingId) {
792 if (!confirm('Cancel this session?')) return
793 try {
794 await api('PUT', `/api/bookings/${bookingId}/cancel`)
795 showToast('Session cancelled', 'success')
796 renderCalendar()
797 } catch (err) { showToast(err.message, 'error') }
798}
799
800async function archivePackage (packageId) {
801 if (!confirm('Archive this package?')) return
802 try {
803 await api('DELETE', `/api/packages/${packageId}`)
804 showToast('Package archived', 'success')
805 renderPackages()
806 } catch (err) { showToast(err.message, 'error') }
807}
808
809function exportKeypair () {
810 const data = HyperBookingIdentity.exportKeypair()
811 if (!data) return showToast('No keypair to export', 'error')
812 const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
813 const a = document.createElement('a')
814 a.href = URL.createObjectURL(blob)
815 a.download = 'hyper-booking-keypair.json'
816 a.click()
817 showToast('Keypair exported', 'success')
818}
819
820function importKeypairPrompt () {
821 const input = prompt('Paste your 132-character keypair:')
822 if (input) {
823 try {
824 HyperBookingIdentity.importKeypair(input.trim())
825 showToast('Keypair imported', 'success')
826 location.reload()
827 } catch (err) { showToast(err.message, 'error') }
828 }
829}
830
831function signOut () {
832 if (!confirm('Sign out? Make sure you have exported your keypair first.')) return
833 HyperBookingIdentity.clear()
834 showToast('Signed out')
835 location.href = '/'
836}
837
838function esc (s) {
839 if (!s) return ''
840 return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
841}
842
843function toggleDay (day, checked) {
844 document.getElementById('windows-' + day).style.display = checked ? '' : 'none'
845}
846
847function addWindow (day) {
848 const container = document.getElementById('windows-' + day)
849 const timeOpts = generateTimeOptions()
850 const div = document.createElement('div')
851 div.className = 'time-window'
852 div.innerHTML = `
853 <select class="tw-start">${timeOpts.map(t => `<option value="${t}">${t}</option>`).join('')}</select>
854 <span>to</span>
855 <select class="tw-end">${timeOpts.map(t => `<option value="${t}" ${t === '17:00' ? 'selected' : ''}>${t}</option>`).join('')}</select>
856 <button type="button" class="remove-window" onclick="this.parentElement.remove()">&times;</button>
857 `
858 container.insertBefore(div, container.querySelector('.add-window'))
859}
860
861window.initDashboard = initDashboard
862window.signIn = signIn
863window.completeSession = completeSession
864window.markNoShow = markNoShow
865window.cancelSession = cancelSession
866window.archivePackage = archivePackage
867window.exportKeypair = exportKeypair
868window.importKeypairPrompt = importKeypairPrompt
869window.signOut = signOut
870window.toggleDay = toggleDay
871window.addWindow = addWindow
872window.searchClients = searchClients