Pro Website — Ministry Blocks (v1) — Acceptance Spec
Status: APPROVED — Founder approved 2026-09-28 (decisions: free-text need categories; quick exit → weather.com; per-tenant home section order, default = mockup; contact_routing cap 10). This is the source of truth: code that doesn't match it is wrong, and tests assert it.
Scope: Almost Home Ministries (ai-company-os/tasks/artifacts/TASK-20260928-almost-home/design/brief.md) is the forcing customer, but this ships as a portfolio capability for every ministry-type Pro Website customer (recovery homes, pregnancy centres, shelters) — not an Almost Home one-off. Five things, v1-scoped to be buildable in about a week:
- Crisis bar + Quick exit (site-wide, opt-in per tenant)
need_listblockvideo_testimoniesblockrsvpblock + submitter confirmation email (portfolio-wide fix, not ministry-only)ways_to_helpblock
Design source: ai-company-os/tasks/artifacts/TASK-20260928-almost-home/design/brief.md §6–8 (concept, crisis-bar spec, page wireframes, component list). Code-reality source: ai-company-os/tasks/artifacts/TASK-20260928-almost-home/codemap.md (what exists today, cited file:line).
Foundational decisions (read this before the per-item sections)
- Standard on every Pro Website plan, same as multi-page (
pro-website-multipage.mddecision 1). No new entitlement flag, no separate Stripe price.need_list/video_testimonies/rsvp/ways_to_helpare four new entries in the existingBlockunion — any Pro Website customer with the Pages editor already has them. - Block-embedded data, not new top-level JSONB columns — for the four block types.
pro-website-multipage.md's "additive data model" principle extends one level further: aneed_listblock carries its ownitems: NeedItem[]insideextra_pages(the same way animageblock already carries its ownurl/alt), instead of adding a newpremium_churchescolumn per feature. This is decision #5 to sanity-check — see rationale below. - Crisis bar is the one exception — it needs its own top-level columns, because it must render on the home page AND every extra page simultaneously, and a page-scoped block can't do that. New nullable
crisis_bar_config/draft_crisis_bar_configjsonb columns, registered in the draft/publish cycle exactly likeextra_pages/draft_extra_pages(pro-website-multipage.md§Draft/publish contract). Defaultnull= off. This satisfies the opt-in-per-tenant memory rule (feedback_vertical_behavior_is_opt_in_never_a_cross_tenant_default.md) for free — there is no global flag to accidentally flip, only a per-row JSONB value a tenant's own admin must set. - One new column outside
premium_churches:contact_submissions.route_label text(nullable, additive). Today a submission through a configured route (routeIndex) is inserted withcategoryleft at its fallback default and the resolved route label is used only in the email subject — never persisted (api/contact/church/route.ts:97,152,162-172). That means no submission is attributable to a specific route after the fact. Three of the five items in this spec need that attribution: RSVP's "staff see a count," Ways to Help's per-card reporting, and the null-destination dashboard warning (item 4) all require knowing which route a row came from.route_labelis populated server-side from the samerouteLabelvariable already computed atroute.ts:141-160— no new input, no client trust boundary crossed. ContactForm'scontactTypesprop is extended with an optionalinitialMessageprop and (block-scoped) route restriction, not rebuilt.need_list"I can help with this" andrsvp's form are both thin wrappers around the existing sharedContactForm(src/components/templates/shared/ContactForm.tsx) — new blocks reuse it rather than each growing a parallel form implementation.
Why block-embedded, not new columns (decision #5, expanded)
pro-website-ministry-media.md and pro-website-multipage.md both extended an existing JSONB column (custom_ministries, extra_pages) precisely because the data was naturally scoped to one place a pastor edits it. need_list, video_testimonies, rsvp and ways_to_help are the same shape: each lives on exactly one extra page in Almost Home's design (Village page, Testimonies page, Open House page, Ways to Help page respectively) and nothing in this v1 needs them anywhere else. Six new DB columns (three features × canonical + draft) is real migration surface, real DRAFT_TO_CANONICAL registration, real revalidation-path work — for data that only ever needs to exist inside the one page block. If a later customer wants the same need list echoed as a compact teaser on Home, that is exactly when it graduates to a top-level column (mirroring how custom_ministries already serves both the home Ministries section and the ministries_grid block) — not before. This is also why the home-page "compact variant" and "teaser" placements from the design brief are OUT OF SCOPE for v1 (see the per-item sections and Out of Scope).
1. Crisis bar + Quick exit
New component: CrisisBar (site-wide, mounted once by UnifiedTemplate.tsx and once by SimplePageTemplate.tsx, not a page block). New component: GetHelpSection — a normal-looking content section, but domain-specific enough (Call/Text buttons, 911/988/hotline lines) that it isn't buildable from today's generic blocks; ships as a 5th new block type (get_help) so it can be dropped onto any page, typically the Home page's story flow per the design brief.
Data model
New premium_churches columns, nullable, default null:
crisis_bar_config (jsonb) / draft_crisis_bar_config (jsonb)
CrisisBarConfig = {
enabled: boolean // master on/off — absent/null = off
message: string (≤ 140 chars) // desktop bar copy, e.g. "Need a safe place tonight? It's free."
intake_phone: string // tel-clean digits, required if enabled
intake_phone_display: string (optional) // e.g. "(304) 389-0805" — what's shown; falls back to intake_phone
supports_text: boolean // shows a second "Text" button/label when true
show_quick_exit: boolean (default true)
exit_url: string (optional) // defaults to a neutral fallback if empty (see Quick exit)
}
Registered in DRAFT_TO_CANONICAL, DRAFT_SELECT_COLUMNS, ProWebsiteDraftColumns (src/lib/pro-website-draft.ts) exactly like extra_pages. /api/premium/update's website section gains a crisis_bar_config field behind its own formData.has('crisis_bar_config') guard, validated server-side (normalizeCrisisBarConfig(), mirrors normalizeExtraPages()'s defensive-never-throws contract: bad/missing input → null → bar doesn't render).
get_help block (page-embedded, in the Block union):
GetHelpBlock = {
id: string; type: 'get_help';
free_text: string (≤ 800 chars) // "what free covers" — plain text, paragraphs on blank lines like a `text` block
eligibility_text: string (≤ 500 chars, optional)
next_steps: string[] (≤ 5 items, ≤ 120 chars each, optional) // "1. Call or text. 2. We'll ask a few questions. 3. ..."
}
intake_phone for the Call/Text buttons and the 911/988/hotline lines are NOT re-entered on the block — they read crisis_bar_config.intake_phone (single source of truth) and a fixed, non-editable trio of US public safety numbers baked into the component (911, 988, National Human Trafficking Hotline 1-888-373-7888 / text 233733 — these are not editable per-tenant; they are public national lines, not ministry-specific data). If crisis_bar_config is null/disabled, the get_help block still renders its free-text content but hides the Call/Text buttons and shows only the 3 fixed hotline lines.
Desktop behaviour
- Fixed to the top of the viewport, 44px tall,
z-indexaboveStickyNav.--plum-deep-equivalent dark background (tenant'swebsite_accent_hex-derived dark tone, or a fixed dark neutral if the theme has no dark variant — see Colour below), linen/white text. - Layout: message text (left/center) +
Quick exitbutton (far right, text label, never icon-only). StickyNav(and, on Home, the hero) render below the crisis bar — the bar is a completely separate fixed layer, first in DOM, so screen readers and sighted keyboard users hit it first.- Esc pressed 3 times within a 2-second rolling window triggers Quick exit. A counter resets on each keydown that isn't Esc, and resets to 0 after 2s of no Esc presses (so three unrelated single Esc presses across a browsing session don't accidentally trigger it).
Mobile behaviour
- Fixed to the bottom, 64px +
env(safe-area-inset-bottom). Two buttons ("Call", and "Text" only ifsupports_text), eachtel:/sms:links, min 48px tall, full-width split. A label above the buttons: themessagetext (or a truncated version if it doesn't fit two lines).Quick exitis a third, smaller button/link in the same bar (icon + the word "Exit" — never icon-only per the design brief). - The floating chat bubble (
ChatWidgetStream) is offset upward by the bar's height (64px + safe-area) via a new optional prop (e.g.bottomOffset) so it never overlaps the bar.ChatWidgetStreamreadscrisis_bar_config?.enabledfrom the page props it's already passed. - Every page's
<main>getspadding-bottom: calc(64px + env(safe-area-inset-bottom))on mobile when the bar is enabled, so the bar never covers the last visible content or a page's own bottom CTA button. Desktop getspadding-top: 44pxequivalent (or the bar is simply first in flow, pushing content down — either implementation is acceptable; the requirement is that no other content, and no other fixed element, renders underneath the bar).
Quick exit mechanics
onClick:window.location.replace(exit_url?.trim() || DEFAULT_EXIT_URL). DefaultDEFAULT_EXIT_URL = 'https://weather.com'(a plain, unremarkable destination) when the tenant leavesexit_urlempty. Decision 2026-09-28: weather.com, not weather.gov — it matches the founder-approved Almost Home mockup and the built template (DEFAULT_EXIT_URLinchurchwiseai-web/src/lib/ministry.ts). A tenant may set any http(s)exit_url. As built (v1), the crisis bar's config lives incap_info.ministry.crisis_bar, not a newcrisis_bar_configcolumn — seeacceptance/pro-website-ministry-template.md§2 and §10.4.location.replaceoverwrites the CURRENT history entry — pressing Back after Quick Exit does NOT return to this page. It does not erase entries from BEFORE this page was reached (a real limitation, not solvable client-side). Theget_helpblock'seligibility_text/free-text area is where a one-line note about manually clearing browser history belongs — this is copy, not a code requirement, and is not enforced by the spec.- Fires identically from the Esc×3 desktop shortcut and the mobile button.
Colour
Uses the site's resolved website_accent_hex (resolveWebsiteStyle, per pro-website-colors.md) darkened to a fixed contrast-safe multiplier for the bar background, with white/near-white text — this is a computed value, not a new admin field, so the bar always passes AA regardless of which of the 8 curated themes or custom accent the tenant picked. Known limitation carried over from pro-website-colors.md: extra pages don't inherit the custom accent yet (SimplePageTemplate.tsx:34) — the crisis bar on an extra page will use the denomination-default accent until that limitation is fixed, same as every other themed element on extra pages today. Not a new gap this spec introduces; documented so it isn't rediscovered as a "bug."
A11y / reduced motion
- The bar is a landmark:
<div role="region" aria-label="Get help">. - No animation on mount/dismiss in v1 (there is no dismiss — it's permanent while enabled), so
prefers-reduced-motionhas nothing to override; if a future revision adds a slide-in, it must respectprefers-reduced-motion: reduce. Quick exitandCall/Textare real<a>/<button>elements, keyboard-reachable, with visible focus rings — never<div onClick>.
Expected outputs
| Given | The page shows |
|---|---|
crisis_bar_config null or enabled: false | No bar. No layout padding change. get_help block (if placed) shows only the 3 fixed hotline lines, no Call/Text buttons. |
crisis_bar_config.enabled: true, desktop viewport | Top-fixed 44px bar, message + Quick exit, above the nav. |
Same, mobile viewport (<640px) | Bottom-fixed 64px+safe-area bar, Call/Text/Exit buttons, chat bubble shifted up. |
| Visitor clicks Quick exit (either device) | Tab navigates to exit_url or the weather.com default; browser Back does not return here. |
| Visitor presses Esc 3× within 2s (desktop) | Same as clicking Quick exit. Esc pressed 3× over 10 seconds (not within the window) does nothing. |
intake_phone unset while enabled: true | Save is rejected server-side with a clear error ("Add a phone number before turning the crisis bar on.") — an enabled bar with no working Call button is worse than no bar. |
QA checklist rows
- Bar renders identically on Home and every extra page when enabled (visit at least 2 extra pages).
- Bar never covers the bottom CTA of the shortest extra page (test on a page with only 1 block).
- Chat bubble does not overlap the mobile bar (visual check, ≤375px width).
- Quick exit tested on both desktop click and mobile tap; confirm
location.replace(nothref=) via network/history inspection. - Esc×3 works; Esc×3 spread over >2s does not fire.
- Bar background/text passes WCAG AA on at least 2 of the 8 curated themes + 1 custom accent hex.
- Disabling the bar after it was live removes it on next publish with no residual padding/offset.
2. need_list block
Purpose
A page-embedded, categorized wishlist a ministry keeps current from a phone — "16 tags in 6 categories" per the Almost Home research, but the category set is staff-defined free text, not a fixed enum (decision to sanity-check — see below).
Data model (block-embedded)
NeedListBlock = {
id: string; type: 'need_list';
route_label?: string // which contact_routing label "I can help with this" submits to;
// falls back to the FIRST contact_routing entry if unset/invalid
items: NeedItem[] // ≤ 40 items
}
NeedItem = {
id: string // stable, for edit/reorder/mark-received
label: string (≤ 80 chars, required)
category: string (≤ 40 chars, required) // free text — see decision below
description?: string (≤ 200 chars) // the staff-written "why" line
quantity_wanted?: number (1–999)
quantity_received?: number (0–999, ≤ quantity_wanted when both set)
needed_now?: boolean // renders the "Needed now" oak tag
external_link?: string // optional "Buy from our wish list" outbound URL — same isSafeButtonHref() gate as button blocks
}
Enforced in normalizeBlock()/parseExtraPagesForSave() alongside the other block types: cap 40 items, per-field length caps, drop items with no label, clamp quantity_received to quantity_wanted when both present, defensive-never-throws on render.
Decision to sanity-check: category is free text, not an enum
The design brief's 8 categories (Building materials, Tools, Gift cards, Hygiene & home, Tech, Bibles & books, Vehicles, Hands) are Almost Home's own list. A fixed enum would need per-vertical customization work for every future ministry customer (a pregnancy centre's needs look nothing like a recovery home's). v1 ships category as a plain string the staff type once per item; the filter-chip row is computed from the distinct set of categories actually present in items (sorted by first-appearance order), exactly the way the header count is computed rather than hand-typed. This generalizes for free and matches the "not an Almost Home one-off" instruction in the design brief.
Editor UX (phone)
- Under the block editor (same
SectionEditorSheetpattern as other blocks): a vertical list of item cards, each collapsed tolabel+ category chip + received/wanted fraction if set; tap to expand into a form (label, category, description, quantity wanted, quantity received, "Needed now" toggle, optional buy-link). - "Add item" appends a blank item at the end. Reorder via up/down chevrons (matches
PagesEditor.tsx's existing reorder pattern — no drag-and-drop, consistent with the rest of the editor). - One block-level field: a
<select>of the site'scontact_routinglabels for "Route 'I can help' submissions to," defaulting to the first route. - Marking an item received is just editing
quantity_receivedon that item's form — no separate "mark received" action needed since the progress bar already reflects it.
Public rendering — desktop
- Wood-tag card grid (2–3 columns), category filter chips above the grid (all categories + "All," clicking filters client-side, no page reload).
- Each card: label, description, urgency tag if
needed_now, a progress indicator ONLY when bothquantity_wantedandquantity_receivedare set (plum fill, e.g. "3 of 5"), one button "I can help with this," and — ifexternal_linkis set — a secondary "Buy from our wish list" outbound link (target="_blank" rel="noopener noreferrer"). - Header: "
nthings still needed", computed asitems.lengthminus any item wherequantity_received >= quantity_wanted(an item with no quantities set always counts as needed) — never a hand-typed number, matching the design brief's "counted, never a hand-typed statistic" rule.
Public rendering — mobile
- Category chips scroll horizontally in their own contained strip (the only horizontal scroll allowed on the site, per the design brief's mobile rules). Cards stack one per row. "I can help with this" button is full width.
"I can help with this" flow
- Opens the shared
ContactFormin a modal/bottom-sheet, withcontactTypesrestricted to the single resolvedroute_label(so the visitor doesn't have to re-pick a category — it's already implied) and a newinitialMessageprop pre-filling the message textarea with"Re: ${item.label}\n\n"so the visitor writes into context rather than starting blank. The visitor can still edit or clear the prefilled text. - This requires
ContactForm.tsxto gain one new optional prop (initialMessage?: string, used as themessagefield's initialuseStatevalue) — additive, no behavior change for every existing caller that omits it.
Graceful degradation
items: []→ the block renders nothing (matches the "empty custom_ministries → section doesn't render" precedent).- A malformed item (no
label) is dropped silently on render (never throws), matching every other block's contract.
QA checklist rows
- Add 3 items across 2 categories; confirm the chip row shows exactly those 2 categories, no others.
- Filter by one category; confirm only matching cards show.
- Set
quantity_wanted=5, quantity_received=5; confirm the header count excludes that item. - "I can help with this" opens the form with the item name pre-filled in the message; submit; confirm it lands with
route_labelincontact_submissions.route_label. - Mobile: category strip scrolls horizontally without scrolling the whole page.
3. video_testimonies block
Purpose
Almost Home's strongest asset — 9 real video stories — currently has nowhere to live. Ships as its own block type, not a repurposing of the sermons_list block/sermons array.
Decision to sanity-check: new array, not a kind field on sermons
Reusing sermons (adding a kind: 'sermon' | 'testimony' discriminator) would touch every existing consumer of that array — the home page's Sermons section, SermonEditor in WebsiteTabEditor.tsx, the sermons_list block, the chatbot's fact-block assembly (route.ts:1531 region per codemap §5), and any sitemap/GEO generator that walks sermons — to add a filter everywhere it's read, or risk a testimony video silently appearing in a "Recent Sermons" section (or vice versa) the moment two features share one array. It also conflates two different consent models: sermons carry no consent gate; testimony videos MUST be hard-gated on consent (§ below) and that gate has to be impossible to bypass by accident. A separate, block-embedded array keeps the consent boundary structurally isolated and touches zero existing Sermons code. This is the same reasoning pro-website-ministry-media.md used to keep custom_ministries.links as a new typed field rather than overloading an existing one.
Data model (block-embedded)
VideoTestimonyBlock = {
id: string; type: 'video_testimonies';
items: VideoTestimony[] // ≤ 20 items
}
VideoTestimony = {
id: string
title: string (≤ 100 chars, required) // e.g. "Once broken, now changed."
video_url: string (required) // YouTube/Vimeo watch URL, http(s) only
first_name?: string (≤ 40 chars) // omit for "Anonymous"
role?: string (≤ 40 chars, optional) // "graduate", "staff" — free text, not enum
duration_label?: string (≤ 10 chars, optional) // "2:56" — staff-entered, not computed (no server-side video probing in v1)
poster_url?: string (optional — falls back to a plain platform thumbnail via the video URL's oEmbed-derivable ID when omitted; if that also fails, a plain play-icon-on-plum placeholder renders)
featured?: boolean // exactly one item may set this; if 0 or >1 do, normalization keeps the FIRST match and demotes the rest
consent_confirmed: boolean (required, default false)
}
Hard consent gate: any item with consent_confirmed !== true is dropped on normalize for public render (never rendered, not even greyed-out) — mirrors the "unconfirmed videos never render publicly" instruction exactly. The editor still shows unconfirmed items (with a visible "Not yet confirmed — won't appear on the live site" badge) so staff can prep entries before consent paperwork lands, but the save-time and render-time normalizer both filter on consent_confirmed === true for anything that reaches a visitor.
Editor UX (phone)
- Item list, each card shows title + first name + a red/amber consent badge if unconfirmed. Tap to expand: title, video URL (paste), first name, role, duration label, poster URL (optional upload via the same
/api/upload/custom-page-imageroute), Featured toggle, and a required consent checkbox with copy: "I confirm this person has given written permission for this video to appear publicly." Unchecked = saved as a draft item, not published. - "Add video" appends a blank item. Reorder via chevrons.
Public rendering — desktop
- One featured item (the
featured: trueone, or item 0 if none flagged) rendered large (roughly 2×2 grid-cell equivalent), the rest as a grid of tiles below: poster image, play button overlay, duration badge, first name + role caption underneath. - Tapping a tile swaps in an inline player (embedded YouTube/Vimeo iframe or native
<video>depending onvideo_url's host) — never autoplays with sound; playback starts muted-off but requires the click that opened it (a user gesture), so autoplay policies are moot. - Anonymity line rendered once under the whole grid, verbatim per the design brief's photo policy: "Names are used only with permission. Some women choose to stay anonymous, and we honour that." (This is fixed copy in the component, not a per-tenant editable field — every ministry customer using this block gets the same sentence, edited only if a future customer's legal counsel requires different wording, which is a code change, not a config field, to keep the language deliberate rather than accidentally softened.)
Public rendering — mobile
- Horizontal snap-scroll strip, ~72% card width per tile (matches the design brief's swipe-strip spec), featured item first in the strip rather than a separate large block (no room for a 2×2 treatment on a phone).
Graceful degradation
items: [], or every item unconfirmed → block renders nothing.- A malformed
video_url(not http/https) drops that item on normalize, same as animageblock with a bad URL.
QA checklist rows
- Add one item with
consent_confirmed: false; publish; confirm it does NOT render on the live page. - Confirm it DOES render in the editor with the "not confirmed" badge.
- Check the consent box, republish; confirm it now renders.
- Two items flagged
featured: true; confirm only the first (by array order) renders as featured. - Mobile: swipe strip snaps; verify no vertical-page-scroll hijack while swiping horizontally.
- Video does not autoplay with sound on page load (test with browser autoplay policy at default/blocked).
4. rsvp block + submitter confirmation email
Purpose
An Open-House-style RSVP that (a) lands in the ministry's inbox like any other contact_routing submission, and (b) — new for every Pro Website tenant, not just ministries — sends the submitter a confirmation email, closing the false claim already live in ContactForm.tsx:98 ("We've also sent a confirmation to your email") for every existing customer.
Decision to sanity-check: confirmation email ships for ALL contact-form submissions, not just RSVP
ContactForm.tsx:98 is rendered by every tenant's contact form today — Visit/Prayer/Other AND configured routing alike — and it already lies to every visitor who submits. Scoping the fix to RSVP-only would leave the lie live everywhere else and create two different submission behaviors (confirmed vs. not) with no visible distinction to the visitor. v1 fixes POST /api/contact/church once, for every submission, every tenant, every route — RSVP is simply the block that made the gap visible, not a special case.
rsvp block data model (block-embedded)
RsvpBlock = {
id: string; type: 'rsvp';
event_title: string (≤ 100 chars, required) // "Open House"
date_tbc: boolean // when true, renders "Date to be announced" instead of a date
event_date?: string (ISO date, required unless date_tbc)
event_time?: string (≤ 40 chars, optional free text — "6:00–8:00 PM")
address_note: string (≤ 200 chars, default: "We'll send the address and parking details after you RSVP.")
// address itself is deliberately NOT a field here — per the research pass,
// the street address is sent after RSVP, not printed on the page (safety reasons
// for a recovery-home address apply to any ministry in this vertical)
route_label?: string // which contact_routing entry submissions go to; falls back to first route
capacity_note?: string (≤ 100 chars, optional) // e.g. "Space is limited"
}
Public rendering
- Date block: big numerals (Playfair per the design system) if
date_tbcis false; "Date to be announced" text treatment if true. Time andaddress_notebeneath. - Form: name, "email or phone" (at least one required — same validation the shared
ContactFormalready does for email; phone-only submitters get the confirmation email skipped, see below), party size (stepper, 1–10, default 1), an optional "Is this your church or group?" text field, an optional note. ReusesContactFormwith a newextraFieldscapability (party size stepper + group text field) rather than a parallel form component — see Editor/implementation note. - On submit success, the confirmation copy changes from the generic "Message Sent!" to: "We've saved you a seat for [event_title]. We'll send the address and parking details the week before." (block-specific success copy, passed as a prop to
ContactForm, defaulting to the existing generic copy for every other caller). - Party size + group name + note are composed into the submitted
messagefield as a readable summary block server-side is NOT needed — the client composes it ("Party size: 3\nGroup: First Baptist Church\nNote: ..."prepended to any free-text note) before posting, exactly likeneed_list'sinitialMessage. No newcontact_submissionscolumns for party size in v1 — this keeps the RSVP feature inside the existing insert shape; a structuredparty_size intcolumn is a reasonable fast-follow if staff want to sum RSVP headcounts later, explicitly out of scope now.
Staff sees a count
"Staff see a count" is satisfied by the admin dashboard's existing Requests view, filterable by the new contact_submissions.route_label column (decision #4 above) — no new dashboard surface required for v1, just the one new filterable column. A dedicated "RSVP count for this event" widget is out of scope (there's no event-instance linkage in v1; a ministry with recurring monthly Open Houses would see a running total, not per-occurrence counts — acceptable since Almost Home's Open House is one recurring RSVP form, not per-date event rows).
Submitter confirmation email (portfolio-wide, /api/contact/church)
New behaviour in POST /api/contact/church: after the existing admin-notification email send (success or failure — the two are independent), send a second, separate email TO the submitter's address (only when email was provided — phone-only submissions get no confirmation email, since there's nowhere to send it; the on-page success message must not claim otherwise for a phone-only submission, see Expected outputs).
- From:
ChurchWiseAI <hello@churchwiseai.com>— same verified sending domain as the admin notification (matches memory rulefeedback_gmail_mcp_reply_drafts_go_to_sender_not_reply_to.md's spirit: the reply-to should NOT be the tenant's inbox, since that would let a visitor's confirmation email double as a spoofable path into the church's mail;replyTois omitted/left as the system default, not the visitor's own address, unlike the admin-notification email which legitimately setsreplyTo: emailso staff can reply directly). - Subject:
"We got your message — {churchName}"(or, for thersvproute specifically,"You're on the list for {event_title} — {churchName}"when the route resolved from anrsvpblock — the route resolution already knows the label, so this is a simple subject-line branch onrouteLabel, not a new data field). - Body: short, warm, no address disclosure ever (this email must never leak an address the ministry hasn't chosen to publish) — restates the route label, says someone will be in touch, and for the crisis-adjacent context includes NOTHING alarming (no "you contacted a recovery home" framing that could be dangerous if the visitor's inbox isn't private — this is a real constraint given the vertical, not boilerplate caution: the confirmation email subject and body must never name the ministry's specific service type in a way that could out a visitor to someone reading their email over their shoulder. For Almost Home specifically,
churchNamein the copy should be the public brand name ("Almost Home Ministries") which is no more revealing than any other charity's confirmation email — this is acceptable; the constraint is about not adding EXTRA context like "your request for a safe place has been received.") - Rate limit: covered by the existing 5/min/IP limiter on the route (
rateLimit({ limit: 5, windowMs: 60_000 })) — no separate limiter needed since this is one extraresend.emails.send()call inside the same request, not a new endpoint. - No-reply handling: the confirmation email is genuinely no-reply in spirit (no action expected of the visitor) but does not need a literal
noreply@address — using the samehello@churchwiseai.comkeeps deliverability consistent with the admin email and avoids adding a second sending identity to warm up in Resend. - Failure handling: a failed confirmation-email send is logged (
console.error, matching the existing admin-email failure handling) but never fails the request or blocks the on-page success state — the submission is already saved regardless. - The existing
ContactForm.tsx:98success copy is corrected to only claim a confirmation was sent when an email address was actually provided:email ? "We've also sent a confirmation to your email." : ''(component already hasform.emailin scope at submit time; pass a boolean down or check it in the success-state render).
Null-destination logging + dashboard warning (closes the silent-drop bug)
Today (api/contact/church/route.ts:178), when toAddresses.length === 0 there is no else branch — the admin notification silently never sends, though the submission is still saved. This spec requires:
- When
toAddresses.length === 0after resolution: callreportError({ source: 'contact-form', route: '/api/contact/church', message: 'Contact submission has no resolvable destination email', metadata: { church_id: churchId, route_label: routeLabel, route_index: idx } })(src/lib/ops-reporter.ts, existingops_errorspipeline — no new table). - The admin dashboard (Website tab or Requests tab — implementer's call, but it must be visible on first load, not buried) shows a warning banner when an unresolved
ops_errorsrow withsource='contact-form'exists for this church: "Some messages couldn't reach you by email because '[route_label]' has no address set. [Fix in Contact Routing →]" deep-linking toContactRoutingEditor. - This applies to EVERY route, not just RSVP — it's the general fix for the gap codemap.md flagged, surfaced through the RSVP work because RSVP is the feature most likely to hit it on day one (Almost Home's current
destination-churchclone has all 5 routes ANDadmin_emailnull).
contact_routing cap collision — flag, don't silently fix
Decision 2026-09-28 (founder): cap raised from 6 to 10.
Almost Home's page plan alone wants routes for: Volunteer, Pray, Book a Speaker (Ways to Help, item 5), Village donation (need_list), Open House RSVP (rsvp), Share your story (Testimonies page) — 6 routes, exactly at today's contact_routing cap of 6 (pro-website-ministry-media.md-adjacent constant, enforced in ContactRoutingEditor.tsx + api/premium/update), leaving zero room for a general "Other/General Inquiry" catch-all. Recommend raising the cap from 6 to 10 as part of this build (a single constant change + its two enforcement sites) since it's a low-risk, purely additive limit increase with no schema change — but this is a call for the founder/build-order decision, not assumed here. If declined, Almost Home (and any future ministry with a similar page count) will need to combine routes (e.g., Give+Volunteer under one "Ways to Help" inbox) — noted as a real constraint, not silently designed around.
Expected outputs
| Given | The page/email shows |
|---|---|
| Visitor submits RSVP with email + party size 3 | On-page: "We've saved you a seat for Open House..." Email to visitor: confirmation with event title, no address. Admin email: existing template, subject includes route label, body includes party size in the message text. contact_submissions.route_label = 'Open House RSVP'. |
| Visitor submits with phone only, no email | On-page success message does NOT claim an email confirmation was sent. No confirmation email is attempted (no address to send to). Admin notification still sends normally (unaffected by submitter's email presence). |
Route resolves to no email (admin_email and route email both null) | Submission still saved to contact_submissions. No admin notification (unchanged from today). NEW: an ops_errors row is created; next admin dashboard load shows the warning banner. |
date_tbc: true | "Date to be announced" renders instead of numerals; RSVP form still fully functional (visitors can still RSVP interest before a date is set). |
QA checklist rows
- Submit with email set → confirmation email arrives, no address disclosed, correct subject for the
rsvproute. - Submit with phone only, no email → on-page copy does not claim an email confirmation; no confirmation email sent (check Resend logs).
- Temporarily null a route's destination email on a demo church → submit → confirm
ops_errorsrow appears and dashboard banner renders on next load. -
contact_submissions.route_labelpopulated correctly for both legacy (Visit/Prayer/Other) and configured-routing submissions. - Existing (non-RSVP) contact form submissions on an unrelated church still work exactly as before (regression check on the shared route change).
5. ways_to_help block
Purpose
Four equal-weight cards — Give, Volunteer, Pray, Book a Speaker — so a visitor who can't give money still has an obvious next step.
Data model (block-embedded, projection-style — reads existing data, minimal own fields)
WaysToHelpBlock = {
id: string; type: 'ways_to_help';
volunteer_route_index?: number // index into contact_routing; omitted/invalid → card hidden
pray_route_index?: number
speaker_route_index?: number
}
No fields for Give — it always reads the site's existing giving_url (same field the home page's GivingSection already uses); the card is hidden entirely if giving_url is unset (never a dead "Give" button). No new copy fields per card in v1 — each card's one-line description is fixed component copy ("Support the ministry financially" / "Bring your hands to a build day" / "Commit to pray for the house" / "Invite us to share at your church or event") to keep the editor to three dropdowns rather than 4 cards × (title+description+button) of free text; this is the tightest scope cut in the spec and is called out explicitly in case the founder wants per-card custom copy in v1 instead (would add ~12 short text fields).
Editor UX (phone)
- Block editor shows 4 read-only rows (Give — auto, shows "✓ giving_url is set" or "⚠ set a Give link in the Give section first") plus 3
<select>dropdowns (Volunteer/Pray/Book a Speaker) populated from the site's currentcontact_routinglabels, each with a "— none —" option that hides that card.
Public rendering — desktop
- 2×2 (or 4-across on wide screens) grid of equal-size cards: icon (fixed per role — heart/give, hands/volunteer, praying-hands/pray, mic/speaker — lucide icons, not editable), title, fixed one-line description, a button that either scrolls/links to
giving_url(new tab if external) or opens the sharedContactFormpre-restricted to that one route (same modal pattern asneed_list's "I can help with this").
Public rendering — mobile
- 2×2 grid down to 360px, then single column below that (matches the design brief's stated breakpoint).
Graceful degradation
- All 4 sources unset (no
giving_url, no valid route indices) → the block renders nothing (consistent with every other block's empty-state contract). - A stale
route_index(the referencedcontact_routingentry was deleted after the block was configured) → that card is dropped defensively on render, same pattern as a danglingparent_idin the Pages nav tree.
QA checklist rows
- All 4 cards render when
giving_url+ all 3 routes are set. - Unset
giving_url→ Give card disappears, other 3 unaffected. - Delete a
contact_routingentry aways_to_helpblock pointed at → that card silently disappears on next render, no error thrown. - Each of the 3 form cards opens the shared
ContactFormrestricted to its one route; submission lands with the correctroute_label.
Regression guardrails (must hold, across all 5 items)
- A church with none of
crisis_bar_config, and no page using any of the 4 new block types, renders byte-identically to today — every new column defaultsnull, every new block type is opt-in per page, the sharedContactFormand/api/contact/churchchanges are additive (new optional props/fields, existing callers unaffected). BlockRenderer.tsxgains 4 newcasebranches; none of the existing 8 cases change./api/contact/church/route.ts's confirmation-email addition androute_labelpersistence must not change response shape, status codes, or timing behavior visible to existing callers (ContactForm.tsx's success/error branching is unchanged).- The multipage regression suite (
cwa-pro-website-ssr,cwa-pro-website-edit, perpro-website-multipage.md§5) stays green; new specs are additive (crisis bar render, each new block type render, RSVP submit → confirmation email, null-destination → ops_errors row). service_businesstemplate rows remain untouched — none of these 5 items are reachable on aServiceBusinessTemplaterender, matching the existing extra-page gate (codemap.md§1).
Out of scope (v1)
- Fixed-order Home-page placements for
need_list(compact/teaser variant) andvideo_testimonies(Home page centerpiece) from the design brief — Home's section order is fixed today (a separate, larger project percodemap.mdGAP LIST); v1 ships both strictly as extra-page blocks. Decision to sanity-check. - Written-testimony quote cards (
TestimonyCardsin the design brief) — video testimonies only in v1. TimelineSection("what a year looks like" / Village build stages) — no new block type in this spec; the existingheading+textblocks can approximate it manually.ProductStory/ProductGrid(Plaques & Jewelry) — link-out via existingimage+text+buttonblocks is sufficient; no store feature.- Non-church label set (
template-labels.ts5th config entry) and chat-side de-churching (_apply_non_church_substitutions()chat equivalent) — real gapscodemap.mdflags as blocking a ministry launch, but they are copy/prompt-engineering work orthogonal to the block system this spec covers; tracked separately. - Per-event RSVP instances / structured party-size columns / per-occurrence counts.
- AI-generated content of any kind in any of these 5 items.
- Progress bars beyond the single simple wanted/received fraction on
need_listitems. Raising the— approved 2026-09-28: cap raised to 10 (built in the ministry dashboard PR;contact_routingcap above 6ContactRoutingEditor.tsx+/api/premium/update).
Build order recommendation
contact_submissions.route_labelcolumn +/api/contact/churchroute-label persistence + null-destinationreportError()call. Smallest, unblocks accurate testing of everything downstream, and is the one portfolio-wide bug fix independent of the other 4 items.- Submitter confirmation email (same route, same PR as #1 — both touch
api/contact/church/route.tsin one pass) + theContactForm.tsx:98copy fix. ways_to_helpblock — smallest new block (no embedded data model beyond 3 indices), proves out the "projection-style new block type" pattern before the two data-heavier ones.need_listblock — introducesContactForm's newinitialMessageprop, reused byrsvpnext.rsvpblock — reusesinitialMessagepattern + needs the extra party-size/group fields onContactForm.video_testimoniesblock — most self-contained (noContactFormdependency), can run in parallel with 4–5 if two builders are available.- Crisis bar +
get_helpblock — last, because it's the only item touching shared, always-rendered chrome (UnifiedTemplate.tsx,SimplePageTemplate.tsx,ChatWidgetStream.tsx) rather than an isolated block, so it benefits from the block-editor patterns (item picker, save/normalize conventions) already being proven out by 3–6.
Files each item touches (from this session's reading — not exhaustive, but the load-bearing ones)
- Shared / all items:
src/lib/pro-website-pages.ts(Block union,BLOCK_TYPE_META,normalizeBlock,createBlock),src/components/templates/shared/BlockRenderer.tsx,src/app/(cwa)/admin/[token]/components/WebsiteEditor/PagesEditor.tsx(per-block editor forms),knowledge/tests/registry.yaml(new spec entries). - Crisis bar: new
src/components/templates/shared/CrisisBar.tsx,GetHelpSection.tsx; edits tosrc/components/templates/UnifiedTemplate.tsx,src/components/templates/SimplePageTemplate.tsx,src/components/ChatWidgetStream.tsx,src/lib/premium-shared.ts(types),src/lib/pro-website-draft.ts(draft-column registration),src/app/api/premium/update/route.ts,src/app/(cwa)/admin/[token]/components/WebsiteTabEditor.tsx(new editor group), a new migration file (crisis_bar_config/draft_crisis_bar_config). need_list: newsrc/components/templates/shared/NeedList.tsx; edits toContactForm.tsx(initialMessageprop).video_testimonies: newsrc/components/templates/shared/VideoTestimonyGrid.tsx.rsvp: newsrc/components/templates/shared/RsvpForm.tsx(or anextraFields-capable extension ofContactForm.tsx); edits tosrc/app/api/contact/church/route.ts(confirmation email,route_label),src/lib/ops-reporter.ts(call site, no lib change needed), a migration forcontact_submissions.route_label.ways_to_help: newsrc/components/templates/shared/WaysToHelp.tsx.- Cap increase (if approved):
src/app/(cwa)/admin/[token]/components/ContactRoutingEditor.tsx,src/app/api/premium/update/route.ts.
Proposed FEATURE_REGISTRY row (text only — not applied)
| Feature | Description | Status | Owner | Origin | Depends On | Notes |
|---|---|---|---|---|---|---|
| Ministry Pro Website blocks (crisis bar, need list, video testimonies, RSVP, ways to help) | 5 new Pro Website capabilities for ministry-vertical customers (recovery homes, pregnancy centres, shelters): site-wide opt-in crisis bar + quick exit, a categorized need list block, a video testimonies block, an RSVP block with submitter confirmation email, and a Ways-to-Help hub | planned | CWA | founder | pro-website-multipage (block system), contact_routing | Forcing customer: Almost Home Ministries. Spec: knowledge/acceptance/pro-website-ministry-blocks.md. Confirmation email + null-destination logging are portfolio-wide fixes, not ministry-scoped. |
Verification (evidence-or-nothing, once built)
pnpm build+pnpm lintgreen.- Playwright on a demo ministry church (do NOT touch Almost Home's real row before it's provisioned — use a
00000000-0000-4000-a000-*demo UUID per the never-touch-customer-data rule): enable the crisis bar, add a need-list item and mark it partially received, add an unconfirmed then confirmed video testimony, RSVP with an email and confirm the submitter email arrives with no address disclosed, configure and click all 4 Ways-to-Help cards, publish, and reload/s/[slug]+ at least one extra page to confirm every item above renders as specified. - Confirm a church with none of these features configured still renders byte-identically (regression guardrail #1).