calcomblock/booking.templ
Alex Dunmow 18b7825592 Extract calcomblock into a standalone wasm plugin repo
Convert the bundled backend/internal/plugins/calcomblock package into a
standalone reactor-mode wasm plugin, dropping every git.dev.alexdunmow.com/block/cms
import (forbidden in standalone plugins):

- package calcomblock -> package main; add wasip1 main.go with
  wasmguest.Serve(Registration). go.mod module
  git.dev.alexdunmow.com/block/calcomblock, block/core v0.18.2, no replace
  directives.
- Settings persistence: replace the pool-backed poolQuerier (direct SQL against
  the public-schema `settings` table, which a sandboxed plugin role cannot read)
  with capabilityQuerier over the SDK settings.Settings / settings.Updater
  capabilities. GetSiteTimezone now resolves via GetSiteSettings host-side.
- Vendor the small CMS-internal helpers the plugin used into internal/helpers
  (GetRealIP, StartCleanupLoop, MaskSecret, GetStringOr, the PluginSettingsQuerier
  settings helpers, PluginCrypto for tests) and internal/db (minimal Setting /
  UpsertSettingParams), each with a provenance header.
- Inline the captcha widget: vendor blocks.CaptchaWidget as a local
  CaptchaWidget templ (captcha_widget.templ) and regenerate templ; drop the
  block/cms/blocks import from booking.templ.
- blockConfig (server-authoritative captcha requirement via a
  page_block_snapshots scan) has no wasm-ABI capability, so it is left nil:
  honeypot + per-IP rate limit still apply. Documented as a follow-up.
- web: @block-ninja/ui workspace:* -> ^0.1.0 registry version; eslint.config.js
  repointed at ../../../cms/web/eslint.config.js; rebuild web/dist.
- Rewrite CLAUDE.md for the standalone wasm reality; add .gitignore.

Full test suite preserved and passing; builds to calcomblock-2.0.0.bnp;
check-safety passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 03:04:18 +08:00

923 lines
41 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"fmt"
"strconv"
)
// CalcomBookingWidget renders the main booking widget container.
//
// The widget is divided into four regions, each with a stable ID so HTMX can
// swap them independently:
//
// - #calcom-steps-{id} step indicator (OOB-updated on every transition)
// - #calcom-date-grid-{id} the date picker
// - #calcom-slots-{id} time slots, loaded after a date click
// - #calcom-form-{id} booking form, loaded after a slot click
// - #calcom-confirm-{id} success panel, populated after submit
//
// Each partial returned by the server emits hx-swap-oob directives to keep
// the step indicator in sync with the user's progress, and to clear stale
// regions when the user changes their mind (Change date / Change time) or
// finishes (post-confirmation).
templ CalcomBookingWidget(config BookingConfig) {
<div
id={ fmt.Sprintf("calcom-widget-%s", config.BlockID) }
class="calcom-booking-widget bg-card border border-border rounded-xl p-4 sm:p-6 shadow-sm"
data-username={ config.Username }
data-event-type={ config.EventTypeSlug }
>
// Booking times render server-side in the MEETING's timezone (the
// event's Cal.com availability schedule) — the visitor's browser
// timezone is deliberately never detected or sent. Client-side
// timezone plumbing is what caused the 2026-06-10 incident: a hidden
// input's "UTC" default never got updated, every hx-include sent
// timezone=UTC, and visitors saw UTC slot times.
//
// This script only:
// - captures the browser locale for phone dial-code inference
// - wires the date-grid selection highlight
// - drives the phone country combobox
//
// Init is deferred to DOMContentLoaded: the locale input sits after
// this script in the widget and doesn't exist while it parses.
<script>
(function(){
var s = document.currentScript;
if (!s) return;
var w = s.parentElement;
if (!w || !w.classList.contains("calcom-booking-widget")) return;
function init(){
// Capture the browser locale (e.g. "en-AU") so the server can infer the
// phone dial code and convert national numbers to E.164 — Cal.com rejects
// national-format attendee phone numbers as invalid_number.
var loc = "";
try { loc = navigator.language || (navigator.languages && navigator.languages[0]) || ""; } catch(e) {}
var locInput = w.querySelector("input[name=\"locale\"]");
if (locInput) locInput.value = loc;
// Click delegate: mark the clicked date cell aria-pressed=true,
// clearing any previously pressed cell. Visual styling is handled
// by aria-pressed Tailwind variants on the button class.
w.addEventListener("click", function(ev){
var btn = ev.target.closest("[role=\"grid\"][aria-label=\"Booking date picker\"] button[role=\"gridcell\"]");
if (!btn || btn.disabled) return;
w.querySelectorAll("[role=\"grid\"][aria-label=\"Booking date picker\"] button[role=\"gridcell\"][aria-pressed=\"true\"]").forEach(function(b){
b.setAttribute("aria-pressed", "false");
});
btn.setAttribute("aria-pressed", "true");
});
// Country combobox (phone field). Delegated on the widget root so it works
// for the form partial HTMX swaps in after this script first runs.
function ccReset(box){
var hidden = box.querySelector("input[name=\"phoneCountry\"]");
var search = box.querySelector(".cc-search");
if (!hidden || !search) return;
var opt = box.querySelector(".cc-option[data-code=\"" + (hidden.value || "") + "\"]");
search.value = opt ? opt.textContent.trim() : "";
}
function ccClose(except){
w.querySelectorAll("[data-cc-combobox]").forEach(function(box){
var ul = box.querySelector(".cc-options");
if (!ul || ul === except) return;
if (!ul.classList.contains("hidden")) { ul.classList.add("hidden"); ccReset(box); }
var s = box.querySelector(".cc-search"); if (s) s.setAttribute("aria-expanded", "false");
});
}
function ccOpen(box, showAll){
var ul = box.querySelector(".cc-options"); var s = box.querySelector(".cc-search");
if (!ul || !s) return;
if (showAll) ul.querySelectorAll(".cc-option").forEach(function(li){ li.classList.remove("hidden"); });
ul.classList.remove("hidden"); s.setAttribute("aria-expanded", "true");
}
w.addEventListener("input", function(ev){
var s = ev.target.closest(".cc-search"); if (!s) return;
var box = s.closest("[data-cc-combobox]"); var ul = box.querySelector(".cc-options");
var q = s.value.trim().toLowerCase();
ul.querySelectorAll(".cc-option").forEach(function(li){
li.classList.toggle("hidden", q !== "" && li.textContent.toLowerCase().indexOf(q) === -1);
});
ccOpen(box, false);
});
w.addEventListener("click", function(ev){
var opt = ev.target.closest(".cc-option");
if (opt){
var box = opt.closest("[data-cc-combobox]");
box.querySelector("input[name=\"phoneCountry\"]").value = opt.getAttribute("data-code");
box.querySelector(".cc-search").value = opt.textContent.trim();
ccClose(); return;
}
var s = ev.target.closest(".cc-search");
if (s){ var b = s.closest("[data-cc-combobox]"); ccClose(b.querySelector(".cc-options")); ccOpen(b, true); s.select(); return; }
if (!ev.target.closest("[data-cc-combobox]")) ccClose();
});
w.addEventListener("keydown", function(ev){
var s = ev.target.closest(".cc-search"); if (!s) return;
if (ev.key === "Escape") { ccClose(); return; }
if (ev.key === "Enter"){
ev.preventDefault();
var box = s.closest("[data-cc-combobox]");
var first = box.querySelector(".cc-option:not(.hidden)");
if (first){
box.querySelector("input[name=\"phoneCountry\"]").value = first.getAttribute("data-code");
s.value = first.textContent.trim(); ccClose();
}
}
});
// ── "Manage your booking" (localStorage-backed) ───────────────
// After a successful booking, an inline script in the confirmation
// partial persists {uid, when, email, savedAt} to localStorage under
// a STABLE key derived from this widget's username + event type (NOT
// the per-render blockId, which changes each page render). On a later
// visit we read it back here and show a panel offering Cancel / redo,
// hiding the date grid so the visitor manages the existing booking
// instead of accidentally double-booking.
//
// SECURITY: the UID is Cal.com's own unguessable booking identifier
// and lives only in THIS visitor's localStorage — the same capability
// model as Cal.com's emailed cancel link. The server rate-limits the
// cancel POST by IP as the abuse guard.
function bookingKey(){
return "calcom:booking:" + (w.getAttribute("data-username") || "") + ":" + (w.getAttribute("data-event-type") || "");
}
function readSavedBooking(){
try {
var raw = window.localStorage.getItem(bookingKey());
if (!raw) return null;
var b = JSON.parse(raw);
return (b && b.uid) ? b : null;
} catch(e) { return null; }
}
function clearSavedBooking(){
try { window.localStorage.removeItem(bookingKey()); } catch(e) {}
}
var savedPanel = w.querySelector("[data-calcom-saved]");
var dateGrid = w.querySelector(".calcom-date-picker");
function showGrid(){
if (dateGrid) dateGrid.classList.remove("hidden");
if (savedPanel) savedPanel.classList.add("hidden");
}
function showSavedPanel(b){
if (!savedPanel) return;
var whenEl = savedPanel.querySelector("[data-calcom-saved-when]");
if (whenEl) whenEl.textContent = b.when || "";
var emailEl = savedPanel.querySelector("[data-calcom-saved-email]");
if (emailEl) emailEl.textContent = b.email || "";
// Populate the Cancel form's hidden uid so its HTMX POST carries it.
var uidInput = savedPanel.querySelector("input[name=\"uid\"]");
if (uidInput) uidInput.value = b.uid || "";
savedPanel.classList.remove("hidden");
if (dateGrid) dateGrid.classList.add("hidden");
}
function refreshSavedPanel(){
var b = readSavedBooking();
if (b) { showSavedPanel(b); } else { showGrid(); }
}
// Reschedule / redo: cancel the existing booking (so the visitor
// can't double-book), then reveal the grid to book afresh. The
// Cancel form already POSTs /cancel and its afterRequest handler
// clears storage + restores the grid on success, so "redo" just
// triggers that same cancel — the restored grid IS the rebook UI.
var redoBtn = savedPanel ? savedPanel.querySelector("[data-calcom-redo]") : null;
if (redoBtn) {
redoBtn.addEventListener("click", function(){
var cancelBtn = savedPanel.querySelector("[data-calcom-cancel-submit]");
if (cancelBtn) cancelBtn.click();
});
}
// When the Cancel POST succeeds, the server swaps a "cancelled"
// message into #calcom-confirm. Clear the localStorage key, hide the
// saved panel, and re-show the date grid (only CSS-hidden while the
// panel was up — never removed — so the visitor can rebook) so a
// reload no longer shows the saved panel.
w.addEventListener("htmx:afterRequest", function(ev){
var d = ev.detail || {};
var path = (d.requestConfig && d.requestConfig.path) || "";
if (path.indexOf("/api/plugins/calcomblock/cancel") === -1) return;
if (d.successful) {
clearSavedBooking();
showGrid(); // re-show the grid + hide the saved panel
}
});
refreshSavedPanel();
}
// init() queries elements parsed after this script — defer until
// the document is complete. (w is captured outside the callback:
// document.currentScript is null by the time it fires.)
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
</script>
<input type="hidden" name="locale" value=""/>
// Admin-authored fallback copy for a non-fixable booking failure
// (Tier-2). Stamped once from block content here and hx-included into
// the POST /book request (like locale) so HandleCreateBooking can read
// it back. templ auto-escapes the value. Defaults to
// DefaultUnavailableMessage when the admin leaves it blank (resolved in
// block.go), so it is never empty.
<input type="hidden" name="unavailableMessage" value={ config.UnavailableMessage }/>
if config.Title != "" {
<h3 class="text-2xl font-bold text-foreground mb-2">{ config.Title }</h3>
}
if config.Description != "" {
<p class="text-muted-foreground mb-6">{ config.Description }</p>
}
// Step indicator — wrapped in a stable container so partial responses
// can replace its contents via hx-swap-oob.
<div id={ fmt.Sprintf("calcom-steps-%s", config.BlockID) } class="mb-6">
@calcomStepIndicator(1)
</div>
if config.ShowTimezone {
<div class="text-xs text-muted-foreground mb-4 flex items-center gap-1.5 flex-wrap">
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="12" cy="12" r="9"></circle>
<path d="M3 12h18M12 3a14 14 0 010 18M12 3a14 14 0 000 18"></path>
</svg>
<span>All times shown in</span>
<span class="font-medium text-foreground">{ config.TimeZone }</span>
</div>
}
// Saved-booking panel — normally hidden. The widget script reveals it on
// DOMContentLoaded when this visitor's localStorage holds a booking for
// this username/event, hiding the date grid below so they manage the
// existing booking (cancel / redo) instead of double-booking.
@calcomSavedBooking(config.BlockID)
// Date picker section — wrapped so confirmation can OOB-clear it.
<div
id={ fmt.Sprintf("calcom-date-grid-%s", config.BlockID) }
class="calcom-date-picker"
>
@calcomDateGrid(config)
</div>
// Time slots section — populated after a date click.
<div
id={ fmt.Sprintf("calcom-slots-%s", config.BlockID) }
class="calcom-time-slots mt-6"
aria-live="polite"
aria-busy="false"
></div>
// Booking form section — populated after a slot click.
<div
id={ fmt.Sprintf("calcom-form-%s", config.BlockID) }
class="calcom-booking-form"
aria-live="polite"
aria-busy="false"
></div>
// Confirmation section — populated after successful booking.
<div
id={ fmt.Sprintf("calcom-confirm-%s", config.BlockID) }
class="calcom-confirmation"
aria-live="polite"
aria-busy="false"
></div>
// Central loading indicator shown during HTMX requests.
<div
id={ fmt.Sprintf("calcom-loading-%s", config.BlockID) }
class="htmx-indicator flex items-center justify-center py-8"
role="status"
aria-label="Loading"
>
<svg class="animate-spin h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
</div>
}
// calcomStepIndicator renders the three-step progress indicator. `active` is
// the user's current step (1 = Select Date, 2 = Select Time, 3 = Confirm,
// 4 = Done — all three checkmarked). When active > n the step is rendered as
// completed; when active == n it is current; when active < n it is upcoming.
templ calcomStepIndicator(active int) {
<div class="flex items-center gap-2 flex-wrap text-sm">
@calcomStep(1, "Select Date", active)
<div class="h-px flex-1 min-w-[1rem] bg-border"></div>
@calcomStep(2, "Select Time", active)
<div class="h-px flex-1 min-w-[1rem] bg-border"></div>
@calcomStep(3, "Confirm", active)
</div>
}
// calcomStep renders one step pill inside the progress indicator.
templ calcomStep(n int, label string, active int) {
<div class={
"flex items-center gap-2",
templ.KV("opacity-50", active < n),
}>
<span class={
"flex items-center justify-center w-6 h-6 rounded-full text-xs font-medium shrink-0",
templ.KV("bg-primary text-primary-foreground", active >= n),
templ.KV("bg-muted text-muted-foreground", active < n),
}>
if active > n {
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"></path>
</svg>
} else {
{ strconv.Itoa(n) }
}
</span>
<span class={
"hidden sm:inline",
templ.KV("text-foreground font-medium", active == n),
templ.KV("text-muted-foreground", active != n),
}>{ label }</span>
</div>
}
// calcomStepIndicatorOOB wraps calcomStepIndicator in the OOB-swap envelope
// so partial responses can update the indicator out-of-band.
templ calcomStepIndicatorOOB(blockID string, active int) {
<div id={ fmt.Sprintf("calcom-steps-%s", blockID) } class="mb-6" hx-swap-oob="true">
@calcomStepIndicator(active)
</div>
}
// calcomClearRegion emits an empty container with the given ID and an OOB
// swap directive, used to wipe stale regions when the user transitions
// between flow steps (Change date / Change time / post-confirmation).
templ calcomClearRegion(id, extraClasses string) {
<div id={ id } class={ extraClasses } aria-live="polite" aria-busy="false" hx-swap-oob="innerHTML"></div>
}
// calcomDateGrid renders the calendar date picker. The prev/next buttons fire
// hx-get against /api/plugins/calcomblock/date-grid, which returns a fresh
// instance of this template scoped to a new WeekStart — innerHTML-swapped
// into the #calcom-date-grid-{id} container in the widget root.
templ calcomDateGrid(config BookingConfig) {
<div class="space-y-4">
<div class="flex items-center justify-between gap-2">
if config.CanGoBack {
<button
type="button"
aria-label="Previous dates"
class="p-1.5 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-ring"
hx-get={ fmt.Sprintf("/api/plugins/calcomblock/date-grid?blockId=%s&username=%s&eventType=%s&weeks=%d&weekStart=%s",
config.BlockID, config.Username, config.EventTypeSlug, config.WeeksToShow, prevWeekStart(config.WeekStart, config.WeeksToShow, config.Today)) }
hx-target={ fmt.Sprintf("#calcom-date-grid-%s", config.BlockID) }
hx-swap="innerHTML"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
</button>
} else {
<span class="p-1.5 w-7 h-7" aria-hidden="true"></span>
}
<span class="text-sm font-medium text-foreground" aria-live="polite">{ config.RangeLabel }</span>
<button
type="button"
aria-label="Next dates"
class="p-1.5 rounded-md text-muted-foreground hover:bg-muted hover:text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-ring"
hx-get={ fmt.Sprintf("/api/plugins/calcomblock/date-grid?blockId=%s&username=%s&eventType=%s&weeks=%d&weekStart=%s",
config.BlockID, config.Username, config.EventTypeSlug, config.WeeksToShow, nextWeekStart(config.WeekStart, config.WeeksToShow)) }
hx-target={ fmt.Sprintf("#calcom-date-grid-%s", config.BlockID) }
hx-swap="innerHTML"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
</button>
</div>
<div class="grid grid-cols-7 gap-1 text-center" role="row">
for _, day := range []string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"} {
<div class="text-xs font-medium text-muted-foreground py-2" role="columnheader">
{ day }
</div>
}
</div>
<div class="grid grid-cols-7 gap-1" role="grid" aria-label="Booking date picker">
// Offset the first date to its weekday column. Without these pad
// cells a mid-week window pours in at the "Sun" column and every
// date renders under the wrong header — the second bidbuddy
// incident (2026-06-10): tapped the cell under "Tue", got Friday.
for i, pad := 0, leadingBlanks(config.Dates); i < pad; i++ {
<div class="calcom-date-pad" aria-hidden="true"></div>
}
for _, date := range config.Dates {
if date.IsPast {
<button
type="button"
disabled
aria-disabled="true"
class="flex aspect-square w-full items-center justify-center rounded-lg font-medium text-muted-foreground opacity-40 cursor-not-allowed"
role="gridcell"
>
<span class="text-base sm:text-lg">{ date.DayOfMonth }</span>
</button>
} else {
<button
type="button"
role="gridcell"
class={
// aspect-square + flex centring (not fixed p-3): cells track
// the column width, so a narrow phone container yields small
// squares instead of 25×52 stretched pills (iOS report,
// 2026-06-10 21:52).
"flex aspect-square w-full items-center justify-center rounded-lg font-medium transition-all",
"hover:bg-primary hover:text-primary-foreground",
"focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:ring-2 aria-pressed:ring-primary",
templ.KV("bg-primary/10 text-primary ring-1 ring-primary", date.IsToday && !date.IsSelected),
templ.KV("text-foreground", !date.IsToday && !date.IsSelected),
}
aria-pressed={ fmt.Sprintf("%t", date.IsSelected) }
hx-get={ fmt.Sprintf("/api/plugins/calcomblock/slots?username=%s&eventType=%s&date=%s&blockId=%s",
config.Username, config.EventTypeSlug, date.Date, config.BlockID) }
hx-target={ fmt.Sprintf("#calcom-slots-%s", config.BlockID) }
hx-swap="innerHTML"
hx-indicator={ fmt.Sprintf("#calcom-loading-%s", config.BlockID) }
>
<span class="text-base sm:text-lg">{ date.DayOfMonth }</span>
</button>
}
}
</div>
</div>
}
// CalcomTimeSlots renders available time slots for a selected date. Returned
// as the body of GET /slots; also emits OOB updates for the step indicator
// (active=2) and an OOB clear of the form region so a previously-rendered
// form for an earlier slot doesn't linger.
templ CalcomTimeSlots(slots []TimeSlot, config SlotConfig) {
<div class="border-t border-border pt-6">
<div class="flex items-center justify-between mb-4 flex-wrap gap-2">
<h4 class="text-lg font-semibold text-foreground">
Available times for { config.SelectedDate }
</h4>
<button
type="button"
class="text-sm text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring rounded px-1"
hx-get={ fmt.Sprintf("/api/plugins/calcomblock/reset?blockId=%s", config.BlockID) }
hx-target={ fmt.Sprintf("#calcom-slots-%s", config.BlockID) }
hx-swap="innerHTML"
>
← Change date
</button>
</div>
if len(slots) == 0 {
<p class="text-muted-foreground py-4">No available times for this date. Please select another date.</p>
} else {
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-2" role="grid" aria-label="Available time slots">
for _, slot := range slots {
<button
type="button"
role="gridcell"
class="px-4 py-4 border border-border rounded-lg text-sm font-medium text-foreground hover:bg-primary hover:text-primary-foreground hover:border-primary transition-all focus:outline-none focus:ring-2 focus:ring-ring"
hx-get={ fmt.Sprintf("/api/plugins/calcomblock/form?blockId=%s&start=%s&date=%s&username=%s&eventType=%s",
config.BlockID, slot.Start, config.SelectedDate, config.Username, config.EventType) }
hx-target={ fmt.Sprintf("#calcom-form-%s", config.BlockID) }
hx-swap="innerHTML"
hx-include={ fmt.Sprintf("#calcom-widget-%s input[name='locale']", config.BlockID) }
>
{ slot.DisplayTime }
</button>
}
</div>
}
</div>
// OOB: advance step indicator to "Select Time" + clear any stale form
// from a previous slot selection.
@calcomStepIndicatorOOB(config.BlockID, 2)
@calcomClearRegion(fmt.Sprintf("calcom-form-%s", config.BlockID), "calcom-booking-form")
}
// CalcomBookingForm renders the contact form for booking. When the event type
// has custom bookingFields configured, each is rendered via bookingField. When
// the list is empty (typical for default event types or a transient fetch
// error), a built-in name + email pair is rendered so the flow still works.
//
// Returned as the body of GET /form. Also emits an OOB update advancing the
// step indicator to "Confirm".
templ CalcomBookingForm(formConfig FormConfig) {
<div class="border-t border-border pt-6 mt-6">
<div class="flex items-center justify-between mb-4 flex-wrap gap-2">
<div>
<h4 class="text-lg font-semibold text-foreground" id={ fmt.Sprintf("booking-summary-%s", formConfig.BlockID) }>Your Details</h4>
<p class="text-sm text-muted-foreground" id={ fmt.Sprintf("booking-when-%s", formConfig.BlockID) }>
Booking for { formConfig.SelectedDate } at { formConfig.SelectedTime }
</p>
</div>
<button
type="button"
class="text-sm text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring rounded px-1"
hx-get={ fmt.Sprintf("/api/plugins/calcomblock/slots?username=%s&eventType=%s&date=%s&blockId=%s",
formConfig.Username, formConfig.EventType, formConfig.SelectedDate, formConfig.BlockID) }
hx-target={ fmt.Sprintf("#calcom-slots-%s", formConfig.BlockID) }
hx-swap="innerHTML"
>
← Change time
</button>
</div>
// hx-include pulls the widget-level hidden inputs into the POST: locale
// (phone dial-code inference) and unavailableMessage (admin-authored
// Tier-2 copy). Both live in the widget root, outside this form partial.
<form
hx-post="/api/plugins/calcomblock/book"
hx-target={ fmt.Sprintf("#calcom-confirm-%s", formConfig.BlockID) }
hx-swap="innerHTML"
hx-include={ fmt.Sprintf("#calcom-widget-%s input[name='locale'], #calcom-widget-%s input[name='unavailableMessage']", formConfig.BlockID, formConfig.BlockID) }
hx-indicator={ fmt.Sprintf("#calcom-loading-%s", formConfig.BlockID) }
hx-disabled-elt="find button[type='submit']"
class="space-y-4"
>
<input type="hidden" name="blockId" value={ formConfig.BlockID }/>
<input type="hidden" name="username" value={ formConfig.Username }/>
<input type="hidden" name="eventType" value={ formConfig.EventType }/>
<input type="hidden" name="start" value={ formConfig.StartTime }/>
// Honeypot: a real user can't see this off-screen input; bots and
// over-eager autofill heuristics still hit it. The name is
// deliberately non-semantic so Chrome's autofill doesn't fire on
// "company" / "address" / "website".
<input
type="text"
name="bn_message_extra"
tabindex="-1"
autocomplete="off"
aria-hidden="true"
class="absolute -left-[9999px] h-0 w-0 overflow-hidden"
value=""
/>
if len(formConfig.BookingFields) > 0 {
for _, f := range formConfig.BookingFields {
@bookingField(f, formConfig.BlockID, formConfig.DefaultCountryCode)
}
} else {
@bookingField(BookingField{Name: "name", Label: "Name", Type: "name", Required: true, Placeholder: "Your name"}, formConfig.BlockID, "")
@bookingField(BookingField{Name: "email", Label: "Email", Type: "email", Required: true, Placeholder: "your@email.com"}, formConfig.BlockID, "")
}
// Cap proof-of-work widget — rendered only when the block enables
// captcha. On solve it injects a hidden cap-token field into this form,
// which HandleCreateBooking verifies (server-authoritative: same flag).
if formConfig.CaptchaEnabled {
<div class="pt-1">
@CaptchaWidget()
</div>
}
<button
type="submit"
aria-describedby={ fmt.Sprintf("booking-when-%s", formConfig.BlockID) }
class="w-full bg-primary text-primary-foreground px-6 py-3 rounded-lg font-medium hover:bg-primary/90 transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:opacity-60 disabled:cursor-wait"
>
Confirm Booking
</button>
</form>
</div>
// OOB: advance step indicator to "Confirm".
@calcomStepIndicatorOOB(formConfig.BlockID, 3)
}
// bookingField renders a single Cal.com bookingField in the correct widget for
// its type. Unknown types fall back to a text input. defaultCountry is the ISO
// region pre-selected in the phone-field country combobox ("" = none).
templ bookingField(f BookingField, blockID string, defaultCountry string) {
<div class="space-y-2">
<label for={ fmt.Sprintf("%s-%s", f.Name, blockID) } class="block text-sm font-medium text-foreground">
{ f.Label }
if f.Required {
<span class="text-destructive" aria-label="required">*</span>
}
</label>
switch f.Type {
case "textarea":
<textarea
id={ fmt.Sprintf("%s-%s", f.Name, blockID) }
name={ f.Name }
rows="3"
placeholder={ f.Placeholder }
if f.Required {
required
aria-required="true"
}
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent resize-none"
>{ f.DefaultValue }</textarea>
case "boolean", "checkbox":
<div class="flex items-center gap-2">
<input
type="checkbox"
id={ fmt.Sprintf("%s-%s", f.Name, blockID) }
name={ f.Name }
value="true"
if f.Required {
required
aria-required="true"
}
class="h-4 w-4 rounded border-border text-primary focus:ring-2 focus:ring-ring"
/>
<span class="text-sm text-muted-foreground">{ f.Placeholder }</span>
</div>
case "select":
<select
id={ fmt.Sprintf("%s-%s", f.Name, blockID) }
name={ f.Name }
if f.Required {
required
aria-required="true"
}
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
if f.Placeholder != "" {
<option value="">{ f.Placeholder }</option>
}
for _, opt := range f.Options {
<option value={ opt.Value } selected?={ opt.Value == f.DefaultValue }>{ opt.Label }</option>
}
</select>
case "multiselect":
<div class="space-y-1" role="group" aria-label={ f.Label }>
for _, opt := range f.Options {
<label class="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
name={ f.Name }
value={ opt.Value }
class="h-4 w-4 rounded border-border text-primary focus:ring-2 focus:ring-ring"
/>
{ opt.Label }
</label>
}
</div>
case "radio":
<div class="space-y-1" role="radiogroup" aria-label={ f.Label }>
for _, opt := range f.Options {
<label class="flex items-center gap-2 text-sm text-foreground">
<input
type="radio"
name={ f.Name }
value={ opt.Value }
checked?={ opt.Value == f.DefaultValue }
if f.Required {
required
aria-required="true"
}
class="h-4 w-4 border-border text-primary focus:ring-2 focus:ring-ring"
/>
{ opt.Label }
</label>
}
</div>
case "phone":
<div class="flex gap-2">
<div class="relative w-40 sm:w-48 shrink-0" data-cc-combobox>
<input type="hidden" name="phoneCountry" value={ defaultCountry }/>
<input
type="text"
class="cc-search w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
role="combobox"
aria-expanded="false"
aria-label="Phone country code"
autocomplete="off"
placeholder="Country"
value={ countryLabel(defaultCountry) }
/>
<ul class="cc-options hidden absolute z-50 mt-1 max-h-60 w-64 overflow-auto rounded-lg border border-border bg-popover text-popover-foreground shadow-lg" role="listbox" aria-label="Select country">
for _, c := range countries {
<li
class="cc-option px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground aria-selected:bg-accent aria-selected:text-accent-foreground"
role="option"
data-code={ c.Code }
data-dial={ c.Dial }
aria-selected={ fmt.Sprintf("%t", c.Code == defaultCountry) }
>
{ countryFlag(c.Code) } { c.Name } ({ c.Dial })
</li>
}
</ul>
</div>
<input
type="tel"
id={ fmt.Sprintf("%s-%s", f.Name, blockID) }
name={ f.Name }
value={ f.DefaultValue }
placeholder={ f.Placeholder }
if f.Required {
required
aria-required="true"
}
class="flex-1 px-3 py-2 border border-border rounded-lg bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent"
/>
</div>
default:
<input
type={ inputTypeFor(f.Type) }
id={ fmt.Sprintf("%s-%s", f.Name, blockID) }
name={ f.Name }
value={ f.DefaultValue }
placeholder={ f.Placeholder }
if f.Required {
required
aria-required="true"
}
class="w-full px-3 py-2 border border-border rounded-lg bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent"
/>
}
</div>
}
// CalcomConfirmation renders the success panel after a booking is created.
// Returned as the body of POST /book; also emits OOB clears that wipe the
// date grid + slots + form so the visitor is presented with only the
// confirmation (preventing accidental double-submits) and an OOB step-indicator
// update marking all three steps complete.
templ CalcomConfirmation(result BookingResult, blockID string) {
<div class="mt-6 text-center p-8 bg-success/10 border border-success/20 rounded-xl">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-success/15 mb-4">
<svg class="h-8 w-8 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
<h4 class="text-xl font-semibold text-foreground mb-2">Booking Confirmed!</h4>
<p class="text-muted-foreground mb-4">
Your appointment has been scheduled for { result.FormattedDateTime }.
</p>
<p class="text-sm text-muted-foreground">
A confirmation email has been sent to <span class="font-medium">{ result.Email }</span>.
</p>
if result.MeetingLink != "" {
<a
href={ templ.SafeURL(result.MeetingLink) }
target="_blank"
rel="noopener"
class="inline-flex items-center gap-2 mt-4 text-primary hover:underline"
>
<span>Join meeting</span>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
</svg>
</a>
}
// Machine-readable booking identity for the "manage your booking" feature.
// Hidden from view; the script below reads it and persists the booking to
// this visitor's localStorage so they can cancel / redo it on a later
// visit. templ auto-escapes every attribute value.
<div
data-calcom-booking
data-uid={ result.BookingID }
data-when={ result.FormattedDateTime }
data-email={ result.Email }
hidden
></div>
// Persist {uid, when, email, savedAt} under a STABLE key derived from the
// widget root's data-username + data-event-type (NOT the per-render
// blockId). HTMX evaluates this script when it swaps the confirmation in.
// Skips when uid is empty — the honeypot path renders a fake confirmation
// with no UID, which must never poison the saved-booking panel.
<script>
(function(){
var s = document.currentScript;
if (!s) return;
var holder = s.parentElement ? s.parentElement.querySelector("[data-calcom-booking]") : null;
var w = s.closest ? s.closest(".calcom-booking-widget") : null;
if (!holder || !w) return;
var uid = holder.getAttribute("data-uid") || "";
if (!uid) return; // honeypot / no real booking — do not persist
var key = "calcom:booking:" + (w.getAttribute("data-username") || "") + ":" + (w.getAttribute("data-event-type") || "");
try {
window.localStorage.setItem(key, JSON.stringify({
uid: uid,
when: holder.getAttribute("data-when") || "",
email: holder.getAttribute("data-email") || "",
savedAt: new Date().toISOString()
}));
} catch(e) {}
})();
</script>
</div>
// OOB: mark all three steps complete and clear the upstream regions so
// the visitor cannot re-submit the form (prevents the double-booking
// footgun the adversarial review caught).
@calcomStepIndicatorOOB(blockID, 4)
@calcomClearRegion(fmt.Sprintf("calcom-date-grid-%s", blockID), "calcom-date-picker")
@calcomClearRegion(fmt.Sprintf("calcom-slots-%s", blockID), "calcom-time-slots mt-6")
@calcomClearRegion(fmt.Sprintf("calcom-form-%s", blockID), "calcom-booking-form")
}
// calcomSavedBooking renders the normally-hidden "manage your booking" panel.
// The widget script un-hides it (and hides the date grid) on DOMContentLoaded
// when this visitor's localStorage holds a booking for this username/event,
// filling in the date/time + email from storage. It offers two actions:
//
// - Cancel: an HTMX form POSTing /cancel with a hidden `uid` the script
// populates from localStorage; on a successful swap the script clears the
// storage key (htmx:afterRequest listener in the widget script).
// - Reschedule (redo): cancels the existing booking then reveals the grid to
// rebook — wired in the widget script to trigger the same Cancel POST, so
// the visitor can't end up double-booked.
//
// Cancellation is authorized by the Cal.com booking UID alone (the same
// capability model as Cal.com's emailed cancel link); the server rate-limits
// the POST by IP as the abuse guard.
templ calcomSavedBooking(blockID string) {
<div
id={ fmt.Sprintf("calcom-saved-%s", blockID) }
data-calcom-saved
class="calcom-saved-booking hidden mt-2 p-4 sm:p-6 bg-muted/40 border border-border rounded-xl"
>
<div class="flex items-start gap-3">
<div class="inline-flex items-center justify-center w-10 h-10 shrink-0 rounded-full bg-primary/10 text-primary">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
</div>
<div class="min-w-0">
<h4 class="text-lg font-semibold text-foreground">You have a booking</h4>
<p class="text-sm text-muted-foreground mt-0.5">
<span data-calcom-saved-when class="font-medium text-foreground"></span>
</p>
<p class="text-xs text-muted-foreground mt-0.5">
Confirmation sent to <span data-calcom-saved-email class="font-medium"></span>
</p>
</div>
</div>
<div class="flex flex-wrap gap-3 mt-4">
// Cancel: HTMX form whose hidden uid the widget script fills from
// localStorage. Targets the confirm region; on success the script
// clears the localStorage key.
<form
hx-post="/api/plugins/calcomblock/cancel"
hx-target={ fmt.Sprintf("#calcom-confirm-%s", blockID) }
hx-swap="innerHTML"
hx-indicator={ fmt.Sprintf("#calcom-loading-%s", blockID) }
hx-disabled-elt="find button[type='submit']"
>
<input type="hidden" name="blockId" value={ blockID }/>
<input type="hidden" name="uid" value=""/>
<button
type="submit"
data-calcom-cancel-submit
data-action="cancel"
data-entity="booking"
class="inline-flex items-center gap-2 bg-destructive text-destructive-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-destructive/90 transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:opacity-60 disabled:cursor-wait"
>
Cancel booking
</button>
</form>
// Reschedule / redo: cancels the existing booking then reveals the
// grid to book again (handled in the widget script).
<button
type="button"
data-calcom-redo
data-action="reschedule"
data-entity="booking"
class="inline-flex items-center gap-2 border border-border text-foreground px-4 py-2 rounded-lg text-sm font-medium hover:bg-muted transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
Reschedule
</button>
</div>
</div>
}
// CalcomCancelled is the body returned by POST /cancel on success. It swaps a
// "your booking was cancelled" message into #calcom-confirm-{id} and rewinds
// the step indicator to step 1. The date grid is restored client-side: when
// cancel fires from the saved panel the grid is only CSS-hidden (never removed),
// so the widget script's htmx:afterRequest listener re-shows it (showGrid) —
// avoiding a server-side grid re-render that would need the booking config the
// cancel POST doesn't carry. That same listener also clears the saved booking
// from localStorage and hides the saved panel.
templ CalcomCancelled(blockID string) {
<div class="mt-6 text-center p-8 bg-muted/40 border border-border rounded-xl" role="status">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-muted mb-4">
<svg class="h-8 w-8 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</div>
<h4 class="text-xl font-semibold text-foreground mb-2">Booking cancelled</h4>
<p class="text-muted-foreground">
Your booking has been cancelled. You can book another time below whenever you're ready.
</p>
</div>
// OOB: rewind the step indicator to step 1 (the grid is re-shown client-side).
@calcomStepIndicatorOOB(blockID, 1)
}
// CalcomReset is the body returned by GET /reset — empty slots region with
// an OOB clear of the form (so the user pressing "Change date" loses any
// stale form rendered from an earlier slot click) and an OOB rewind of the
// step indicator to step 1.
templ CalcomReset(blockID string) {
// Main response body — empty, replaces #calcom-slots-{id}.
// OOB: clear the form region and rewind the step indicator.
@calcomClearRegion(fmt.Sprintf("calcom-form-%s", blockID), "calcom-booking-form")
@calcomStepIndicatorOOB(blockID, 1)
}
// CalcomError renders an error message.
templ CalcomError(message string, blockID string, canRetry bool) {
<div class="mt-6 p-4 bg-destructive/10 border border-destructive/20 rounded-lg" role="alert">
<p class="text-sm text-destructive">{ message }</p>
if canRetry {
<button
type="button"
class="mt-3 text-sm text-destructive hover:underline focus:outline-none focus:ring-2 focus:ring-ring rounded px-1"
hx-get={ fmt.Sprintf("/api/plugins/calcomblock/reset?blockId=%s", blockID) }
hx-target={ fmt.Sprintf("#calcom-widget-%s", blockID) }
hx-swap="outerHTML"
>
Try again
</button>
}
</div>
}