Kneadly — online booking platform for massage shops

Fresha-style booking: shops sign up, add services/therapists/hours, and get a
public booking page they can drop into their Google Business Profile so
customers book from Maps. Deposits via Stripe stop no-shows.

Stack: Hono + Cloudflare Workers + D1 + Stripe, server-rendered HTML.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
King Omar 2026-07-21 19:09:21 +10:00
commit aa45ebc769
18 changed files with 3084 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
node_modules/
.wrangler/
dist/
*.local
.env
.dev.vars

77
README.md Normal file
View file

@ -0,0 +1,77 @@
# 💆 Kneadly
Online booking for massage & bodywork shops — a lightweight Fresha alternative.
Each shop gets a public booking page they can drop straight into their **Google
Business Profile** so customers book them from Google Maps.
Live: https://kneadly.theradicalparty.com
## What it does
- **Shop owners** sign up → instantly get a booking page at `/<shop-slug>`
- Add **services** (duration + price), **therapists**, and per-therapist **weekly hours**
- **Customers** browse → pick service → pick therapist (or "anyone available") →
pick a time → pay a deposit → booked
- **Deposits** (a configurable % of price) are taken via Stripe Checkout to stop no-shows;
cancelling refunds automatically
- **Owner dashboard**: bookings list, mark complete / no-show / cancel+refund, edit
services / staff / hours / settings
- **Google Maps ready**: every shop page emits `schema.org` `HealthAndBeautyBusiness`
+ `ReserveAction` JSON-LD, and the dashboard hands owners the exact URL to paste into
their Google Business Profile "Appointment links" field
## Stack
Hono · Cloudflare Workers · D1 (SQLite) · Stripe · zero client framework (server-rendered HTML)
## Layout
```
src/
index.js router, session middleware, favicon/OG/robots/sitemap
lib/
auth.js PBKDF2 password hashing, sessions
stripe.js tiny Stripe REST client (checkout, refunds, webhook verify)
slots.js timezone-aware slot generation
booking.js eligible-staff + availability logic (shared by API & booking)
views.js layout + design system (spa aesthetic)
routes/
auth.js signup (creates owner + shop + default therapist) / login / logout
public.js landing page, shop pages, booking flow, confirmation
dashboard.js owner dashboard (overview, bookings, services, staff, settings)
api.js /api/slots — available dates & times
webhooks.js /webhooks/stripe — confirm booking on payment
schema.sql D1 schema
```
## Develop
```bash
npm install
npm run db:migrate:local # apply schema to local D1
npm run dev # wrangler dev
```
## Deploy
```bash
npm run db:migrate # apply schema to remote D1
npm run deploy # wrangler deploy
```
### Stripe (deposits)
Deposits are optional — with no Stripe key set, bookings confirm instantly (no payment).
To enable deposits, set the platform Stripe secrets:
```bash
npx wrangler secret put STRIPE_SECRET_KEY
npx wrangler secret put STRIPE_WEBHOOK_SECRET
```
Then add a webhook endpoint in Stripe pointing at
`https://kneadly.theradicalparty.com/webhooks/stripe` for the
`checkout.session.completed` and `checkout.session.expired` events.
> Deposits currently settle to the platform Stripe account. Per-shop payouts via
> Stripe Connect is the natural next step.

19
dev.log Normal file
View file

@ -0,0 +1,19 @@
⛅️ wrangler 4.112.0
────────────────────
Cloudflare collects anonymous telemetry about your usage of Wrangler. Learn more at https://github.com/cloudflare/workers-sdk/tree/main/packages/wrangler/telemetry.md
Your Worker has access to the following bindings:
Binding Resource Mode
env.DB (kneadly) D1 Database local
env.BASE_URL ("https://kneadly.theradicalparty.com") Environment Variable local
⎔ Starting local server...
[wrangler:info] Ready on http://localhost:8811
[wrangler:info] GET /serenity-massage-bodywork/booked/6ed7fb492b564f7ead9acd15e1fbd377 200 OK (373ms)
[wrangler:info] GET /dashboard/bookings 200 OK (23ms)
[wrangler:info] GET /no-such-shop 404 Not Found (32ms)
[wrangler:info] POST /dashboard/bookings/6ed7fb492b564f7ead9acd15e1fbd377/complete 302 Found (14ms)
[wrangler:info] POST /dashboard/settings 302 Found (9ms)
[wrangler:info] POST /dashboard/staff 302 Found (12ms)
[wrangler:info] GET /serenity-massage-bodywork 200 OK (14ms)

1517
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

18
package.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "kneadly",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"db:migrate": "wrangler d1 execute kneadly --remote --file=schema.sql",
"db:migrate:local": "wrangler d1 execute kneadly --local --file=schema.sql",
"db:seed": "wrangler d1 execute kneadly --remote --file=seed.sql"
},
"dependencies": {
"hono": "^4.6.0"
},
"devDependencies": {
"wrangler": "^4.0.0"
}
}

126
schema.sql Normal file
View file

@ -0,0 +1,126 @@
-- Kneadly — booking platform for massage shops
-- Multi-tenant: one owner account owns one shop (schema allows more later).
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- The massage business / shop
CREATE TABLE IF NOT EXISTS shops (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL,
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
tagline TEXT,
about TEXT,
phone TEXT,
email TEXT,
address TEXT,
suburb TEXT,
state TEXT,
postcode TEXT,
timezone TEXT NOT NULL DEFAULT 'Australia/Sydney',
emoji TEXT NOT NULL DEFAULT '💆',
accent TEXT NOT NULL DEFAULT '#0f766e',
currency TEXT NOT NULL DEFAULT 'aud',
deposit_pct INTEGER NOT NULL DEFAULT 20, -- % of service price taken as deposit
cancellation_hours INTEGER NOT NULL DEFAULT 24,
is_published INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
FOREIGN KEY (owner_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_shops_owner ON shops(owner_id);
-- Services offered (e.g. "60min Deep Tissue")
CREATE TABLE IF NOT EXISTS services (
id TEXT PRIMARY KEY,
shop_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
duration_minutes INTEGER NOT NULL DEFAULT 60,
price_cents INTEGER NOT NULL DEFAULT 0,
category TEXT NOT NULL DEFAULT 'Massage',
is_active INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_services_shop ON services(shop_id, is_active);
-- Therapists / massage staff
CREATE TABLE IF NOT EXISTS staff (
id TEXT PRIMARY KEY,
shop_id TEXT NOT NULL,
name TEXT NOT NULL,
title TEXT DEFAULT 'Massage Therapist',
bio TEXT,
emoji TEXT NOT NULL DEFAULT '🧑‍⚕️',
is_active INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_staff_shop ON staff(shop_id, is_active);
-- Which staff can perform which services (absence of rows for a service = all staff)
CREATE TABLE IF NOT EXISTS staff_services (
staff_id TEXT NOT NULL,
service_id TEXT NOT NULL,
PRIMARY KEY (staff_id, service_id),
FOREIGN KEY (staff_id) REFERENCES staff(id) ON DELETE CASCADE,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
);
-- Weekly working hours, per staff member
CREATE TABLE IF NOT EXISTS availability (
id TEXT PRIMARY KEY,
staff_id TEXT NOT NULL,
day_of_week INTEGER NOT NULL, -- 0 = Sunday .. 6 = Saturday
start_time TEXT NOT NULL, -- 'HH:MM'
end_time TEXT NOT NULL,
UNIQUE(staff_id, day_of_week),
FOREIGN KEY (staff_id) REFERENCES staff(id) ON DELETE CASCADE
);
-- Customer appointments
CREATE TABLE IF NOT EXISTS bookings (
id TEXT PRIMARY KEY,
shop_id TEXT NOT NULL,
service_id TEXT NOT NULL,
staff_id TEXT NOT NULL,
customer_name TEXT NOT NULL,
customer_email TEXT NOT NULL,
customer_phone TEXT,
start_time INTEGER NOT NULL, -- unix seconds (UTC)
end_time INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending_payment', -- pending_payment | confirmed | completed | cancelled | no_show
price_cents INTEGER NOT NULL DEFAULT 0,
deposit_cents INTEGER NOT NULL DEFAULT 0,
service_name TEXT,
staff_name TEXT,
notes TEXT,
stripe_session_id TEXT,
stripe_payment_intent_id TEXT,
stripe_charge_id TEXT,
refunded_at INTEGER,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE,
FOREIGN KEY (service_id) REFERENCES services(id),
FOREIGN KEY (staff_id) REFERENCES staff(id)
);
CREATE INDEX IF NOT EXISTS idx_bookings_shop ON bookings(shop_id, start_time);
CREATE INDEX IF NOT EXISTS idx_bookings_staff ON bookings(staff_id, start_time);
CREATE INDEX IF NOT EXISTS idx_bookings_stripe ON bookings(stripe_session_id);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);

64
src/index.js Normal file
View file

@ -0,0 +1,64 @@
import { Hono } from 'hono'
import { getCookie } from 'hono/cookie'
import { getSession } from './lib/auth.js'
import authRoutes from './routes/auth.js'
import dashboardRoutes from './routes/dashboard.js'
import apiRoutes from './routes/api.js'
import webhookRoutes from './routes/webhooks.js'
import publicRoutes from './routes/public.js'
const app = new Hono()
// Attach logged-in owner (if any) to the request context
app.use('*', async (c, next) => {
const sessionId = getCookie(c, 'kneadly_session')
if (sessionId) {
const user = await getSession(c.env.DB, sessionId)
if (user) c.set('user', user)
}
await next()
})
// ─── Static assets ───────────────────────────────────────────────────────────
app.get('/favicon.svg', (c) =>
c.body(`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>💆</text></svg>`,
200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=86400' }))
app.get('/og.svg', (c) => {
const svg = `<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
<rect width="1200" height="630" fill="#0f766e"/>
<rect width="1200" height="630" fill="url(#g)"/>
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#0f766e"/><stop offset="1" stop-color="#0b5750"/></linearGradient></defs>
<text x="90" y="250" font-family="Georgia,serif" font-weight="600" font-size="120" fill="#faf8f5">💆 Kneadly</text>
<text x="96" y="330" font-family="Georgia,serif" font-size="42" fill="#c99b5b">Online booking for massage shops</text>
<text x="96" y="405" font-family="monospace" font-size="24" fill="#a7d3ce">Take bookings from Google Maps · Collect deposits · Fill your calendar</text>
<rect x="96" y="470" width="360" height="70" rx="35" fill="#c99b5b"/>
<text x="130" y="515" font-family="Georgia,serif" font-size="30" fill="#241a08" font-weight="600">Start free </text>
</svg>`
return c.body(svg, 200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=3600' })
})
app.get('/robots.txt', (c) => {
const base = c.env.BASE_URL || 'https://kneadly.theradicalparty.com'
return c.text(`User-agent: *\nAllow: /\nDisallow: /dashboard\nDisallow: /api\nDisallow: /webhooks\nSitemap: ${base}/sitemap.xml`)
})
app.get('/sitemap.xml', async (c) => {
const base = c.env.BASE_URL || 'https://kneadly.theradicalparty.com'
const rows = await c.env.DB.prepare(
`SELECT slug FROM shops WHERE is_published = 1 ORDER BY created_at DESC`).all()
const urls = [`<url><loc>${base}/</loc><priority>1.0</priority></url>`]
for (const r of (rows.results || []))
urls.push(`<url><loc>${base}/${r.slug}</loc><changefreq>weekly</changefreq><priority>0.8</priority></url>`)
return c.body(`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls.join('')}</urlset>`,
200, { 'Content-Type': 'application/xml', 'Cache-Control': 'public, max-age=3600' })
})
// ─── Routes ──────────────────────────────────────────────────────────────────
app.route('/api', apiRoutes)
app.route('/webhooks', webhookRoutes)
app.route('/', authRoutes)
app.route('/dashboard', dashboardRoutes)
app.route('/', publicRoutes) // shop pages + booking flow live at the root, keep last
export default app

38
src/lib/auth.js Normal file
View file

@ -0,0 +1,38 @@
export async function hashPassword(password) {
const enc = new TextEncoder()
const salt = crypto.getRandomValues(new Uint8Array(16))
const key = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveBits'])
const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' }, key, 256)
const toHex = buf => Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('')
return toHex(salt.buffer) + ':' + toHex(bits)
}
export async function verifyPassword(password, hash) {
const [saltHex, hashHex] = hash.split(':')
const salt = new Uint8Array(saltHex.match(/.{2}/g).map(h => parseInt(h, 16)))
const enc = new TextEncoder()
const key = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveBits'])
const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' }, key, 256)
const computed = Array.from(new Uint8Array(bits)).map(b => b.toString(16).padStart(2, '0')).join('')
return computed === hashHex
}
export const genId = () => crypto.randomUUID().replace(/-/g, '')
export async function createSession(db, userId) {
const id = genId()
const expires = Math.floor(Date.now() / 1000) + 86400 * 30
await db.prepare('INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)').bind(id, userId, expires).run()
return id
}
export async function getSession(db, sessionId) {
if (!sessionId) return null
return db.prepare(
'SELECT s.user_id, u.email, u.name FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = ? AND s.expires_at > ?'
).bind(sessionId, Math.floor(Date.now() / 1000)).first()
}
export async function deleteSession(db, sessionId) {
await db.prepare('DELETE FROM sessions WHERE id = ?').bind(sessionId).run()
}

70
src/lib/booking.js Normal file
View file

@ -0,0 +1,70 @@
import { generateSlots, getDayOfWeek, dateTzString } from './slots.js'
export async function getShopBySlug(db, slug) {
return db.prepare('SELECT * FROM shops WHERE slug = ?').bind(slug).first()
}
// Active staff who can perform a service. If nobody is explicitly linked to the
// service, every active staff member is considered eligible.
export async function eligibleStaff(db, shopId, serviceId) {
const linked = await db.prepare(
`SELECT s.* FROM staff s JOIN staff_services ss ON ss.staff_id = s.id
WHERE ss.service_id = ? AND s.shop_id = ? AND s.is_active = 1 ORDER BY s.sort_order, s.created_at`
).bind(serviceId, shopId).all()
if ((linked.results || []).length) return linked.results
const all = await db.prepare(
`SELECT * FROM staff WHERE shop_id = ? AND is_active = 1 ORDER BY sort_order, created_at`
).bind(shopId).all()
return all.results || []
}
// Free time-slots for a date. Returns [{ time, unix, display, staffIds:[...] }]
// staffIds = the therapists actually free at that moment (used to assign "any").
export async function slotsForDate(db, shop, service, staffId, dateStr) {
let staff = await eligibleStaff(db, shop.id, service.id)
if (staffId && staffId !== 'any') staff = staff.filter(s => s.id === staffId)
if (!staff.length) return []
const dow = getDayOfWeek(dateStr, shop.timezone)
const dayStart = Math.floor(new Date(dateStr + 'T00:00:00Z').getTime() / 1000) - 86400
const dayEnd = dayStart + 86400 * 3
const byTime = new Map()
for (const st of staff) {
const avail = await db.prepare(
'SELECT * FROM availability WHERE staff_id = ? AND day_of_week = ?').bind(st.id, dow).first()
if (!avail) continue
const booked = await db.prepare(
`SELECT start_time, end_time FROM bookings
WHERE staff_id = ? AND status IN ('pending_payment','confirmed','completed')
AND start_time BETWEEN ? AND ?`).bind(st.id, dayStart, dayEnd).all()
const slots = generateSlots(avail, booked.results || [], dateStr, service.duration_minutes, shop.timezone)
for (const s of slots) {
if (!byTime.has(s.time)) byTime.set(s.time, { ...s, staffIds: [] })
byTime.get(s.time).staffIds.push(st.id)
}
}
return [...byTime.values()].sort((a, b) => a.unix - b.unix)
}
// Dates within the next `daysAhead` that have at least one open slot pattern.
export async function availableDates(db, shop, service, staffId, daysAhead = 45) {
let staff = await eligibleStaff(db, shop.id, service.id)
if (staffId && staffId !== 'any') staff = staff.filter(s => s.id === staffId)
if (!staff.length) return []
const days = new Set()
for (const st of staff) {
const rows = await db.prepare('SELECT day_of_week FROM availability WHERE staff_id = ?').bind(st.id).all()
for (const r of (rows.results || [])) days.add(r.day_of_week)
}
const out = []
const now = new Date()
const todayStr = dateTzString(now, shop.timezone)
for (let i = 0; i <= daysAhead; i++) {
const d = new Date(now.getTime() + i * 86400000)
const ds = dateTzString(d, shop.timezone)
if (ds < todayStr) continue
if (days.has(getDayOfWeek(ds, shop.timezone))) out.push(ds)
}
return out
}

84
src/lib/slots.js Normal file
View file

@ -0,0 +1,84 @@
// Convert a local date+time in a given timezone to UTC milliseconds
export function localToUtcMs(dateStr, timeStr, tz) {
const utcDate = new Date(`${dateStr}T${timeStr}:00Z`)
const parts = new Intl.DateTimeFormat('en', {
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
}).formatToParts(utcDate)
const get = t => parseInt(parts.find(p => p.type === t)?.value || '0')
const localUtcMs = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour') % 24, get('minute'))
return utcDate.getTime() - (localUtcMs - utcDate.getTime())
}
export function getDayOfWeek(dateStr, tz) {
const d = new Date(dateStr + 'T12:00:00Z')
const name = new Intl.DateTimeFormat('en-US', { timeZone: tz, weekday: 'long' }).format(d)
return ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'].indexOf(name)
}
// Returns YYYY-MM-DD in a given timezone for a Date
export function dateTzString(date, tz) {
return new Intl.DateTimeFormat('en-CA', { timeZone: tz }).format(date)
}
// Generate available dates for the next N days
export function getAvailableDates(availability, tz, daysAhead = 60) {
const dates = []
const now = new Date()
const todayStr = dateTzString(now, tz)
for (let i = 0; i <= daysAhead; i++) {
const d = new Date(now.getTime() + i * 86400000)
const dateStr = dateTzString(d, tz)
if (dateStr < todayStr) continue
const dow = getDayOfWeek(dateStr, tz)
if (availability.some(a => a.day_of_week === dow)) dates.push(dateStr)
}
return dates
}
// Generate time slots for a specific date given host availability and existing bookings
export function generateSlots(avail, existingBookings, dateStr, durationMin, tz) {
const [startH, startM] = avail.start_time.split(':').map(Number)
const [endH, endM] = avail.end_time.split(':').map(Number)
const startMin = startH * 60 + startM
const endMin = endH * 60 + endM
const nowMs = Date.now() + 30 * 60000 // 30 min booking lead time
const slots = []
for (let cur = startMin; cur + durationMin <= endMin; cur += durationMin) {
const h = Math.floor(cur / 60)
const m = cur % 60
const timeStr = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
const slotStartMs = localToUtcMs(dateStr, timeStr, tz)
const slotEndMs = slotStartMs + durationMin * 60000
if (slotStartMs <= nowMs) continue
const conflict = existingBookings.some(b => {
const bS = b.start_time * 1000, bE = b.end_time * 1000
return !(slotEndMs <= bS || slotStartMs >= bE)
})
if (conflict) continue
const dH = h % 12 || 12
const period = h >= 12 ? 'PM' : 'AM'
slots.push({
time: timeStr,
unix: Math.floor(slotStartMs / 1000),
display: `${dH}:${String(m).padStart(2, '0')} ${period}`
})
}
return slots
}
export function formatBookingTime(unixTs, tz) {
return new Intl.DateTimeFormat('en-US', {
timeZone: tz, weekday: 'short', month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit', hour12: true
}).format(new Date(unixTs * 1000))
}
export function formatDate(dateStr) {
const d = new Date(dateStr + 'T12:00:00Z')
return d.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })
}

51
src/lib/stripe.js Normal file
View file

@ -0,0 +1,51 @@
export function stripeClient(secretKey) {
const baseHeaders = { 'Authorization': `Bearer ${secretKey}`, 'Content-Type': 'application/x-www-form-urlencoded' }
async function req(method, path, data) {
const url = `https://api.stripe.com/v1${path}`
const opts = { method, headers: baseHeaders }
if (data) opts.body = new URLSearchParams(flattenParams(data)).toString()
const res = await fetch(url, opts)
const json = await res.json()
if (!res.ok) throw new Error(json.error?.message || `Stripe error ${res.status}`)
return json
}
function flattenParams(obj, prefix = '') {
const result = {}
for (const [k, v] of Object.entries(obj)) {
if (v === null || v === undefined) continue
const key = prefix ? `${prefix}[${k}]` : k
if (typeof v === 'object' && !Array.isArray(v)) {
Object.assign(result, flattenParams(v, key))
} else if (Array.isArray(v)) {
v.forEach((item, i) => {
if (typeof item === 'object') Object.assign(result, flattenParams(item, `${key}[${i}]`))
else result[`${key}[${i}]`] = item
})
} else {
result[key] = String(v)
}
}
return result
}
return {
createCheckoutSession: (data) => req('POST', '/checkout/sessions', data),
retrieveCheckoutSession: (id) => req('GET', `/checkout/sessions/${id}`),
createRefund: (data) => req('POST', '/refunds', data),
retrievePaymentIntent: (id) => req('GET', `/payment_intents/${id}`),
async verifyWebhook(payload, sigHeader, secret) {
const enc = new TextEncoder()
const parts = sigHeader.split(',')
const t = parts.find(p => p.startsWith('t=')).slice(2)
const v1 = parts.find(p => p.startsWith('v1=')).slice(3)
const signed = `${t}.${payload}`
const key = await crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
const mac = await crypto.subtle.sign('HMAC', key, enc.encode(signed))
const computed = Array.from(new Uint8Array(mac)).map(b => b.toString(16).padStart(2, '0')).join('')
if (computed !== v1) throw new Error('Invalid webhook signature')
return JSON.parse(payload)
}
}
}

89
src/lib/views.js Normal file
View file

@ -0,0 +1,89 @@
// Shared layout + design system for Kneadly
export const money = (cents, currency = 'aud') =>
new Intl.NumberFormat('en-AU', { style: 'currency', currency: currency.toUpperCase() }).format((cents || 0) / 100)
export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))
export const BASE_CSS = `
:root{
--bg:#faf8f5; --card:#ffffff; --ink:#1c2b2a; --muted:#6b7c7a; --line:#e8e2da;
--accent:#0f766e; --accent-ink:#0b5750; --gold:#c99b5b; --danger:#c0492f; --ok:#2f8a5b;
--radius:16px; --shadow:0 1px 2px rgba(28,43,42,.05),0 10px 30px rgba(28,43,42,.06);
}
*{box-sizing:border-box}
html,body{margin:0;padding:0}
body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--ink);line-height:1.55;-webkit-font-smoothing:antialiased}
a{color:var(--accent);text-decoration:none}
a:hover{text-decoration:underline}
.wrap{max-width:1080px;margin:0 auto;padding:0 20px}
.narrow{max-width:560px}
h1,h2,h3{font-family:'Fraunces','Georgia',serif;font-weight:600;letter-spacing:-.02em;line-height:1.15;margin:0 0 .4em}
h1{font-size:clamp(2rem,5vw,3.2rem)}
h2{font-size:1.6rem}
.muted{color:var(--muted)}
.pill{display:inline-block;padding:4px 12px;border-radius:999px;font-size:.78rem;font-weight:600;background:#eef4f3;color:var(--accent-ink)}
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:var(--shadow)}
.btn{display:inline-flex;align-items:center;gap:8px;justify-content:center;padding:12px 22px;border-radius:999px;border:1px solid transparent;font-weight:600;font-size:.98rem;cursor:pointer;transition:.15s;background:var(--accent);color:#fff;text-decoration:none}
.btn:hover{background:var(--accent-ink);text-decoration:none;transform:translateY(-1px)}
.btn.ghost{background:#fff;color:var(--ink);border-color:var(--line)}
.btn.ghost:hover{background:#f4f0ea;transform:none}
.btn.gold{background:var(--gold);color:#241a08}
.btn.danger{background:#fff;color:var(--danger);border-color:#e7c6bd}
.btn.sm{padding:7px 14px;font-size:.85rem}
.btn:disabled{opacity:.5;cursor:not-allowed}
input,select,textarea{width:100%;padding:12px 14px;border:1px solid var(--line);border-radius:12px;font:inherit;background:#fff;color:var(--ink)}
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(15,118,110,.12)}
label{display:block;font-size:.85rem;font-weight:600;margin:0 0 6px;color:var(--ink)}
.field{margin-bottom:16px}
.row{display:flex;gap:14px;flex-wrap:wrap}
.row>*{flex:1;min-width:120px}
.grid{display:grid;gap:18px}
@media(min-width:720px){.g2{grid-template-columns:1fr 1fr}.g3{grid-template-columns:repeat(3,1fr)}}
.nav{display:flex;align-items:center;justify-content:space-between;padding:18px 0}
.brand{font-family:'Fraunces',serif;font-weight:600;font-size:1.4rem;color:var(--ink);display:flex;align-items:center;gap:8px}
.brand:hover{text-decoration:none}
.notice{padding:12px 16px;border-radius:12px;margin-bottom:16px;font-size:.9rem}
.notice.err{background:#fbeae5;color:var(--danger)}
.notice.ok{background:#e4f3ea;color:var(--ok)}
.tag{font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.05em;padding:3px 9px;border-radius:6px}
.tag.pending_payment{background:#fdf1dc;color:#8a6414}
.tag.confirmed{background:#e0efff;color:#1e5aa8}
.tag.completed{background:#e4f3ea;color:var(--ok)}
.tag.cancelled{background:#f0eeec;color:#7a736c}
.tag.no_show{background:#fbeae5;color:var(--danger)}
footer{border-top:1px solid var(--line);margin-top:60px;padding:30px 0;color:var(--muted);font-size:.85rem}
`
const FONTS = `<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">`
export function layout(title, body, opts = {}) {
const desc = opts.description || 'Online booking for massage & bodywork. Fill your calendar, take deposits, and let clients book from Google in seconds.'
const accent = opts.accent
return `<!DOCTYPE html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(title)}</title>
<meta name="description" content="${esc(desc)}">
<meta property="og:title" content="${esc(title)}"><meta property="og:description" content="${esc(desc)}">
<meta property="og:type" content="website"><meta property="og:image" content="/og.svg">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="/favicon.svg">
${FONTS}
${opts.jsonld ? `<script type="application/ld+json">${JSON.stringify(opts.jsonld)}</script>` : ''}
<style>${BASE_CSS}${accent ? `:root{--accent:${accent};--accent-ink:${accent}}` : ''}${opts.css || ''}</style>
</head><body>${body}
<footer><div class="wrap">Powered by <a href="/">Kneadly</a> · Online booking for massage shops</div></footer>
</body></html>`
}
export function siteNav(user) {
return `<div class="wrap"><nav class="nav">
<a class="brand" href="/">💆 Kneadly</a>
<div class="row" style="flex:0">
${user
? `<a class="btn ghost sm" href="/dashboard">Dashboard</a>`
: `<a class="btn ghost sm" href="/login">Log in</a><a class="btn sm" href="/signup">Start free</a>`}
</div>
</nav></div>`
}

24
src/routes/api.js Normal file
View file

@ -0,0 +1,24 @@
import { Hono } from 'hono'
import { getShopBySlug, slotsForDate, availableDates } from '../lib/booking.js'
const app = new Hono()
// GET /api/slots?shop=slug&service=ID&staff=ID|any&date=YYYY-MM-DD
app.get('/slots', async (c) => {
const db = c.env.DB
const { shop: slug, service: serviceId, staff = 'any', date } = c.req.query()
const shop = await getShopBySlug(db, slug)
if (!shop) return c.json({ error: 'shop not found' }, 404)
const service = await db.prepare('SELECT * FROM services WHERE id = ? AND shop_id = ? AND is_active = 1')
.bind(serviceId, shop.id).first()
if (!service) return c.json({ error: 'service not found' }, 404)
if (date) {
const slots = await slotsForDate(db, shop, service, staff, date)
return c.json({ slots: slots.map(s => ({ time: s.time, unix: s.unix, display: s.display })) })
}
const dates = await availableDates(db, shop, service, staff)
return c.json({ dates })
})
export default app

126
src/routes/auth.js Normal file
View file

@ -0,0 +1,126 @@
import { Hono } from 'hono'
import { setCookie, deleteCookie } from 'hono/cookie'
import { hashPassword, verifyPassword, genId, createSession, deleteSession } from '../lib/auth.js'
import { layout, siteNav, esc } from '../lib/views.js'
const app = new Hono()
const slugify = (s) => s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
const RESERVED = new Set(['login', 'signup', 'logout', 'dashboard', 'api', 'webhooks', 'favicon.svg', 'og.svg', 'robots.txt', 'sitemap.xml', 'book', 'admin', 'about', 'pricing'])
function authPage(title, body, err) {
return layout(title, `${siteNav(null)}<div class="wrap narrow" style="padding:40px 20px">
<div class="card" style="padding:34px">
${err ? `<div class="notice err">${esc(err)}</div>` : ''}
${body}
</div>
</div>`)
}
app.get('/signup', (c) => {
if (c.get('user')) return c.redirect('/dashboard')
return c.html(authPage('Start your massage shop — Kneadly', `
<h2>Start taking bookings</h2>
<p class="muted">Free to set up. You'll have a booking page in about two minutes.</p>
<form method="post" action="/signup">
<div class="field"><label>Shop name</label><input name="shop_name" required placeholder="Serenity Massage & Bodywork"></div>
<div class="field"><label>Your name</label><input name="name" required placeholder="Alex Nguyen"></div>
<div class="field"><label>Email</label><input type="email" name="email" required placeholder="you@shop.com"></div>
<div class="field"><label>Password</label><input type="password" name="password" required minlength="8" placeholder="At least 8 characters"></div>
<button class="btn" style="width:100%">Create my booking page</button>
</form>
<p class="muted" style="text-align:center;margin-top:16px">Already have an account? <a href="/login">Log in</a></p>
`))
})
app.post('/signup', async (c) => {
const db = c.env.DB
const form = await c.req.parseBody()
const shopName = (form.shop_name || '').toString().trim()
const name = (form.name || '').toString().trim()
const email = (form.email || '').toString().trim().toLowerCase()
const password = (form.password || '').toString()
if (!shopName || !name || !email || password.length < 8)
return c.html(authPage('Sign up', signupForm(form), 'Please fill in every field (password 8+ chars).'), 400)
const existing = await db.prepare('SELECT id FROM users WHERE email = ?').bind(email).first()
if (existing) return c.html(authPage('Sign up', signupForm(form), 'That email is already registered. Try logging in.'), 400)
// Unique slug from shop name
let base = slugify(shopName) || 'shop'
if (RESERVED.has(base)) base = base + '-massage'
let slug = base, n = 1
while (await db.prepare('SELECT id FROM shops WHERE slug = ?').bind(slug).first()) slug = `${base}-${++n}`
const userId = genId(), shopId = genId()
await db.prepare('INSERT INTO users (id, email, name, password_hash) VALUES (?, ?, ?, ?)')
.bind(userId, email, name, await hashPassword(password)).run()
await db.prepare(`INSERT INTO shops (id, owner_id, name, slug, email, tagline) VALUES (?, ?, ?, ?, ?, ?)`)
.bind(shopId, userId, shopName, slug, email, 'Relax. Recover. Rebook.').run()
// Seed a default therapist with sensible MonSat hours so the shop can take bookings immediately
const staffId = genId()
await db.prepare(`INSERT INTO staff (id, shop_id, name, title) VALUES (?, ?, ?, ?)`)
.bind(staffId, shopId, name, 'Massage Therapist').run()
for (let dow = 1; dow <= 6; dow++)
await db.prepare(`INSERT INTO availability (id, staff_id, day_of_week, start_time, end_time) VALUES (?, ?, ?, ?, ?)`)
.bind(genId(), staffId, dow, '09:00', '18:00').run()
const sessionId = await createSession(db, userId)
setCookie(c, 'kneadly_session', sessionId, { httpOnly: true, secure: true, sameSite: 'Lax', maxAge: 86400 * 30, path: '/' })
return c.redirect('/dashboard?welcome=1')
})
function signupForm(form = {}) {
return `<h2>Start taking bookings</h2>
<form method="post" action="/signup">
<div class="field"><label>Shop name</label><input name="shop_name" required value="${esc(form.shop_name || '')}"></div>
<div class="field"><label>Your name</label><input name="name" required value="${esc(form.name || '')}"></div>
<div class="field"><label>Email</label><input type="email" name="email" required value="${esc(form.email || '')}"></div>
<div class="field"><label>Password</label><input type="password" name="password" required minlength="8"></div>
<button class="btn" style="width:100%">Create my booking page</button>
</form>`
}
app.get('/login', (c) => {
if (c.get('user')) return c.redirect('/dashboard')
return c.html(authPage('Log in — Kneadly', `
<h2>Welcome back</h2>
<form method="post" action="/login">
<div class="field"><label>Email</label><input type="email" name="email" required></div>
<div class="field"><label>Password</label><input type="password" name="password" required></div>
<button class="btn" style="width:100%">Log in</button>
</form>
<p class="muted" style="text-align:center;margin-top:16px">New here? <a href="/signup">Create your shop</a></p>
`))
})
app.post('/login', async (c) => {
const db = c.env.DB
const form = await c.req.parseBody()
const email = (form.email || '').toString().trim().toLowerCase()
const password = (form.password || '').toString()
const user = await db.prepare('SELECT * FROM users WHERE email = ?').bind(email).first()
if (!user || !(await verifyPassword(password, user.password_hash)))
return c.html(authPage('Log in', `<h2>Welcome back</h2>
<form method="post" action="/login">
<div class="field"><label>Email</label><input type="email" name="email" value="${esc(email)}" required></div>
<div class="field"><label>Password</label><input type="password" name="password" required></div>
<button class="btn" style="width:100%">Log in</button>
</form>`, 'Incorrect email or password.'), 401)
const sessionId = await createSession(db, user.id)
setCookie(c, 'kneadly_session', sessionId, { httpOnly: true, secure: true, sameSite: 'Lax', maxAge: 86400 * 30, path: '/' })
return c.redirect('/dashboard')
})
app.get('/logout', async (c) => {
const sid = c.req.header('cookie')?.match(/kneadly_session=([^;]+)/)?.[1]
if (sid) await deleteSession(c.env.DB, sid)
deleteCookie(c, 'kneadly_session', { path: '/' })
return c.redirect('/')
})
export default app

376
src/routes/dashboard.js Normal file
View file

@ -0,0 +1,376 @@
import { Hono } from 'hono'
import { layout, money, esc } from '../lib/views.js'
import { genId } from '../lib/auth.js'
import { formatBookingTime } from '../lib/slots.js'
import { stripeClient } from '../lib/stripe.js'
const app = new Hono()
const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
// Guard + load the owner's shop for every dashboard route
app.use('*', async (c, next) => {
const user = c.get('user')
if (!user) return c.redirect('/login')
const shop = await c.env.DB.prepare('SELECT * FROM shops WHERE owner_id = ?').bind(user.user_id).first()
if (!shop) return c.redirect('/signup')
c.set('shop', shop)
await next()
})
function shell(c, active, title, body, notice) {
const shop = c.get('shop')
const tab = (id, label) => `<a href="/dashboard${id ? '/' + id : ''}" class="dtab${active === id ? ' on' : ''}">${label}</a>`
return c.html(layout(`${title} — Kneadly`, `
<div class="dwrap">
<aside class="dside">
<a class="brand" href="/dashboard" style="padding:6px 10px 14px">💆 Kneadly</a>
${tab('', '📊 Overview')}
${tab('bookings', '🗓️ Bookings')}
${tab('services', '💆 Services')}
${tab('staff', '🧑‍⚕️ Therapists')}
${tab('settings', '⚙️ Settings')}
<div style="flex:1"></div>
<a class="dtab" href="/${shop.slug}" target="_blank">🔗 View my page</a>
<a class="dtab" href="/logout"> Log out</a>
</aside>
<main class="dmain">
${notice ? `<div class="notice ok">${esc(notice)}</div>` : ''}
${body}
</main>
</div>`, {
css: `
.dwrap{display:flex;min-height:100vh;max-width:1200px;margin:0 auto}
.dside{width:220px;flex:0 0 220px;padding:18px 12px;border-right:1px solid var(--line);display:flex;flex-direction:column;gap:2px;position:sticky;top:0;height:100vh}
.dtab{padding:10px 12px;border-radius:10px;color:var(--ink);font-weight:500;font-size:.92rem}
.dtab:hover{background:#f1ece5;text-decoration:none}
.dtab.on{background:var(--accent);color:#fff}
.dmain{flex:1;padding:26px 30px;min-width:0}
.stat{font-family:'Fraunces',serif;font-size:2rem;font-weight:600}
table{width:100%;border-collapse:collapse}
th,td{text-align:left;padding:10px 8px;border-bottom:1px solid var(--line);font-size:.9rem;vertical-align:top}
th{font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted)}
.inline{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
@media(max-width:720px){.dwrap{flex-direction:column}.dside{width:auto;flex:none;height:auto;position:static;flex-direction:row;flex-wrap:wrap;border-right:none;border-bottom:1px solid var(--line)}.dside>div{display:none}}
`
}))
}
const upcomingWhere = `status IN ('confirmed','pending_payment') AND start_time > unixepoch()`
// ─── Overview ────────────────────────────────────────────────────────────────
app.get('/', async (c) => {
const db = c.env.DB, shop = c.get('shop')
const base = c.env.BASE_URL || 'https://kneadly.theradicalparty.com'
const link = `${base}/${shop.slug}`
const welcome = c.req.query('welcome')
const upcoming = (await db.prepare(
`SELECT * FROM bookings WHERE shop_id = ? AND ${upcomingWhere} ORDER BY start_time LIMIT 6`).bind(shop.id).all()).results || []
const counts = await db.prepare(
`SELECT
(SELECT COUNT(*) FROM bookings WHERE shop_id=?1 AND ${upcomingWhere}) AS upcoming,
(SELECT COUNT(*) FROM bookings WHERE shop_id=?1 AND status='completed') AS done,
(SELECT COALESCE(SUM(deposit_cents),0) FROM bookings WHERE shop_id=?1 AND status IN ('confirmed','completed')) AS deposits,
(SELECT COUNT(*) FROM services WHERE shop_id=?1 AND is_active=1) AS services,
(SELECT COUNT(*) FROM staff WHERE shop_id=?1 AND is_active=1) AS staff`).bind(shop.id).first()
const setup = counts.services === 0 || counts.staff === 0
return shell(c, '', 'Overview', `
<h2>Welcome back 👋</h2>
${welcome ? `<div class="notice ok">Your booking page is live! Add your services below, then share your link.</div>` : ''}
<div class="card" style="padding:20px;margin-bottom:20px">
<label>Your booking link</label>
<div class="inline">
<input id="link" value="${esc(link)}" readonly style="max-width:420px">
<button class="btn ghost sm" onclick="navigator.clipboard.writeText(document.getElementById('link').value);this.textContent='Copied ✓'">Copy</button>
<a class="btn ghost sm" href="${esc(link)}" target="_blank">Open</a>
</div>
</div>
<div class="grid g3" style="margin-bottom:20px">
<div class="card" style="padding:18px"><div class="muted">Upcoming</div><div class="stat">${counts.upcoming}</div></div>
<div class="card" style="padding:18px"><div class="muted">Completed</div><div class="stat">${counts.done}</div></div>
<div class="card" style="padding:18px"><div class="muted">Deposits collected</div><div class="stat">${money(counts.deposits, shop.currency)}</div></div>
</div>
${setup ? `<div class="card" style="padding:20px;margin-bottom:20px;border-color:var(--gold)">
<strong>Finish setting up:</strong>
<ul style="margin:8px 0 0">
${counts.services === 0 ? '<li>Add at least one <a href="/dashboard/services">service</a>.</li>' : ''}
${counts.staff === 0 ? '<li>Add a <a href="/dashboard/staff">therapist</a> with working hours.</li>' : ''}
</ul></div>` : ''}
<div class="card" style="padding:20px;margin-bottom:20px;background:#f6f2ec;border-style:dashed">
<h3 style="margin:0 0 6px;font-size:1.05rem">📍 Get bookings from Google Maps</h3>
<p class="muted" style="margin:0 0 10px;font-size:.9rem">In your <a href="https://business.google.com" target="_blank">Google Business Profile</a> → <strong>Edit profile → Booking / Appointment links</strong>, paste this URL. Customers will see a <strong>Book</strong> button on your Maps listing.</p>
<div class="inline"><input value="${esc(link)}" readonly style="max-width:420px"><button class="btn ghost sm" onclick="navigator.clipboard.writeText('${esc(link)}');this.textContent='Copied ✓'">Copy link</button></div>
</div>
<h3>Next appointments</h3>
${upcoming.length ? `<div class="card" style="padding:6px 18px"><table>
<tr><th>When</th><th>Service</th><th>Client</th><th>Therapist</th><th></th></tr>
${upcoming.map(b => `<tr><td>${formatBookingTime(b.start_time, shop.timezone)}</td><td>${esc(b.service_name)}</td><td>${esc(b.customer_name)}</td><td>${esc(b.staff_name || '')}</td><td><span class="tag ${b.status}">${b.status.replace('_', ' ')}</span></td></tr>`).join('')}
</table></div>` : '<p class="muted">No upcoming bookings yet share your link to get started.</p>'}
`)
})
// ─── Bookings ────────────────────────────────────────────────────────────────
app.get('/bookings', async (c) => {
const db = c.env.DB, shop = c.get('shop')
const filter = c.req.query('f') || 'upcoming'
const where = {
upcoming: `AND ${upcomingWhere}`,
past: `AND (start_time <= unixepoch() OR status IN ('completed','no_show'))`,
all: ''
}[filter] ?? ''
const order = filter === 'past' ? 'DESC' : 'ASC'
const rows = (await db.prepare(
`SELECT * FROM bookings WHERE shop_id = ? ${where} ORDER BY start_time ${order} LIMIT 200`).bind(shop.id).all()).results || []
const tabs = ['upcoming', 'past', 'all'].map(f =>
`<a href="/dashboard/bookings?f=${f}" class="btn ${filter === f ? '' : 'ghost'} sm">${f[0].toUpperCase() + f.slice(1)}</a>`).join(' ')
return shell(c, 'bookings', 'Bookings', `
<div class="inline" style="justify-content:space-between"><h2>Bookings</h2><div class="inline">${tabs}</div></div>
${rows.length ? `<div class="card" style="padding:6px 18px"><table>
<tr><th>When</th><th>Client</th><th>Service</th><th>Therapist</th><th>Deposit</th><th>Status</th><th></th></tr>
${rows.map(b => `<tr>
<td>${formatBookingTime(b.start_time, shop.timezone)}</td>
<td>${esc(b.customer_name)}<div class="muted" style="font-size:.8rem">${esc(b.customer_email)}${b.customer_phone ? ' · ' + esc(b.customer_phone) : ''}</div>${b.notes ? `<div class="muted" style="font-size:.8rem">📝 ${esc(b.notes)}</div>` : ''}</td>
<td>${esc(b.service_name)}</td>
<td>${esc(b.staff_name || '')}</td>
<td>${b.deposit_cents ? money(b.deposit_cents, shop.currency) : '—'}${b.refunded_at ? '<div class="muted" style="font-size:.75rem">refunded</div>' : ''}</td>
<td><span class="tag ${b.status}">${b.status.replace('_', ' ')}</span></td>
<td><div class="inline">
${['confirmed', 'pending_payment'].includes(b.status) ? `
<form method="post" action="/dashboard/bookings/${b.id}/complete"><button class="btn sm"> Done</button></form>
<form method="post" action="/dashboard/bookings/${b.id}/no_show"><button class="btn ghost sm">No-show</button></form>
<form method="post" action="/dashboard/bookings/${b.id}/cancel" onsubmit="return confirm('Cancel${b.deposit_cents && b.stripe_charge_id ? ' and refund the deposit' : ''}?')"><button class="btn danger sm">Cancel</button></form>
` : ''}
</div></td>
</tr>`).join('')}
</table></div>` : '<p class="muted">Nothing here yet.</p>'}
`)
})
async function bookingAction(c, action) {
const db = c.env.DB, shop = c.get('shop')
const id = c.req.param('id')
const b = await db.prepare('SELECT * FROM bookings WHERE id = ? AND shop_id = ?').bind(id, shop.id).first()
if (!b) return c.redirect('/dashboard/bookings')
if (action === 'complete') await db.prepare("UPDATE bookings SET status='completed' WHERE id=?").bind(id).run()
if (action === 'no_show') await db.prepare("UPDATE bookings SET status='no_show' WHERE id=?").bind(id).run()
if (action === 'cancel') {
if (b.deposit_cents && b.stripe_charge_id && !b.refunded_at && c.env.STRIPE_SECRET_KEY) {
try {
await stripeClient(c.env.STRIPE_SECRET_KEY).createRefund({ charge: b.stripe_charge_id, reason: 'requested_by_customer' })
await db.prepare("UPDATE bookings SET refunded_at = unixepoch() WHERE id=?").bind(id).run()
} catch (e) { console.error('refund failed:', e.message) }
}
await db.prepare("UPDATE bookings SET status='cancelled' WHERE id=?").bind(id).run()
}
return c.redirect('/dashboard/bookings')
}
app.post('/bookings/:id/complete', c => bookingAction(c, 'complete'))
app.post('/bookings/:id/no_show', c => bookingAction(c, 'no_show'))
app.post('/bookings/:id/cancel', c => bookingAction(c, 'cancel'))
// ─── Services ────────────────────────────────────────────────────────────────
app.get('/services', async (c) => {
const db = c.env.DB, shop = c.get('shop')
const rows = (await db.prepare('SELECT * FROM services WHERE shop_id = ? ORDER BY sort_order, created_at').bind(shop.id).all()).results || []
return shell(c, 'services', 'Services', `
<h2>Services</h2>
<div class="card" style="padding:6px 18px;margin-bottom:20px">
${rows.length ? `<table><tr><th>Name</th><th>Duration</th><th>Price</th><th>Deposit*</th><th></th></tr>
${rows.map(s => `<tr>
<td><strong>${esc(s.name)}</strong>${s.is_active ? '' : ' <span class="muted">(hidden)</span>'}${s.description ? `<div class="muted" style="font-size:.8rem">${esc(s.description)}</div>` : ''}</td>
<td>${s.duration_minutes} min</td><td>${money(s.price_cents, shop.currency)}</td>
<td>${money(Math.round(s.price_cents * shop.deposit_pct / 100), shop.currency)}</td>
<td><form method="post" action="/dashboard/services/${s.id}/delete" onsubmit="return confirm('Delete this service?')"><button class="btn danger sm">Delete</button></form></td>
</tr>`).join('')}</table>` : '<p class="muted" style="padding:12px 0">No services yet.</p>'}
</div>
<p class="muted" style="font-size:.8rem">*Deposit is ${shop.deposit_pct}% of the price (change it in <a href="/dashboard/settings">Settings</a>).</p>
<div class="card" style="padding:22px">
<h3 style="margin-top:0">Add a service</h3>
<form method="post" action="/dashboard/services">
<div class="field"><label>Name</label><input name="name" required placeholder="60min Deep Tissue Massage"></div>
<div class="field"><label>Description</label><input name="description" placeholder="Firm pressure to release deep muscle tension"></div>
<div class="row">
<div class="field"><label>Duration (minutes)</label><input type="number" name="duration" value="60" min="10" step="5" required></div>
<div class="field"><label>Price (${shop.currency.toUpperCase()})</label><input type="number" name="price" value="120" min="0" step="1" required></div>
</div>
<button class="btn">Add service</button>
</form>
</div>
`)
})
app.post('/services', async (c) => {
const db = c.env.DB, shop = c.get('shop')
const f = await c.req.parseBody()
await db.prepare(`INSERT INTO services (id, shop_id, name, description, duration_minutes, price_cents, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?)`).bind(genId(), shop.id, (f.name || '').toString().trim(), (f.description || '').toString().trim(),
parseInt(f.duration) || 60, Math.round((parseFloat(f.price) || 0) * 100),
Math.floor(Date.now() / 1000)).run()
return c.redirect('/dashboard/services')
})
app.post('/services/:id/delete', async (c) => {
await c.env.DB.prepare('DELETE FROM services WHERE id = ? AND shop_id = ?').bind(c.req.param('id'), c.get('shop').id).run()
return c.redirect('/dashboard/services')
})
// ─── Staff + hours ───────────────────────────────────────────────────────────
app.get('/staff', async (c) => {
const db = c.env.DB, shop = c.get('shop')
const staff = (await db.prepare('SELECT * FROM staff WHERE shop_id = ? ORDER BY sort_order, created_at').bind(shop.id).all()).results || []
const hoursFor = async (id) => (await db.prepare('SELECT * FROM availability WHERE staff_id = ? ORDER BY day_of_week').bind(id).all()).results || []
const cards = []
for (const st of staff) {
const hours = await hoursFor(st.id)
const hByDow = Object.fromEntries(hours.map(h => [h.day_of_week, h]))
cards.push(`<div class="card" style="padding:20px;margin-bottom:16px">
<div class="inline" style="justify-content:space-between">
<div><span style="font-size:1.4rem">${esc(st.emoji)}</span> <strong>${esc(st.name)}</strong> <span class="muted">${esc(st.title || '')}</span></div>
<form method="post" action="/dashboard/staff/${st.id}/delete" onsubmit="return confirm('Remove this therapist?')"><button class="btn danger sm">Remove</button></form>
</div>
<form method="post" action="/dashboard/staff/${st.id}/hours" style="margin-top:14px">
<label>Weekly hours</label>
<table style="margin-bottom:12px"><tr><th></th><th>Open</th><th>Start</th><th>End</th></tr>
${DOW.map((d, i) => {
const h = hByDow[i]
return `<tr><td><strong>${d}</strong></td>
<td><input type="checkbox" name="on_${i}" ${h ? 'checked' : ''} style="width:auto"></td>
<td><input type="time" name="start_${i}" value="${h?.start_time || '09:00'}" style="max-width:130px"></td>
<td><input type="time" name="end_${i}" value="${h?.end_time || '18:00'}" style="max-width:130px"></td></tr>`
}).join('')}</table>
<button class="btn sm">Save hours</button>
</form>
</div>`)
}
return shell(c, 'staff', 'Therapists', `
<h2>Therapists</h2>
${cards.join('') || '<p class="muted">No therapists yet.</p>'}
<div class="card" style="padding:22px">
<h3 style="margin-top:0">Add a therapist</h3>
<form method="post" action="/dashboard/staff">
<div class="row">
<div class="field"><label>Name</label><input name="name" required placeholder="Jordan Lee"></div>
<div class="field"><label>Title</label><input name="title" value="Massage Therapist"></div>
<div class="field" style="flex:0 0 90px"><label>Emoji</label><input name="emoji" value="🧑" maxlength="4"></div>
</div>
<button class="btn">Add therapist</button>
</form>
</div>
`)
})
app.post('/staff', async (c) => {
const db = c.env.DB, shop = c.get('shop')
const f = await c.req.parseBody()
const id = genId()
await db.prepare('INSERT INTO staff (id, shop_id, name, title, emoji, sort_order) VALUES (?, ?, ?, ?, ?, ?)')
.bind(id, shop.id, (f.name || '').toString().trim(), (f.title || 'Massage Therapist').toString().trim(),
(f.emoji || '🧑‍⚕️').toString().trim() || '🧑‍⚕️', Math.floor(Date.now() / 1000)).run()
// Default MonSat 96 so they can be booked right away
for (let dow = 1; dow <= 6; dow++)
await db.prepare('INSERT INTO availability (id, staff_id, day_of_week, start_time, end_time) VALUES (?, ?, ?, ?, ?)')
.bind(genId(), id, dow, '09:00', '18:00').run()
return c.redirect('/dashboard/staff')
})
app.post('/staff/:id/delete', async (c) => {
await c.env.DB.prepare('DELETE FROM staff WHERE id = ? AND shop_id = ?').bind(c.req.param('id'), c.get('shop').id).run()
return c.redirect('/dashboard/staff')
})
app.post('/staff/:id/hours', async (c) => {
const db = c.env.DB, shop = c.get('shop')
const id = c.req.param('id')
const st = await db.prepare('SELECT id FROM staff WHERE id = ? AND shop_id = ?').bind(id, shop.id).first()
if (!st) return c.redirect('/dashboard/staff')
const f = await c.req.parseBody()
await db.prepare('DELETE FROM availability WHERE staff_id = ?').bind(id).run()
for (let i = 0; i < 7; i++) {
if (!f[`on_${i}`]) continue
const start = (f[`start_${i}`] || '09:00').toString(), end = (f[`end_${i}`] || '18:00').toString()
if (end <= start) continue
await db.prepare('INSERT INTO availability (id, staff_id, day_of_week, start_time, end_time) VALUES (?, ?, ?, ?, ?)')
.bind(genId(), id, i, start, end).run()
}
return c.redirect('/dashboard/staff')
})
// ─── Settings ────────────────────────────────────────────────────────────────
app.get('/settings', async (c) => {
const shop = c.get('shop')
const f = (k, v) => esc(shop[k] ?? v ?? '')
return shell(c, 'settings', 'Settings', `
<h2>Shop settings</h2>
<form method="post" action="/dashboard/settings">
<div class="card" style="padding:22px;margin-bottom:18px">
<div class="row">
<div class="field"><label>Shop name</label><input name="name" value="${f('name')}" required></div>
<div class="field" style="flex:0 0 90px"><label>Emoji</label><input name="emoji" value="${f('emoji')}" maxlength="4"></div>
</div>
<div class="field"><label>Tagline</label><input name="tagline" value="${f('tagline')}"></div>
<div class="field"><label>About</label><textarea name="about" rows="4">${f('about')}</textarea></div>
<div class="row">
<div class="field"><label>Public link slug</label><input name="slug" value="${f('slug')}" required></div>
<div class="field"><label>Accent colour</label><input type="color" name="accent" value="${f('accent', '#0f766e')}"></div>
</div>
</div>
<div class="card" style="padding:22px;margin-bottom:18px">
<h3 style="margin-top:0">Contact &amp; location</h3>
<div class="row"><div class="field"><label>Phone</label><input name="phone" value="${f('phone')}"></div>
<div class="field"><label>Public email</label><input name="email" value="${f('email')}"></div></div>
<div class="field"><label>Street address</label><input name="address" value="${f('address')}"></div>
<div class="row">
<div class="field"><label>Suburb</label><input name="suburb" value="${f('suburb')}"></div>
<div class="field"><label>State</label><input name="state" value="${f('state')}"></div>
<div class="field"><label>Postcode</label><input name="postcode" value="${f('postcode')}"></div>
</div>
<div class="field"><label>Timezone</label>
<select name="timezone">${['Australia/Sydney', 'Australia/Melbourne', 'Australia/Brisbane', 'Australia/Adelaide', 'Australia/Perth', 'Pacific/Auckland', 'America/New_York', 'America/Los_Angeles', 'Europe/London']
.map(tz => `<option ${shop.timezone === tz ? 'selected' : ''}>${tz}</option>`).join('')}</select></div>
</div>
<div class="card" style="padding:22px;margin-bottom:18px">
<h3 style="margin-top:0">Deposits &amp; cancellation</h3>
<div class="row">
<div class="field"><label>Deposit (% of price)</label><input type="number" name="deposit_pct" value="${f('deposit_pct', 20)}" min="0" max="100"></div>
<div class="field"><label>Free cancellation window (hours)</label><input type="number" name="cancellation_hours" value="${f('cancellation_hours', 24)}" min="0"></div>
</div>
<p class="muted" style="font-size:.82rem;margin:0">Set deposit to 0% to take bookings with no upfront payment.</p>
</div>
<button class="btn">Save settings</button>
</form>
`)
})
const slugify = (s) => s.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
app.post('/settings', async (c) => {
const db = c.env.DB, shop = c.get('shop')
const f = await c.req.parseBody()
let slug = slugify((f.slug || shop.slug).toString()) || shop.slug
// keep slug unique (ignore our own row)
const clash = await db.prepare('SELECT id FROM shops WHERE slug = ? AND id != ?').bind(slug, shop.id).first()
if (clash) slug = `${slug}-${shop.id.slice(0, 4)}`
await db.prepare(`UPDATE shops SET name=?, emoji=?, tagline=?, about=?, slug=?, accent=?, phone=?, email=?,
address=?, suburb=?, state=?, postcode=?, timezone=?, deposit_pct=?, cancellation_hours=? WHERE id=?`)
.bind((f.name || shop.name).toString().trim(), (f.emoji || '💆').toString().trim() || '💆',
(f.tagline || '').toString(), (f.about || '').toString(), slug, (f.accent || '#0f766e').toString(),
(f.phone || '').toString(), (f.email || '').toString(), (f.address || '').toString(),
(f.suburb || '').toString(), (f.state || '').toString(), (f.postcode || '').toString(),
(f.timezone || shop.timezone).toString(), parseInt(f.deposit_pct) || 0, parseInt(f.cancellation_hours) || 0, shop.id).run()
return c.redirect('/dashboard/settings')
})
export default app

334
src/routes/public.js Normal file
View file

@ -0,0 +1,334 @@
import { Hono } from 'hono'
import { layout, siteNav, money, esc } from '../lib/views.js'
import { getShopBySlug, eligibleStaff, slotsForDate } from '../lib/booking.js'
import { formatBookingTime, formatDate } from '../lib/slots.js'
import { stripeClient } from '../lib/stripe.js'
import { genId } from '../lib/auth.js'
const app = new Hono()
// ─── Marketing landing ───────────────────────────────────────────────────────
app.get('/', (c) => {
const user = c.get('user')
return c.html(layout('Kneadly — Online booking for massage shops', `
${siteNav(user)}
<div class="wrap" style="text-align:center;padding:60px 20px 40px">
<span class="pill">For massage &amp; bodywork shops</span>
<h1 style="margin-top:18px">Let clients book you<br>straight from Google.</h1>
<p class="muted" style="font-size:1.15rem;max-width:600px;margin:0 auto 28px">
Your own booking page, deposits that stop no-shows, and a link you can drop
into your Google Business Profile so customers book you from Maps.
</p>
<div class="row" style="justify-content:center;flex:0">
<a class="btn gold" href="/signup">Create your booking page </a>
<a class="btn ghost" href="#how">See how it works</a>
</div>
<p class="muted" style="margin-top:14px;font-size:.85rem">Free to set up · No app to install · Live in 2 minutes</p>
</div>
<div class="wrap grid g3" style="padding:20px 20px 10px" id="how">
${[
['🗓️', 'Book anytime', 'Clients pick a service, a therapist and a time. You wake up to a full calendar — no phone tag.'],
['💳', 'Deposits stop no-shows', 'Take a deposit at booking. If they cancel late, you keep it. If they show, it comes off the bill.'],
['📍', 'Right from Google Maps', 'Add your Kneadly link to your Google Business Profile. Customers tap “Book” on Maps and land on your page.'],
['🧖', 'Multiple therapists', 'Add your whole team, set each persons hours, and let clients choose “anyone available”.'],
['⏱️', 'Set your own hours', 'Per-therapist weekly schedules. Kneadly only ever offers times youre actually open.'],
['🔗', 'One shareable page', 'Put it in your Instagram bio, on flyers, in texts. Everything books through one clean link.'],
].map(([e, t, d]) => `<div class="card" style="padding:24px"><div style="font-size:2rem">${e}</div>
<h3 style="margin:.5em 0 .2em;font-size:1.15rem">${t}</h3><p class="muted" style="margin:0">${d}</p></div>`).join('')}
</div>
<div class="wrap" style="padding:50px 20px">
<div class="card" style="padding:34px;text-align:center;background:linear-gradient(135deg,#0f766e,#0b5750);color:#fff;border:none">
<h2 style="color:#fff">Ready to fill your table?</h2>
<p style="color:#a7d3ce;max-width:460px;margin:0 auto 22px">Set up your services and hours, then share your link. Thats it.</p>
<a class="btn gold" href="/signup">Start free </a>
</div>
</div>
`, {
jsonld: {
'@context': 'https://schema.org', '@type': 'SoftwareApplication',
name: 'Kneadly', applicationCategory: 'BusinessApplication',
description: 'Online booking software for massage and bodywork businesses.',
offers: { '@type': 'Offer', price: '0', priceCurrency: 'AUD' }
}
}))
})
// ─── Shop public page ────────────────────────────────────────────────────────
app.get('/:slug', async (c) => {
const db = c.env.DB
const shop = await getShopBySlug(db, c.req.param('slug'))
if (!shop || !shop.is_published) return c.notFound()
const services = (await db.prepare(
'SELECT * FROM services WHERE shop_id = ? AND is_active = 1 ORDER BY sort_order, created_at').bind(shop.id).all()).results || []
const staff = (await db.prepare(
'SELECT * FROM staff WHERE shop_id = ? AND is_active = 1 ORDER BY sort_order, created_at').bind(shop.id).all()).results || []
const base = c.env.BASE_URL || 'https://kneadly.theradicalparty.com'
const addr = [shop.address, shop.suburb, shop.state, shop.postcode].filter(Boolean).join(', ')
const jsonld = {
'@context': 'https://schema.org', '@type': 'HealthAndBeautyBusiness',
name: shop.name, description: shop.about || shop.tagline || undefined,
url: `${base}/${shop.slug}`, telephone: shop.phone || undefined,
priceRange: services.length ? `${money(Math.min(...services.map(s => s.price_cents)), shop.currency)}${money(Math.max(...services.map(s => s.price_cents)), shop.currency)}` : undefined,
address: addr ? {
'@type': 'PostalAddress', streetAddress: shop.address || undefined,
addressLocality: shop.suburb || undefined, addressRegion: shop.state || undefined,
postalCode: shop.postcode || undefined, addressCountry: 'AU'
} : undefined,
makesOffer: services.map(s => ({
'@type': 'Offer', name: s.name,
priceSpecification: { '@type': 'PriceSpecification', price: (s.price_cents / 100).toFixed(2), priceCurrency: shop.currency.toUpperCase() }
})),
potentialAction: {
'@type': 'ReserveAction',
target: { '@type': 'EntryPoint', urlTemplate: `${base}/${shop.slug}/book`, actionPlatform: ['http://schema.org/DesktopWebPlatform', 'http://schema.org/MobileWebPlatform'] },
result: { '@type': 'Reservation', name: `Booking at ${shop.name}` }
}
}
const serviceCard = (s) => `
<div class="card svc" style="padding:18px 20px;display:flex;justify-content:space-between;align-items:center;gap:14px">
<div>
<div style="font-weight:600">${esc(s.name)}</div>
${s.description ? `<div class="muted" style="font-size:.88rem">${esc(s.description)}</div>` : ''}
<div class="muted" style="font-size:.85rem;margin-top:4px"> ${s.duration_minutes} min · ${money(s.price_cents, shop.currency)}</div>
</div>
<a class="btn sm" href="/${shop.slug}/book?service=${s.id}">Book</a>
</div>`
return c.html(layout(`${shop.name} — Book online`, `
${siteNav(c.get('user'))}
<div class="wrap" style="padding:14px 20px 0">
<div class="card" style="padding:30px;background:linear-gradient(135deg,${esc(shop.accent)},#0b5750);color:#fff;border:none">
<div style="font-size:2.6rem">${esc(shop.emoji)}</div>
<h1 style="color:#fff;margin:.15em 0 .1em">${esc(shop.name)}</h1>
${shop.tagline ? `<p style="color:#d9ede9;margin:0 0 6px;font-size:1.05rem">${esc(shop.tagline)}</p>` : ''}
<p style="color:#a7d3ce;margin:0;font-size:.9rem">${[addr, shop.phone].filter(Boolean).map(esc).join(' · ')}</p>
</div>
</div>
<div class="wrap grid g2" style="padding:26px 20px;align-items:start">
<div>
<h2>Services</h2>
${services.length ? services.map(serviceCard).join('') : `<p class="muted">No services listed yet.</p>`}
</div>
<div>
${shop.about ? `<h2>About</h2><div class="card" style="padding:20px"><p class="muted" style="margin:0;white-space:pre-wrap">${esc(shop.about)}</p></div>` : ''}
${staff.length ? `<h2 style="margin-top:22px">Our therapists</h2>
<div class="card" style="padding:8px 20px">
${staff.map(st => `<div style="padding:12px 0;border-bottom:1px solid var(--line);display:flex;gap:12px;align-items:center">
<div style="font-size:1.6rem">${esc(st.emoji)}</div>
<div><div style="font-weight:600">${esc(st.name)}</div><div class="muted" style="font-size:.85rem">${esc(st.title || '')}</div></div>
</div>`).join('')}
</div>` : ''}
</div>
</div>
`, { accent: shop.accent, description: shop.tagline || `Book ${shop.name} online.`, jsonld }))
})
// ─── Booking flow ────────────────────────────────────────────────────────────
app.get('/:slug/book', async (c) => {
const db = c.env.DB
const shop = await getShopBySlug(db, c.req.param('slug'))
if (!shop || !shop.is_published) return c.notFound()
const serviceId = c.req.query('service')
const service = serviceId
? await db.prepare('SELECT * FROM services WHERE id = ? AND shop_id = ? AND is_active = 1').bind(serviceId, shop.id).first()
: null
// No service chosen → show the picker
if (!service) {
const services = (await db.prepare('SELECT * FROM services WHERE shop_id = ? AND is_active = 1 ORDER BY sort_order, created_at').bind(shop.id).all()).results || []
return c.html(layout(`Book — ${shop.name}`, `${siteNav(c.get('user'))}<div class="wrap narrow" style="padding:30px 20px">
<a href="/${shop.slug}" class="muted"> ${esc(shop.name)}</a><h2 style="margin-top:10px">Choose a service</h2>
${services.map(s => `<a class="card svc" style="padding:16px 20px;display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;text-decoration:none;color:inherit" href="/${shop.slug}/book?service=${s.id}">
<div><div style="font-weight:600">${esc(s.name)}</div><div class="muted" style="font-size:.85rem">${s.duration_minutes} min · ${money(s.price_cents, shop.currency)}</div></div><span class="btn sm">Select</span></a>`).join('')}
</div>`, { accent: shop.accent }))
}
const staff = await eligibleStaff(db, shop.id, service.id)
const depositCents = Math.round(service.price_cents * shop.deposit_pct / 100)
return c.html(layout(`Book ${service.name}${shop.name}`, `
${siteNav(c.get('user'))}
<div class="wrap narrow" style="padding:26px 20px">
<a href="/${shop.slug}" class="muted"> ${esc(shop.name)}</a>
<div class="card" style="padding:26px;margin-top:12px">
<div class="pill">${service.duration_minutes} min · ${money(service.price_cents, shop.currency)}</div>
<h2 style="margin:.4em 0 0">${esc(service.name)}</h2>
${service.description ? `<p class="muted" style="margin:.3em 0 0">${esc(service.description)}</p>` : ''}
<form method="post" action="/${shop.slug}/book" id="bk">
<input type="hidden" name="service" value="${service.id}">
<input type="hidden" name="start" id="start">
<h3 style="margin:1.4em 0 .4em;font-size:1.05rem">1. Choose a therapist</h3>
<select name="staff" id="staff">
<option value="any">Anyone available</option>
${staff.map(s => `<option value="${s.id}">${esc(s.emoji)} ${esc(s.name)}</option>`).join('')}
</select>
<h3 style="margin:1.4em 0 .4em;font-size:1.05rem">2. Pick a date</h3>
<div id="dates" class="row" style="gap:8px"><span class="muted">Loading</span></div>
<h3 style="margin:1.4em 0 .4em;font-size:1.05rem">3. Pick a time</h3>
<div id="times" class="row" style="gap:8px"><span class="muted">Choose a date first.</span></div>
<h3 style="margin:1.4em 0 .4em;font-size:1.05rem">4. Your details</h3>
<div class="field"><label>Full name</label><input name="name" required></div>
<div class="field"><label>Email</label><input type="email" name="email" required></div>
<div class="field"><label>Mobile</label><input name="phone" placeholder="Optional"></div>
<div class="field"><label>Anything we should know?</label><textarea name="notes" rows="2" placeholder="Injuries, pressure preference, parking"></textarea></div>
<div class="card" style="padding:14px 16px;background:#f6f2ec;border-style:dashed;margin-bottom:16px">
${depositCents > 0
? `💳 <strong>${money(depositCents, shop.currency)} deposit</strong> to confirm — the rest (${money(service.price_cents - depositCents, shop.currency)}) is paid in-store. Free cancellation up to ${shop.cancellation_hours}h before.`
: `No deposit required — just confirm your spot.`}
</div>
<button class="btn" style="width:100%" id="submit" disabled>Pick a time to continue</button>
</form>
</div>
</div>
<script>
const slug=${JSON.stringify(shop.slug)}, service=${JSON.stringify(service.id)};
const datesEl=document.getElementById('dates'), timesEl=document.getElementById('times');
const startEl=document.getElementById('start'), submitEl=document.getElementById('submit'), staffEl=document.getElementById('staff');
let selDate=null;
const fmtDate=ds=>{const d=new Date(ds+'T12:00:00Z');return d.toLocaleDateString('en-AU',{weekday:'short',day:'numeric',month:'short'})};
async function loadDates(){
startEl.value='';submitEl.disabled=true;submitEl.textContent='Pick a time to continue';
timesEl.innerHTML='<span class="muted">Choose a date first.</span>';
datesEl.innerHTML='<span class="muted">Loading…</span>';
const r=await fetch('/api/slots?shop='+slug+'&service='+service+'&staff='+staffEl.value);
const {dates}=await r.json();
if(!dates||!dates.length){datesEl.innerHTML='<span class="muted">No availability right now.</span>';return;}
datesEl.innerHTML='';
dates.slice(0,21).forEach(ds=>{const b=document.createElement('button');b.type='button';b.className='btn ghost sm';b.textContent=fmtDate(ds);
b.onclick=()=>{selDate=ds;[...datesEl.children].forEach(x=>x.classList.add('ghost'));b.classList.remove('ghost');loadTimes(ds)};datesEl.appendChild(b)});
}
async function loadTimes(ds){
startEl.value='';submitEl.disabled=true;submitEl.textContent='Pick a time to continue';
timesEl.innerHTML='<span class="muted">Loading…</span>';
const r=await fetch('/api/slots?shop='+slug+'&service='+service+'&staff='+staffEl.value+'&date='+ds);
const {slots}=await r.json();
if(!slots||!slots.length){timesEl.innerHTML='<span class="muted">Fully booked — try another day.</span>';return;}
timesEl.innerHTML='';
slots.forEach(s=>{const b=document.createElement('button');b.type='button';b.className='btn ghost sm';b.textContent=s.display;
b.onclick=()=>{startEl.value=s.unix;[...timesEl.children].forEach(x=>x.classList.add('ghost'));b.classList.remove('ghost');
submitEl.disabled=false;submitEl.textContent=${depositCents > 0 ? `'Confirm & pay deposit →'` : `'Confirm booking →'`}};timesEl.appendChild(b)});
}
staffEl.onchange=loadDates;loadDates();
</script>
`, { accent: shop.accent }))
})
app.post('/:slug/book', async (c) => {
const db = c.env.DB
const shop = await getShopBySlug(db, c.req.param('slug'))
if (!shop || !shop.is_published) return c.notFound()
const form = await c.req.parseBody()
const service = await db.prepare('SELECT * FROM services WHERE id = ? AND shop_id = ? AND is_active = 1')
.bind(form.service, shop.id).first()
if (!service) return c.text('Service unavailable', 400)
const startUnix = parseInt(form.start)
const name = (form.name || '').toString().trim()
const email = (form.email || '').toString().trim().toLowerCase()
if (!startUnix || !name || !email) return c.text('Missing details', 400)
// Re-derive the date in the shop TZ and re-check the slot is genuinely free
const dateStr = new Intl.DateTimeFormat('en-CA', { timeZone: shop.timezone }).format(new Date(startUnix * 1000))
const slots = await slotsForDate(db, shop, service, form.staff?.toString() || 'any', dateStr)
const slot = slots.find(s => s.unix === startUnix)
if (!slot) return c.text('Sorry, that time was just taken. Please go back and pick another.', 409)
// Assign a concrete therapist (first free one for "anyone available")
const staffId = (form.staff && form.staff !== 'any' && slot.staffIds.includes(form.staff.toString()))
? form.staff.toString() : slot.staffIds[0]
const staffRow = await db.prepare('SELECT name FROM staff WHERE id = ?').bind(staffId).first()
const depositCents = Math.round(service.price_cents * shop.deposit_pct / 100)
const bookingId = genId()
const endUnix = startUnix + service.duration_minutes * 60
const status = depositCents > 0 && c.env.STRIPE_SECRET_KEY ? 'pending_payment' : 'confirmed'
await db.prepare(`INSERT INTO bookings
(id, shop_id, service_id, staff_id, customer_name, customer_email, customer_phone,
start_time, end_time, status, price_cents, deposit_cents, service_name, staff_name, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.bind(bookingId, shop.id, service.id, staffId, name, email, (form.phone || '').toString(),
startUnix, endUnix, status, service.price_cents, depositCents, service.name, staffRow?.name || '', (form.notes || '').toString()).run()
// Payment required → Stripe Checkout
if (status === 'pending_payment') {
const base = c.env.BASE_URL || 'https://kneadly.theradicalparty.com'
try {
const session = await stripeClient(c.env.STRIPE_SECRET_KEY).createCheckoutSession({
mode: 'payment',
success_url: `${base}/${shop.slug}/booked/${bookingId}`,
cancel_url: `${base}/${shop.slug}/book?service=${service.id}`,
customer_email: email,
line_items: [{
quantity: 1,
price_data: {
currency: shop.currency,
unit_amount: depositCents,
product_data: { name: `Deposit — ${service.name} at ${shop.name}`, description: `${formatBookingTime(startUnix, shop.timezone)} with ${staffRow?.name || 'our team'}` }
}
}],
metadata: { booking_id: bookingId },
expires_at: Math.floor(Date.now() / 1000) + 1800
})
await db.prepare('UPDATE bookings SET stripe_session_id = ? WHERE id = ?').bind(session.id, bookingId).run()
return c.redirect(session.url)
} catch (err) {
console.error('Stripe error:', err.message)
// Fall back to a confirmed (unpaid) booking so the customer isn't stuck
await db.prepare("UPDATE bookings SET status = 'confirmed' WHERE id = ?").bind(bookingId).run()
}
}
return c.redirect(`/${shop.slug}/booked/${bookingId}`)
})
// ─── Confirmation ────────────────────────────────────────────────────────────
app.get('/:slug/booked/:id', async (c) => {
const db = c.env.DB
const shop = await getShopBySlug(db, c.req.param('slug'))
if (!shop) return c.notFound()
const b = await db.prepare('SELECT * FROM bookings WHERE id = ? AND shop_id = ?').bind(c.req.param('id'), shop.id).first()
if (!b) return c.notFound()
// Returning from Stripe before the webhook lands? Confirm optimistically.
const paid = b.status === 'confirmed' || b.status === 'completed'
const pending = b.status === 'pending_payment'
return c.html(layout(`Booking confirmed — ${shop.name}`, `
${siteNav(c.get('user'))}
<div class="wrap narrow" style="padding:40px 20px;text-align:center">
<div style="font-size:3rem">${pending ? '⏳' : '✅'}</div>
<h2>${pending ? 'Almost there…' : 'Youre booked in!'}</h2>
<p class="muted">${pending ? 'Were confirming your deposit. This page will update shortly.' : `See you soon at ${esc(shop.name)}.`}</p>
<div class="card" style="padding:22px;text-align:left;margin-top:18px">
<div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--line)"><span class="muted">Service</span><strong>${esc(b.service_name)}</strong></div>
<div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--line)"><span class="muted">Therapist</span><strong>${esc(b.staff_name || 'Our team')}</strong></div>
<div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--line)"><span class="muted">When</span><strong>${formatBookingTime(b.start_time, shop.timezone)}</strong></div>
<div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--line)"><span class="muted">Price</span><strong>${money(b.price_cents, shop.currency)}</strong></div>
${b.deposit_cents > 0 ? `<div style="display:flex;justify-content:space-between;padding:8px 0"><span class="muted">Deposit paid</span><strong>${money(b.deposit_cents, shop.currency)}</strong></div>` : ''}
</div>
<p class="muted" style="font-size:.85rem;margin-top:16px">A confirmation was sent to ${esc(b.customer_email)}. Need to change it? Call ${esc(shop.phone || shop.name)}.</p>
<a class="btn ghost" style="margin-top:8px" href="/${shop.slug}">Back to ${esc(shop.name)}</a>
</div>
${pending ? '<script>setTimeout(()=>location.reload(),4000)</script>' : ''}
`, { accent: shop.accent }))
})
export default app

49
src/routes/webhooks.js Normal file
View file

@ -0,0 +1,49 @@
import { Hono } from 'hono'
import { stripeClient } from '../lib/stripe.js'
const app = new Hono()
app.post('/stripe', async (c) => {
const sig = c.req.header('stripe-signature')
if (!sig) return c.json({ error: 'No signature' }, 400)
const payload = await c.req.text()
let event
try {
event = await stripeClient(c.env.STRIPE_SECRET_KEY).verifyWebhook(payload, sig, c.env.STRIPE_WEBHOOK_SECRET)
} catch (err) {
console.error('Webhook verification failed:', err.message)
return c.json({ error: 'Invalid signature' }, 400)
}
const db = c.env.DB
if (event.type === 'checkout.session.completed') {
const session = event.data.object
const bookingId = session.metadata?.booking_id
if (!bookingId) return c.json({ ok: true })
const booking = await db.prepare('SELECT * FROM bookings WHERE id = ?').bind(bookingId).first()
if (!booking) return c.json({ ok: true })
let chargeId = null
try {
const pi = await stripeClient(c.env.STRIPE_SECRET_KEY).retrievePaymentIntent(session.payment_intent)
chargeId = pi.latest_charge
} catch (err) { console.error('charge lookup failed:', err.message) }
await db.prepare(
"UPDATE bookings SET status = 'confirmed', stripe_session_id = ?, stripe_payment_intent_id = ?, stripe_charge_id = ? WHERE id = ?"
).bind(session.id, session.payment_intent, chargeId, bookingId).run()
}
if (event.type === 'checkout.session.expired') {
const bookingId = event.data.object.metadata?.booking_id
if (bookingId)
await db.prepare("UPDATE bookings SET status = 'cancelled' WHERE id = ? AND status = 'pending_payment'")
.bind(bookingId).run()
}
return c.json({ ok: true })
})
export default app

16
wrangler.toml Normal file
View file

@ -0,0 +1,16 @@
name = "kneadly"
main = "src/index.js"
compatibility_date = "2024-11-01"
compatibility_flags = ["nodejs_compat"]
[[d1_databases]]
binding = "DB"
database_name = "kneadly"
database_id = "795c6a38-c6e9-4221-b429-4890a39a8934"
[[routes]]
pattern = "kneadly.theradicalparty.com"
custom_domain = true
[vars]
BASE_URL = "https://kneadly.theradicalparty.com"