Public booking page + iframe embed (#17)

Adds the customer-facing /book/<slug> page: service/slot picker, booking
form, and confirmation screen, built on #16's existing booking JSON API.
Includes iframe auto-fit height reporting (mirroring
deploy/booking/booking_layout.js's eaBookingHeight message), brand-color
theming via a ?color= query param, a honeypot field with a fake-success
response indistinguishable from a real booking, and a clear "just taken"
message on slot-conflict. Caddy per-IP rate limiting is documented in
deploy/booking/RATE_LIMIT.md for manual application (no Caddyfile is
tracked in this repo).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 15:15:29 +02:00
parent 2f6e0c1459
commit b895663c3a
7 changed files with 556 additions and 5 deletions
+41
View File
@@ -0,0 +1,41 @@
"""Public booking page blueprint (#17): the customer-facing /book/<slug> page,
embedded via iframe into a client's landing page. This route only resolves a
slug to a client and renders the slot-grid/booking-form page around it -- all
booking mutations still go through booking_api.py's JSON API (#16), which the
page's own JS calls via fetch.
IP rate limiting on these public routes is enforced at the Caddy layer (per
#17's acceptance criteria), not in Flask -- see deploy/booking/RATE_LIMIT.md
for the Caddy config to apply by hand on the homelab.
"""
from flask import Blueprint, abort, render_template, request
import booking_db as bdb
bp = Blueprint("public_booking", __name__)
DEFAULT_BRAND_COLOR = "#0f8a7e"
@bp.get("/book/<slug>")
def book_page(slug):
client = bdb.get_client_by_slug(slug)
if client is None:
abort(404)
resources = bdb.list_active_resources(client["client_id"])
services = bdb.list_active_services(client["client_id"])
if not resources or not services:
# No bookable services/resources configured yet -- nothing to show a
# customer rather than a broken/empty booking form.
abort(404)
return render_template(
"book.html",
client_id=client["client_id"],
business_name=client.get("business_name") or slug,
resources=[{"resource_id": r["resource_id"], "name": r["name"]} for r in resources],
services=[{"service_id": s["service_id"], "name": s["name"],
"duration_minutes": s["duration_minutes"],
"price": float(s["price"]) if s["price"] is not None else None}
for s in services],
brand_color=request.args.get("color") or DEFAULT_BRAND_COLOR,
)