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:
commit
aa45ebc769
18 changed files with 3084 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
node_modules/
|
||||||
|
.wrangler/
|
||||||
|
dist/
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
.dev.vars
|
||||||
77
README.md
Normal file
77
README.md
Normal 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
19
dev.log
Normal 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
1517
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
18
package.json
Normal file
18
package.json
Normal 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
126
schema.sql
Normal 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
64
src/index.js
Normal 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
38
src/lib/auth.js
Normal 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
70
src/lib/booking.js
Normal 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
84
src/lib/slots.js
Normal 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
51
src/lib/stripe.js
Normal 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
89
src/lib/views.js
Normal 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 =>
|
||||||
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
24
src/routes/api.js
Normal 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
126
src/routes/auth.js
Normal 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 Mon–Sat 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
376
src/routes/dashboard.js
Normal 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 Mon–Sat 9–6 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 & 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 & 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
334
src/routes/public.js
Normal 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 & 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 person’s hours, and let clients choose “anyone available”.'],
|
||||||
|
['⏱️', 'Set your own hours', 'Per-therapist weekly schedules. Kneadly only ever offers times you’re 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. That’s 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…' : 'You’re booked in!'}</h2>
|
||||||
|
<p class="muted">${pending ? 'We’re 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
49
src/routes/webhooks.js
Normal 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
16
wrangler.toml
Normal 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"
|
||||||
Loading…
Add table
Reference in a new issue