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)
| Given | Renders |
|---|---|
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 else | Unchanged: 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:
| Surface | Shows |
|---|---|
| Crisis bar | Nothing (off until crisis_bar.enabled + a phone). |
| Nav / footer | The 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). |
| Home | Hero 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. |
| Colours | Accent = 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. |
| Never | Another 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:
| Key | Default |
|---|---|
| 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.
| Block | Shape | Home 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 button | First 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)
| Given | Shows |
|---|---|
crisis_bar.enabled and a phone, desktop ≥ 861px | A 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 ≤ 860px | The 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 exit | location.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 phone | No bar, no padding, chat launcher unchanged. |
| Always | Nav: 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". |
| Always | Skip 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):
| # | Section | Given | Shows | Hidden when |
|---|---|---|---|---|
| 1 | Hero | hero | Pills, 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) |
| 2 | Get help (#help) | get_help | Dark section: headline, Call button (crisis_bar.phone), Free / Safe / How to get in columns (steps as a list), then the three public lines | get_help absent |
| 3 | Scripture band | scripture | Centred italic verse, wood-grain rule, reference | scripture absent |
| 4 | Story + a year (#story) | story and/or timeline | Heading, 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 |
| 5 | Need-list teaser (#need-teaser) | need_teaser + a need_list block | Heading + 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}#needs | no need_list items |
| 6 | Open house (#open-house) | an rsvp block | Plum 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 |
| 7 | Testimonies (#stories) | testimonies + consented video_testimonies | Heading + 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 line | zero consented videos AND zero news posts |
| 8 | Wood shop (#shop) | products | Story copy, price line, product cards (Order → href), "Also:" line, fundraiser card (opens help sheet for "Plaques for a Purpose fundraiser") | products absent |
| 9 | Ways to help (#ways) | a ways_to_help block | Up 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 |
| 10 | Closing | closing + giving_url | Paper card with wood-grain top rule, heading, text, Give monthly / Give once, fine print | closing absent or no giving_url |
5. Extra pages (MinistryPageTemplate)
| Given | Shows |
|---|---|
| Any ministry extra page | Same 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_list | The 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 block | Bone 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 dark | Plum-deep give card (#give when it is the first dark callout); button → button_href (new tab if external). |
callout tone light | Photo + 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 block | Same components as the home sections. |
heading / text / button / image | Ministry typography (Playfair headings, 17px Manrope body, plum pill buttons). |
staff_grid / ministries_grid / sermons_list / contact_block | The 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/churchunchanged (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 rendersUnifiedTemplate(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 Timestext 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).
| Given | Shows |
|---|---|
| A church opens the Template section | Two cards, Church selected, a one-line description each (phone-first — no longer flagged desktop-only). |
| Picks Ministry / Nonprofit, saves, previews | The draft preview renders the ministry template with §2a defaults; the public site is unchanged. |
| Publishes | Live site switches; cap_info.vertical = 'ministry'. Switching back to Church and publishing restores UnifiedTemplate and clears the flag. |
service_business demo rows | Picker hidden (those rows never reach either template). |
| A ministry site | The 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)
| Sheet | Fields |
|---|---|
| Crisis bar | on/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 help | eyebrow, title, italic words, the three column titles + texts, up to 5 steps, location note; the three public lines are read-only |
| Products | eyebrow, 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) |
| Timeline | title, up to 6 steps (when, title, text), up/down |
| Need list | the page's need_list block: items (label, category, description, needed now, wanted/received, buy link), up/down, routed inbox |
| Testimonies | the 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.
| Slot | Has a URL | No 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 photo | Photo beside the rows | One column (village no-media) |
Wood-shop photo (shop-top) | Photo beside the story | One column (shop-top no-media) |
Product card photo (★products.items[].photo_url, http(s) only) | Square photo tile on the card | A text card: category, title, price, Order. No tile. |
News post thumbnail (hero_image) | Thumbnail beside the title | Title-only row |
| Testimony card poster, video not hosted yet | Poster + "Video coming" | Text card: name, role, "Video coming". No dark tile. |
| Testimony card with a hosted video but no poster | Unchanged: the dark tile is the play surface for a real video | — |
Founder headshot (custom_staff[].photo_url) | Round photo | Name + 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.
| Given | Shows |
|---|---|
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. |
| Form | The 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 column | Phone (tel: + selectable text), email, mailing address only when show_mailing_address, office hours only if set. |
show_location: true | Below the panel, the shared MapSection for the churches row address, plus a "Get directions" link (Google Maps directions URL). |
show_location false/absent | No 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 surface | A 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 + footer | The 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:
| Field | Notes |
|---|---|
| Heading, intro | text |
| Email, phone | shown on the page; blank = fall back as above |
| "Show a mailing address" + mailing address | off 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 form | one 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
contactpage indraft_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: falseuntil 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_locationfalse → noiframe, 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) —
ContactFormgains 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:
textfor the tenant's verbatim donor copy, e.g. how to become a corporate partner.- a dark
calloutfor recurring giving, withbutton_href= the tenant'sgiving_url. - a light
calloutor the Villageneed_listfor in-kind gifts and the wish list. partnersfor the wall.give_by_mailfor cheques.
| Given | Shows |
|---|---|
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 logo | Its NAME as text in the tile, never an empty tile (§11). |
| A partner with a link | The tile links there in a new tab (target="_blank" rel="noopener noreferrer"). |
greyscale: true | Logos 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 address | Nothing. |
give_by_mail with the toggle on | "Give by mail" (or its title), its text, the address with line breaks kept. |
| No partners | No 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_addressis 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.
| Given | Shows |
|---|---|
| The form | Phone-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). |
| Submit | POST /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." |
| Failure | Inline error; everything typed is kept. |
| Payment | Never. 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 tocontact_routingwith no email yet.bulk_route_label= the fundraiser's route.- On
/plaques, the "Order a plaque" Wix button is replaced by theorder_formblock. 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_submissionsrow 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)
| Given | Shows |
|---|---|
/articles on a ministry tenant with ≥ 1 published article | Ministry 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> published | Breadcrumb 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 token | 404 — 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 tenant | 308 → /articles and /articles/<slug>, so the ministry never renders the church-chromed News page. Church /blog unchanged. |
articles as an extra-page slug | Rejected 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
| audience | Block | Primary | Secondary |
|---|---|---|---|
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 / /#help | Call {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 on | Give → 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 =Personwhen named else the organisation, publisher =NGO{name, url, logo}, image, mainEntityOfPage,speakable→.art-dek+h1),BreadcrumbList(Home → Articles → title), andFAQPageonly whenmetadata.faqis non-empty. Index:CollectionPage+BreadcrumbList. noindex, nofollowon 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">. (Notfeed.xml: on*.john316.churchthe middleware serves any*.xmlpath as a static file, so a.xmlroute 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.txtadds 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-imageroute) · 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_blockedwith the list). Unpublish returns it to draft (keepspublished_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);
articlesis not a valid page slug (positive control:villageis); article slugfeed→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 tomain.
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 item | One-line why | Section key | Renders |
|---|---|---|---|
| About your ministry | Who you are, what you offer, and where you send people. | church-knowledge | The 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 & answers | The questions people ask most, answered your way. | faqs | FAQManagement, unchanged. |
| Greeting & hand-off | How your assistant says hello, and who it hands people to. | agents | AgentSettingsPanel 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 item | Section key | Renders |
|---|---|---|
| Safety rules | safety | SafetyCompliance — unchanged in BEHAVIOUR; copy ministry-branched ("Your community is protected…", never "congregation"). |
| Try a conversation | simulator | SimulatorPanel, unchanged (still hidden in Pastor view, as for churches). |
| Integrations & widget | integrations (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 help | benevolence (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
| Given | Shows / does |
|---|---|
A ministry admin opens ?tab=training with nothing stored | Tier 1 visible; Advanced collapsed (aria-expanded="false", the list is hidden); "About your ministry" active. |
| Clicks the Advanced toggle | The 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-off | Only 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 & widget | The 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 / capability | Unchanged 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. |
| Copy | Plain 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 card | Counts 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 tenant | Rail, 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-integrationsopens the fold with Integrations & widget active. Screenshots inai-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_enabledtrue (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 questions | service times · get involved · first visit | "How do I get help?" · "How can I support the ministry?" · "Can I visit?" |
| Facts block, first line | Church: {name} | Organization: {name} |
Denomination: line | when the directory row has one | never |
| Theological lens | explicit lens row → else denomination mapping → else Christocentric | explicit lens row → else Christocentric (no denomination lookup) |
Denomination starter packs (canned_responses, source pack) | universal + denomination pack | skipped — they are church FAQs and would pollute a recovery home's knowledge |
Everything else (tone, language, knowledge_base, availability, source, agent_config) | identical | identical |
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); nosai_theological_lensesquery 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 → noorganization_settingswrite, flags still set; lookup error → no write at all. - Almost Home (premium
7968e999-…, church23cfe6b1-…): after the founder clicks Set up chatbot on the real dashboard,organization_settings.chatbot_config.welcome_message.textbegins "Hi, I'm the assistant for Almost Home Ministries",church_factsbegins "Organization:",canned_responseshas zero rows withsource='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).
| Church (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 invite | role 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) | unchanged | ministry 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.