package main import ( "errors" "strings" ) // DefaultUnavailableMessage is the Tier-2 fallback shown when an online booking // can't be completed for a reason the visitor can't fix (our auth/config, a // Cal.com 5xx, a network timeout, or anything we don't recognise). It is the // default both for the admin-authored block field (booking.templ / the editor's // textarea default) AND for the handler's fallback when that field is left // blank, so the two never drift. Admins can override it per block. const DefaultUnavailableMessage = "Sorry — we couldn't complete your booking online. Please use our contact form and we'll get you booked in." // Tier-1 curated, user-actionable booking-error copy. Each names the actual // problem the visitor can act on. The raw Cal.com message is NEVER surfaced — // these strings are all the visitor ever sees. canRetry decides whether the // error renders with the "Try again" reset control (true only when picking a // different slot/time is the fix). const ( msgSlotUnavailable = "Sorry, that time isn't available anymore. Please choose another slot." msgEventFullyBooked = "This event is fully booked right now. Please try a different time." msgInvalidPhone = "That phone number doesn't look valid — please check it and try again." msgInvalidGuestEmail = "One or more guest email addresses are invalid — please check them." msgRejectedAnswer = "One of your answers wasn't accepted — please review the form and try again." ) // bookingErrorResult is the curated, visitor-facing outcome of classifying a // CreateBooking failure: the message to render and whether to offer the // retry/reset control. type bookingErrorResult struct { message string canRetry bool } // classifyBookingError maps a CreateBooking failure to curated visitor copy. // // Tier 1 (user-fixable) → a specific, actionable message, with canRetry=true // where re-picking a slot/time is the fix. Tier 2 (our-side / non-fixable: // auth/config 401-403, Cal.com 5xx, network/timeout, or anything unrecognised) // → the admin-authored adminMessage (already defaulted to // DefaultUnavailableMessage by the caller when blank). // // Cal.com's booking endpoint does NOT return a discriminating error.code — live // probing (2026-06-21) showed every 400 carrying a generic "BadRequestException" // or "BAD_REQUEST". The real signal is the message text, so classification is // driven by case-insensitive substring matching on Status + Message, with Code // only as a coarse gate. The raw Cal.com message never reaches the visitor. func classifyBookingError(err error, adminMessage string) bookingErrorResult { tier2 := bookingErrorResult{message: adminMessage, canRetry: false} var apiErr *CalcomAPIError if !errors.As(err, &apiErr) { // Network/transport error (DNS, timeout, connection reset) — no HTTP // response came back. Not the visitor's to fix. return tier2 } // 401/403/5xx are our side (bad/expired API key, Cal.com outage). Anything // outside the user-error band (HTTP 400 / 409 / 422) is Tier 2 too. switch { case apiErr.Status == 401 || apiErr.Status == 403: return tier2 case apiErr.Status >= 500: return tier2 case apiErr.Status != 400 && apiErr.Status != 409 && apiErr.Status != 422: return tier2 } msg := strings.ToLower(apiErr.Message) switch { // Slot gone: in the past, already taken, host no longer available. All // recoverable by choosing another slot → render WITH the reset control. case containsAny(msg, "in the past", "not available", "no longer available", "already has booking", "already booked", "slot", "no_available_users", "already_booked", "booking_seats_full_error", "host"): return bookingErrorResult{message: msgSlotUnavailable, canRetry: true} // Event-level capacity exhausted (distinct copy; still time-recoverable). case containsAny(msg, "booking_limit", "fully booked", "no more slots", "limit reached", "exceeded", "sold out"): return bookingErrorResult{message: msgEventFullyBooked, canRetry: true} // Bad attendee phone number (Cal.com: "{attendeePhoneNumber} invalid_number"). case containsAny(msg, "invalid_number", "phone"): return bookingErrorResult{message: msgInvalidPhone, canRetry: false} // Invalid guest email(s). Live probe: "responses - {guests}email_validation_error". case strings.Contains(msg, "guest"): return bookingErrorResult{message: msgInvalidGuestEmail, canRetry: false} // A rejected custom-field answer. "responses" / "bookingfields" signal a // per-field validation rejection; render generic-but-actionable copy. // (We do not echo Cal.com's field token — it isn't safe curated text.) case containsAny(msg, "responses", "bookingfields", "booking field", "required", "email_validation_error", "validation"): return bookingErrorResult{message: msgRejectedAnswer, canRetry: false} } // Recognised as a 400-class API error but no signal we can map to specific, // safe copy → admin-authored message (never Cal.com's raw text). return tier2 } // containsAny reports whether s contains any of the given (already // lower-cased) substrings. func containsAny(s string, subs ...string) bool { for _, sub := range subs { if strings.Contains(s, sub) { return true } } return false }