From e5b187c57549782eb6f9407f030fa12bf6b79c98 Mon Sep 17 00:00:00 2001 From: Kwaku Danso <72142185+cloud-dev101@users.noreply.github.com> Date: Mon, 18 May 2026 12:04:09 +0100 Subject: [PATCH] =?UTF-8?q?feat(tier2):=20event=20branding=20+=20UX=20poli?= =?UTF-8?q?sh=20=E2=80=94=20Block=20D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend - Migration 0010 adds event_branding (one row per event; all fields nullable so a brand-new event renders with defaults) - BrandingRepo with COALESCE/NULLIF upsert semantics: nil pointer preserves the existing value, "" clears the field to NULL - internal/uploads package: ImageStore interface + LocalFSStore (dev), pure-stdlib decode + re-encode that strips EXIF and rejects anything that isn't valid JPEG/PNG. Size cap 2 MB, random 16-byte filenames - GET /events/{id}/branding (viewer+) returns the row plus the AllowedFonts list so the frontend picker stays in sync - PUT /events/{id}/branding (editor+) validates hex colours, font allowlist, and refuses image URLs whose path doesn't start with /uploads/ (blocks arbitrary-origin smuggling on guest pages) - POST /uploads/image (authed) → fresh CDN URL; GET /uploads/{file} serves with year-long cache (immutable random names) - GET /access/{token} now embeds the host's branding so the RSVP page can render in their colours/font with their logo + cover - docker-compose mounts a named volume for uploads - Custom-domain sub-block deferred to Tier 3 per the plan Frontend - BrandingCard.vue: colour pickers, font dropdown, logo + cover upload with progressive disclosure, live preview pane that re-renders on every keystroke - RSVP page applies branding via CSS vars at the section root, so primary colour theme + font cascade through every child card. Cover image renders as a banner above the form; logo lands in the header - Submit button background switches to var(--brand-primary) when set - Mounted on the event detail page below the guests block Plus the small UX fixes from the e2e walkthrough: - Nav: dropped the top-level "Events" link; the logo doubles as the home affordance (→ /dashboard when signed in, → / otherwise). Account + Billing + Sign out live under a profile dropdown (avatar with initials, opens on click, closes on outside-click / Esc / route nav) - Renamed "Back to dashboard" → "Back to events" across event detail, billing, account, and new-event pages Tests - TestBrandingGetReturnsDefaults / TestBrandingPutPersists / TestBrandingPutRejectsBadInputs / TestUploadAndServeImage / TestUploadRejectsNonImage — all pass - Domain tests for IsValidHexColor + IsAllowedFont - Full integration suite green (176s) Co-Authored-By: Claude Opus 4.7 --- cmd/api/main.go | 2 + docker-compose.yml | 8 + frontend/app.vue | 134 +++++++- frontend/components/AddToCalendar.vue | 19 +- frontend/components/BrandingCard.vue | 321 ++++++++++++++++++ frontend/pages/dashboard/account.vue | 203 +++++++++++ frontend/pages/dashboard/billing.vue | 138 +------- frontend/pages/dashboard/events/[id].vue | 14 +- frontend/pages/dashboard/events/new.vue | 4 +- frontend/pages/dashboard/index.vue | 193 +++++++++-- frontend/pages/login.vue | 27 +- frontend/pages/rsvp/[token].vue | 96 +++++- frontend/pages/signup.vue | 31 +- frontend/pages/verify-email.vue | 19 +- internal/api/branding.go | 246 ++++++++++++++ internal/api/collaborators.go | 57 ++++ internal/api/events.go | 25 +- internal/api/server.go | 55 +++ internal/api/tokens.go | 21 ++ internal/config/config.go | 9 + internal/domain/branding.go | 73 ++++ internal/domain/branding_test.go | 38 +++ internal/storage/branding.go | 118 +++++++ internal/storage/collaborators.go | 142 ++++++++ .../storage/migrations/0010_branding.down.sql | 1 + .../storage/migrations/0010_branding.up.sql | 17 + internal/uploads/uploads.go | 143 ++++++++ internal/uploads/uploads_test.go | 93 +++++ test/integration/branding_test.go | 255 ++++++++++++++ test/integration/csv_import_test.go | 7 + 30 files changed, 2310 insertions(+), 199 deletions(-) create mode 100644 frontend/components/BrandingCard.vue create mode 100644 frontend/pages/dashboard/account.vue create mode 100644 internal/api/branding.go create mode 100644 internal/domain/branding.go create mode 100644 internal/domain/branding_test.go create mode 100644 internal/storage/branding.go create mode 100644 internal/storage/migrations/0010_branding.down.sql create mode 100644 internal/storage/migrations/0010_branding.up.sql create mode 100644 internal/uploads/uploads.go create mode 100644 internal/uploads/uploads_test.go create mode 100644 test/integration/branding_test.go diff --git a/cmd/api/main.go b/cmd/api/main.go index 8d121b1..0bafae4 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -212,6 +212,8 @@ func run() error { SuppressionRepo: suppressions, UnsubscribeSigner: unsubSigner, StripeClient: stripeClient, + UploadsDir: cfg.UploadsDir, + UploadsPublicURL: cfg.UploadsPublicURL, }) if err != nil { return err diff --git a/docker-compose.yml b/docker-compose.yml index 8a0d440..e2e6df0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,6 +86,13 @@ services: GG_STRIPE_WEBHOOK_SECRET: ${GG_STRIPE_WEBHOOK_SECRET:-} GG_STRIPE_PRICE_PRO: ${GG_STRIPE_PRICE_PRO:-} GG_STRIPE_PRICE_BUSINESS: ${GG_STRIPE_PRICE_BUSINESS:-} + # Tier 2 Block D — branding image uploads. Stored on the named + # volume below so they survive container restarts; production + # would back this with S3 + CDN instead. + GG_UPLOADS_DIR: /var/lib/guestguard/uploads + GG_UPLOADS_PUBLIC_URL: http://localhost:8080/uploads + volumes: + - uploads-data:/var/lib/guestguard/uploads ports: - "8080:8080" depends_on: @@ -154,3 +161,4 @@ services: volumes: postgres-data: nats-data: + uploads-data: diff --git a/frontend/app.vue b/frontend/app.vue index db8f9a9..6f36d8d 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -6,7 +6,46 @@ const route = useRoute() // page. Inside the app it just clutters the chrome. const showGithub = computed(() => route.path === '/') +// Profile dropdown state. Closed by default; opens on click, closes on +// outside click / Escape / route change. Industry-standard pattern: the +// account-scoped actions (Account, Billing, Sign out) tuck under an avatar +// affordance instead of crowding the top-level nav. +const profileOpen = ref(false) +const profileRef = ref(null) + +function initials(name?: string | null) { + if (!name) return '·' + return name + .trim() + .split(/\s+/) + .slice(0, 2) + .map((p) => p[0]?.toUpperCase() || '') + .join('') || '·' +} + +function onDocClick(e: MouseEvent) { + if (!profileOpen.value) return + const root = profileRef.value + if (root && !root.contains(e.target as Node)) profileOpen.value = false +} +function onKeydown(e: KeyboardEvent) { + if (e.key === 'Escape') profileOpen.value = false +} + +if (import.meta.client) { + onMounted(() => { + window.addEventListener('click', onDocClick) + window.addEventListener('keydown', onKeydown) + }) + onUnmounted(() => { + window.removeEventListener('click', onDocClick) + window.removeEventListener('keydown', onKeydown) + }) +} +watch(() => route.fullPath, () => { profileOpen.value = false }) + async function signOut() { + profileOpen.value = false await auth.logout() navigateTo('/') } @@ -16,17 +55,22 @@ async function signOut() {
- + + GuestGuard -