feat(tier2): event branding + UX polish — Block D
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 <img> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,15 @@ interface CalendarLinks {
|
||||
ics: string
|
||||
}
|
||||
|
||||
interface BrandingPayload {
|
||||
primary_color?: string | null
|
||||
accent_color?: string | null
|
||||
logo_url?: string | null
|
||||
cover_image_url?: string | null
|
||||
font_family?: string | null
|
||||
greeting_message?: string | null
|
||||
}
|
||||
|
||||
interface AccessResponse {
|
||||
guest: { id: string; name: string; email?: string | null; plus_ones: number }
|
||||
event: { id: string; name: string; venue: string; event_date: string }
|
||||
@@ -22,6 +31,7 @@ interface AccessResponse {
|
||||
access_log_id: string
|
||||
rsvp?: ExistingRSVP | null
|
||||
calendar?: CalendarLinks
|
||||
branding?: BrandingPayload | null
|
||||
}
|
||||
|
||||
interface RSVPSubmitResponse {
|
||||
@@ -143,6 +153,20 @@ function fmtDate(iso?: string) {
|
||||
// as /access loads — the guest can grab them before submitting.
|
||||
const calendar = computed<CalendarLinks | null>(() => access.value?.calendar ?? null)
|
||||
|
||||
// Branding (Tier 2 Block D). Null = use defaults. Each field is optional;
|
||||
// missing pieces fall back to the GuestGuard look.
|
||||
const branding = computed<BrandingPayload | null>(() => access.value?.branding ?? null)
|
||||
const brandingStyle = computed(() => {
|
||||
const b = branding.value
|
||||
if (!b) return {}
|
||||
const style: Record<string, string> = {}
|
||||
if (b.primary_color) style['--brand-primary'] = b.primary_color
|
||||
if (b.accent_color) style['--brand-accent'] = b.accent_color
|
||||
if (b.font_family) style.fontFamily = b.font_family
|
||||
return style
|
||||
})
|
||||
const greetingMessage = computed(() => branding.value?.greeting_message || '')
|
||||
|
||||
// showForm = no prior submission, or the guest has clicked "Change my response"
|
||||
const showForm = computed(() => editing.value || !existing.value)
|
||||
const submitLabel = computed(() => {
|
||||
@@ -152,7 +176,10 @@ const submitLabel = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-auto max-w-xl py-8">
|
||||
<!-- Branding (Tier 2 Block D) lives at the section root so every card
|
||||
below inherits the CSS vars + font. brandingStyle is empty when the
|
||||
host hasn't customised, so defaults apply naturally. -->
|
||||
<section class="mx-auto max-w-xl py-8" :style="brandingStyle">
|
||||
<div v-if="loading" class="text-sm text-zinc-500">Looking up your invitation…</div>
|
||||
|
||||
<div v-else-if="loadError" class="card border-red-900/60 bg-red-950/30">
|
||||
@@ -186,6 +213,7 @@ const submitLabel = computed(() => {
|
||||
<AddToCalendar
|
||||
v-if="calendar && result.rsvp.response === 'attending'"
|
||||
:links="calendar"
|
||||
:token="token"
|
||||
class="mt-5 border-t border-zinc-800 pt-4"
|
||||
/>
|
||||
|
||||
@@ -217,6 +245,7 @@ const submitLabel = computed(() => {
|
||||
<AddToCalendar
|
||||
v-if="calendar && existing.response === 'attending'"
|
||||
:links="calendar"
|
||||
:token="token"
|
||||
class="mb-4 border-t border-zinc-800 pt-4"
|
||||
/>
|
||||
|
||||
@@ -231,21 +260,48 @@ const submitLabel = computed(() => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="access && showForm" class="card">
|
||||
<p class="text-xs uppercase tracking-widest text-brand-500">
|
||||
{{ existing ? 'Update your response' : 'Invitation' }}
|
||||
</p>
|
||||
<h1 class="mb-1 text-2xl font-semibold">{{ access.event.name }}</h1>
|
||||
<p class="mb-6 text-sm text-zinc-400">
|
||||
{{ access.event.venue }} · {{ fmtDate(access.event.event_date) }}
|
||||
</p>
|
||||
<div v-else-if="access && showForm" class="card overflow-hidden p-0">
|
||||
<!-- Cover image — only renders when the host uploaded one. -->
|
||||
<div
|
||||
v-if="branding?.cover_image_url"
|
||||
class="h-32 w-full bg-cover bg-center"
|
||||
:style="{ backgroundImage: `url(${branding.cover_image_url})` }"
|
||||
></div>
|
||||
|
||||
<p class="mb-6 text-sm">
|
||||
Hi <span class="font-medium text-zinc-100">{{ access.guest.name }}</span> —
|
||||
<template v-if="existing">change your response below — {{ editsRemaining }}
|
||||
{{ editsRemaining === 1 ? 'edit' : 'edits' }} remaining.</template>
|
||||
<template v-else>please confirm your response below.</template>
|
||||
</p>
|
||||
<div class="p-6">
|
||||
<div class="mb-3 flex items-center gap-3">
|
||||
<img
|
||||
v-if="branding?.logo_url"
|
||||
:src="branding.logo_url"
|
||||
alt=""
|
||||
class="h-10 w-10 rounded object-contain bg-zinc-900"
|
||||
/>
|
||||
<div>
|
||||
<p
|
||||
class="text-xs uppercase tracking-widest"
|
||||
:style="branding?.primary_color ? { color: 'var(--brand-primary)' } : undefined"
|
||||
:class="branding?.primary_color ? '' : 'text-brand-500'"
|
||||
>
|
||||
{{ existing ? 'Update your response' : 'Invitation' }}
|
||||
</p>
|
||||
<h1 class="text-2xl font-semibold">{{ access.event.name }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mb-4 text-sm text-zinc-400">
|
||||
{{ access.event.venue }} · {{ fmtDate(access.event.event_date) }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="greetingMessage"
|
||||
class="mb-4 rounded-md border border-brand-900/40 bg-brand-500/[0.04] p-3 text-sm text-zinc-300"
|
||||
>{{ greetingMessage }}</p>
|
||||
|
||||
<p class="mb-6 text-sm">
|
||||
Hi <span class="font-medium text-zinc-100">{{ access.guest.name }}</span> —
|
||||
<template v-if="existing">change your response below — {{ editsRemaining }}
|
||||
{{ editsRemaining === 1 ? 'edit' : 'edits' }} remaining.</template>
|
||||
<template v-else>please confirm your response below.</template>
|
||||
</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="label">Response</label>
|
||||
@@ -294,7 +350,12 @@ const submitLabel = computed(() => {
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="btn-primary flex-1" :disabled="submitting" @click="submit">
|
||||
<button
|
||||
class="btn-primary flex-1"
|
||||
:style="branding?.primary_color ? { background: 'var(--brand-primary)' } : undefined"
|
||||
:disabled="submitting"
|
||||
@click="submit"
|
||||
>
|
||||
{{ submitLabel }}
|
||||
</button>
|
||||
<button v-if="existing" type="button" class="btn-ghost" :disabled="submitting" @click="cancelEditing">
|
||||
@@ -302,7 +363,8 @@ const submitLabel = computed(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="submitError" class="mt-3 text-sm text-red-400">{{ submitError }}</p>
|
||||
<p v-if="submitError" class="mt-3 text-sm text-red-400">{{ submitError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user