package main import ( "bytes" "context" "time" "git.dev.alexdunmow.com/block/core/blocks" "git.dev.alexdunmow.com/block/core/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 `
Cal.com Booking
Select an event type in the editor
Cal.com booking block is not configured properly.
` + msg + `