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>
231 lines
8.2 KiB
Go
231 lines
8.2 KiB
Go
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"
|
||
}
|
||
}
|