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>
923 lines
41 KiB
Plaintext
923 lines
41 KiB
Plaintext
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>
|
||
}
|