diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 58defaab..b3a39f8f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -713,6 +713,8 @@ as the desired state: ## 15. Frontend toolchain & conventions +Each account can set `booking_accent` in its profile. The default preserves the dark booking controls. Booking pages, management pages, and the embed widget use the event owner's color, with a contrasting text color chosen by luminance. The profile API accepts only six-digit hex colors. + - Svelte 5, SvelteKit 2 (adapter-static SPA), Vite 8 (Rolldown), Tailwind v4 (`@tailwindcss/vite`), shadcn-svelte (nova style) + bits-ui, tailwind-variants 3, **tailwind-merge v3** (must match Tailwind v4 — v2 mis-merges v4 classes; memory diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 89b63a16..9a884435 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,4 +1,5 @@ export type User = { + booking_accent: string; id: string; email: string; name: string; diff --git a/frontend/src/routes/settings/profile/+page.svelte b/frontend/src/routes/settings/profile/+page.svelte index 429cde91..e68da97d 100644 --- a/frontend/src/routes/settings/profile/+page.svelte +++ b/frontend/src/routes/settings/profile/+page.svelte @@ -23,6 +23,7 @@ let fileInput = $state(undefined); let name = $state(''); + let booking_accent = $state('#111827'); let timezone = $state('UTC'); let time_format = $state<'12h' | '24h'>('12h'); let week_start = $state(1); @@ -74,6 +75,7 @@ onMount(() => loadingFlag.run(async () => { user = await api.get('/v1/users/me'); name = user.name ?? ''; + booking_accent = user.booking_accent; timezone = user.timezone; time_format = user.time_format ?? '12h'; week_start = user.week_start ?? 1; @@ -122,7 +124,7 @@ async function save() { await savingFlag.run(async () => { const updated = await api.patch('/v1/users/me', { - name, timezone, time_format, week_start, date_format, + name, timezone, time_format, week_start, date_format, booking_accent, }); currentUser.set(updated); prefs.set(prefsFromUser(updated)); @@ -182,6 +184,14 @@ +
+ +
+ + {booking_accent} +
+

Used on your booking pages.

+
diff --git a/internal/db/migrations/00060_booking_accent.sql b/internal/db/migrations/00060_booking_accent.sql new file mode 100644 index 00000000..7f97e068 --- /dev/null +++ b/internal/db/migrations/00060_booking_accent.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE users ADD COLUMN booking_accent TEXT NOT NULL DEFAULT '#111827'; + +-- +goose Down +ALTER TABLE users DROP COLUMN booking_accent; diff --git a/internal/handler/book.go b/internal/handler/book.go index cb5ef2b1..717cc45c 100644 --- a/internal/handler/book.go +++ b/internal/handler/book.go @@ -44,15 +44,17 @@ type bookQuestion struct { } type bookPageData struct { - Slug string - Name string - Description template.HTML - DurationLabel string - HostName string - HostInitial string - AvatarURL string - Hosts []hostDisplay // faces for the info panel (1 = single, >1 = group stack) - HostsLabel string // "Alex, Sam & 2 others" for the group case + AccentColor string + AccentForeground string + Slug string + Name string + Description template.HTML + DurationLabel string + HostName string + HostInitial string + AvatarURL string + Hosts []hostDisplay // faces for the info panel (1 = single, >1 = group stack) + HostsLabel string // "Alex, Sam & 2 others" for the group case // SoleHostName is the host's name when this event type has exactly one, and "" when // it has several. It is what lets an empty day read "Alex has no available times on // …": a group label ("Alex, Sam & 2 others") in that sentence would need a plural @@ -319,16 +321,17 @@ func (h *Handler) PublicEventType(w http.ResponseWriter, r *http.Request) { hostName, avatarURL, routingMode, currency string durMins, maxDays, minNotice, priceCents int msgGreeting sql.NullString + accentColor string ) err := h.db.QueryRowContext(r.Context(), ` SELECT et.id, et.name, COALESCE(et.description, ''), et.duration_minutes, et.location_type, COALESCE(et.location_value, ''), et.max_future_days, et.min_notice_minutes, et.routing_mode, u.name, COALESCE(u.avatar_url, ''), - et.price_cents, et.currency, et.msg_greeting + et.price_cents, et.currency, et.msg_greeting, u.booking_accent FROM event_types et JOIN users u ON u.id = et.user_id WHERE et.slug = ? AND et.is_active = 1 AND et.is_public = 1`, - slug).Scan(&etID, &name, &description, &durMins, &locType, &locValue, &maxDays, &minNotice, &routingMode, &hostName, &avatarURL, &priceCents, ¤cy, &msgGreeting) + slug).Scan(&etID, &name, &description, &durMins, &locType, &locValue, &maxDays, &minNotice, &routingMode, &hostName, &avatarURL, &priceCents, ¤cy, &msgGreeting, &accentColor) if errors.Is(err, sql.ErrNoRows) { h.writeError(w, http.StatusNotFound, "event type not found") return @@ -387,10 +390,12 @@ func (h *Handler) PublicEventType(w http.ResponseWriter, r *http.Request) { // doesn't have to rebuild it from duration_minutes (it used to hardcode " min", // which both skipped translation and disagreed with the pages for >= 60 min). // duration_minutes stays for clients that want the raw number. - "duration_label": durationLabel(durMins, loc), - "location_type": locType, - "location_label": locationLabel(locType, locValue, loc), - "max_future_days": maxDays, + "duration_label": durationLabel(durMins, loc), + "location_type": locType, + "booking_accent": accentColor, + "booking_accent_foreground": accentForeground(accentColor), + "location_label": locationLabel(locType, locValue, loc), + "max_future_days": maxDays, // min_notice_label is the translated minimum-notice duration ("4 hours"), empty // when the event type sets none. The widget needs it here because the /slots call // it makes later carries no language of its own, and min_notice_minutes alone @@ -429,16 +434,17 @@ func (h *Handler) BookPage(w http.ResponseWriter, r *http.Request) { priceCents int currency string msgGreeting sql.NullString + accentColor string ) err := h.db.QueryRowContext(r.Context(), ` SELECT et.id, et.name, COALESCE(et.description, ''), et.duration_minutes, et.location_type, COALESCE(et.location_value, ''), et.max_future_days, et.min_notice_minutes, et.routing_mode, u.name, COALESCE(u.avatar_url, ''), - et.price_cents, et.currency, et.msg_greeting + et.price_cents, et.currency, et.msg_greeting, u.booking_accent FROM event_types et JOIN users u ON u.id = et.user_id WHERE et.slug = ? AND et.is_active = 1 AND et.is_public = 1`, - slug).Scan(&etID, &name, &description, &durMins, &locType, &locValue, &maxDays, &minNotice, &routingMode, &hostName, &avatarURL, &priceCents, ¤cy, &msgGreeting) + slug).Scan(&etID, &name, &description, &durMins, &locType, &locValue, &maxDays, &minNotice, &routingMode, &hostName, &avatarURL, &priceCents, ¤cy, &msgGreeting, &accentColor) if errors.Is(err, sql.ErrNoRows) { http.Error(w, "Page not found", http.StatusNotFound) @@ -500,6 +506,8 @@ func (h *Handler) BookPage(w http.ResponseWriter, r *http.Request) { i18nJSON, _ := loc.JSON() data := bookPageData{ + AccentColor: accentColor, + AccentForeground: accentForeground(accentColor), Slug: slug, Name: name, Description: renderMarkdown(description), diff --git a/internal/handler/booking_accent.go b/internal/handler/booking_accent.go new file mode 100644 index 00000000..ad939733 --- /dev/null +++ b/internal/handler/booking_accent.go @@ -0,0 +1,28 @@ +package handler + +import ( + "math" + "strconv" +) + +func accentForeground(color string) string { + if len(color) != 7 { + return "#ffffff" + } + value, err := strconv.ParseUint(color[1:], 16, 32) + if err != nil { + return "#ffffff" + } + linear := func(v uint64) float64 { + c := float64(v) / 255 + if c <= 0.04045 { + return c / 12.92 + } + return math.Pow((c+0.055)/1.055, 2.4) + } + luminance := 0.2126*linear(value>>16) + 0.7152*linear((value>>8)&255) + 0.0722*linear(value&255) + if luminance > 0.179 { + return "#000000" + } + return "#ffffff" +} diff --git a/internal/handler/booking_accent_internal_test.go b/internal/handler/booking_accent_internal_test.go new file mode 100644 index 00000000..9a417bed --- /dev/null +++ b/internal/handler/booking_accent_internal_test.go @@ -0,0 +1,13 @@ +package handler + +import "testing" + +func TestBookingAccentReadableForeground(t *testing.T) { + for _, tc := range []struct{ color, foreground string }{ + {"#111827", "#ffffff"}, {"#ffe500", "#000000"}, {"#ffffff", "#000000"}, {"#000000", "#ffffff"}, {"invalid", "#ffffff"}, + } { + if got := accentForeground(tc.color); got != tc.foreground { + t.Errorf("%s: got %s, want %s", tc.color, got, tc.foreground) + } + } +} diff --git a/internal/handler/booking_accent_test.go b/internal/handler/booking_accent_test.go new file mode 100644 index 00000000..1f212479 --- /dev/null +++ b/internal/handler/booking_accent_test.go @@ -0,0 +1,22 @@ +package handler_test + +import "testing" + +func TestProfileBookingAccent(t *testing.T) { + h, db, key, userID := setupWorkspaceWithDB(t) + for _, body := range []string{`{"booking_accent":"red"}`, `{"booking_accent":"#fff;background:red"}`} { + if rec := patchMe(t, h, body, key); rec.Code != 400 { + t.Fatalf("invalid accent accepted: %s", rec.Body) + } + } + if rec := patchMe(t, h, `{"booking_accent":"#FFE500"}`, key); rec.Code != 200 { + t.Fatal(rec.Body) + } + var accent string + if err := db.QueryRow(`SELECT booking_accent FROM users WHERE id = ?`, userID).Scan(&accent); err != nil { + t.Fatal(err) + } + if accent != "#ffe500" { + t.Fatalf("stored accent %q", accent) + } +} diff --git a/internal/handler/embed.js b/internal/handler/embed.js index f9f1c279..97b9fc49 100644 --- a/internal/handler/embed.js +++ b/internal/handler/embed.js @@ -221,6 +221,8 @@ api('/v1/event-types/' + encodeURIComponent(this.slug) + '/questions'), ]); this.info = r[0]; + this.style.setProperty('--booking-accent', this.info.booking_accent || '#111827'); + this.style.setProperty('--booking-accent-text', this.info.booking_accent_foreground || '#ffffff'); this.locale = this.info.locale || ''; this.i18n = this.info.i18n || {}; this.dow = dowLabels(this.locale); diff --git a/internal/handler/manage_handler.go b/internal/handler/manage_handler.go index 412df064..abd6b5b7 100644 --- a/internal/handler/manage_handler.go +++ b/internal/handler/manage_handler.go @@ -26,13 +26,15 @@ var manageTmpl = template.Must(template.Must(template.New("manage").Funcs(templa }).Parse(sharedPartialsSrc)).Parse(manageTmplSrc)) type managePageData struct { - Token string - BookingID string - EventTypeName string - EventTypeSlug string - HostName string - HostInitial string - AvatarURL string + AccentColor string + AccentForeground string + Token string + BookingID string + EventTypeName string + EventTypeSlug string + HostName string + HostInitial string + AvatarURL string // SoleHostName is the host's name when this booking has exactly one, else "" — see // bookPageData.SoleHostName. HostName can be a group label ("Alex, Sam & 2 others"), // which no "%s has no available times" sentence can use grammatically. @@ -95,13 +97,13 @@ func (h *Handler) ManagePage(w http.ResponseWriter, r *http.Request) { var etName, etSlug, locType, locValue string var durMins, maxDays, minNotice int - var hostName string + var hostName, accentColor string if err := h.db.QueryRowContext(r.Context(), ` SELECT et.name, et.slug, et.duration_minutes, et.max_future_days, et.min_notice_minutes, - et.location_type, COALESCE(et.location_value,''), u.name + et.location_type, COALESCE(et.location_value,''), u.name, u.booking_accent FROM event_types et JOIN users u ON u.id = et.user_id WHERE et.id = ?`, b.EventTypeID). - Scan(&etName, &etSlug, &durMins, &maxDays, &minNotice, &locType, &locValue, &hostName); err != nil { + Scan(&etName, &etSlug, &durMins, &maxDays, &minNotice, &locType, &locValue, &hostName, &accentColor); err != nil { h.logger.ErrorContext(r.Context(), "manage page: load event type", "error", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return @@ -133,22 +135,24 @@ func (h *Handler) ManagePage(w http.ResponseWriter, r *http.Request) { } data := managePageData{ - Token: token, - BookingID: b.ID, - EventTypeName: etName, - EventTypeSlug: etSlug, - HostName: hostName, - HostInitial: hostInitial, - AvatarURL: avatarURL, - SoleHostName: soleHost, - MinNoticeLabel: noticeLabel(minNotice, loc), - DurationLabel: durationLabel(durMins, loc), - LocationLabel: locationLabel(locType, locValue, loc), - MaxFutureDays: maxDays, - DurationMinutes: durMins, - CurrentStartISO: b.StartAt.UTC().Format(time.RFC3339), - OrganizerTZ: orgTZ, - Status: b.Status, + AccentColor: accentColor, + AccentForeground: accentForeground(accentColor), + Token: token, + BookingID: b.ID, + EventTypeName: etName, + EventTypeSlug: etSlug, + HostName: hostName, + HostInitial: hostInitial, + AvatarURL: avatarURL, + SoleHostName: soleHost, + MinNoticeLabel: noticeLabel(minNotice, loc), + DurationLabel: durationLabel(durMins, loc), + LocationLabel: locationLabel(locType, locValue, loc), + MaxFutureDays: maxDays, + DurationMinutes: durMins, + CurrentStartISO: b.StartAt.UTC().Format(time.RFC3339), + OrganizerTZ: orgTZ, + Status: b.Status, } h.renderManage(w, r, data, loc) } diff --git a/internal/handler/setup.go b/internal/handler/setup.go index ba99c9da..5ad79de7 100644 --- a/internal/handler/setup.go +++ b/internal/handler/setup.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "encoding/json" "net/http" + "regexp" "strings" "time" @@ -104,17 +105,23 @@ func (h *Handler) Setup(w http.ResponseWriter, r *http.Request) { // GetMe handles GET /v1/users/me. func (h *Handler) GetMe(w http.ResponseWriter, r *http.Request) { user, _ := userFromContext(r.Context()) + var accent string + if err := h.db.QueryRowContext(r.Context(), `SELECT booking_accent FROM users WHERE id = ?`, user.ID).Scan(&accent); err != nil { + h.writeError(w, http.StatusInternalServerError, "could not load profile") + return + } out := map[string]any{ - "id": user.ID, - "email": user.Email, - "name": user.Name, - "timezone": user.IANATZ, - "time_format": user.TimeFormat, - "week_start": user.WeekStart, - "date_format": user.DateFormat, - "is_admin": user.IsAdmin, - "is_owner": user.IsOwner, - "role": user.Role(), + "booking_accent": accent, + "id": user.ID, + "email": user.Email, + "name": user.Name, + "timezone": user.IANATZ, + "time_format": user.TimeFormat, + "week_start": user.WeekStart, + "date_format": user.DateFormat, + "is_admin": user.IsAdmin, + "is_owner": user.IsOwner, + "role": user.Role(), // Notification preferences "notify_confirmation": user.NotifyConfirmation, "notify_cancellation": user.NotifyCancellation, @@ -137,6 +144,7 @@ func (h *Handler) PatchMe(w http.ResponseWriter, r *http.Request) { var req struct { Name *string `json:"name"` + BookingAccent *string `json:"booking_accent"` Timezone *string `json:"timezone"` TimeFormat *string `json:"time_format"` WeekStart *int `json:"week_start"` @@ -154,6 +162,18 @@ func (h *Handler) PatchMe(w http.ResponseWriter, r *http.Request) { return } + var accent string + if err := h.db.QueryRowContext(r.Context(), `SELECT booking_accent FROM users WHERE id = ?`, user.ID).Scan(&accent); err != nil { + h.writeError(w, http.StatusInternalServerError, "could not load profile") + return + } + if req.BookingAccent != nil { + if !regexp.MustCompile(`^#[0-9a-fA-F]{6}$`).MatchString(*req.BookingAccent) { + h.writeError(w, http.StatusBadRequest, "booking_accent must be a six-digit hex color") + return + } + accent = strings.ToLower(*req.BookingAccent) + } current := struct { Name string Timezone string @@ -246,11 +266,11 @@ func (h *Handler) PatchMe(w http.ResponseWriter, r *http.Request) { if _, err := h.db.ExecContext(r.Context(), ` UPDATE users SET - name = ?, iana_timezone = ?, time_format = ?, week_start = ?, date_format = ?, + booking_accent = ?, name = ?, iana_timezone = ?, time_format = ?, week_start = ?, date_format = ?, notify_confirmation = ?, notify_cancellation = ?, notify_reschedule = ?, notify_reminder = ?, notify_host_booking = ?, notify_host_cancel = ?, notify_host_reschedule = ? WHERE id = ?`, - current.Name, current.Timezone, current.TimeFormat, current.WeekStart, current.DateFormat, + accent, current.Name, current.Timezone, current.TimeFormat, current.WeekStart, current.DateFormat, boolToInt(current.NotifyConfirmation), boolToInt(current.NotifyCancellation), boolToInt(current.NotifyReschedule), boolToInt(current.NotifyReminder), boolToInt(current.NotifyHostBooking), boolToInt(current.NotifyHostCancel), @@ -262,16 +282,17 @@ func (h *Handler) PatchMe(w http.ResponseWriter, r *http.Request) { } out := map[string]any{ - "id": user.ID, - "email": user.Email, - "name": current.Name, - "timezone": current.Timezone, - "time_format": current.TimeFormat, - "week_start": current.WeekStart, - "date_format": current.DateFormat, - "is_admin": user.IsAdmin, - "is_owner": user.IsOwner, - "role": user.Role(), + "booking_accent": accent, + "id": user.ID, + "email": user.Email, + "name": current.Name, + "timezone": current.Timezone, + "time_format": current.TimeFormat, + "week_start": current.WeekStart, + "date_format": current.DateFormat, + "is_admin": user.IsAdmin, + "is_owner": user.IsOwner, + "role": user.Role(), // Notification preferences "notify_confirmation": current.NotifyConfirmation, "notify_cancellation": current.NotifyCancellation, diff --git a/internal/handler/templates/book.html b/internal/handler/templates/book.html index 4145bedb..b481b327 100644 --- a/internal/handler/templates/book.html +++ b/internal/handler/templates/book.html @@ -78,7 +78,7 @@ } - + {{template "demoBanner" .}} {{/* GTM