Acceptance Spec — Local Business: Photos & Branding Editors
- Status: SHIPPED 2026-06-09 (founder-requested; retroactive spec — see status note above).
- Owner: ChurchWiseAI (CWA) / WiseAI Agency vertical.
- Surface: the WiseAI Agency customer dashboard
wiseaiagency.com/business/[token]→ Setup tab (the/business/[token]route), plus the public service-business site/s/[slug](ServiceBusinessTemplate →HomeServicesDemo). - Feature Registry id:
wiseaiagency-local-business-media - Plan gating: none beyond the dashboard itself. The Setup tab is always shown for an active business. Editors render read-only for
paused/cancelledbusinesses (no upload/remove controls).
Why
Church Pro Websites can upload a logo, hero slideshow, ministry photos, staff
photos, and per-page images. The service-business (WiseAI Agency) Pro Websites —
a separate stack — shipped with no image upload anywhere: every image was a
trade/template default. A plumber, vet, or dentist could not show their own
logo, their real work, their team, or their services. The public template
(HomeServicesDemo) already rendered all four image types from the data
model; the gap was purely the dashboard editors + persistence. This spec covers
closing that gap.
Founder request, 2026-06-08: "we have our service business pro websites as well. can we add this feature/fix to those as well?" → all four surfaces (logo, hero photos, staff photos, service photos).
Data model — where each image persists (no new columns)
Four surfaces, two stores. All reads happen in HomeServicesDemo; all writes go
through one shared upload route plus the per-store save route.
| Surface | Stored at | Saved via | Rendered by (HomeServicesDemo) |
|---|---|---|---|
| Logo | local_businesses.metadata.logo_url (string) | PATCH /api/local-business/branding | header logo (premium.logo_url) |
| Hero photos | local_businesses.metadata.custom_slideshow_urls (string[], ≤ 8) | PATCH /api/local-business/branding | hero Ken-Burns slideshow (premium.custom_slideshow_urls, overrides the trade default) |
| Service photos | local_businesses.metadata.service_groups[].image_url | PATCH /api/local-business/branding (full service_groups array) | service-category card banner (group.image_url) |
| Staff photos | local_business_setup_profiles.staff_contacts[].photo_url | PATCH /api/local-business/setup-profile (full staff_contacts array) | "Meet the team" card (staff[].photo_url) |
metadata writes use the atomic lb_merge_metadata(p_id, p_patch) Postgres
function (metadata || p_patch), NOT a JS read-modify-write — two partial
patches in quick succession (e.g. logo then hero) must not clobber each other's
keys over pooled connections. Migration: 2026-06-08-lb-merge-metadata-fn.sql.
Upload primitive — POST /api/upload/local-business-image
The single shared uploader for all four surfaces.
- Auth: per-business
Authorization: Bearer <token>(local_businesses.admin_token, or the founder override) viarequireLocalBusinessApiAuth. No CSRF check — auth is a bearer secret in a header, not an ambient cookie, so it is not forgeable cross-site. Every upload is scoped to the caller'sbusiness_id(scopeBusinessId); a business can only write under its own storage prefix. - Input: one
fileor manyfiles;kind∈{logo, gallery, staff, service}namespaces the path. 1–8 files, ≤ 15 MB each,image/jpeg|png|webponly. - Storage:
wiseaiagency-mediabucket, pathbusiness-photos/{business_id}/{kind}/{uuid}.{ext}(uuid filename so re-uploads never collide). - Output:
{ url, urls }only. Does NOT mutate the business — the caller persists the returned URL(s) via/branding(logo/gallery/service) or/setup-profile(staff).
Persistence routes
/api/local-business/branding(GET+PATCH) — reads/writeslogo_url,custom_slideshow_urls(≤ 8), andservice_groups(≤ 24, each with a per-groupimage_url). Per-business Bearer auth +business_idscoping. Atomic merge; preserves unrelated metadata keys (category,showcase,sample_call_audio_url, …). Validation: every URL must behttp(s)and ≤ 1000 chars; each service group must have a name; image_url ishttp(s)or null./api/local-business/setup-profile(PATCH, existing) — the staff-photo path sends the fullstaff_contactsarray with updatedphoto_urlvalues.
Admin editor (Setup tab, BusinessDashboard)
Three sections render in the Setup tab above the existing setup-wizard display, each read-only for paused/cancelled businesses:
BrandingPhotosSection(lb-branding-section) — self-contained; GETs/brandingon mount.- Logo: preview (
lb-logo-preview) + upload/replace (lb-logo-upload,lb-logo-file) + remove. Single image →logo_url. - Hero photos: multi-upload (
lb-gallery-upload,lb-gallery-file), grid (lb-gallery-grid) with per-photo reorder + remove, ≤ 8 →custom_slideshow_urls.
- Logo: preview (
StaffPhotosSection(lb-staff-section) — props-driven; receivesstaff_contacts+ anonSavethat PATCHes setup-profile. Lists EXISTING staff only (one row each,lb-staff-row); per-row headshot upload (lb-staff-upload-{i}, sharedlb-staff-file, previewlb-staff-photo-{i}) + remove. Photo-only — names/roles come from the setup wizard, not here. Empty statelb-staff-empty.ServicePhotosSection(lb-service-section) — self-contained; GETs/brandingservice_groups. Lists EXISTING service categories (lb-service-row); per-row card-image upload (lb-service-upload-{i}, sharedlb-service-file, previewlb-service-photo-{i}) + remove. Photo-only. Empty statelb-service-empty.
All three update their thumbnails optimistically (UI shows the image before
the persistence write returns) and revert on save failure. Errors surface inline
(lb-*-error).
Public display (HomeServicesDemo, no change required)
The template already consumes every field: logo in the sticky header; hero photos
as the standalone Ken-Burns slideshow (a business's own photos win over the
vertical-default stock slideshow); each service-group card shows image_url as a
banner (icon fallback when null); each team card shows photo_url (icon fallback).
Graceful degradation (regression guards)
- A business with no uploaded media renders exactly as before — trade-default hero slideshow, no logo, icon-only team/service cards.
- Empty
service_groups(the demo falls back to the flatservicesgrid) → the Service-photos editor shows its empty state and the public site is unchanged. - No staff → Team-photos editor shows its empty state.
- Atomic metadata merge guarantees uploading one surface never wipes another.
Verification (evidence-or-nothing)
tsc+pnpm buildgreen (both PRs); all CI gates green.- Playwright on the deployed preview against the
demo-lb-playgroundbusiness (id…00aa, slugwiseai-demo-home-services-playground-demo, seeded with 2 staff + 2 service groups): upload logo + 2 hero photos + 1 staff headshot + 1 service photo, assert each upload returns 200, the editor shows the thumbnail, the value persists (logo/gallery/service tometadata, staff to the setup profile), the public/s/[slug]renders the uploaded images, and an unauthenticated branding write is rejected (401). DB rows confirmed. - Test note: the editors update thumbnails optimistically, so e2e must wait
for the persistence PATCH (
/brandingor/setup-profile), NOT the thumbnail, before asserting saved state.