Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export type User = {
booking_accent: string;
id: string;
email: string;
name: string;
Expand Down
12 changes: 11 additions & 1 deletion frontend/src/routes/settings/profile/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
let fileInput = $state<HTMLInputElement | undefined>(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);
Expand Down Expand Up @@ -74,6 +75,7 @@
onMount(() => loadingFlag.run(async () => {
user = await api.get<User>('/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;
Expand Down Expand Up @@ -122,7 +124,7 @@
async function save() {
await savingFlag.run(async () => {
const updated = await api.patch<User>('/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));
Expand Down Expand Up @@ -182,6 +184,14 @@
</div>
</div>

<div class="space-y-2">
<Label for="booking-accent">Booking accent color</Label>
<div class="flex items-center gap-3">
<input id="booking-accent" type="color" bind:value={booking_accent} class="h-10 w-14 cursor-pointer rounded border p-1" />
<span class="text-sm text-muted-foreground">{booking_accent}</span>
</div>
<p class="text-xs text-muted-foreground">Used on your booking pages.</p>
</div>
<div class="space-y-1.5">
<Label for="name">Name</Label>
<Input id="name" type="text" bind:value={name} placeholder="Your name" />
Expand Down
5 changes: 5 additions & 0 deletions internal/db/migrations/00060_booking_accent.sql
Original file line number Diff line number Diff line change
@@ -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;
42 changes: 25 additions & 17 deletions internal/handler/book.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, &currency, &msgGreeting)
slug).Scan(&etID, &name, &description, &durMins, &locType, &locValue, &maxDays, &minNotice, &routingMode, &hostName, &avatarURL, &priceCents, &currency, &msgGreeting, &accentColor)
if errors.Is(err, sql.ErrNoRows) {
h.writeError(w, http.StatusNotFound, "event type not found")
return
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, &currency, &msgGreeting)
slug).Scan(&etID, &name, &description, &durMins, &locType, &locValue, &maxDays, &minNotice, &routingMode, &hostName, &avatarURL, &priceCents, &currency, &msgGreeting, &accentColor)

if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "Page not found", http.StatusNotFound)
Expand Down Expand Up @@ -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),
Expand Down
28 changes: 28 additions & 0 deletions internal/handler/booking_accent.go
Original file line number Diff line number Diff line change
@@ -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"
}
13 changes: 13 additions & 0 deletions internal/handler/booking_accent_internal_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
22 changes: 22 additions & 0 deletions internal/handler/booking_accent_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 2 additions & 0 deletions internal/handler/embed.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
56 changes: 30 additions & 26 deletions internal/handler/manage_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
Loading
Loading