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-pageministries_gridblock - 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-photosSupabase Storage bucket viaPOST /api/upload/ministry-photo(gate:train:church_knowledge:edit). - Contact: separate optional
contact_name+contact_email+contact_phoneat each level. Email renders asmailto:, phone astel:on the public site. - Server validation (
api/premium/update,ministriescase): cap ≤ 60 categories, ≤ 40 sub-groups each, ≤ 3 gallery photos; trim/length-bound all strings; drop unknown keys; drop categories with nonameand sub-groups with nolabel.
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 (
DraftAwareSaveForm→draft_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(MinistriesSectionfor the one-page site;MinistryCardGridreused by the multi-pageministries_gridblock 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).
Amendment — Ministry links (2026-06-02, founder-approved)
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 ornull. Web types are coerced tohttps://(bare domains accepted); onlyhttp/https/mailto/telever pass —javascript:and malformed URLs are dropped. Email requires a basica@b.cshape; phone strips to digits/+. - Server validation (
api/premium/update,ministriescase,mLinks()): coercetypeto the allowlist (unknown →website), boundvalue≤ 2000 andlabel≤ 80, drop any link whoseministryLinkHref()isnull, 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}andps-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:
MinistryLinksinMinistriesSection.tsx(renders insideMinistryCard, so both one-page and multi-pageministries_gridget 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 lintgreen.- 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.