Skip to main content

Acceptance Spec — Pro Website: Nested Ministries with Photos & Contacts

  • Status: APPROVED (founder, 2026-05-31) — build in progress
  • Owner: ChurchWiseAI (CWA)
  • Surface: churchwiseai.com/admin/[token] (Website → Content → Ministries) + public /s/[slug] (UnifiedTemplate) + multi-page ministries_grid block
  • Feature Registry id: cwa-pro-website-ministry-media
  • Plan gating: Pro Website (planIsProWebsite) — same as the rest of the Website editor

Why

Churches organize ministries as broad categories (Children's, Youth, Care) that contain sub-groups (age ranges, formats). The old Ministries section was a flat text-only card (name + short description). Founder request (2026-05-31): let a church add sub-ministry groups under each category, and attach photos + a contact person at both the category and sub-group level, so a visitor can see who to talk to and what each group looks like.

Data model (stored in existing custom_ministries JSONB — no migration)

Ministry (category)
name: string (required)
description?: string
photo_url?: string (main category photo)
gallery_urls?: string[] (≤ 3 additional photos)
contact_name?, contact_email?, contact_phone?
subgroups?: MinistrySubgroup[]
label: string (required, e.g. "Nursery (Ages 0–2)")
description?: string
photo_url?: string
gallery_urls?: string[] (≤ 3 additional photos)
contact_name?, contact_email?, contact_phone?

Types: Ministry, MinistrySubgroup, MinistryContact in src/lib/premium-shared.ts. custom_ministries (+ draft_custom_ministries) are JSONB; the new keys ride through the existing save/draft/publish path with no schema change.

Constraints

  • Photos: 1 main + up to 3 additional per level (category AND each sub-group). PNG / JPEG / WebP only. Max 3.5 MB per photo (client- and server-checked). Stored in the church-photos Supabase Storage bucket via POST /api/upload/ministry-photo (gate: train:church_knowledge:edit).
  • Contact: separate optional contact_name + contact_email + contact_phone at each level. Email renders as mailto:, phone as tel: on the public site.
  • Server validation (api/premium/update, ministries case): cap ≤ 60 categories, ≤ 40 sub-groups each, ≤ 3 gallery photos; trim/length-bound all strings; drop unknown keys; drop categories with no name and sub-groups with no label.

Admin editor (Website → Content → Ministries)

  • Each category is a collapsible accordion row: header shows the name (or "Untitled ministry") + a "N groups" badge + chevron + remove (✕).
  • Expanded panel: Name, Description, Photos (main + up to 3, drag-free upload boxes with per-photo remove), Contact (name / email / phone), then a Sub-groups block.
  • Sub-groups block: each sub-group is a card with Label, Description, Photos (main + 3), Contact; plus "Add sub-group". "Add Ministry" appends + opens a new category. The existing bulk TextImporter still appends name/description.
  • Saves are draft-aware (DraftAwareSaveFormdraft_custom_ministries), published via the normal Publish action.

Public display — Expandable Cards (founder pick, 2026-05-31)

  • Ministries render as a responsive card grid (1 / 2 / 3 columns).
  • A card shows: main photo as a banner (or the people icon if no photo), name, description, contact line (clickable email/phone), and a gallery thumbnail row.
  • If a category has sub-groups, the card shows a "View N groups" toggle. Expanding reveals the sub-groups (label, photo, gallery, description, contact); the expanded card spans the full row width on desktop for breathing room.
  • Sub-group content is rendered in the DOM (toggled with hidden) so it stays indexable for search/AI. Gallery thumbnails open the full image in a new tab.
  • Component: src/components/templates/shared/MinistriesSection.tsx (MinistriesSection for the one-page site; MinistryCardGrid reused by the multi-page ministries_grid block for parity).

Graceful degradation (regression guards)

  • A ministry with only {name, description} (every existing church today) renders exactly like before: icon + name + truncated description, no photo banner, no contact line, no expand toggle.
  • A category with no sub-groups shows no "View groups" toggle.
  • Empty custom_ministries → the section does not render (unchanged).
  • The three legacy named templates (protestant_modern / catholic_liturgical / nondenominational_community) are NOT wired into /s/[slug] today; they keep their flat ministry rendering and are unaffected (type change is additive).

Customer request (Kevin Soodsma): attach an outbound link to a ministry card (his case: a playground ministry's Facebook page). Shipped as a typed-link widget rather than a bare URL field so socials get a recognizable icon/label.

Data model (additive — same custom_ministries JSONB, no migration)

Ministry
links?: MinistryLink[] (≤ 6 per ministry)
type: 'website' | 'facebook' | 'instagram' | 'youtube' | 'email' | 'phone'
value: string (URL for web types; address for email; number for phone)
label?: string (overrides the per-type default button text)

Types + the shared ministryLinkHref() / ministryLinkDefaultLabel() helpers live in src/lib/premium-shared.ts. ministryLinkHref() is the single security boundary — used by both the save validator and the renderer so they cannot drift.

Constraints

  • Safe-href only: ministryLinkHref() returns the final href or null. Web types are coerced to https:// (bare domains accepted); only http/https/mailto/tel ever pass — javascript: and malformed URLs are dropped. Email requires a basic a@b.c shape; phone strips to digits/+.
  • Server validation (api/premium/update, ministries case, mLinks()): coerce type to the allowlist (unknown → website), bound value ≤ 2000 and label ≤ 80, drop any link whose ministryLinkHref() is null, cap ≤ 6.

Admin editor

  • A Links block under each ministry (after Photos/Contact, before Sub-groups): each row = type <select> + value input (type-aware placeholder) + optional "Button text" input + remove (✕); plus an "Add link" button.
  • Test ids: ps-admin-ministries-{i}-link-{type|value|label|remove}-{k} and ps-admin-ministries-{i}-link-add.

Public display

  • Links render as a row of chips at the bottom of the card body (after the gallery): per-type lucide icon (Facebook/Instagram/YouTube/Globe/ Mail/Phone) + label. Web links open in a new tab with rel="noopener noreferrer"; mailto:/tel: open in-place.
  • Component: MinistryLinks in MinistriesSection.tsx (renders inside MinistryCard, so both one-page and multi-page ministries_grid get it).

Graceful degradation

  • A ministry with no links (every existing church) renders exactly as before — no chip row. Additive type change; legacy flat templates unaffected.

Verification (evidence-or-nothing)

  • pnpm build + pnpm lint green.
  • Playwright on the deployed preview against a demo church: add a category + sub-group, upload a photo (<3.5 MB), fill contact, add a Facebook link, Publish, load /s/[slug], confirm the expandable card + sub-group photo + clickable contact + the Facebook chip (new tab, correct href) render and that a legacy flat ministry still renders unchanged.