Skip to main content

Pro Website — Ministry Template (v1)

Why this exists. A ministry that is not a church (Almost Home Ministries — a free, faith-based recovery home for women in West Virginia) must never be shown the church template: no "Join us this Sunday", no "Plan a Visit", no "Our Church", no "Service Times". On seeing the church template on staging the founder said: "you showed me the beautiful layout... and then you scrapped it and just used our regular Church pro website... what gives?" This template IS the approved mockup, made data-driven and reusable for the next ministry.

Scope. The public render only (/s/[slug] and /s/[slug]/[page]). Editing the new config/blocks from the admin editor is a later phase — v1 is seeded by the ChurchWiseAI team.

1. The fork (and the church guarantee)​

GivenRenders
premium_churches.cap_info.vertical === 'ministry' (and website_template is not service_business)Home → MinistryHomeTemplate; extra page → MinistryPageTemplate
cap_info.vertical absent / null / 'church' / anything elseUnchanged: home → UnifiedTemplate, extra page → SimplePageTemplate, byte-for-byte as before. Locked by src/lib/__tests__/ministry-template-fork.test.ts (a church fixture resolves to the church template).
website_template === 'service_business'Unchanged (the prospect-demo branch runs first; the ministry flag is not consulted).

The flag is the SAME cap_info.vertical flag the ministry-vertical branch (feat/ministry-vertical, labels + chatbot wording) reads — one flag, two consumers. Helper: isMinistryTenant() in src/lib/ministry.ts (same semantics as the helper in premium-shared.ts on that branch; either can import the other after both merge).

Opt-in per tenant, never a cross-tenant default (memory: feedback_vertical_behavior_is_opt_in_never_a_cross_tenant_default).

2. Data contract​

The ministry template is a TENANT OPTION, not an Almost Home special (founder, 2026-09-28: "design this with future ministry orgs in mind, so that there are options to select in the dash"). No component may hardcode a tenant's text, colours, phone numbers, crisis lines, item lists or section copy: every string comes from the row, or from the documented defaults in §2a. A unit test audits the component folder for tenant strings.

In PR A all ministry-only copy lives in cap_info.ministry (JSONB, no migration, no draft twin — the ChurchWiseAI team edits it). PR B moves it to a draft-aware column so the tenant edits it from the dashboard (§10). Every key is optional; a section whose data is absent does not render (never a placeholder, never [TBC] text on a live page).

cap_info.ministry = {
brand?: { name, sub?, mark? } // nav + footer wordmark ("Almost Home" / "Ministries · West Virginia" / "A")
crisis_bar?: { enabled, lead?, message?, phone, phone_display?, mobile_label?, footer_label?,
show_quick_exit? (default true), exit_url? (default https://weather.com) }
hero?: { pills?: string[], title, title_em?, title_end?, lead?, primary_label?,
secondary_label?, note?, note_link_label? }
get_help?: { eyebrow?, title, title_em?, free_title?, free_text?, safe_title?, safe_text?,
steps_title?, steps?: string[], location_note?,
lines?: {number, text, short?}[] } // lines default: 911 / 988 / 1-888-373-7888 (text 233733)
scripture?: { text, highlight?, dim?, reference } // home band; `highlight` word blooms to plum, `dim` word is ash
story?: { eyebrow?, title, paragraphs?: string[], witness?: { quote, cite } }
timeline?: { when, title, text }[] // ≤ 6 steps
timeline_title?: string // default "What to expect"
need_teaser?: { page_slug, eyebrow?, title, title_em?, lead? }
open_house?: { eyebrow?, title, text?, fine? }
testimonies?: { eyebrow?, title, title_em?, intro_quote?, page_slug?, consent_line?, updates_heading? }
products?: { eyebrow?, title, title_em?, paragraphs?, price_line?, items?: {category, title, price?, cta_label?, href?, ★photo_url?}[],
also?, fundraiser?: { title, text?, cta_label?, route_label? } }
ways?: { eyebrow?, title, title_em?, lead? }
closing?: { title, title_em?, text?, fine? }
footer?: { tagline?, email?, address_note?, legal? }
contact?: { title?, intro?, email?, phone?, show_mailing_address?, mailing_address?, show_location?, routes?: string[] } // §12
partners?: { title?, intro?, strip_title?, greyscale?, items: {name, logo_url?, link?, tier?, note?}[] } // §13
nav_highlight_slug?: string // the one nav link drawn in the accent
sections?: { type, enabled }[] // home order + on/off — see §4
}

2a. Defaults — what a brand-new ministry tenant sees​

The moment a tenant is switched to the ministry template with NO cap_info.ministry data:

SurfaceShows
Crisis barNothing (off until crisis_bar.enabled + a phone).
Nav / footerThe site name (custom_name) as the wordmark, its first letter as the mark (or the uploaded logo), one link per published extra page, Give only if giving_url is set; footer "Write & call" from the churches row, "Need help now" = the three public US lines (911 / 988 / trafficking hotline).
HomeHero only, with the site name as the H1 and custom_description as the lead. Story section only if story/timeline is set. Every other section hides until its data exists.
ColoursAccent = website_accent_hex, else the curated website_color_theme swatch, else the template plum #7A3F62; darker/lighter tones are derived from it. Linen/bone/oak neutrals are the template's own.
NeverAnother tenant's content. Nothing from Almost Home is a fallback.

Documented copy defaults (MINISTRY_DEFAULTS in src/lib/ministry.ts) — the only strings a component may show without row data:

KeyDefault
hero primary / secondary / note link"Get help" / "See what's needed" / "Come and see"
get-help title / columns"Need help?" / "What we offer." · "Your privacy." · "How to reach us."
timeline title · story eyebrow"What to expect" · "Our story"
testimonies title · consent line · updates heading"Their stories" · "Names are used only with permission. Some people choose to stay anonymous, and we honour that." · "Latest updates"
pending video label"Video coming"
need list title / note"What we need right now" / "Tap “I can help with this” on anything below. We’ll get back to you about drop-off, shipping, or a pickup."
ways to help title / cards"Ways to help" / Give "Support the work financially." · Volunteer "Give your time and skills." · Pray "Pray for the people we serve." · Book a speaker "Invite us to speak at your church or event."
phone label (mobile bar, footer)"Call us"

2b. Tier gating​

Standard on every plan that includes the Pro Website — cwa_website, cwa_complete (+ _annual) and the grandfathered Pro Website keys — exactly like multi-page (pro-website-multipage.md decision 1). No new entitlement, no Stripe change. Phone-only plans have no website and never see the option.

Other home data comes from existing columns: founders = custom_staff (name + title), story paragraphs fall back to custom_description (split on blank lines / sentences) when story.paragraphs is absent, Give = giving_url, phone/address = the churches row, Facebook = custom_social_media.facebook, chat = the existing ChatWidgetStream gate.

Ministry block types (in extra_pages[].blocks)​

Shapes follow pro-website-ministry-blocks.md; the extra optional fields marked ★ are additive.

BlockShapeHome use
need_list{ route_label?, items: NeedItem[] ≤ 40 }, NeedItem = { id, label, category, description?, quantity_wanted?, quantity_received?, needed_now?, external_link? }First 3 items → need-list teaser rows
video_testimonies{ items: { id, title (defaults to first_name), ★video_url? , first_name?, role?, duration_label?, poster_url?, featured?, consent_confirmed }[] ≤ 20 } — video_url absent = not hosted yet: the card shows its poster with "Video coming", no play buttonFirst 3 consented → home cards
rsvp{ event_title, date_tbc, event_date?, event_time?, ★event_month? (YYYY-MM), ★description?, address_note, route_label?, capacity_note? }Open House card
ways_to_help{ volunteer_route_index?, pray_route_index?, speaker_route_index?, ★copy?: { give?, volunteer?, pray?, speaker? } }Ways to help grid
★get_help{ free_text?, eligibility_text?, next_steps?: string[] } (spec §1)— (home reads cap_info.ministry.get_help)
★scripture{ text, reference, highlight? }—
★callout{ eyebrow?, title, title_em?, text?, bullets?: string[], button_label?, button_href?, route_label?, tone: 'dark' | 'light' }—

"Home use" = the home page reads the FIRST block of that type found across the published extra pages, so a ministry edits its need list once and both pages show it. All four new types are dropped by the church BlockRenderer (default branch) and are hidden from the editor's "Add block" picker until their editor forms exist.

Consent gate (hard): a video_testimonies item with consent_confirmed !== true never renders, anywhere. Zero consented items → the home Testimonies video row and the page block render nothing.

3. Chrome (every ministry page)​

GivenShows
crisis_bar.enabled and a phone, desktop ≥ 861pxA 44px --plum-night bar pinned to the TOP (sticky, above the nav): dot · "{lead} {message} Call {phone_display}." (tel: link) · Quick exit button (text label, never icon-only).
Same, phone ≤ 860pxThe top bar is hidden; a fixed BOTTOM bar (52px row + safe-area inset) shows: Call button "{mobile_label} / {phone_display}" · Chat button (only when the chat widget is on the page — opens it via cwa:open-chat) · Quick exit. The floating chat launcher is hidden on phones (docked in the bar instead). The footer reserves the bar height so the last line is never under it.
Visitor clicks/taps Quick exitlocation.replace(exit_url) — Back does not return to the site.
Visitor presses Esc three times within 2s (desktop)Same as Quick exit. Three Escs spread wider than 2s do nothing.
crisis_bar absent / enabled:false / no phoneNo bar, no padding, chat launcher unchanged.
AlwaysNav: wordmark (brand.mark tile + brand.name + brand.sub), one link per published in-nav extra page (the nav_highlight_slug link in plum), plum Give button when giving_url is set; ≤ 1080px the links collapse into a working menu button. Footer: tagline, "Write & call" (address, phone, email, Facebook), "Explore" (page links), "Need help now" (intake phone + 911/988/hotline, shown FIRST on phones), legal line, "Website by ChurchWiseAI".
AlwaysSkip link, visible focus rings, prefers-reduced-motion stops the Scripture bloom and hover lifts. Body 17px, tap targets ≥ 44px. No church words: "Join us this Sunday", "Plan a Visit", "Our Church", "Service Times" never appear.

4. Home page, section by section​

Order and visibility are per tenant: cap_info.ministry.sections is an ordered array of { type, enabled } over hero · get_help · scripture · story · need_teaser · open_house · testimonies · products · ways · partners · contact · closing. Absent = that list, all on (= the mockup + Partners strip §13 + Contact §12). Unknown types and duplicates are dropped; a type the tenant's list does not mention is inserted (enabled) at its default position, after its default predecessor, so a section added later never silently disappears. A disabled section never renders, and the hero hides a CTA whose target section is disabled.

Default order (= the mockup):

#SectionGivenShowsHidden when
1HeroheroPills, H1 {title} <em>{title_em}</em>{title_end}, lead, two CTAs (primary → #help, secondary → the Village page), note linking #open-house; arch media = custom_hero_url video (muted, no autoplay under reduced-motion) or hero_photo_url / first slideshow photo, else a plain plum arch (no "[VIDEO]" label)hero absent (falls back to name + description)
2Get help (#help)get_helpDark section: headline, Call button (crisis_bar.phone), Free / Safe / How to get in columns (steps as a list), then the three public linesget_help absent
3Scripture bandscriptureCentred italic verse, wood-grain rule, referencescripture absent
4Story + a year (#story)story and/or timelineHeading, founders from custom_staff (first 2, initials tile, no photo), paragraphs, witness quote; then the 4-step timeline (horizontal, last step plum; vertical on phones)both absent
5Need-list teaser (#need-teaser)need_teaser + a need_list blockHeading + lead + first 3 need items as tag rows each with "I can help with this" (opens the help sheet for that item) + "See the full need list" → /{page_slug}#needsno need_list items
6Open house (#open-house)an rsvp blockPlum card: eyebrow, heading, text, date tile (event_date → month + day; date_tbc + event_month → "Next · Oct / October 2026 · date to be announced"), address note; RSVP form (name, email, phone optional, party-size stepper 1–10, group) → "You're on the list."no rsvp block
7Testimonies (#stories)testimonies + consented video_testimoniesHeading + intro quote, 3 video cards (poster or accent tile, play button + duration — or "Video coming" with no play button when the video isn't hosted yet — first name, role), "See all N stories" → testimonies page, consent linezero consented videos AND zero news posts
8Wood shop (#shop)productsStory copy, price line, product cards (Order → href), "Also:" line, fundraiser card (opens help sheet for "Plaques for a Purpose fundraiser")products absent
9Ways to help (#ways)a ways_to_help blockUp to 4 cards: Give (→ giving_url, new tab) + Volunteer / Pray / Book a speaker (each opens the help sheet routed to that contact_routing index)no block, or no Give + no valid routes
10Closingclosing + giving_urlPaper card with wood-grain top rule, heading, text, Give monthly / Give once, fine printclosing absent or no giving_url

5. Extra pages (MinistryPageTemplate)​

GivenShows
Any ministry extra pageSame crisis bar, nav (current page marked aria-current), footer. Page hero: breadcrumb "Home / {title}", H1 = page title with its last word in plum italic, lead = the page's first text block (when it is the first block), photo arch = the first image block or a plain arch.
The page has a need_listThe hero adds the computed count tile ("14 things still needed to build it.") and CTAs "See what's needed" (→ #needs) + "Give toward the build" (→ #give, only when the page has a dark callout).
scripture blockBone band, italic verse, highlight word in plum.
need_list block#needs: heading, category chips computed from the items in first-appearance order plus "Everything {n}"; clicking a chip filters the cards client-side (count of visible cards changes); wood-tag cards with category, "Needed now" flag, description, progress bar only when both quantities are set, full-width "I can help with this".
"I can help with this"Dialog (bottom sheet on phones): item name as title, four "how" choices, name, email, phone (optional), note → POST /api/contact/church with routeIndex of route_label (fallback: first route) and message "Re: {item}\nHow: {choice}\n\n{note}" → "Thank you. The team will be in touch about {item}." Esc / backdrop closes and focus returns to the opener.
callout tone darkPlum-deep give card (#give when it is the first dark callout); button → button_href (new tab if external).
callout tone lightPhoto + text section with tick bullets; button opens the help sheet routed to route_label when set, else links button_href.
rsvp / video_testimonies / ways_to_help / get_help blockSame components as the home sections.
heading / text / button / imageMinistry typography (Playfair headings, 17px Manrope body, plum pill buttons).
staff_grid / ministries_grid / sermons_list / contact_blockThe shared church-block components in a plum-accent style (no church copy of their own besides what the tenant entered).

6. Forms — honest behaviour​

  • All forms POST the existing /api/contact/church unchanged (email is required by that API, so the forms ask for Email + optional Phone rather than the mockup's single "Email or phone" field). A form mentions a confirmation email only when the API says one was sent (confirmationSent, #1750); the help sheet and RSVP don't claim one.
  • A failed POST shows an inline error and keeps what the visitor typed.

7. Fonts​

Playfair Display (upright + a true italic, self-hosted src/fonts/ministry.ts, preload:false) and Manrope (already the root body font).

8. Out of scope (v1)​

Editor UI (moved to §10, PR B) · stage/timeline block on the Village page (the mockup's stage copy is placeholder — waits for the 2026-09-30 call) · "From the build site" updates on the Village page (renders from news posts only when posts exist) · chat widget restyle (owned by feat/ministry-vertical) · written-quote testimony cards.

9. QA checklist​

  • /s/<ministry> and /s/<ministry>/<page> render the ministry template; a church slug still renders UnifiedTemplate (unit test + one live church URL).
  • Crisis bar visible and pinned at 1440 (top) and 390 (bottom); Quick exit leaves the site with location.replace.
  • Need-list chips change the visible card count; "I can help with this" opens the sheet prefilled.
  • RSVP reaches "You're on the list." (Playwright stubs the POST so no row is written to prod).
  • No Join us this Sunday|Plan a Visit|Our Church|Service Times text on either page.
  • Screenshots at 1440 and 390 compared side by side with the mockup screenshots.

10. Dashboard (PR B) — pick the template, order the sections, edit the ministry content​

Split. PR A (#1751) = template + data contract + Almost Home seed (the team edits the data). PR B = the admin surfaces below, so the next ministry's admin runs their own site from a phone.

10.1 Template picker (Website tab → Template)​

The read-only "Template switching is coming in the next release" panel in WebsiteTabEditor.tsx is now two cards: Church (the current UnifiedTemplate) and Ministry / Nonprofit (this template). Choosing one writes draft_website_template through the existing draft path (/api/premium/update, section=website); Publish copies it to website_template like every other draft field. Values: Ministry = 'ministry'; Church = the site's existing church key (protestant_modern / catholic_liturgical / nondenominational_community, default protestant_modern) — never null, because a null draft means "no change" to Publish and could not switch back. /api/premium/update accepts only those four values (PICKABLE_TEMPLATES).

homeTemplateFor() precedence: service_business → sb; an EXPLICIT choice wins ('ministry' → ministry, any other non-null key → church); no choice (null, e.g. a team-seeded row) → the cap_info.vertical flag. Publish keeps cap_info.vertical in step ('ministry' sets it, a church key removes it), so the vertical labels + chatbot wording (feat/ministry-vertical) follow the picker. ?draft= previews the chosen template on the home page AND extra pages before Publish. The editor reads its ministry state from GET /api/premium/ministry-config (read-only; labels only, never an email).

GivenShows
A church opens the Template sectionTwo cards, Church selected, a one-line description each (phone-first — no longer flagged desktop-only).
Picks Ministry / Nonprofit, saves, previewsThe draft preview renders the ministry template with §2a defaults; the public site is unchanged.
PublishesLive site switches; cap_info.vertical = 'ministry'. Switching back to Church and publishing restores UnifiedTemplate and clears the flag.
service_business demo rowsPicker hidden (those rows never reach either template).
A ministry siteThe editor sidebar shows a Ministry group and hides the church-only rows (Services, Beliefs, Ministries, What to Expect, Events, Sermons, Pastor welcome video).

10.2 Home sections list​

"Home sections" group, visible only when the template is Ministry: the ten section types in the tenant's order, each row = label + on/off switch + up/down chevrons (≥ 44px targets, same pattern as PagesEditor's reorder). Saves sections through the draft path.

10.3 Editor sheets (SectionEditorSheet, one per group, fields from §2 only)​

SheetFields
Crisis baron/off, lead, message, phone, phone display, phone label, Quick exit on/off, exit URL (http or https only) — save rejected with "Add a phone number before turning the crisis bar on." when on without a phone
Get helpeyebrow, title, italic words, the three column titles + texts, up to 5 steps, location note; the three public lines are read-only
Productseyebrow, title, italic words, up to 4 paragraphs, price line, up to 6 items (category, title, price, button label, link), "also" line, fundraiser (title, text, button label, routed inbox)
Timelinetitle, up to 6 steps (when, title, text), up/down
Need listthe page's need_list block: items (label, category, description, needed now, wanted/received, buy link), up/down, routed inbox
Testimoniesthe page's video_testimonies block: items (first name, role, duration, poster upload, video link, featured) + the consent checkbox "I confirm this person has given written permission for this video to appear publicly." — unchecked items show a "Won't appear on the live site" badge

10.4 Data home for the editable config (founder-approved 2026-09-28)​

cap_info has no draft twin, so dashboard edits to it would go live instantly and bypass Publish. PR B adds nullable ministry_config jsonb + draft_ministry_config jsonb to premium_churches (registered in pro-website-draft.ts exactly like extra_pages), copies Almost Home's cap_info.ministry into both, and the renderer reads ministry_config (falling back to cap_info.ministry for one release). Migration file migrations/2026-09-28-premium-churches-ministry-config.sql (additive, nullable, backfills rows that carry cap_info.ministry); applied by the team lead at merge. The code tolerates the columns' absence: ministry_config is deliberately NOT in DRAFT_SELECT_COLUMNS / DRAFT_TO_CANONICAL (adding it there would break every church's preview and Publish before the migration); the renderer, the editor read route and the Publish step detect a missing column by code (42703 read / PGRST204 write) AND message text and fall back; saving a ministry sheet before the migration returns 409 "Ministry settings can't be saved yet — they switch on after a short system update. Nothing was changed." The need list and testimonies editors need no migration: those blocks live in draft_extra_pages. contact_routing cap raised 6 → 10 (founder decision).

10.5 QA (PR B)​

  • Church → Ministry → preview → publish → live, and back again; church site byte-identical after returning.
  • Reorder + disable sections on a phone (390px); the live home follows after Publish.
  • Each sheet saves, survives reload, shows in ?draft= preview, and goes live only on Publish.
  • Crisis bar save rejected with no phone; exit URL rejects non-https.
  • Unconsented testimony shows the badge in the editor and never on the live site.

11. Empty media slots collapse (founder review 2026-09-29)​

On the live Almost Home site the founder saw blank arches and tiles wherever no photo exists yet: the Village page hero, the product tiles, the need-list teaser, and the build-day and poster tiles. Rule: a media slot with no URL is not rendered. The section reflows to a text-only layout (one column, readable width) rather than showing an empty arch, tile or "[PHOTO]" box. When a URL exists, the arch or tile renders exactly as before.

SlotHas a URLNo URL
Home hero arch (custom_hero_url video, hero photo, first slideshow photo)Arch with the video/photo (+ inset photo when set)No arch. The hero is one column (hero no-media), text at reading width. If only the inset photo exists, it fills the arch.
Extra-page hero photo (first image block)Arch beside the title (and the need-count tile)No arch. vhero solo: title, lead, need-count tile and CTAs in one column.
Need-list teaser photoPhoto beside the rowsOne column (village no-media)
Wood-shop photo (shop-top)Photo beside the storyOne column (shop-top no-media)
Product card photo (★products.items[].photo_url, http(s) only)Square photo tile on the cardA text card: category, title, price, Order. No tile.
News post thumbnail (hero_image)Thumbnail beside the titleTitle-only row
Testimony card poster, video not hosted yetPoster + "Video coming"Text card: name, role, "Video coming". No dark tile.
Testimony card with a hosted video but no posterUnchanged: the dark tile is the play surface for a real video—
Founder headshot (custom_staff[].photo_url)Round photoName + title only, no initials circle

Invariant (tested): every rendered .ph element carries .has-media. A unit test renders each component with no image URLs and asserts that no class="ph…" element without has-media is emitted. The e2e asserts document.querySelectorAll('.mh-root .ph:not(.has-media)').length === 0 on the live ministry home and every extra page.

12. Contact page + directions (founder review 2026-09-29)​

A ministry needs one obvious place to reach a person. That place is distinct from Get help, which is the crisis path. Per tenant, all optional:

ministry_config.contact = {
title?: string // home section heading; default "Get in touch"
intro?: string // the tenant's own words, above the form
email?: string // shown in the details column; fallback footer.email → churches.email
phone?: string // shown; fallback churches.phone
show_mailing_address?: boolean // default false
mailing_address?: string // shown ONLY when show_mailing_address is true
show_location?: boolean // default FALSE (safety: a recovery home may not publish its address)
routes?: string[] // contact_routing labels offered on the form; absent = all of them
}

Home section contact (new type, default on). It sits between ways and closing in the default order. A section type missing from a tenant's saved list is now inserted at its default position (after its default predecessor), not appended at the end, so existing tenants get Contact above the closing card.

GivenShows
Home, contact enabled#contact: heading (title / "Get in touch"), intro, details (phone as a tel: link with the number as selectable text, email as mailto:). The tenant has a contact page → "Send us a message" button to it. No contact page → the form inline.
An extra page containing a contact_block (the Contact page)Page hero (title + page lead, or contact.intro). Then a two-column panel: form | details. One column on phones, form first.
FormThe existing ContactForm: "I want to…" route chips = the tenant's contact_routing labels (filtered by contact.routes when set; each chip posts that route's REAL index), name, email, phone (optional), message, honeypot + the 3s time-trap, and the #1750 confirmationSent success copy. A tenant with no routes shows a single "General question" chip that falls back to admin_email. Never the church Visit/Prayer/Other chips, and never "this Sunday" placeholder copy.
Details columnPhone (tel: + selectable text), email, mailing address only when show_mailing_address, office hours only if set.
show_location: trueBelow the panel, the shared MapSection for the churches row address, plus a "Get directions" link (Google Maps directions URL).
show_location false/absentNo map, no street address, no directions, on ANY ministry page. That covers the footer's "Write & call" (city/state only) and the JSON-LD PostalAddress (no streetAddress/postalCode). Found live on Almost Home 2026-09-29: street address in the footer + JSON-LD.
Every contact surfaceA cross-link card: "Need a safe place now?" → Get help (the get-help page when one exists, else /#help), plus "Call {crisis phone}" when the crisis bar is on.
Nav + footerThe Contact page appears like any in-nav page. No contact page but the home section is on → a "Contact" link to /#contact in the nav and the footer "Explore" list.

Editor: a Contact sheet in the Ministry group. It writes draft_ministry_config.contact through the patch path, and Publish promotes it:

FieldNotes
Heading, introtext
Email, phoneshown on the page; blank = fall back as above
"Show a mailing address" + mailing addressoff by default
"Show your location and directions"off by default, with the help text "Leave off if your address must stay private."
Which inboxes appear on the formone checkbox per contact_routing label; all checked = all
"Add a Contact page"only when the site has none; adds a contact page (contact_block) to the draft pages

Almost Home seed:

  • A contact page in draft_extra_pages; the team publishes it.
  • "General question" appended to contact_routing (7 routes; existing indexes unchanged).
  • contact = their own words for the intro, phone 304-389-0805, email partnerimpact@almosthomeministries.org, show_mailing_address: false, show_location: false until the 2026-09-30 call confirms the address may be public.

12.1 QA​

  • Contact page at 1440 and 390: form | details, then details under the form on phones. Chips = all 7 Almost Home labels.
  • Submitting (Playwright stubs the POST) reaches the success state. With the stub returning confirmationSent: true, the copy mentions the confirmation email.
  • show_location false → no iframe, no street address, no "Get directions". True (demo tenant) → the map and the directions link.
  • The "Need a safe place now?" link resolves to the get-help surface.
  • §11 invariant: zero .ph:not(.has-media) on the home page and every extra page.
  • Church pages byte-identical (HTML diff = 0) — ContactForm gains only optional props.

13. Partners + Give page (founder gap, 2026-09-29)​

The ministry's old site had a corporate-donor logo wall and donor information that ours did not carry. Partners are per tenant and optional; with no partners, nothing renders.

ministry_config.partners = {
title?: string // wall heading; default "Our partners"
intro?: string // tenant words above the logos
strip_title?: string // home strip heading; default "Thank you to our partners"
greyscale?: boolean // logos grey until hover/focus; default false (full colour)
items: { name, logo_url? (http(s)), link? (http(s)), tier?, note?, dark_bg? }[] // ≤ 60; name required; dark_bg = light logo on a dark tile
}

Blocks. partners renders the wall and has no fields of its own. give_by_mail (optional title and text) renders the mailing address from ministry_config.contact, and ONLY when show_mailing_address is on. Both are ministry-only; the church BlockRenderer drops them.

Give page. Built from existing blocks. Nothing else is new:

  • text for the tenant's verbatim donor copy, e.g. how to become a corporate partner.
  • a dark callout for recurring giving, with button_href = the tenant's giving_url.
  • a light callout or the Village need_list for in-kind gifts and the wish list.
  • partners for the wall.
  • give_by_mail for cheques.
GivenShows
A page with a partners block and partners exist#partners: heading, intro, logos in a responsive grid. When any partner has a tier, the logos are grouped under tier headings in first-appearance order, and untiered partners form one untitled group. No tiers: one flat wall.
A partner with a logo<img alt="{name}">, contained, never cropped, in a fixed 3:2 tile (every tile the same size). dark_bg puts a light logo (e.g. white on transparent) on a dark tile.
A partner without a logoIts NAME as text in the tile, never an empty tile (§11).
A partner with a linkThe tile links there in a new tab (target="_blank" rel="noopener noreferrer").
greyscale: trueLogos grey until hover or keyboard focus. Off by default.
Home partners section (new type, default on, between ways and contact)A compact strip "Thank you to our partners" of LOGOS ONLY, plus "See all our partners →" (to /{page}#partners) when a page carries the wall. Only when ≥ 3 partners have logos, otherwise nothing.
give_by_mail with show_mailing_address off or no addressNothing.
give_by_mail with the toggle on"Give by mail" (or its title), its text, the address with line breaks kept.
No partnersNo wall, no strip, no heading.

Editor. A Partners sheet in the Ministry group:

  • heading, intro, strip heading, greyscale switch.
  • per partner: name, logo upload (the existing image route: JPEG/PNG/WebP), link, group (tier), short note, and up / down / remove.
  • "Add a partner", up to 60.
  • "Add a Partners page" when no page has the wall.

It writes draft_ministry_config.partners through the patch path. Removing every partner removes the section.

Almost Home seed.

  • Source: screenshots/almost-home/media/donors/DONORS.md.
  • Logos uploaded to Storage; names verbatim; no invented tiers (a tier only if their site groups them).
  • The donor blocks go on their existing Ways to Help page: donor lead, the Ways grid, the donor copy, "Purchase or donate today" (→ giving_url), the wall, and Give by mail. It is not a new page because a site has at most 8 extra pages (MAX_EXTRA_PAGES) and a 9th is silently dropped. A separate Give page made Contact the 9th and it 404'd. The home strip links to /ways-to-help#partners.
  • Written to DRAFT for the team to publish.

Reserved slugs. Every literal /s/[slug]/<folder> route is now in RESERVED_PAGE_SLUGS: blog, coming-soon, featured, guides, listing, my-homes, partners, reviews, scan, search, sold, tools, unsubscribe. Those routes win over [page], so a page with one of these slugs saved fine but 404'd; this was found when a ministry partners page hit the realtor /partners route. No existing tenant page used any of them (checked 2026-09-29). The editor's "Add a Partners page" uses our-partners.

13.1 QA​

  • Wall: tier headings only when tiers exist; alt text = name; links open a new tab with rel="noopener noreferrer"; a partner without a logo shows its name.
  • Home strip: absent with < 3 logos, present with ≥ 3; logos only.
  • Give by mail: absent unless show_mailing_address is on with an address.
  • §11 invariant holds on every page; church pages are byte-identical.

14. Order form (founder gap, 2026-09-29)​

Founder: "what about the order a plaque and all the customization — did we create a form?" Until now /plaques linked out to the ministry's old Wix order form. That form asks for name, email, phone, physical address, and up to three lines of "plaque number + quantity". The ministry template gets its own order form. It takes the order and never takes payment: the team confirms and arranges payment.

ministry_config.products.order = {
categories: string[] // chip list, e.g. Scriptures, Sayings, Hymns, Pro Football, College Sports, Hunting & Military, Big Foot (≤ 20)
unit_cents: number // price of one item, e.g. 2500
bundle?: { qty: number, cents: number } // e.g. { qty: 2, cents: 4500 } = "2 for $45"
item_noun?: string // default "plaque"
personalization_max?: number // 0 / absent = no personalization field; else char limit (≤ 200)
pickup?: boolean // default true
ship?: boolean // default true
route_label?: string // contact_routing label for orders; default "Plaque order"
bulk_route_label?: string // "Order in bulk / fundraiser" chip → this route (e.g. the fundraiser's)
}

Price. Total quantity across all lines is q: floor(q / bundle.qty) × bundle.cents + (q mod bundle.qty) × unit_cents. With no bundle it is q × unit_cents. Integer cents, shown as dollars. Almost Home: 1 → $25, 2 → $45, 3 → $70, 4 → $90. Shipping is never added: shipping cost is "confirmed by the team".

Block order_form (ministry-only; the church BlockRenderer drops it), with optional title and intro. It renders only when products.order exists with unit_cents > 0, and renders nothing otherwise.

GivenShows
The formPhone-first single column. Kind: "Plaques" / "Order in bulk / fundraiser" chips; bulk only when bulk_route_label resolves. Items: 1–5 lines, each with category chips, "Design number or description" (required, ≤ 80), and a quantity stepper (1–50, 44px buttons). "Add another" / "Remove". Personalization (only when personalization_max, with a live character count). Delivery: Pick up / Ship (only the enabled ones). Ship reveals street, city, state/province and ZIP/postal code, all required. You: name, email (required), phone (optional). Notes (optional). A live total ("3 plaques · $70", plus "shipping confirmed by the team" when shipping).
SubmitPOST /api/contact/church (unchanged): name, email, phone, routeIndex of route_label (or of bulk_route_label for bulk), honeypot website, renderedAt (3s guard), and a structured message (below). The button is disabled while sending.
Success"We got your order — the team will confirm and arrange payment." When confirmationSent, also: "We've also sent a confirmation to your email."
FailureInline error; everything typed is kept.
PaymentNever. No card fields, no checkout.

Structured message (the team reads this in the notification email and the dashboard):

PLAQUE ORDER (or BULK / FUNDRAISER ORDER)
1. Scriptures — design: #12 — qty 2
2. Hymns — design: Amazing Grace — qty 1
Total: 3 plaques · $70.00 (before shipping)
Personalization: For Mom, 2026
Delivery: Ship to 1 Main St, Springfield, OH 45501 (or: Pick up)
Notes: ...

Editor. In the Products sheet, an "Online order form" group:

  • a categories list;
  • the price for one item;
  • an optional bundle ("__ for $__");
  • the personalization character limit (0 = off);
  • pickup and ship switches;
  • which inbox gets orders, and which gets bulk/fundraiser orders.

Almost Home seed (draft; the team publishes).

  • Categories from their store page, verbatim: Scriptures, Sayings, Pro Football, Hymns, Big Foot, College Sports, Hunting & Military.
  • unit_cents 2500, bundle {2, 4500}, pickup + ship, personalization on at 120 characters (optional to the buyer; the founder asked for "all the customization").
  • route_label "Plaque order", appended to contact_routing with no email yet. bulk_route_label = the fundraiser's route.
  • On /plaques, the "Order a plaque" Wix button is replaced by the order_form block. The "Plaques for a Purpose" text stays.

14.1 QA​

  • Unit: price 1 → 2500, 2 → 4500, 3 → 7000, 4 → 9000 (and no-bundle: 3 → 7500 as the positive control); message builder output; validation (missing design, ship without address).
  • e2e on the Almost Home draft: fill 2 lines (qty 2 + 1), ship, submit → success copy; the contact_submissions row for that submission has a message containing each line, "Total: 3 plaques · $70.00" and the address. Test identity "QA test — plaque order" at a john+alias address.
  • Bulk chip posts to the bulk route; the form renders at 390 with 44px targets.
  • Church pages byte-identical.

15. Articles — "Stories & Articles" (founder ask, 2026-09-29)​

"Write a couple of articles and create a blog section to drive traffic for donors and for women to find them." — founder, 2026-09-29

A ministry publishes articles that answer the questions two audiences type into Google and AI assistants: a donor ("how do I support a women's recovery home in West Virginia?") and a woman who needs help ("is there a free faith-based recovery home for women near me?"). Each article ends by handing that reader to the right next step (Give / Get help / Come and see).

15.0 Data model (decision — no third content model)​

Articles ARE the church News posts: rows in re_content_items with church_id = the tenant's church, type='blog' (spec church-blog-mvp.md). That store already has title, slug (unique per church), excerpt, body, hero image, draft/published, published_at stamped on first publish, author name, and a metadata JSONB. The brand-partitioned articles table (wiseaiagency / wisedatingprep) was rejected: it is partitioned by brand, not tenant, and would be a second per-tenant post store beside this one. No migration.

Ministry-only fields live in metadata (church posts ignore them):

metadata.audience 'donors' | 'help' | 'updates' // required to publish
metadata.body_markdown string // the source the owner edits
metadata.faq { q: string, a: string }[] // optional, ≤ 8, drives FAQPage JSON-LD
metadata.author_image string | null // unchanged (church posts)
excerpt = the "dek": 1–2 sentence answer-first summary (≤ 320)
author_name null = the ministry is the author; a name = a named team member

Render rule: when metadata.body_markdown is present the page renders it through marked and then the SAME allowlist sanitizer church posts use (sanitizeRichTextHtml: p, h2, h3, lists, blockquote, links forced rel=nofollow noopener, bucket images only); otherwise body_html through the same sanitizer. Saving from the editor also writes the sanitized HTML to body_html.

Tenant settings (ministry_config.articles, draft → Publish like every other ministry setting): { nav_label?: string (default "Stories & Articles"), title?: string (index H1, default = nav label), intro?: string }.

15.1 Routes (ministry tenants only)​

GivenShows
/articles on a ministry tenant with ≥ 1 published articleMinistry chrome (crisis bar, nav with the Articles link current, footer). H1 = articles.title, intro. Cards newest-first by published_at: hero image (only when set — no empty image box), audience tag ("For donors" / "If you need help" / "Updates"), title, dek, date, "N min read" (words ÷ 220, min 1). 12 per page; ?page=2… with Newer/Older links; a page number past the end 404s.
/articles/<slug> publishedBreadcrumb Home / {nav label} / title. Audience tag, H1 title, dek (.art-dek, the Speakable target), byline "By {author_name or ministry name} · {date} · N min read", hero image when set, body in a 62ch column (Playfair headings, template body font), FAQ (when present) under "Questions people ask", the safe-place strip ("Need a safe place now?" → Get help + Call {crisis phone}; shown whenever the tenant has a Get help target or an active crisis bar — on every article except a help article whose CTA renders, because that CTA already is Get help + Call and stacking both repeats the same two buttons), the audience CTA (15.2), then up to 3 related articles (same audience first, then newest).
Draft article, no/invalid token404 — drafts never render publicly, never appear on the index, home, sitemap, llms.txt or feed.
Draft article, ?draft=<admin_token>Renders with a "Draft — only you can see this" banner and noindex. The index with the token lists drafts too, badged.
/articles, /articles/* on a church tenant (or a ministry with no published articles and no owner token)404 — exactly as before this feature (no church had an extra page slugged articles; verified 2026-09-29).
/blog and /blog/<slug> on a ministry tenant308 → /articles and /articles/<slug>, so the ministry never renders the church-chromed News page. Church /blog unchanged.
articles as an extra-page slugRejected by the Pages editor (RESERVED_PAGE_SLUGS). Article slugs feed and page are suffixed (feed-2) so they cannot shadow the feed route.

Home. The "Latest updates" block inside the Stories section shows the 3 newest published articles (was 2), linking to /articles/<slug>; "All updates" → /articles. The nav shows the Articles link (label = articles.nav_label) on every ministry page only while ≥ 1 article is published (never an empty tab).

15.2 Audience CTA​

audienceBlockPrimarySecondary
donors"Stand with {brand}"Give → giving_url (new tab)"See what's needed" → the need-list page, when one exists
help"You don't have to do this alone"Get help → Get-help page / /#helpCall {crisis phone} (tel:) when the crisis bar is on
updates"Come and see"Open house → /#open-house when an RSVP block exists and the home section is onGive → giving_url

A button whose target does not exist is not rendered; a block with no buttons is not rendered.

15.3 SEO / GEO​

  • <title> {title} | {brand}, description = seo.metaDescription ?? dek, canonical = resolveProWebsiteCanonical(slug, '/articles/<slug>') (custom domain → subdomain → /s/slug).
  • OG: type=article, title, description, image = hero image ?? logo, article:published_time.
  • JSON-LD (one @graph): Article (headline, description, datePublished, dateModified, author = Person when named else the organisation, publisher = NGO {name, url, logo}, image, mainEntityOfPage, speakable → .art-dek + h1), BreadcrumbList (Home → Articles → title), and FAQPage only when metadata.faq is non-empty. Index: CollectionPage + BreadcrumbList.
  • noindex, nofollow on every articles page while the tenant is staged (cap_info.search_indexable === 'false') or when previewing a draft.
  • Feed: /articles/feed (RSS 2.0, application/rss+xml, newest 20 published) linked from every articles page via <link rel="alternate" type="application/rss+xml">. (Not feed.xml: on *.john316.church the middleware serves any *.xml path as a static file, so a .xml route under a tenant would 404 there.)
  • Tenant sitemap ({host}/sitemap.xml) adds /articles + each published article (lastModified = updated_at) for an indexable ministry tenant; llms.txt adds an "Articles" section (title — dek) for the same. Church sitemaps / llms.txt unchanged.

15.4 Editor — Website tab → Ministry → "Articles"​

Phone-first sheet, same primitives as the other ministry sheets (16px inputs, 44px targets).

  • List: every article, newest activity first — title, audience tag, Draft/Published pill, date. "New article" button. Nav-label field (saves ministry_config.articles.nav_label, goes live with the site's Publish like other ministry settings).
  • Edit: Title · Link (slug, auto from the title until edited; shown as /articles/<slug>) · Dek (320) · Audience (3 choices) · Body (Markdown, plain textarea, a Write / Preview toggle — Preview is the server's sanitized render) · Hero image (the existing /api/upload/custom-page-image route) · Author (blank = the ministry) · FAQ (add/remove question + answer rows, ≤ 8) · live word count ("312 words" / "212 of 300 words to publish").
  • Save draft always allowed. Publish requires title, dek, audience and body ≥ 300 words; the editor lists what is missing and the server enforces the same rule (400 publish_blocked with the list). Unpublish returns it to draft (keeps published_at). Delete asks to confirm. Publishing an article is immediate — it does not wait for the site Publish button (same as church News posts).
  • RBAC: list/save = website:sections:edit; publish/unpublish = website:publish. API: /api/premium/ministry-articles (GET, POST), /[id] (PATCH fields | {action:'publish'|'unpublish'}, DELETE), /preview (POST markdown → sanitized HTML). 404 for a church tenant.

15.5 QA​

  • Unit: audience → CTA map (each audience, missing-target buttons dropped, empty → null); publish gate (299 vs 300 words, missing dek/audience); articles is not a valid page slug (positive control: village is); article slug feed → feed-2; JSON-LD shape (Article + BreadcrumbList, FAQPage only with FAQ, NGO publisher, Person author only when named); drafts excluded from the public reader.
  • Playwright on the preview, Almost Home staging row: create a draft in the editor → 404 at /articles/<slug> without token → publish → index + article render at 1440 and 390 → JSON-LD parses with Article + BreadcrumbList → unpublish → 404 → delete the test article.
  • Church byte-diff: / and one extra page of a church tenant identical to main.

17. Train AI for ministry tenants — basics + Advanced fold (founder review 2026-09-29)​

Why. Founder, 2026-09-29: "some of the Training AI features might be too complicated for most orgs. Maybe we can simplify some of this.. I think we can still have the advanced features, but make it clear these are advanced tech/admin features... and make the UI user-friendly." And: "i don't want to mess anything up that's working." So this is a regrouping of the Train AI tab (?tab=training) for MINISTRY tenants only — nothing is deleted, no form field or save route changes, and a church tenant renders byte-identically to today. (§15 and §16 are reserved for in-flight specs on other branches.)

Scope. cap_info.vertical === 'ministry' (the useIsMinistryTenant() flag from VerticalProvider, set server-side in admin/[token]/page.tsx from premium.vertical, which PREMIUM_CANONICAL_COLUMNS selects as vertical:cap_info->>vertical). Opt-in per tenant, never a cross-tenant default. Every other tenant — every church — keeps the existing rail (Church basics / Greeting & tone / Questions & answers, seven sections, same labels, same progress numbers, same AgentSettingsPanel).

17.1 The rail — two tiers​

Tier 1 — "The basics." Three plain-language items, each with a one-line "why" under the label.

Rail itemOne-line whySection keyRenders
About your ministryWho you are, what you offer, and where you send people.church-knowledgeThe church-knowledge card grid with ministry copy, cards in this order: Team · Programs & services · What to expect · Where we refer people · Events · Documents. "Where we refer people" IS the existing Local Resources card, relabelled. No Service Times card; the Benevolence card is not here (it moves to Advanced, §17.2). Card summaries stay data-driven (counts) exactly as today. Sheet eyebrow reads "About your ministry".
Questions & answersThe questions people ask most, answered your way.faqsFAQManagement, unchanged.
Greeting & hand-offHow your assistant says hello, and who it hands people to.agentsAgentSettingsPanel in layout="basics": the welcome greeting, the lead contact name, the "I want a real person" hand-off message (voice plans), the chat welcome message (chat plans), and the tone / personality controls ("How your assistant responds"). Nothing else is shown here.

Tier 2 — "Advanced — for your tech or admin person." COLLAPSED by default. Explainer line, verbatim: "You don't need these to get started. They're here for whoever set up your website." Each item carries a small Advanced pill (in the rail and in the section header).

Rail itemSection keyRenders
Safety rulessafetySafetyCompliance — unchanged in BEHAVIOUR; copy ministry-branched ("Your community is protected…", never "congregation").
Try a conversationsimulatorSimulatorPanel, unchanged (still hidden in Pastor view, as for churches).
Integrations & widgetintegrations (new key)AgentSettingsPanel in layout="advanced": the blocks moved out of Greeting & hand-off — core call tools (prayer / visitor / callback), Planning Center, Cal.com, Online giving (URL, e-transfer, giving message), voice capabilities; the chat widget preview, embed-anywhere code, Disable chatbot, widget customization, chat capabilities. Same fields, same names, same /api/premium/update sections (voice_agent, chatbot).
Requests for financial helpbenevolence (new key)The existing BenevolencePolicyEditor — unchanged in BEHAVIOUR (same policy, toggles, API); copy ministry-branched ("Turn on requests-for-help handling for your assistant", "your assistant refers them to Where we refer people"); relabelled, moved out of the card grid.

17.2 Rules​

GivenShows / does
A ministry admin opens ?tab=training with nothing storedTier 1 visible; Advanced collapsed (aria-expanded="false", the list is hidden); "About your ministry" active.
Clicks the Advanced toggleThe four items appear with their Advanced pills and the explainer line; the choice is remembered per admin token in localStorage (cw-trainai-advanced:<token>, wrapped in try/catch — a blocked store just means "collapsed next time").
Follows a deep link to an advanced item — #training-safety, #training-simulator, #training-integrations, #training-benevolence, or the ?tab=training-… forms (HASH_TO_TAB in AdminDashboard.tsx)That section is active AND the fold is open, regardless of the stored state. Church-era hashes (#training-agents, #chatbot, #voice…) still land on Greeting & hand-off.
Saves Greeting & hand-offOnly the greeting / name / hand-off / chat welcome change. The integration fields (Planning Center, Cal.com, giving, core tools) are posted as hidden fields carrying their current values, so a basics save never clears an integration — the voice_agent handler treats a missing checkbox as off, which is exactly why the two tiers may not be two bare forms. Secrets (pco_secret, cal_api_key) are never re-posted; an empty secret means "keep".
Saves Integrations & widgetThe reverse: greeting / name / hand-off ride along as hidden fields with their current values; chatbot_welcome is omitted (the handler skips an empty welcome).
Role / capabilityUnchanged gates: train:church_knowledge:edit (About your ministry, Requests for financial help), train:faqs:edit, train:agents:edit (Greeting & hand-off, Integrations & widget), train:safety:edit, train:simulator:use. A role that lacks every advanced capability sees no Advanced fold at all. Pastor/Tech view: the simulator is hidden in Pastor view exactly as for churches.
CopyPlain English throughout — including the tab strap ("…the better your assistant answers") and the two Advanced panels above. Never "index into searchable chunks", "agents", "Pastor", "sermon", "Sunday", "congregation", "worship" — say "your assistant".
Progress cardCounts tier-1 sections only — "N of 3 done": About your ministry (a team member, a program, or a what-to-expect answer on file), Questions & answers (≥ 3 FAQs, the same hasFaqs the setup checklist uses — real data, not a visit), Greeting & hand-off (a saved greeting or agent config). At 3 of 3 the card reads "You're set" with "Your assistant has what it needs. The advanced settings are optional." No fake checkmarks; Advanced items never count.
A church tenantRail, labels, groups, progress numbers, section descriptions, AgentSettingsPanel (default layout="full") and the card grid are unchanged. Locked by src/lib/__tests__/admin-ministry-trainai-simple.contract.test.tsx (renders the church rail and asserts the exact seven data-section keys, labels and group captions).

17.3 QA​

  • Unit (admin-ministry-trainai-simple.contract.test.tsx): ministry render shows the three basics with their why-lines and a collapsed Advanced group containing safety / simulator / integrations / benevolence; church render matches the literal seven-section list; initialSubTab='safety' renders the fold open with the safety panel active; Pastor view drops the simulator from the fold.
  • Preview, Almost Home (?tab=training): rail collapsed → expanded → Greeting & hand-off shows only greeting / name / hand-off / tone; #training-integrations opens the fold with Integrations & widget active. Screenshots in ai-company-os/tasks/artifacts/TASK-20260928-almost-home/trainai-simple/.
  • Preview vs production, demo church (churchwiseai-demo) Train AI: identical rail and progress card — the "churches unchanged" evidence.
  • Saving Greeting & hand-off on a tenant with Planning Center enabled leaves pco_enabled true (hidden-carrier rule).

18. Chatbot provisioning for ministry tenants (2026-09-29)​

provisionChatbot() (src/lib/chatbot-provision.ts) runs from the dashboard's Set up chatbot button (/api/premium/update chatbot_enable) and from Stripe-driven provisioning (the webhook's activateChurch and provisionNewChurch). It seeds organization_settings.chatbot_config and sets premium_churches.chatbot_enabled / chatbot_agent_id — the flag the dashboard's FAQ panel, "Greeting & hand-off" field and Train AI progress all key off. It must be safe to click for a ministry.

The vertical is read with the same select as everything else (vertical:cap_info->>vertical from premium_churches, alongside custom_name) and tested with isMinistryTenant.

Church (unchanged, byte-identical)Ministry (cap_info.vertical === 'ministry')
Welcome text"Hi! I'm the AI assistant for {name}. Ask me anything about our services, events, or how to get involved!""Hi, I'm the assistant for {name}. I can help you get in touch, learn about our programs, or find ways to support the work."
Suggested questionsservice times · get involved · first visit"How do I get help?" · "How can I support the ministry?" · "Can I visit?"
Facts block, first lineChurch: {name}Organization: {name}
Denomination: linewhen the directory row has onenever
Theological lensexplicit lens row → else denomination mapping → else Christocentricexplicit lens row → else Christocentric (no denomination lookup)
Denomination starter packs (canned_responses, source pack)universal + denomination packskipped — they are church FAQs and would pollute a recovery home's knowledge
Everything else (tone, language, knowledge_base, availability, source, agent_config)identicalidentical

The ministry greeting stays AI-disclosed ("I'm the assistant") and is a bridge to the humans who run the ministry (get in touch / programs / support) — never "services", "sermons" or "first visit".

Never overwrite. If an organization_settings row already exists for the tenant, chatbot_config and agent_config are left exactly as they are; only the premium_churches flags are (re)set. The write itself is ON CONFLICT DO NOTHING (upsert … ignoreDuplicates), so a webhook and a button click racing cannot clobber a customised greeting either. Before this, a second run replaced the whole config.

18.1 QA​

  • Unit (src/lib/__tests__/chatbot-provision.ministry.contract.test.ts, in the CI list): ministry copy strings (no church / service times / sermon / first visit / denomination in the copy blocks; positive control on the church copy); no sai_theological_lenses query for a ministry; packs not called for a ministry, called with the pre-change arguments for a church; church config deep-equals the pre-change snapshot; an existing row → no organization_settings write, flags still set; lookup error → no write at all.
  • Almost Home (premium 7968e999-…, church 23cfe6b1-…): after the founder clicks Set up chatbot on the real dashboard, organization_settings.chatbot_config.welcome_message.text begins "Hi, I'm the assistant for Almost Home Ministries", church_facts begins "Organization:", canned_responses has zero rows with source='pack' for that church, and the FAQ panel / greeting field / Train AI progress render. Agents never click the button themselves.
  • Church byte-diff: provision a demo church (00000000-0000-4000-a000-000000000001) → config identical to the snapshot in the unit test.

22. Emails for ministry tenants (2026-09-30)​

Every email a ministry admin (or a team member they invite) receives from the platform speaks their language. A church tenant's emails do not change by a single byte. Founder directive 2026-09-29: "an organization dashboard that has no evidence of churches" — the emails are part of that dashboard.

Gate. isMinistryTenant(premium.vertical) — cap_info.vertical === 'ministry', read with the same vertical:cap_info->>vertical select alias every other reader uses, added to the row each caller ALREADY loads (no extra round-trip). Absent or false = church. Never a cross-tenant default.

One table. src/lib/email-ministry-copy.ts holds every fragment that differs. EMAIL_COPY.church is the pre-existing literals verbatim; EMAIL_COPY.ministry follows the approved ministry voice (church → ministry, congregation → community, pastor → team, theology → dropped, bulletin → flyer). Templates interpolate the table, so no church literal lives in two places.

Copy only. Links, unsubscribe, From, Resend plumbing and subjects are unchanged; a subject changes for a ministry only where the church subject itself carries church vocabulary. No AI-disclosure line is added to any email (disclosure lives in the agent).

EmailChurch (unchanged)Ministry
Welcome / magic link — Pro Website"Your new church website is live" · "add your staff and ministries, connect your church calendar" · "Most churches finish in under 10 minutes""Your new ministry website is live" · "add your team and programs, connect your calendar" · "Most teams finish in under 10 minutes"
Welcome / magic link — chat/voice"your church profile, hours, theology, … part of your church … Most pastors finish""your ministry profile, hours, … part of your team … Most teams finish"
Team inviterole label from getRoleLabel · "Contact your church admin"same role KEY (RBAC untouched); Worship Leader → Program Lead, Spiritual Leader → Care Director · "Contact your ministry admin"
Trial ending"Congregation Care messaging""Community care messaging"
Phone number ready"website, bulletins, and social media" · "Forward your existing church line""website, flyers, and social media" · "Forward your existing line"
Plan upgrade notice"Someone at your church …" · "your church's ChurchWiseAI plan""Someone on your team …" · "your ministry's ChurchWiseAI plan"
Product added (voice)"Your Church Phone Number" · "Share it with your congregation.""Your Phone Number" · "Share it with your community."
Product cancel scheduled (voice)"Your church number … is reserved""Your number … is reserved"
Lifecycle drips (welcome, day 2, day 7, day 13, day 30, win-back)unchangedministry table: hours instead of service times, "how ministries are using their chatbot", no theology items, "feedback from teams like yours"
Fallbacks when a name is missing"Your Church" / "your church" · "Hi Pastor,""Your Ministry" / "your ministry" · "Hi Friend,"

Drips a ministry never receives. The day-5 theology nudge ("Make your chatbot sound like your church"), the AI Starter Kit (the church PDF "107 Ways Your Church Can Use AI") and the ShareWiseAI cross-promo ("built for churches"). They are dropped from the ministry cron schedule (getCronScheduleForPlan(plan, channel, isMinistry)), the cron skips the cross-promo loop for a ministry, and sendAndLog refuses the keys for props.isMinistry before any dedup row is written.

Not sent to ministry tenants (left as church copy, by design). Newsletter / 7-day course / kit-buyer sequences (subscribers, not premium tenants), PewSearch / ITW / SermonWise cross-promos, prospect outreach, SermonWise moderation, the PewSearch church-approval email, founder notifications.

Critical paths. The Stripe webhook and /api/premium/update diffs only add the select alias and pass isMinistry through; no behaviour changes.

22.1 QA​

  • Unit (src/lib/__tests__/email-ministry-copy.contract.test.ts, in the CI list): renders 47 cases (5 welcome variants, trial ending, phone ready, team invite × 10 roles, upgrade notice, product added / cancel scheduled × 3 products, 23 lifecycle keys). CHURCH: subject + HTML sha256 equal to goldens captured from origin/main's pre-change code. MINISTRY: no church / congregation / pastor / sermon / worship / sunday / parish / theolog / bulletin / denomination / doctrin in any subject or visible body (brand name and URLs stripped), same hrefs as church. Positive controls: a one-space change to a church fragment fails the golden; a church word in the ministry table fails.
  • Evidence: rendered HTML for church (origin/main + branch, byte-identical) and ministry in ai-company-os/tasks/artifacts/TASK-20260928-almost-home/copy-d/.
  • Almost Home: the next real welcome / invite / trial email a ministry admin receives reads as above. Any preview send goes only to john+ministrytest@churchwiseai.com; agents never send.