calcomblock/block.go
Alex Dunmow 324a2e6fe0 chore: migrate from block/core to block/pluginsdk (keep core for captcha)
Rewrite imports for plugin, blocks, templates, auth, settings, crypto, and
rbac packages from git.dev.alexdunmow.com/block/core/* to
git.dev.alexdunmow.com/block/pluginsdk/* across all Go files. The captcha
package stays on block/core, so the repo keeps both requires.

go.mod: add pluginsdk v0.1.0; bump core v0.20.2 -> v0.20.3 (satisfies the
check-safety core-version gate); transitive churn pgx v5.9.2 -> v5.10.0 and
x/mod v0.34.0 -> v0.37.0 from go mod tidy.

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

231 lines
8.2 KiB
Go
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 (
"bytes"
"context"
"time"
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
"git.dev.alexdunmow.com/block/pluginsdk/settings"
)
// renderSettings is the SettingsManager available to CalcomBookingFunc, which
// renders during the public page pipeline where no HTTP request (and thus no
// visitor timezone) exists. Set once by NewCalcomRouter when the plugin's HTTP
// routes are mounted at startup — before any page render. Nil in editor-only
// or test contexts, where the grid falls back to the server clock.
var renderSettings *SettingsManager
// BookingConfig holds the configuration for rendering the booking widget
type BookingConfig struct {
BlockID string
Username string
EventTypeSlug string
Title string
Description string
ShowTimezone bool
WeeksToShow int
WeekStart string // YYYY-MM-DD — first date in the current grid window
Today string // YYYY-MM-DD — today in the resolved timezone, for past-clamping
TimeZone string // IANA name shown in the "All times shown in" label
RangeLabel string // e.g. "May 27 Jun 10, 2026" — populated by handler
CanGoBack bool // false when the window starts today (no point going earlier)
Dates []DateCell
// UnavailableMessage is the admin-authored copy shown when an online booking
// can't be completed for a non-fixable reason (Tier-2). Emitted as a hidden
// form input so the POST /book handler can read it back; defaults to
// DefaultUnavailableMessage when the admin leaves it blank.
UnavailableMessage string
}
// DateCell represents a single date in the calendar grid
type DateCell struct {
Date string // YYYY-MM-DD format
DayOfMonth string // e.g., "15"
DayOfWeek string // e.g., "Mon"
IsToday bool
IsPast bool
IsSelected bool // server-rendered selected state — set by HandleGetSlots
}
// CalcomBookingFunc renders the Cal.com booking widget
func CalcomBookingFunc(ctx context.Context, content map[string]any) string {
// Extract block ID from context or content
blockID := settings.GetStringOr(content, "_block_id", "")
if blockID == "" {
// Generate a simple ID if not provided
blockID = "calcom-" + time.Now().Format("150405")
}
config := BookingConfig{
BlockID: blockID,
Username: settings.GetStringOr(content, "username", ""),
EventTypeSlug: settings.GetStringOr(content, "eventTypeSlug", ""),
Title: settings.GetStringOr(content, "title", ""),
Description: settings.GetStringOr(content, "description", ""),
ShowTimezone: settings.GetBoolOr(content, "showTimezone", true),
WeeksToShow: settings.GetIntOr(content, "weeksToShow", 2),
UnavailableMessage: settings.GetStringOr(content, "unavailableMessage", DefaultUnavailableMessage),
}
// Check for missing required configuration
if config.Username == "" || config.EventTypeSlug == "" {
if blocks.IsEditor(ctx) {
return renderEditorPlaceholder()
}
return renderConfigError()
}
// Generate date grid for the calendar — initial window starts today on
// the MEETING's calendar: the cached event timezone when warm (filled by
// the first HTMX hit against the plugin endpoints), else the site
// Localization setting. Page render never blocks on a Cal.com round-trip.
// Without this, a container running UTC starts the grid on yesterday's
// date for a Perth schedule until 08:00 AWST.
loc, tzName := time.UTC, "UTC"
if renderSettings != nil {
loc, tzName = renderSettings.resolveLocation(ctx, peekEventTZ(config.Username, config.EventTypeSlug))
}
now := time.Now().In(loc)
today := now.Format("2006-01-02")
config.WeekStart = today
config.Today = today
config.TimeZone = tzName
config.Dates = generateDateGridFrom(now, config.WeeksToShow, today)
config.RangeLabel = formatRangeLabel(now, config.WeeksToShow)
config.CanGoBack = false // initial window is already at today
// Render the booking widget
var buf bytes.Buffer
if err := CalcomBookingWidget(config).Render(ctx, &buf); err != nil {
return renderError("Failed to render booking widget: " + err.Error())
}
return buf.String()
}
// formatRangeLabel renders the human-readable label for the date grid header,
// e.g. "May 27 Jun 10, 2026". If start and end fall in the same month, the
// label collapses to "May 27 31, 2026".
func formatRangeLabel(start time.Time, weeks int) string {
if weeks <= 0 {
weeks = 1
}
end := start.AddDate(0, 0, weeks*7-1)
if start.Year() == end.Year() && start.Month() == end.Month() {
return start.Format("Jan 2") + " " + end.Format("2, 2006")
}
if start.Year() == end.Year() {
return start.Format("Jan 2") + " " + end.Format("Jan 2, 2006")
}
return start.Format("Jan 2, 2006") + " " + end.Format("Jan 2, 2006")
}
// prevWeekStart returns the YYYY-MM-DD string for the previous date-grid
// window — weeks*7 days before the current weekStart, but clamped to today
// (the visitor can't book in the past). Used by the date-grid prev button.
// today is the YYYY-MM-DD date in the resolved timezone (BookingConfig.Today)
// — not the server clock's, which can be a day behind the visitor's.
func prevWeekStart(currentStart string, weeks int, today string) string {
if weeks <= 0 {
weeks = 1
}
t, err := time.Parse("2006-01-02", currentStart)
if err != nil || today > currentStart {
return today
}
prev := t.AddDate(0, 0, -weeks*7)
if prev.Format("2006-01-02") < today {
return today
}
return prev.Format("2006-01-02")
}
// nextWeekStart returns the YYYY-MM-DD string for the next date-grid window —
// weeks*7 days after the current weekStart.
func nextWeekStart(currentStart string, weeks int) string {
if weeks <= 0 {
weeks = 1
}
t, err := time.Parse("2006-01-02", currentStart)
if err != nil {
t = time.Now()
}
return t.AddDate(0, 0, weeks*7).Format("2006-01-02")
}
// generateDateGridFrom produces `weeks*7` consecutive day cells starting at
// `start`. Days strictly before `today` (YYYY-MM-DD in the resolved timezone)
// are flagged IsPast so the templ can render them disabled.
func generateDateGridFrom(start time.Time, weeks int, today string) []DateCell {
if weeks <= 0 {
weeks = 1
}
dates := make([]DateCell, 0, weeks*7)
for i := 0; i < weeks*7; i++ {
date := start.AddDate(0, 0, i)
dateStr := date.Format("2006-01-02")
dates = append(dates, DateCell{
Date: dateStr,
DayOfMonth: date.Format("2"),
DayOfWeek: date.Format("Mon"),
IsToday: dateStr == today,
IsPast: dateStr < today,
})
}
return dates
}
// leadingBlanks returns how many empty spacer cells must precede the first
// date so it lands under its weekday column header in the 7-column grid
// (Sun = column 1). Without these, a window starting mid-week pours into
// column 1 and every date renders under the wrong weekday — the bidbuddy
// "tapped Tue, got Friday" incident.
func leadingBlanks(dates []DateCell) int {
if len(dates) == 0 {
return 0
}
t, err := time.Parse("2006-01-02", dates[0].Date)
if err != nil {
return 0
}
return int(t.Weekday())
}
func renderEditorPlaceholder() string {
return `<div class="p-8 border-2 border-dashed border-border rounded-lg text-center">
<svg class="h-12 w-12 mx-auto mb-4 text-muted-foreground opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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"/>
</svg>
<p class="text-lg font-medium text-foreground">Cal.com Booking</p>
<p class="text-sm text-muted-foreground">Select an event type in the editor</p>
</div>`
}
func renderConfigError() string {
return `<div class="p-6 bg-destructive/10 border border-destructive/20 rounded-lg text-center">
<p class="text-sm text-destructive">Cal.com booking block is not configured properly.</p>
</div>`
}
func renderError(msg string) string {
return `<div class="p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
<p class="text-sm text-destructive">` + msg + `</p>
</div>`
}
// inputTypeFor maps a Cal.com bookingField type to an HTML <input type> value.
// Unknown types fall through to `text`.
func inputTypeFor(t string) string {
switch t {
case "email":
return "email"
case "phone":
return "tel"
case "number":
return "number"
default:
return "text"
}
}