From 7ed50634b0e96739a07f5235e77403d232f43d5e Mon Sep 17 00:00:00 2001 From: KitKat31337 <4126578+KitKat31337@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:00:29 -0700 Subject: [PATCH 1/2] fix(directory): share configured business branding on public pages --- docs/ARCHITECTURE.md | 6 +- internal/handler/branding_settings.go | 4 +- internal/handler/directory.go | 4 + internal/handler/public_branding_test.go | 101 ++++++++++++++++++++++ internal/handler/shared_partials.go | 7 +- internal/handler/templates/_shared.html | 15 +++- internal/handler/templates/book.html | 6 +- internal/handler/templates/directory.html | 1 + internal/handler/templates/manage.html | 6 +- 9 files changed, 129 insertions(+), 21 deletions(-) create mode 100644 internal/handler/public_branding_test.go diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8baf33cc..11ed3558 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -736,7 +736,11 @@ as the desired state: - **Branding (`branding_settings.go`, migrations 00029/00050):** instance-wide `business_name` + `logo_url` + `banner_url` on the singleton row. Business name is the wordmark fallback (defaults to "Calnode") + public-page header; the logo is the email - header image + public-page header. `GET/PATCH /v1/settings/branding` (name + opacity + header image + public-page header. Booking, manage, person (`/u/{handle}`) and team + (`/team/{slug}`) directories share the `businessHeader` partial, using the configured + logo height (scaled by `pageLogoHeight`) and opacity, or the business name when no + logo is set. Directory avatars remain inside the person/team card. + `GET/PATCH /v1/settings/branding` (name + opacity settings only); the logo and banner are each an **upload** (`POST/DELETE /v1/settings/branding/logo` and `.../banner`, public serve at `GET /branding/logo` / `GET /branding/banner`) reusing the avatar pipeline: diff --git a/internal/handler/branding_settings.go b/internal/handler/branding_settings.go index 98e3f188..42017919 100644 --- a/internal/handler/branding_settings.go +++ b/internal/handler/branding_settings.go @@ -27,7 +27,7 @@ const logoServePath = "/branding/logo" const bannerServePath = "/branding/banner" // brandingSettings is the instance-wide brand identity used in emails and on the -// public booking/manage pages. +// public booking/manage/directory pages. type brandingSettings struct { BusinessName string LogoURL string // served path (relative), e.g. "/branding/logo?v=123"; empty = no logo @@ -69,7 +69,7 @@ func (h *Handler) loadBranding(ctx context.Context) brandingSettings { } // pageLogoHeight scales the email logo height up ~1.3× for the roomier public -// booking/manage page headers. +// booking/manage/directory page headers. func pageLogoHeight(emailPx int) int { if emailPx <= 0 { emailPx = 28 diff --git a/internal/handler/directory.go b/internal/handler/directory.go index cb76df94..ea11ea82 100644 --- a/internal/handler/directory.go +++ b/internal/handler/directory.go @@ -46,6 +46,8 @@ type directoryPageData struct { GA4MeasurementID string BusinessName string LogoURL string + LogoHeight int + LogoOpacity string PrivacyURL string TermsURL string DemoMode bool @@ -98,6 +100,8 @@ func (h *Handler) renderDirectory(w http.ResponseWriter, r *http.Request, data d data.GA4MeasurementID = track.GA4MeasurementID data.BusinessName = brand.BusinessName data.LogoURL = brand.LogoURL + data.LogoHeight = pageLogoHeight(brand.LogoHeight) + data.LogoOpacity = opacityCSS(brand.LogoOpacity) data.PrivacyURL = brand.PrivacyURL data.TermsURL = brand.TermsURL data.DemoMode = h.demoMode diff --git a/internal/handler/public_branding_test.go b/internal/handler/public_branding_test.go new file mode 100644 index 00000000..69a0c7d7 --- /dev/null +++ b/internal/handler/public_branding_test.go @@ -0,0 +1,101 @@ +package handler_test + +import ( + "html" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestPublicPages_businessBranding(t *testing.T) { + for _, tc := range []struct { + name, businessName, logoURL string + height, opacity int + wantHeight, wantOpacity string + }{ + {"logo", `Acme "Studio" & Co`, "/branding/logo?v=123", 40, 60, "52", "0.6"}, + {"logoWithoutName", "", "/branding/logo?v=123", 28, 100, "36", "1"}, + {"nameOnly", `Acme "Studio" & Co`, "", 28, 100, "", ""}, + {"neither", "", "", 28, 100, "", ""}, + {"normalizedSettings", "Acme", "/branding/logo?v=123", 0, 200, "36", "1"}, + } { + t.Run(tc.name, func(t *testing.T) { + h, database, key, userID := setupWorkspaceWithDB(t) + if _, err := database.Exec(`UPDATE server_settings SET business_name = ?, logo_url = ?, logo_height = ?, logo_opacity = ? WHERE id = 1`, + tc.businessName, tc.logoURL, tc.height, tc.opacity); err != nil { + t.Fatal(err) + } + if _, err := database.Exec(`UPDATE users SET handle = 'host', avatar_url = '/avatars/host.png' WHERE id = ?`, userID); err != nil { + t.Fatal(err) + } + if _, err := database.Exec(`INSERT INTO teams (id, name, slug) VALUES ('brand-team', 'Design', 'design')`); err != nil { + t.Fatal(err) + } + slug, etID := seedEventTypeHTTP(t, h, key) + seedBooking(t, database, "brand-booking", etID, userID, "2026-06-20T09:00:00Z", "2026-06-20T09:30:00Z", "confirmed") + token := issueTestToken(t, database, "brand-booking") + for _, page := range []struct { + name, path, pathKey, pathValue string + serve http.HandlerFunc + }{ + {"person", "/u/host", "handle", "host", h.PersonPage}, + {"team", "/team/design", "slug", "design", h.TeamPage}, + {"booking", "/book/" + slug, "slug", slug, h.BookPage}, + {"manage", "/manage/" + token, "token", token, h.ManagePage}, + } { + t.Run(page.name, func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, page.path, nil) + req.SetPathValue(page.pathKey, page.pathValue) + page.serve(rec, req) + body := rec.Body.String() + if rec.Code != http.StatusOK || !strings.Contains(body, "") { + t.Fatalf("incomplete page: status %d, body %.200s", rec.Code, body) + } + start := strings.Index(body, "
") + if end == -1 { + t.Fatal("incomplete business header") + } + header := body[start : start+end] + if tc.logoURL != "" { + alt := tc.businessName + if alt == "" { + alt = "Business logo" + } + for _, want := range []string{ + `src="` + tc.logoURL + `"`, `alt="` + html.EscapeString(alt) + `"`, + "height:" + tc.wantHeight + "px;width:auto;max-width:100%;opacity:" + tc.wantOpacity, + "max-width:100%;box-sizing:border-box;", + } { + if !strings.Contains(header, want) { + t.Errorf("header missing %q: %s", want, header) + } + } + } else if strings.Contains(header, ""+html.EscapeString(tc.businessName)+"") { + t.Errorf("incorrect name-only header: %s", header) + } + } + if page.name == "person" && !strings.Contains(body, ``) { + t.Error("person avatar missing from directory card") + } + if page.name == "team" && !strings.Contains(body, ``) { + t.Error("team avatar missing from directory card") + } + if (page.name == "person" || page.name == "team") && start > strings.Index(body, `
`) { + t.Error("business header should precede the directory card") + } + }) + } + }) + } +} diff --git a/internal/handler/shared_partials.go b/internal/handler/shared_partials.go index fbb7ef0d..38dd9c32 100644 --- a/internal/handler/shared_partials.go +++ b/internal/handler/shared_partials.go @@ -2,11 +2,10 @@ package handler import _ "embed" -// sharedPartialsSrc holds the template partials shared by the book and manage public -// pages (see templates/_shared.html): the consent/tracking/footer chrome +// sharedPartialsSrc holds the template partials shared by the book, manage and directory +// public pages (see templates/_shared.html): the business header and consent/tracking/footer chrome // (trackingHead, consentBanner, legalFooter) plus structural parts (calendarGrid). -// It is parsed into BOTH the book and manage template sets so these pieces have a -// single source and the two surfaces can't drift apart. +// It is parsed into all three template sets so these pieces have a single source. // //go:embed templates/_shared.html var sharedPartialsSrc string diff --git a/internal/handler/templates/_shared.html b/internal/handler/templates/_shared.html index a415e4f1..c4855008 100644 --- a/internal/handler/templates/_shared.html +++ b/internal/handler/templates/_shared.html @@ -1,8 +1,7 @@ {{/* - Partials SHARED by the book and manage public pages — parsed into BOTH template - sets (see book.go / manage_handler.go) so these pieces live in ONE place and the - two surfaces can't drift apart: - - chrome: trackingHead, consentBanner, legalFooter (consent/analytics/footer) + Partials SHARED by the book, manage and directory public pages — parsed into all + three template sets so these pieces live in ONE place: + - chrome: businessHeader, trackingHead, consentBanner, legalFooter - structural: calendarGrid (month nav + the JS-filled day grid) The embed widget (embed.js) is deliberately NOT included: it's a vanilla-JS web @@ -14,6 +13,14 @@ booking-logic.js at runtime). */}} +{{define "businessHeader"}} +{{if or .LogoURL .BusinessName}} +
+ {{if .LogoURL}}{{if .BusinessName}}{{.BusinessName}}{{else}}Business logo{{end}}{{else}}{{.BusinessName}}{{end}} +
+{{end}} +{{end}} + {{define "trackingHead"}}{{if or .GTMContainerID .GA4MeasurementID}}