commit aa45ebc7692ccf9bcdd297ca1daad4685185c746 Author: King Omar Date: Tue Jul 21 19:09:21 2026 +1000 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c9402c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.wrangler/ +dist/ +*.local +.env +.dev.vars diff --git a/README.md b/README.md new file mode 100644 index 0000000..f78c560 --- /dev/null +++ b/README.md @@ -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 `/` +- 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. diff --git a/dev.log b/dev.log new file mode 100644 index 0000000..1e01393 --- /dev/null +++ b/dev.log @@ -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) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..af40b97 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1517 @@ +{ + "name": "kneadly", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kneadly", + "version": "1.0.0", + "dependencies": { + "hono": "^4.6.0" + }, + "devDependencies": { + "wrangler": "^4.0.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260714.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260714.1.tgz", + "integrity": "sha512-ZWXqAN8G7Cx9hMRQuk+59ziJhR3j1F4iO+Qs8aHdfKZ3Dq5Yi/57xvkJTgCGBnW1YU/L78r8f6HEy51bwbTpNw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260714.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260714.1.tgz", + "integrity": "sha512-tueWxWC3wyCbMG6zRAxsMXX0YLgrRWbiAPYFQ2uJ7dUH8G+5E7UTWaQS9B1HdJ0bpKFW1NWxhs1o2noKVFSUYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260714.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260714.1.tgz", + "integrity": "sha512-1VChTZRb0l0F7R4e1G5RtLKV4oFi6x+rQgxh2+yu887j3l/3TLgatuv1L8/5zhc9gKEhATTxOh0e52Rtd9dDWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260714.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260714.1.tgz", + "integrity": "sha512-rMm3G+NirG2UdgHIRDdF1asNC6FqgIzZzkRG+VDhhDGcVxAQwvrMT1E38BivEvHr3G04MB4AfhcOczX0+GtRkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260714.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260714.1.tgz", + "integrity": "sha512-cGqnU3Hg2YZS/k3SAqrMp1DjpdsyFde72tWltdl6ZT9+SFz/Zrk/8gyTU1TcxC4YApXeNVH5TyU5cOGPgUJ0pg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", + "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hono": { + "version": "4.12.31", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "4.20260714.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260714.0.tgz", + "integrity": "sha512-MYlTCLdWCPqvrYY2uLwOjXwmglXuiHE3TGGkbOW4BwjUPa1r07E0iuHwrNDIs/sxK21r+o90Jx58AV2KeNdJZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.34.5", + "undici": "7.28.0", + "workerd": "1.20260714.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260714.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260714.1.tgz", + "integrity": "sha512-oIbQzfdyl9UQUnG6XLegcSq0Mgt/7WKDbFOoqGgOWCS+/fhyGB460uKEgdAQQ9RHCO/ttcNCX/KiMIQzdoeu3Q==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260714.1", + "@cloudflare/workerd-darwin-arm64": "1.20260714.1", + "@cloudflare/workerd-linux-64": "1.20260714.1", + "@cloudflare/workerd-linux-arm64": "1.20260714.1", + "@cloudflare/workerd-windows-64": "1.20260714.1" + } + }, + "node_modules/wrangler": { + "version": "4.112.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.112.0.tgz", + "integrity": "sha512-5H+XUD0TySCv1LuktFHDIEOkboH2nTfQs+35L+USt3MtntjDTMVIJprLgQcL2WBjulOyjxpd1vyTiSTJVW5MjQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "4.20260714.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260714.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260714.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e469329 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..0762b41 --- /dev/null +++ b/schema.sql @@ -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); diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..e8dbf33 --- /dev/null +++ b/src/index.js @@ -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(`πŸ’†`, + 200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=86400' })) + +app.get('/og.svg', (c) => { + const svg = ` + + + + πŸ’† Kneadly + Online booking for massage shops + Take bookings from Google Maps Β· Collect deposits Β· Fill your calendar + + Start free β†’ +` + 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 = [`${base}/1.0`] + for (const r of (rows.results || [])) + urls.push(`${base}/${r.slug}weekly0.8`) + return c.body(`${urls.join('')}`, + 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 diff --git a/src/lib/auth.js b/src/lib/auth.js new file mode 100644 index 0000000..e69f9ea --- /dev/null +++ b/src/lib/auth.js @@ -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() +} diff --git a/src/lib/booking.js b/src/lib/booking.js new file mode 100644 index 0000000..954b9fb --- /dev/null +++ b/src/lib/booking.js @@ -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 +} diff --git a/src/lib/slots.js b/src/lib/slots.js new file mode 100644 index 0000000..51234ca --- /dev/null +++ b/src/lib/slots.js @@ -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' }) +} diff --git a/src/lib/stripe.js b/src/lib/stripe.js new file mode 100644 index 0000000..9a8e2aa --- /dev/null +++ b/src/lib/stripe.js @@ -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) + } + } +} diff --git a/src/lib/views.js b/src/lib/views.js new file mode 100644 index 0000000..0a7d7a5 --- /dev/null +++ b/src/lib/views.js @@ -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 = `` + +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 ` + +${esc(title)} + + + + + +${FONTS} +${opts.jsonld ? `` : ''} + +${body} +
Powered by Kneadly Β· Online booking for massage shops
+` +} + +export function siteNav(user) { + return `
` +} diff --git a/src/routes/api.js b/src/routes/api.js new file mode 100644 index 0000000..833f7a3 --- /dev/null +++ b/src/routes/api.js @@ -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 diff --git a/src/routes/auth.js b/src/routes/auth.js new file mode 100644 index 0000000..94aaf9a --- /dev/null +++ b/src/routes/auth.js @@ -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)}
+
+ ${err ? `
${esc(err)}
` : ''} + ${body} +
+
`) +} + +app.get('/signup', (c) => { + if (c.get('user')) return c.redirect('/dashboard') + return c.html(authPage('Start your massage shop β€” Kneadly', ` +

Start taking bookings

+

Free to set up. You'll have a booking page in about two minutes.

+
+
+
+
+
+ +
+

Already have an account? Log in

+ `)) +}) + +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 `

Start taking bookings

+
+
+
+
+
+ +
` +} + +app.get('/login', (c) => { + if (c.get('user')) return c.redirect('/dashboard') + return c.html(authPage('Log in β€” Kneadly', ` +

Welcome back

+
+
+
+ +
+

New here? Create your shop

+ `)) +}) + +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', `

Welcome back

+
+
+
+ +
`, '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 diff --git a/src/routes/dashboard.js b/src/routes/dashboard.js new file mode 100644 index 0000000..7b4206b --- /dev/null +++ b/src/routes/dashboard.js @@ -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) => `${label}` + return c.html(layout(`${title} β€” Kneadly`, ` +
+ +
+ ${notice ? `
${esc(notice)}
` : ''} + ${body} +
+
`, { + 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', ` +

Welcome back πŸ‘‹

+ ${welcome ? `
Your booking page is live! Add your services below, then share your link.
` : ''} + +
+ +
+ + + Open +
+
+ +
+
Upcoming
${counts.upcoming}
+
Completed
${counts.done}
+
Deposits collected
${money(counts.deposits, shop.currency)}
+
+ + ${setup ? `
+ Finish setting up: +
    + ${counts.services === 0 ? '
  • Add at least one service.
  • ' : ''} + ${counts.staff === 0 ? '
  • Add a therapist with working hours.
  • ' : ''} +
` : ''} + +
+

πŸ“ Get bookings from Google Maps

+

In your Google Business Profile β†’ Edit profile β†’ Booking / Appointment links, paste this URL. Customers will see a β€œBook” button on your Maps listing.

+
+
+ +

Next appointments

+ ${upcoming.length ? `
+ + ${upcoming.map(b => ``).join('')} +
WhenServiceClientTherapist
${formatBookingTime(b.start_time, shop.timezone)}${esc(b.service_name)}${esc(b.customer_name)}${esc(b.staff_name || '')}${b.status.replace('_', ' ')}
` : '

No upcoming bookings yet β€” share your link to get started.

'} + `) +}) + +// ─── 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 => + `${f[0].toUpperCase() + f.slice(1)}`).join(' ') + + return shell(c, 'bookings', 'Bookings', ` +

Bookings

${tabs}
+ ${rows.length ? `
+ + ${rows.map(b => ` + + + + + + + + `).join('')} +
WhenClientServiceTherapistDepositStatus
${formatBookingTime(b.start_time, shop.timezone)}${esc(b.customer_name)}
${esc(b.customer_email)}${b.customer_phone ? ' Β· ' + esc(b.customer_phone) : ''}
${b.notes ? `
πŸ“ ${esc(b.notes)}
` : ''}
${esc(b.service_name)}${esc(b.staff_name || '')}${b.deposit_cents ? money(b.deposit_cents, shop.currency) : 'β€”'}${b.refunded_at ? '
refunded
' : ''}
${b.status.replace('_', ' ')}
+ ${['confirmed', 'pending_payment'].includes(b.status) ? ` +
+
+
+ ` : ''} +
` : '

Nothing here yet.

'} + `) +}) + +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', ` +

Services

+
+ ${rows.length ? ` + ${rows.map(s => ` + + + + + `).join('')}
NameDurationPriceDeposit*
${esc(s.name)}${s.is_active ? '' : ' (hidden)'}${s.description ? `
${esc(s.description)}
` : ''}
${s.duration_minutes} min${money(s.price_cents, shop.currency)}${money(Math.round(s.price_cents * shop.deposit_pct / 100), shop.currency)}
` : '

No services yet.

'} +
+

*Deposit is ${shop.deposit_pct}% of the price (change it in Settings).

+
+

Add a service

+
+
+
+
+
+
+
+ +
+
+ `) +}) + +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(`
+
+
${esc(st.emoji)} ${esc(st.name)} ${esc(st.title || '')}
+
+
+
+ + + ${DOW.map((d, i) => { + const h = hByDow[i] + return ` + + + ` + }).join('')}
OpenStartEnd
${d}
+ +
+
`) + } + + return shell(c, 'staff', 'Therapists', ` +

Therapists

+ ${cards.join('') || '

No therapists yet.

'} +
+

Add a therapist

+
+
+
+
+
+
+ +
+
+ `) +}) + +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', ` +

Shop settings

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

Contact & location

+
+
+
+
+
+
+
+
+
+
+
+
+

Deposits & cancellation

+
+
+
+
+

Set deposit to 0% to take bookings with no upfront payment.

+
+ +
+ `) +}) + +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 diff --git a/src/routes/public.js b/src/routes/public.js new file mode 100644 index 0000000..aa5c413 --- /dev/null +++ b/src/routes/public.js @@ -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)} +
+ For massage & bodywork shops +

Let clients book you
straight from Google.

+

+ 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. +

+ +

Free to set up Β· No app to install Β· Live in 2 minutes

+
+ +
+ ${[ + ['πŸ—“οΈ', '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]) => `
${e}
+

${t}

${d}

`).join('')} +
+ +
+
+

Ready to fill your table?

+

Set up your services and hours, then share your link. That’s it.

+ Start free β†’ +
+
+ `, { + 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) => ` +
+
+
${esc(s.name)}
+ ${s.description ? `
${esc(s.description)}
` : ''} +
⏱ ${s.duration_minutes} min · ${money(s.price_cents, shop.currency)}
+
+ Book +
` + + return c.html(layout(`${shop.name} β€” Book online`, ` + ${siteNav(c.get('user'))} +
+
+
${esc(shop.emoji)}
+

${esc(shop.name)}

+ ${shop.tagline ? `

${esc(shop.tagline)}

` : ''} +

${[addr, shop.phone].filter(Boolean).map(esc).join(' Β· ')}

+
+
+ +
+
+

Services

+ ${services.length ? services.map(serviceCard).join('') : `

No services listed yet.

`} +
+
+ ${shop.about ? `

About

${esc(shop.about)}

` : ''} + ${staff.length ? `

Our therapists

+
+ ${staff.map(st => `
+
${esc(st.emoji)}
+
${esc(st.name)}
${esc(st.title || '')}
+
`).join('')} +
` : ''} +
+
+ `, { 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'))}`, { 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'))} +
+ ← ${esc(shop.name)} +
+
${service.duration_minutes} min Β· ${money(service.price_cents, shop.currency)}
+

${esc(service.name)}

+ ${service.description ? `

${esc(service.description)}

` : ''} + +
+ + + +

1. Choose a therapist

+ + +

2. Pick a date

+
Loading…
+ +

3. Pick a time

+
Choose a date first.
+ +

4. Your details

+
+
+
+
+ +
+ ${depositCents > 0 + ? `πŸ’³ ${money(depositCents, shop.currency)} deposit 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.`} +
+ + +
+
+
+ + + `, { 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'))} +
+
${pending ? '⏳' : 'βœ…'}
+

${pending ? 'Almost there…' : 'You’re booked in!'}

+

${pending ? 'We’re confirming your deposit. This page will update shortly.' : `See you soon at ${esc(shop.name)}.`}

+
+
Service${esc(b.service_name)}
+
Therapist${esc(b.staff_name || 'Our team')}
+
When${formatBookingTime(b.start_time, shop.timezone)}
+
Price${money(b.price_cents, shop.currency)}
+ ${b.deposit_cents > 0 ? `
Deposit paid${money(b.deposit_cents, shop.currency)}
` : ''} +
+

A confirmation was sent to ${esc(b.customer_email)}. Need to change it? Call ${esc(shop.phone || shop.name)}.

+ Back to ${esc(shop.name)} +
+ ${pending ? '' : ''} + `, { accent: shop.accent })) +}) + +export default app diff --git a/src/routes/webhooks.js b/src/routes/webhooks.js new file mode 100644 index 0000000..753cc2b --- /dev/null +++ b/src/routes/webhooks.js @@ -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 diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..296c9b2 --- /dev/null +++ b/wrangler.toml @@ -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"