Code Reference
LWC Reference#
Every packaged Lightning web component, grouped by the folder-prefix scheme (public/lex/pf/df/ui/adm/set).
Generated file
Don't hand-edit this page — edit the bundle's <description> in its .js-meta.xml, or add a // doc: ... line comment, and rerun the generator.
Contents
-
Public (Experience Cloud guest pages)
7 bundles
Campaign Gallery · Donation Receipt · Donor Dashboard · Event Registration · Event Tickets · Public Fundraising Experience · Thank You
-
Lex (internal Lightning Experience)
30 bundles
Campaign Documents · Campaign FAQs · Campaign Progress Card · Campaign Custom Questions · Campaign Story · Campaign Updates · Where This Is Used · Fund Performance · Diagnostic Logs · Donation Monitor · Donation Receipt Print View · Donor Giving Summary · Embed & Share · Seat & Order · Event Check-In Board · Event Ticket Tiers · Fundraisers · Fundraising Settings · Annual Giving Statement · Guest Request Detail · Manage Recurring Gift · Donation Payment Breakdown · What Depends On This Card · Recurring Donation Payment History · Recurring Donations Widget · Refund Donation · Refund Requests · Resend Receipt · Send Portal Sign-In Link · Stop Recurring Donation
-
Adm (internal fundraisers-console)
9 bundles
Campaign Documents Tab · Campaign FAQ Tab · Campaign Updates Tab · Custom Questions Tab · Event Check-In Tab · Event Tickets Tab · Fundraiser Type Avatar · Fundraiser Detail Drawer · Guest Requests (Console)
-
15 bundles
Settings: Page Designs · Settings: Donor Portal · Settings: Email & Receipts · Settings: Events & Ticketing · Settings: Get Started · Settings: Health Check · Settings: Logging · Settings: Organization & Donations · Settings: Payment Accounts · Settings: Public Links · Settings: Record Type Mapping · Settings: Recurring & Dunning · Settings: Refunds · Settings: Scheduled Jobs · Settings: Site & Domain
-
Pf (public guest helper blocks)
9 bundles
Attribution Capture Utility · Public Contact Band · Donor Portal Giving Statement · Campaign Hero · Page Blocks · Public Campaign Reference Tabs · Public Campaign Story · Public Shared Style Primitives · Trust Bar
-
2 bundles
-
14 bundles
Apex Error Formatting Utility · Internal Base Component · Font Stack Allowlist · Markdown Renderer · Markdown Editor · Markdown Parser · Ember Modal · Money Formatting Utility · Page Theme (shared) · QR Code · Section Bleed Utility · Ember Segmented Control · Giving Statement Print Utility · Ember Toggle
Public (Experience Cloud guest pages)#
The public campaign directory: masthead, a hero ledger totalling every listed appeal, donation-type filters, and a card grid where finished campaigns stay listed but greyed out. Renders its own chrome and contact band, so it needs no shell around it.
Docs note
This component IS the page, not a block on one, so like publicFundraisingExperience and publicThankYou it has to clear the Experience Builder section padding that would otherwise draw a pale frame around all four edges of a design built to reach the glass. The module carries the full reasoning and the reason it cannot be done in CSS.
Maps the org's default design record onto the --pf-* custom properties this page's CSS reads, and writes them onto this component's own host element. It also holds the two gates that matter on a public page an admin-authored record feeds: colours must be plain hex literals, and Heading_Font_Family__c / Font_Family__c are restricted picklist TOKENS resolved through a closed allowlist, never interpolated raw into CSS. This page used to carry private copies of both; sharing them means there is one place for the gate to be right.
Calls CampaignController.getCampaignGalleryData (a single guest-context read, no token or slug needed — it lists every publicly listed campaign, open and finished). Dropped standalone into an Experience directory page with no publicFundraisingExperience shell wrapping it, which is why it renders its own masthead and contact band and self-loads brand fonts from the PublicFonts static resource rather than relying on shell-level chrome.
The directory paints itself from the ORG's default design record — the same one the campaign pages resolve when a campaign names no design of its own. That is the whole point: the card a visitor clicks must not lead to a page in different colours.
This is a SEPARATE wire from getCampaignGalleryData, and deliberately so. The palette used to ride in on the gallery payload, which meant it could not arrive until every campaign, total and image URL had been queried — so the skeleton, the whole point of which is to show this page's shape while that runs, was guaranteed to render in the package's shipped Ember colours and then snap to the org's on the last frame. This call is a cacheable read of fourteen fields; it lands long before the data does, and on a second visit it comes from the LDS cache with no round-trip at all.
Only the raw design values are applied. Every tint, line and hover shade the CSS needs is derived from them with color-mix in the stylesheet rather than computed here, so a palette change is one variable and the derivation stays visible next to the rule that uses it. The colour and font-token gates live in c/uiPageTheme, which this page shares with the other five public pages rather than keeping the private copy it used to have.
Settings__c.Org_Logo_URL__c, arriving on the payload's identity map — the same value the campaign page and the contact band render, so all three mastheads carry one mark. Blank until the payload lands, which is why the monogram is an lwc:else rather than a second element: showing both for one frame is a visible flicker on every cold load.
Guest tax receipt, opened from an unguessable receipt link. Renders the letter, the refund record, and the clearing/failed/not-found states as one printable sheet.
Docs note
Reached via an unguessable Receipt_Token__c in the URL state (?token=...), never by Opportunity Id — this is the guest/magic-link pattern shared with publicEventTickets (?token=... resolves an order the same way).
Calls ReceiptController.getReceiptData(token). paymentIntentStatus comes from Stripe's own redirect query params (redirect_status), not from Apex. On this card-only flow the redirect that produces it is 3D Secure: the issuing bank takes the donor away mid-confirm and Stripe returns them here with the status appended.
The donor can therefore land on this page in the same instant the charge is confirming, a step ahead of the finalize that writes the receipt row — which is exactly why _load stays lenient about a miss rather than declaring the receipt not found.
Corrected 2026-08-15: this previously described the parameter as an ACH artifact finalized by "the async webhook/reconciliation path". Both halves were wrong. ACH is not offered — StripeGateway pins payment_method_types[]=card on every intent — and this package has no inbound webhook at all; payment outcomes are confirmed by Salesforce re-fetching the PaymentIntent.
Keep the handling: 3DS makes it live on the card path today. The tax figures and the legal statement are all resolved server-side by ReceiptStatusHelper and rendered verbatim — nothing about deductibility is computed in JavaScript, so the web, printed and emailed receipts for one order cannot make three different legal claims.
Printing is window.print() against this component's own DOM plus an @media print block, not a rebuilt document in a popup window.
The popup version meant the sheet a donor filed with an accountant was a second artifact with its own markup and palette, free to drift from the one on screen; it also died silently behind a popup blocker.
The org's default design record, applied as --pf-* custom properties on this component's own host, so the receipt wears the palette an admin chose in the settings console. A receipt is a document the donor keeps and may print; it has no campaign of its own to read a design from, so the organisation's own palette is the only correct answer.
Set on the HOST, not on a wrapper div: this component declares its own primitives on :host as var(--pf-…, fallback), and a custom property is substituted on the element that declares it. See c/uiPageTheme.
Experience Builder wraps every component it hosts in a section with its own 16px padding and no overflow clip. On a component that IS the page that padding renders as a white frame down all four edges, and it also lets this page's full-bleed bands (margin-inline: calc(50% - 50vw)) overhang into a horizontal scrollbar. Releasing it is what makes the masthead and the closing band actually reach the glass.
The serial number assigned by ReceiptNumberService, read from the server. This used to be the last 8 characters of receiptToken — i.e. part of the bearer credential that authorizes this page — printed onto a document donors email, print and file with an accountant. Never derive a visible reference from the token again.
@api properties: receiptToken
Magic-link donor self-service portal — giving summary, history, saved cards, and full management of a recurring gift (pause, resume, cancel, retry, change the amount or fund, skip one payment, replace or remove a card).
Docs note
Guest-request statuses split three ways for the donor. Anything still moving reads as "saving"; Failed/Declined is re-surfaced with the seats still nameable so the donor can retry (a request that dies quietly is worse than one that says so). Completed is deliberately absent — the names are on the seats by then, so unnamedCount alone tells the whole story.
Portal-token gated, not authenticated — a guest with a valid link token calls DonorPortalController.getDashboard/pauseRecurring/resumeRecurring/cancelRecurring/ retryNowRecurring/reinstateRecurring/changeRecurringAmount/changeRecurringDesignation/ skipNextRecurringPayment/removeWalletCard to manage their own recurring gifts and saved cards, and never addresses a record by Id (IDOR prevention, same pattern as publicDonationReceipt's Receipt_Token__c). requestAccessLink is the token-issuance step for a donor who arrives without one.
The card-replace modal reuses the exact same stripePayment.page iframe/postMessage contract as dfForm, but in Stripe's SETUP-intent mode (no charge) via createCardSetupIntent — see the lwc-conventions skill for the shared iframe/postMessage message-type contract. Removing a saved card is refused server-side while a live recurring gift still bills it.
Every schedule- or money-changing action is gated behind _openConfirm, which states what will happen, when, and what will NOT happen, and offers the gentler alternative (pause instead of cancel, change amount instead of pause) before the destructive one.
The org's default design record, applied as --pf-* custom properties on this component's own host. The portal has no campaign to read a design from, so before this it painted the package's shipped clay-and-moss palette no matter what the admin had chosen in the settings console — a donor arriving from a teal directory landed on a warm orange portal. Everything the design record does not name (the moss "steady" tones, the honey cautions, the error reds) keeps its designed value; see c/uiPageTheme for why.
Deliberately independent of the access token: the sign-in screen is themed too, and it renders before any donor is identified.
The footer band's two payloads. On the public pages these are campaign-resolved and arrive with the campaign; the portal has no campaign, so getPortalConfig resolves them through the same CampaignService methods against an empty one and they fall through to the org's Settings values.
Experience Builder wraps every component it hosts in a section with its own 16px padding and no overflow clip. On a component that IS the page that padding renders as a white frame down all four edges, and it also lets this page's full-bleed bands (margin-inline: calc(50% - 50vw)) overhang into a horizontal scrollbar. Releasing it is what makes the masthead and the closing band actually reach the glass.
The link's lifetime is admin-configurable (Settings__c.Portal_Token_TTL_Hours__c), so the three places this page states it read it back from getPortalConfig rather than repeating a literal. The fallback matches SettingsService.portalTokenTtlHours()'s own default.
The refusal case is the interesting one. Whether a card can be removed depends on server-side state the dashboard payload deliberately does not carry (WalletCard exposes no Stripe payment method id, and a gift's brand + last four are display text, not an identity), so the button is offered on every card and DonorPortalService.removeWalletCard is the only authority on whether it proceeds. Its "this card is paying for a recurring gift" message is surfaced verbatim inside the dialog — inventing a client-side in-use guess here would either hide a legitimate removal or promise one the server refuses.
Public event ticket page (Experience Cloud): trust bar, event banner, ticket selection, attendee capture and Stripe checkout. Wears the campaign design's Form Layout Mode, so one component renders every look the donation page has.
Docs note
Column_Ratio__c → the composition class publicEventRegistration.css keys its grid areas off. A closed map, not a string template, for the same reason FONT_STACKS is one: the value comes off a design record and ends up in a class attribute. This replaces the seven-entry Form_Layout_Mode__c map retired on 2026-08-12. An event page is a donation page whose narrow column sells seats, so it wears the same two-column grid: the even split keeps the wider .ev-split composition, and both weighted ratios use the sidebar grid. Which side the form sits on is carried by ev-formleft rather than a third grid class.
This map must mirror publicFundraisingExperience.resolveFontFamily(), or one design record renders a different face on the ticket page than on the donation page. The three legacy entries below (Serif, Modern, System) predate that rule and still diverge from the shell — Modern in particular names Inter, which the PublicFonts resource does not ship, so it resolves to a system fallback. Left as-is deliberately: aligning them would silently restyle every live event page. Every Google_* entry added since 2026-08-05 matches exactly.
Ticket purchase flow, calling EventRegistrationController (getEventCampaign, getEventTickets, createTicketIntent, checkTicketStatus, getTicketReceiptToken) — the events analog of dfForm's DonationFormController sequence. Deliberately re-implements (not imports — LWCs can't share a JS class across bundles) the ~120 lines of stripePayment.page iframe/postMessage glue from dfForm, adapted to always send a one-time CONFIRM_PAYMENT (tickets never go through CONFIRM_SETUP/recurring). On success it calls getTicketReceiptToken to hand the guest a Receipt_Token__c, then the confirmation surfaces through publicEventTickets' ?token= flow rather than staying inline here.
ONE PAGE, NOT A WIZARD (2026-08-05). Tickets, guests, contact details, extras and payment are all rendered at once, with a live order receipt in the rail and a sticky action bar carrying the running total — a buyer comparing tiers should not have to remember what the previous screen said. The only thing still sequenced is the card itself: a Stripe PaymentIntent is minted for a SPECIFIC amount, so the iframe cannot mount before createTicketIntent has run. That is why the action bar is two-phase ("Continue to payment" → "Complete order") and why _resetIdempotencyKey() tears the intent down again whenever the order changes underneath it.
The FIRST paint only — the org's default design record, applied as --pf-* custom properties on this component's own host so the page has the org's palette and geometry from the very first frame, before getEventCampaign has returned. _applyDesignTheme() below overwrites it with the campaign's own design the moment that payload lands.
This wire used to be the LAST word, which was wrong: an event registration page IS the donation page (it is what the /donate route renders for an Event_Fundraiser campaign), so it follows the CAMPAIGN's design record, not the org default. The symptom, found in the browser on 2026-08-14, was a page painted half in each — rootStyle put the campaign's amber on .er-root while the host carried the org default's teal background, 18px radii and soft shadow over a design record that asked for cream, sharp corners and no shadow at all.
Set on the HOST, not on a wrapper div: this component declares its own primitives on :host as var(--pf-…, fallback), and a custom property is substituted on the element that declares it. See c/uiPageTheme.
The campaign's own design, from the same designToMap payload the donation shell themes itself with — so the geometry (radii, shadow, gutters, column width) and the palette come from one record instead of two, and an admin who sets Radius to Sharp on a gala's design gets sharp corners on the gala's ticket page.
The optional page sections, resolved server-side by CampaignService.resolveSections and CampaignPostService — the same two keys the donation shell reads, off the same helper. A ticket page is a whole page, not a form on someone else's, so it renders the campaign's story and its documents / updates / FAQ well exactly as a donation page does.
Facade-vs-iframe choice for video blocks inside the story's Markdown, resolved server-side by SettingsService so this shell never reads Settings__c directly. Defaults false and is only ever set from the === true check in _load's onSuccess below — a missing key must read as false, never undefined, or c-ui-markdown's inline-video-enabled prop becomes a silent facade/iframe coin-flip.
The ticket page is not tied to one look. It wears the same Column_Ratio__c the donation page does, so a black-tie gala, a research symposium and a community 5K are this one component wearing three design records rather than three components. The markup is three grid landmarks — banner, form, aside — placed by named grid areas, so a ratio only has to re-declare grid-template-areas; nothing in the ticket wizard knows or cares where the banner or the order summary ends up.
A closed page has a banner, one sentence and maybe a story — nothing to put in a second column. It still emits the ratio class, which carries the banner and typography the page should keep; ev-ended only neutralises the grid (see .ev-ended in the stylesheet).
The seats rail duplicated the banner's own remaining-seats chip, and only ever appeared on the retired Momentum layout mode. With one canonical layout there is no mode to hang it on and no reason to state the same number twice, so it is off everywhere.
These mirror publicFundraisingExperience's section getters one for one, deliberately. Both pages read the same two server keys, so the gating rule — a section shows when its toggle is on AND it has something behind it — is asserted in the same shape on both. The story gate additionally checks for text: a campaign that switched the section on and never wrote anything must render no heading rather than an empty one.
An event is the case this rule was written for. Before the date, a ticket-holder's next question is how to get in, so Documents leads; once EndDate is past the page's job is reporting on the evening, so Updates leads.
acceptingDonations is the server's own verdict (Campaign.Accept_Donations_After_End_Date__c — createTicketIntent refuses when it is false), included so the page can never offer a checkout the server would reject. It does not replace isPastEvent: this page has always closed on the day after the event and deliberately still does, which is the STRICTER of the two — an event whose campaign opted to keep taking late gifts still stops selling seats to a night that already happened.
Ticket_Accent_Color__c was retired on 2026-08-12 — it was a second accent field that only the ticket page read, so an admin had to keep two accents in sync to get one look. The token stays (the stylesheet consumes it throughout) and now resolves from the design's single Accent_Color__c.
Three steps — Tickets, Who's coming, Payment — and this list is the whole state machine. Step 2 is not merely hidden when there is nothing on it, it is removed: with one seat and no organiser questions its only content would be the buyer's own name, locked, and a meter that counts to three while one of the three is a formality misstates how much is left. The numbers rendered in the meter are positional, so dropping it renumbers Payment to 2.
Highlight_Label__c is the organiser's recommendation ("Most popular"); the field is blank on every tier they did not single out. It never competes with the buyer's own choice — a selected row takes is-active instead, and a sold-out row is not recommended at all, because a badge on something unbuyable only advertises what the buyer has already missed.
The order is paid; leave the form. /thank-you is a real Experience Cloud route, so a completed purchase produces its own URL and therefore its own pageview — that distinct URL is the entire analytics mechanism. The ACCESS token travels in the query string because no receipt token exists yet; publicThankYou polls for it, renders the ticket deck, and swaps the address bar to ?token= once the reconciler has written the order.
@api properties: campaignSlug, columnRatio, prop, showHeader
Printable tickets for one event order — every seat on the order, addressed by its receipt token. Each seat carries a scannable code the check-in desk reads, and a date stub coloured by ticket tier so a door team can separate tiers at a glance.
Docs note
The stub palette, in the order the hash walks it. These are class suffixes, not colours — every hex lives in publicEventTickets.css so a themed org can restyle the sheet without this file knowing anything about ink. Adding a value here means adding it in the CSS too. There is no admin-picked override any more: Event_Ticket_Type__c.Accent_Color__c was retired on 2026-08-12, so every stub colour is derived, and no tier can be left uncoloured.
Guest/magic-link page reached via ?token=... (mirrors publicDonationReceipt's Receipt_Token__c pattern) rather than an Order/Opportunity Id, so a ticket confirmation link can't be walked to view someone else's order. Calls EventTicketsController.getTickets(token) — read-only; the actual purchase/check-in flows live in publicEventRegistration and admEventCheckIn respectively.
The org's default design record, applied as --pf-* custom properties on this component's own host, so the ticket wallet wears the palette an admin chose in the settings console. The wallet is reached from an email, not from a campaign page, so there is no campaign design in scope — the org default is what makes it recognisably the same org.
Set on the HOST, not on a wrapper div: this component declares its own primitives on :host as var(--pf-…, fallback), and a custom property is substituted on the element that declares it. See c/uiPageTheme.
Experience Builder wraps every component it hosts in a section with its own 16px padding and no overflow clip. On a component that IS the page that padding renders as a white frame down all four edges, and it also lets this page's full-bleed bands (margin-inline: calc(50% - 50vw)) overhang into a horizontal scrollbar. Releasing it is what makes the masthead and the closing band actually reach the glass.
The order's receipt serial number, from the server. This used to be the last 8 characters of receiptToken — the bearer credential that authorizes this page — printed at the top of a sheet donors hand to a door attendant. Blank for an order placed before receipt numbering shipped; the page shows no reference rather than reviving the token one.
@api properties: receiptToken
Single Experience Cloud shell: layout from Campaign Design, blocks from record type and subtype.
Docs note
The four preset resolvers and the theme-JSON parser below are shared with c/uiPageTheme, the module that themes the five non-donation public pages. They lived here as private copies until 2026-08-14, when the copies were found to have drifted — Medium radius resolved to 10px here and 11px there — and, worse, the other module had no spacing/shadow/max-width resolvers at all. A design record has to render the same geometry on the receipt as on the page the receipt came from, so there is now one definition and this shell delegates to it.
The non-content states (loading, error, no-campaign) show the org's own trust bar, and it cannot come from the campaign payload — in two of the three states that payload is exactly what is missing. getUiConfig is the right source precisely because it shares no failure mode with getPublicExperienceConfig: it reads Settings__c.getInstance() only, which is a custom setting and so exempt from CRUD/FLS, touches no records, and is cacheable (LDS serves it from cache, so the header paints on the first frame rather than after a round-trip).
This map must mirror publicEventRegistration's FONT_STACKS, or one design record renders a different face on the ticket page than on the donation page. It must also stay in step with the picklist value sets on both fields and with the FAMILIES list in scripts/build/fetch-public-fonts.sh — an entry here whose family has no @font-face rule in the PublicFonts resource silently falls through to the next name in its own stack. 'Modern' is the one deliberate exception: it names no bundled face and resolves to the system UI stack by design.
The shell for the standard (non-embed-widget) guest donation route: one CampaignController.getPublicExperienceConfig call returns the merged campaign + design + theme-token payload that composes pfHero and dfForm underneath it, so those children never call Apex for that shared config themselves. _initialAmount/_initialEmail/_initialName are the embedWidget.js handoff contract — the embed widget's "Give" click appends them as URL state before navigating here (see widget-src/src/widget.jsx navigate() and lexEmbedBuilder's snippet), and this shell forwards them into dfForm as prefill rather than dfForm reading location.search itself, keeping the two entry points (embed vs. direct site visit) on one prefill code path.
Org identity for the trust bar, which is the site header on the content page and on all three non-content states alike. getUiConfig is the source for the name and the EIN because in two of those states the campaign payload is exactly what is missing; the mark prefers the payload's server-resolved identity.logoUrl when there IS a payload, so the page and the state screens can never resolve the same Settings value differently.
There is one page layout. Form_Layout_Mode__c and its six skins were retired on 2026-08-12; what varies now is only the column proportion, which Column_Ratio__c carries and the grid reads (see columnRatio below).
Ticket_Accent_Color__c was retired on 2026-08-12 — a second accent field that no packaged theme ever set differently from Accent_Color__c, so it only ever produced a duplicate of this value. The ticket accent resolves from the single accent now.
Campaign_Design__c.Button_Style__c is INERT on the guest render path as of 2026-08-13, and this method is the deliberate no-op that keeps it that way.
It used to emit a --pf-btn- group that flattened buttons to Outline or Ghost. An audit of every --pf-btn- consumer found they are all PRIMARY CTAs — pfHero's give button, .er-cta, the shell's own, .pf-btn, .primary-button, .thankyou-cta. There is exactly one secondary button in the package (.pf-btn--ghost: the exit-prompt dismiss and two Backs) and it is flat by construction, reading no --pf-btn-* token at all. So the field could only ever do one thing: strip the fill off the single button the whole page exists to get pressed.
On a donation page that is not a style choice. A hollow primary CTA drops the donate button to the same visual weight as the page's links, and on Sunrise and Marigold the flattened text failed WCAG AA outright. Owner decision: the primary CTA always stays filled. The field itself is retained rather than deleted so no subscriber's stored value breaks on upgrade; it is a retirement candidate for the next release, and it is no longer offered in the setDesigns editor.
resolveFontFamily() below is the authoritative picklist-value -> CSS-stack map for the donation shell. publicEventRegistration.FONT_STACKS must mirror it exactly, or the same design record renders one face on the donation page and another on the ticket page.
The soft surface is the sunken tone under disabled inputs, the amount grid, the segmented frequency control and the tab bar — roughly 25 consumers across 10 bundles. It used to return a flat cool #F8FAFC whenever the design's surface was white, and PostInstallHandler sets Surface_Color__c to #FFFFFF on ALL SIX packaged designs, so every one of them rendered its sunken panels in a blue-grey borrowed from no part of its own palette: Marigold's cream page (#FFF9EC) and Sunrise's blush (#FDF3EE) both sat a cold grey block in the middle of a warm page.
Tinting the surface with the design's own page colour keeps the sunken tone in the page's family.
The inner mix is the floor: a design that picks a white page as well as a white surface would otherwise get pure white back and lose the sunken step entirely, so a few percent of the text ink guarantees the panel still reads as recessed. A design that already chose a non-white surface carries its own tint and is left alone.
Server-resolved Column_Ratio__c — Campaign.Column_Ratio_Override__c first, then the design's default, then 60/40. One value carries both the proportion and which side the payment box sits on, which is why nothing here has a second "side" input to reconcile.
One getter drives both the grid template and the source-order reversal, because the ratio value encodes the proportion AND the side: 40/60 is the only value that puts the payment box on the left, so it is the only one that also carries pf-2col--form-left. --pf-col-template itself is deliberately NOT a Theme_Token__mdt entry — it is derived from Column_Ratio__c, and an admin who could set both could desynchronise them.
Three grounds, and none of them is an empty plate: the campaign's own header image, then the design's Default_Hero_Image_URL__c (PostInstallHandler seeds every packaged design's to the packaged DefaultHeroImage), then --plain, which drops the aspect ratio entirely and shrinks the frame to its copy rather than holding a coloured void open. There is no fourth source resolved at render time — Settings' default hero was already stamped onto the design records at install, so reading it again here would be a second answer to a question the payload has already answered.
Section toggles resolved server-side (CampaignService.resolveSections) — the campaign's own Show_* flags, each already defaulted. Consumed by the tabbed well below and, from Task 10 on, by c-pf-story / c-pf-section-tabs / c-pf-contact-band.
Contact and identity arrive already resolved through their per-campaign overrides (CampaignService.resolveContact / resolveIdentity), so the band below is handed finished values and never has to know an override exists.
The toggle alone is not enough: an org that has filled in neither an email nor a phone anywhere — campaign override or Settings — would otherwise get a band headed "Talk to a real person" with no way to reach one, which is worse than no band. Venue does not count; an address answers "where", not "who do I ask".
The story is the campaign's own Story__c, handed straight to c-pf-story, which renders it as Markdown through c-ui-markdown. showStory gates on the toggle AND on there being text, so a campaign that switched the section on and never wrote anything renders no heading.
Facade-vs-iframe choice for video blocks inside the story's Markdown, resolved server-side by SettingsService so this shell never reads Settings__c directly. A missing key must read as false — the safe, no-third-party-script default — never as undefined, which would leave c-ui-markdown's inline-video-enabled prop in a silent facade/iframe coin-flip.
Events only, in practice. Before the event a ticket-holder's next question is how to get in, so Documents leads; once EndDate is past the page's job is reporting, so Updates leads. A campaign with no end date never flips — it has no "after".
Every donation page is the two-column page now, so the form owns it unconditionally. The one exception is unchanged: an Event slug renders c-public-event-registration, which embeds the donation form itself — the bare form must not hijack that page.
The design's content blocks, lifted out of the donation form in the 2026-08-12 redesign. An admin arranges rich text, images, video, testimonials and galleries on the page-design canvas; those used to render interleaved between the form's own field blocks, which made sense only while the form was the whole page. Now the wide column owns narrative and the rail is just the ask, so they render here instead — same blocks, same stored order, same sanitization-safe c-pf-page-blocks renderer, different column.
Steps 1 and 2 both collapse to this one region: step distinguished "before the donor continues to payment" from "after", a distinction the page no longer draws now that the form is a rail beside the content rather than a sequence the content sat inside. Step-3 blocks are excluded — those are the thank-you canvas, which dfForm still renders itself.
An admin who adds a block from the palette and never fills it in leaves an empty block behind. Interleaved between form fields that cost a few pixels nobody noticed; in the wide column an empty block draws its own separator rule and vertical rhythm, so it renders as a stray line across the page. An empty block is not a design decision, so it is not rendered. This is a guard, not a fix for anything currently shipped — the packaged designs all carry real copy in their blocks.
An event page is the donation page's sibling now: it carries its own c/pfTrustbar site header and its own banner, both grid landmarks inside c-public-event-registration so a Column_Ratio__c can move them. c/pfHero can't take part in that — it's a separate block stacked above — so the shell steps out of the way and the event owns its header.
The event branch needs the same full-bleed frame the form-owns-page branch has, so its trust bar can span the viewport and touch the top edge; the ordinary stacked branch (hero + Stepped form) keeps the centred inner column it has always had.
@api properties: campaignSlug
The post-gift confirmation page: gift summary, tax-deductible figure, what-happens-next and, for event orders, the stacked ticket deck with scannable codes. Addressed by the receipt token or the short-lived access token issued at payment.
Docs note
This route is dropped into its own Experience page with no publicFundraisingExperience shell above it, so nothing else on the page loads the webfonts — the families named in this bundle's CSS (and in pfStyles) simply were not present, and every heading fell through to Georgia while the body fell through to system-ui. The gallery and the donor dashboard hit the same problem and solve it the same way: link the PublicFonts static resource same-origin, because LWR silently blocks a dynamically-injected cross-origin (Google Fonts CDN) stylesheet on a guest page. The marker attribute is per-bundle so two components on one document each no-op after the first link lands.
The routed confirmation page (/thank-you). It is a real Experience Cloud route rather than a step inside dfForm specifically so a completed gift produces its own URL and therefore its own pageview — that distinct URL is the whole analytics mechanism; this bundle ships no tag, no SDK and no Settings field, and the admin wires their analytics through Experience Builder's own Google Analytics setting.
Two entry shapes. ?token=<Receipt_Token__c> is the durable one a donor can bookmark, share or return to. ?access=<staging access token> is the hand-off the donation and event forms use the instant Stripe confirms: the gift is still being written at that moment, so this page polls until the receipt token exists and then rewrites the URL in place. Navigating immediately is what makes the conversion pageview fire at t=0 rather than seconds later.
Every QR code arrives as finished SVG markup from QrCodeEncoder via ThankYouController — this bundle encodes nothing and only injects the trusted, monochrome, script-free output.
The org's default design record, applied as --pf-* custom properties on this component's own host, so the thank-you page wears the palette an admin chose in the settings console. A donor lands here seconds after paying on a page that carried the campaign's design. Painting this one in the package's shipped palette instead made the confirmation look like a different site — the worst possible moment to look like a different site.
Set on the HOST, not on a wrapper div: this component declares its own primitives on :host as var(--pf-…, fallback), and a custom property is substituted on the element that declares it. See c/uiPageTheme.
The footer's two payloads come off getThankYou, not off getUiConfig, because they are campaign-resolved: a campaign may override the support email, the phone, the terms and privacy links and the footer line. getUiConfig is cacheable and campaign-blind — bolting these onto it would hand every campaign the same footer and, worse, make a cacheable method read a record, which its own class header rules out.
This page is the whole route, not a block on one, so the Experience Builder section wrapping it must give up its padding — otherwise the paper ground and the footer band both stop 16px short of the glass and the page reads as a card floating on white. The trust bar's 100vw bleed needs the same treatment (the section clips it).
@api properties: accessToken, receiptToken
Lex (internal Lightning Experience)#
Campaign record page component for publishing the files donors download from this campaign's public page — annual reports, budgets, impact statements — and for switching that section on or off.
Docs note
Thin exposure shim around the non-exposed admCampaignDocumentsTab bundle, which the Fundraisers console drawer also mounts. Both entry points read and write Campaign_Post__c rows of type Document through CampaignPostController, plus the Campaign.Show_Documents__c toggle that governs the public section. A document row is saved first and the file attached second, because the upload needs a record Id to hang the file on; publishing is blocked until a file exists, so donors never meet a card that downloads nothing.
Campaign record-page shim for the admCampaignDocumentsTab editor, which the admFundraiserDetail drawer also mounts. There is no subtype gate — any fundraiser may publish downloadable files — so this bundle does nothing but forward recordId and carry the Lightning App Builder exposure. Its twin is lexCampaignUpdates. The two are deliberately separate: a document is a file and an update is text, neither depends on the other, and an admin who only publishes files should not have to scroll past a writing panel to reach the upload button.
@api properties: recordId
Campaign record page component for choosing which frequently asked questions appear on this campaign's public page, and whether the FAQ section is shown at all. Answering common questions on the page itself reduces the queries that reach your team.
Docs note
Thin exposure shim around the non-exposed admCampaignFaqTab bundle, which the Fundraisers console drawer also mounts. Both entry points read and write the same Campaign.FAQ_Ids__c / FAQs_Active__c pair through CampaignFaqController — the FAQs themselves are Content_Template__c records, so this picker selects from a shared library rather than authoring per-campaign copies.
Campaign record-page shim for the admCampaignFaqTab picker, previously reachable only through the admFundraiserDetail drawer on the Fundraisers console tab. Unlike the two event shims there is no subtype gate — FAQs apply to every fundraiser type — so this bundle does nothing but forward recordId and carry the Lightning App Builder exposure.
@api properties: recordId
Record-page card on Campaign showing a goal-progress ring, amount raised vs. goal, donor count, and days remaining.
Docs note
Every field comes from the UI API record wire — the card makes no Apex round trip at all, so it renders from the same cached record the rest of the page already loaded.
@api properties: recordId
Campaign record page component for choosing which extra questions the donation form asks on this campaign, and whether they are switched on. Answers are stored against each donation so staff can report on them afterwards.
Docs note
Thin exposure shim around the non-exposed admCustomQuestionsTab bundle, which the Fundraisers console drawer also mounts. Both entry points read and write the same Campaign.Custom_Question_Ids__c / Custom_Questions_Active__c pair through CampaignQuestionController — the questions themselves are Content_Template__c records, so this picker selects from a shared library rather than authoring per-campaign copies.
Campaign record-page shim for the admCustomQuestionsTab picker, previously reachable only through the admFundraiserDetail drawer on the Fundraisers console tab. Unlike the two event shims there is no subtype gate — custom questions apply to every fundraiser type — so this bundle does nothing but forward recordId and carry the Lightning App Builder exposure.
@api properties: recordId
Campaign record page component for reading and editing the fundraiser's story, and for choosing whether it is shown publicly. It renders the story exactly as a donor sees it, so what you approve here is what they read.
Docs note
Reads with c/uiMarkdown and edits with c/uiMarkdownEditor, the same two bundles the public donation page and the fundraiser wizard use, so the record page, the wizard and the donor page can never disagree about how a story renders. Saving goes through CampaignStoryController, never a lightning-record-edit-form bound to Story__c: a raw field edit would skip CampaignService.normalizeStoryMarkdown, and the public renderer's safety rests on that sanitizer having seen every byte in the field.
The Campaign record page's Story tab. It reads as a published page and edits as one — the same c/uiMarkdown renderer the donor sees and the same c/uiMarkdownEditor the fundraiser wizard uses, so an admin who edits here and an admin who edits in the wizard are looking at identical output. This bundle owns only the read/edit switch and the save round trip. Deliberately NOT a lightning-record-edit-form on Story__c. A raw field edit would bypass CampaignService.normalizeStoryMarkdown, and that sanitizer is what the public renderer's safety rests on — every write to this field has to go through CampaignStoryController.
@api properties: recordId
Campaign record page component for writing the plain-text progress updates donors read on this campaign's public page, and for switching that section on or off. Regular updates are what keep past donors giving again.
Docs note
Thin exposure shim around the non-exposed admCampaignUpdatesTab bundle, which the Fundraisers console drawer also mounts. Both entry points read and write Campaign_Post__c rows of type Update through CampaignPostController, plus the Campaign.Show_Updates__c toggle that governs the public section. Its twin for files is lexCampaignDocuments — the two panels are independent and neither can see the other's rows.
Campaign record-page shim for the admCampaignUpdatesTab editor, which the admFundraiserDetail drawer also mounts. Like lexCampaignFaqs there is no subtype gate — every fundraiser type can post updates — so this bundle does nothing but forward recordId and carry the Lightning App Builder exposure. Its twin is lexCampaignDocuments; the two are separate components because updates and documents are independent (text posts and downloadable files never depend on each other), and an admin who only wants one should not be handed the other.
@api properties: recordId
Record-page card for a shared question or FAQ template: the campaigns referencing it and which are live to donors, plus the two facts the record itself cannot show — editing rewrites all of them, and deactivating does not take a selected one down.
Docs note
Mounted on the Content_Template__c record page. The record's own fields show the question or FAQ; this card shows the blast radius of changing it — every campaign whose donation page is currently rendering it.
That relationship exists only as a JSON id array on Campaign, so the platform can build no related list for it and the record page would otherwise show an author a shared row that looks like it belongs to nobody.
Two facts drive the design. Editing the prompt rewrites it on every campaign in the list at once (the save services deliberately push in-place edits back to the shared row). And Is_Active__c is not a kill switch — an already-selected template keeps rendering to donors after it is deactivated, because the resolver reads the campaign's id array without checking the flag. Both are stated on the card rather than left to be discovered.
@api properties: recordId
Record-page card on Designation showing the fund's refund-netted realized giving, its live recurring commitment annualized, and which campaigns raised it.
Docs note
Mounted on the Designation (fund) record page. The headline figure is NET raised — gross split amount minus each split's prorated share of its parent gift's refund — which is deliberately NOT the Total_Raised__c rollup shown in the record's own fields: that rollup is gross and refund-blind, so the two disagree on any fund that has ever been refunded. The refund line is always rendered when non-zero precisely so that gap is visible rather than looking like a bug.
@api properties: recordId
Internal app tab for the log entries the package writes about its own behaviour. Filters by level, context and date, searches messages and stack traces, and warns when logging is off so an empty list is not read as a healthy org.
Docs note
Its own Lightning tab (Diagnostic_Logs) in the fundraising app, not a Settings-console panel — the "Recent diagnostic logs" list moved out of setLogging on 2026-08-15, the same treatment lexDonationMonitor got the day before and for the same reason: reading logs is troubleshooting, which happens repeatedly and under pressure, while the log LEVEL is configuration you set once. Burying the evidence inside the panel that sets the switch meant scrolling past four settings controls to reach it, and capped it at ten entries because that was all a panel had room for.
Its Apex moved with it, from SetupController to DiagnosticLogController: SetupController is granted to Fundraising_Admin only because it is the sole entry point behind all 16 settings panels, so reaching a log viewer through it would have handed out the entire console.
getRecentLogs returns { rows, rowLimit, logLevel, retentionDays }: rows is the most recent Log__c entries, newest first, capped at rowLimit (200, up from the panel's 10). logLevel and retentionDays are not decoration — they are what makes an empty list readable.
An empty log list has three quite different meanings: nothing went wrong, logging is OFF so nothing is being written, or the entries existed and the retention purge deleted them. The banner and the empty states name which one applies instead of leaving the admin to guess, because the wrong guess ("logs are clean") is the one that ends an investigation early.
All filtering is client-side over the already-loaded rows — one Apex call, instant response. showTruncationNotice fires when the row count lands exactly on rowLimit, so a filtered view of a truncated list never reads as complete.
Stack traces are collapsed per row rather than shown inline: they are the single most useful field when you need one and pure noise on the other 199 rows.
Internal Lightning app tab for donations not yet finalized into an Opportunity, split into rows needing attention and abandoned checkouts nobody was charged for, with a per-row Retry now action and a warning when the finalizer job isn't scheduled.
Docs note
Its own Lightning tab (Donation_Monitor) in the fundraising app, not a Settings-console panel — it moved out of the console on 2026-08-14 because watching the finalize pipeline is daily operational work, not one-time setup, and burying it behind Settings › System meant an admin only found a stalled pipeline when they went looking for something else. Its Apex moved with it, from SetupController to DonationMonitorController: SetupController is granted to Fundraising_Admin only because it is the sole entry point behind all 16 settings panels, so reaching the monitor through it would have handed program staff the whole console.
getStuckDonations/retryDonationStagingNow back this page. getStuckDonations returns { rows, rowLimit, schedulerScheduled }: rows is every non-terminal Donation_Staging__c row (Pending/Payment_Confirmed/Processing/Submitted/Finalizing), oldest first, capped at rowLimit.
ONE list with a Show filter, not two sections (changed 2026-08-14). Show defaults to "Needs attention" — the server's needsAttention flag — because the two kinds of row mean opposite things: a row the donor confirmed (or one carrying an error, or an expired ticket hold still holding seats) is a problem to chase, while a plain Pending row is an abandoned checkout nobody was charged for. Listing them together made abandonment read as failure — an org with 21 abandoned carts looked like an org with 21 broken payments.
Because that default filter hides rows at first paint, hiddenNotice states the count it is hiding with a one-click way to include them: the collapsed section it replaced announced its own size just by existing, and a filter does not. Without it the page silently under-reports.
All filtering is client-side over the already-loaded rows — one Apex call, instant response, and nothing the server needs to re-query. showTruncationNotice fires when the row count lands exactly on rowLimit, so a filtered view of a truncated list never reads as complete.
schedulerScheduled false means the Finalizer family is not scheduled, so NOTHING on this page can advance regardless of what the rows say; the banner sends the admin to the Scheduled Jobs panel rather than letting them debug a queue that simply is not being drained.
"Retry now" re-runs the exact same callout + outcome logic as the scheduled reconciliation batch for that one record (StagingReconciliationBatch.reconcileOne), so an admin doesn't have to wait for the next scheduled pass.
Record page / app page for Opportunity that renders a printable donation receipt (org branding, donor, amount, designations, EIN) and triggers the browser print dialog.
Docs note
Internal-org counterpart to the donor-facing Experience Cloud receipt, sourced through RecordPageController.getReceiptData for a staff user viewing the Opportunity record page rather than through a donor's magic-link token. Printing builds a standalone HTML document and opens it in an isolated window rather than calling window.print() on this component — the record page has surrounding chrome (tabs, related lists) that a bare window.print() would print alongside the receipt.
The serial number assigned by ReceiptNumberService. The letter previously labelled opportunityName as its "Receipt Number", which is an internal record name — not unique, not sequential, and meaningless to a tax authority. Blank for a gift finalized before numbering shipped, in which case the line is omitted rather than back-filled.
Until 2026-08-06 the printed letter hardcoded its own wording — different from the on-screen letter above it and from the guest receipt — and ignored Settings__c.Tax_Receipt_Legal_Text__c entirely. It now prints the same server-resolved statement (ReceiptStatusHelper.legalStatement) as every other surface.
@api properties: recordId
Record-page card on Account/Contact summarizing a donor's total and average giving, one-time vs. recurring split, and a monthly-giving sparkline.
Docs note
Mounted on Contact, Account, and Person Account record pages (objectApiName tells RecordPageController.getDonorSummary which lookup to resolve donations by) so the same bundle works across both the standard and Person Account donor models without branching on IsPersonAccount in this component.
@api properties: objectApiName, recordId
Campaign record-page panel to generate and copy a donation embed snippet (card / form / button) with a live preview.
Docs note
Calls EmbedController.getEmbedConfig for the campaign's slug/site URL/REST base, then builds a static-HTML snippet for the embedWidget.js host script; nothing here talks to the widget at runtime. See the class-doc block above for the site-URL-vs-domain-root distinction and the namespace-qualified REST/static-resource paths — those are the gotchas most likely to break on a copy-paste-only reading of this component.
Was return '/lightning/n/Settings' — a hardcoded tab URL, which is a 404 in every subscriber org because a packaged custom tab is namespace-prefixed (pledgivo__Settings). GenerateUrl on the server-resolved API name is the only prefix-safe way to build it. hasSettingsUrl gates the anchor rather than letting it render href-less: the surrounding sentence ("add that site's domain to …") still reads correctly as plain text, and a link that goes nowhere is worse than no link at all.
@api properties: recordId
Record-page card giving one event seat the context the bulk check-in board cannot: the tier bought, what the order paid, and the other seats on that order — plus a door control writing check-in status through the same service the board uses.
Docs note
Mounted on the Event Attendee record page — the single-seat counterpart to the console's admEventCheckIn board.
The board is built for a queue at the door and manages seats in bulk; this card is what someone lands on when they open ONE seat, and it answers what the board cannot: what tier was bought, what the order actually paid, and who else came in on it.
The door control writes through the SAME EventCheckInController.setCheckInStatus the board uses, so a scan here and a scan there are the same operation on the same field — there is no second write path to keep in step.
A refunded seat is Cancelled, not deleted, so the control is locked rather than hidden: the seat must still be visible (and visibly void) to whoever is holding the ticket at the door.
@api properties: recordId
Campaign record page component for event fundraisers: a live door board that moves each registered attendee between Awaiting, Checked in, and No-show as they arrive. Renders an explanatory note instead on campaigns that are not event fundraisers.
Docs note
Thin exposure shim around the non-exposed admEventCheckIn bundle, which the Fundraisers console drawer also mounts. Both entry points call the same EventCheckInController methods against the same Event_Attendee__c rows, so a seat scanned from the record page and one scanned from the console are the same operation. The shim owns the campaign-subtype gate the drawer would otherwise provide.
Campaign record-page shim for the admEventCheckIn door board, which until now was only reachable through the admFundraiserDetail drawer on the Fundraisers console tab. The board itself is unchanged — this bundle exists purely to expose it to the Lightning App Builder without exposing the console's internals as packaged API. The drawer decides which of its tabs to render, so admEventCheckIn does NOT gate itself on campaign subtype. On a record page there is no parent to do that, so the subtype check lives here — without it a door board would render on every General/Open_Donation campaign.
@api properties: recordId
Campaign record page component for event fundraisers: create and price the ticket tiers a donor can buy on the public registration page, and see how many of each remain. Renders an explanatory note instead on campaigns that are not event fundraisers.
Docs note
Thin exposure shim around the non-exposed admEventTicketsTab bundle, which the Fundraisers console drawer also mounts. Both entry points write the same tier records through EventTicketAdminController, so tiers edited here and in the console are one set, not two. The shim owns the campaign-subtype gate the drawer would otherwise provide.
Campaign record-page shim for the admEventTicketsTab tier editor, previously reachable only through the admFundraiserDetail drawer on the Fundraisers console tab. The editor itself is unchanged — this bundle only adds the Lightning App Builder exposure and the campaign-subtype gate the drawer would otherwise apply (admEventTicketsTab does not gate itself).
@api properties: recordId
Internal Lightning app tab listing all fundraisers/campaigns with a create/edit wizard covering campaign details, story, tickets, questions, FAQs, and fund designations.
Docs note
labels/classes for the list-view Designation Mode badge (Campaign.Designation_Mode__c), mirroring the picklist's 3 stored values (see the field's own description for the semantics).
The Fundraisers tab shell — lists all campaigns and drives the create/edit wizard via FundraiserAdminController, plus EventTicketAdminController.saveTicketTypes/getTicketTemplates and CampaignQuestionController/CampaignFaqController for the Tickets/Questions/FAQ wizard steps (Event fundraisers add the tickets step; every subtype gets questions/FAQ).
The Story step composes c-ui-markdown-editor (its own bundle) rather than a lightning-textarea — it is invisible to the wizard's lightning-* reportValidity() sweep, so validateCurrentStep() calls its @api reportValidity() directly, and every step eyebrow now reads the currentStepNumber getter instead of a hardcoded index so inserting/removing a step can never desync the number from the step's actual position.
Composes admFundraiserAvatar for row/grid icons and admFundraiserDetail as the slide-in drawer (<c-adm-fundraiser-detail record-id={detailId}>) when a row is opened — the console owns which row is open, the drawer self-fetches everything else.
EMBER_THEME mirrors the default brand tokens (see docs/brand-identity.md) as the wizard's page-design preview fallback when a design record leaves a token blank.
'Ended' stays the internal status/filter value (tabs, GANTT_COLOR key) so the existing filter/gantt logic is untouched; "Completed" is a display-only relabel (statusLabel + .pill.completed + a per-stage SVG icon), sourced from the row's Campaign_Closed__c formula field via FundraiserAdminController.GetFundraisers.
blank Designation_Mode__c resolves to All_Active at read time (CampaignDesignationService.resolveDesignationConfig) — mirror that fallback here so the badge never shows an empty/unlabeled state for pre-existing campaigns.
Highlight_Label__c is both the on-switch and the badge copy — blank means "not highlighted" — so the toggle writes a default label on and clears it off, and turning one tier on clears every other row.
One badge per event is the whole point of a badge: three tiers all shouting "Most popular" recommend nothing.
The clearing here is only so the staged rows show a single lit toggle before anything is saved — EventTicketAdminController.demoteOtherHighlights is what actually enforces it, once, for both editors.
Even that is an editing convention rather than a data constraint: an API/Data Loader import can still badge several tiers, and the public page renders what it is given instead of silently picking a winner.
The allowed-funds picker is hand-built rather than a lightning-dual-listbox so it can carry the console's own field styling; it keeps the dueling list's one substantive capability — explicit ordering — because CampaignDesignationService.resolveSelectable walks Allowed_Designation_Ids__c in stored order, so this array's order is literally the order donors see in the donation form's fund picker.
@api properties: reportValidity
Internal Lightning app tab hosting the settings console shell, with left navigation across organization, email, payments, refunds, site, public links, page designs, record types, and health-check panels.
Docs note
Pure navigation shell for the Settings console — holds no Apex calls itself.
Each nav item lazy-renders exactly one set panel component (setOrganization, setEmail, setEvents, setLogging, setPayments, setRefunds, setRecurring, setSite, setPublicLinks, setDesigns, setDonorPortal, setRecordTypes, setHealth) behind an lwc:if, so only the selected panel's constructor/connectedCallback runs; each panel independently calls its own SetupController/Service methods to load and save, plus setGetStarted for the post-install checklist.
The stuck-donation panel used to live here too; on 2026-08-14 it moved out to its own Lightning tab (lexDonationMonitor / the Donation Monitor tab) because watching the finalize pipeline is recurring operational work rather than setup, and it does not belong behind a console an admin visits once.
Contact/Account record page component that builds a donor's year-end giving statement on demand: a tax year picker, that year's receiptable gifts with receipt numbers, the received and deductible totals, and a print action. Reads run as the running user.
Docs note
Staff-facing annual giving statement, placed on the Contact and Account record pages. The donor-facing equivalent is pfGivingStatement inside the magic-link donor portal; both print through c/uiStatementPrint so the two copies of a donor's tax document are identical.
Every read here runs USER_MODE inside GivingStatementService — a fundraiser who cannot see a donor's gifts gets an empty statement, not a privileged one. Only the token-authorized portal path is allowed the system-mode read.
@api properties: recordId
Explains one guest request in plain language: what the visitor asked for and what each verdict does. A reviewer can issue the refund, decline with a reason the donor reads, or close it as handled. Lists the submitted names on a naming request.
Docs note
The Guest Request record page. The object's own fields answer "what does this row contain"; this panel answers the two questions the fields cannot — what a visitor actually asked for, and what each button here will do about it. Every verdict is spelled out before it is offered, because two of the three close the request without moving any money and the third charges Stripe for real.
Same three verdicts as lexRefundRequests, deliberately: a reviewer who lands on the request itself must not have to go find the donation to pay it back.
@api properties: recordId
Record action on Recurring_Donation__c for the reversible mid-life changes to a donor's schedule — pause, resume, skip the next payment, retry a failed charge, change the amount or the fund. Opens read-only without the Manage Recurring Gifts permission.
Docs note
Recurring_Donation__c RecordAction quick action hosting the reversible mid-life schedule changes. Cancellation stays in its own action (lexStopRecurringDonation) — it is terminal and needs a reason, and putting it in this panel would have set an irreversible button one click away from the reversible ones.
The card picker offers only cards the donor has ALREADY saved. There is no "add a card" path here by design: tokenizing a card needs the donor's own browser and their mandate, so a fundraiser keying one in would breach both PCI and consent.
Staff who need a new card send the donor their portal link (lexSendPortalLink) and let the donor add it themselves.
Unlike lexStopRecurringDonation this panel stays open after a successful action and re-reads itself, because staff commonly chain changes (retry a failed charge, then lower the amount). That is also why RecurringDonationAdminController.getManagePanel is not cacheable — a cached read would leave a just-paused gift still rendering as Active.
recordId is read through a setter, not a plain @api field + connectedCallback. The action is launched from the record page's action overflow, where the platform assigns recordId AFTER connectedCallback has already run — loading from connectedCallback called Apex with a null id and the panel opened on "rdId is required". lexStopRecurringDonation does not hit this because its @wire(getRecord) is reactive on '$recordId' and simply re-fires.
retryNow only moves Next_Retry_Date__c — the charge itself goes out with the next renewal-batch run. The toast says "queued" rather than "charged" so nobody reads a result into an attempt that has not happened yet.
Reinstating restarts billing from today and clears the dunning counters, so it is worded as "billing restarts" rather than "resumed" — staff need to know a charge is now imminent.
@api properties: field, recordId
Record-page card on Opportunity detailing payment status, source, fees, net amount, Stripe payment intent, refund date, and tribute (in honor/memory of) information for a donation.
Docs note
every name here has to exist in the org's utility sprite — lightning-icon renders an empty box for one that doesn't, with no console error to point at it. utility:credit_card and utility:app were both fictional and shipped as blank squares next to "Card".
@api properties: recordId
Record-page card on Payment Method listing the recurring schedules charged to this saved card and the annual giving at risk, so an expiring or invalid card can be acted on before a renewal fails.
Docs note
Mounted on the Payment Method (saved card) record page. The record's own fields say what the card IS; this card says what BREAKS if it stops working — the live recurring schedules charged on it and the annual giving they represent.
That is the question an admin actually has when an expiry warning appears, and nothing else in the app answers it.
A schedule flagged "lookup not set" was matched on the raw Stripe token rather than the Payment_Method__c lookup. It IS charged on this card (the renewal batch charges the token), but nothing on the schedule record says so — surfacing it is the whole point of the dual match.
@api properties: recordId
Record-page card on a recurring donation schedule showing its full installment history — every charge, its status, and the schedule's current dunning/cancellation state — so an admin can see how a schedule is performing without leaving the record.
@api properties: recordId
Record-page card on Account/Contact listing that donor's recurring donations with status, monthly value, next-payment date, and overdue/failed-payment indicators.
Docs note
Mounted on Contact/Account/Opportunity record pages; objectApiName tells RecordPageController.getRecurringDonations which lookup field to resolve Recurring_Donation__c rows by, so the same bundle covers both the standard and Person Account donor models without branching here.
@api properties: objectApiName, recordId
Record action (screen action) on Opportunity that lets a user issue a full or partial Stripe refund for a donation, respecting the org's refunds-enabled setting and remaining-refundable amount.
Docs note
Opportunity RecordAction quick action (headless-launched, closes itself via CloseActionScreenEvent). Calls RefundController.refundDonation to issue the Stripe refund and RefundController.getRefundConfig to read the enabled/disabled toggle; the client-side refundsEnabled check is a UX nicety only — RefundController.refundDonation re-checks server-side and is the actual gate. admFundraiserDetail's drawer calls the same two controller methods for its inline row-action refund, so behavior matches whichever entry point a user starts from.
@api properties: field, recordId
Refund requests donors raised from the public site: on a donation, that gift's requests; on a campaign, every request across it. A reviewer can issue the refund, decline it with a reason the donor reads, or close one that was refunded some other way.
Docs note
One bundle, two record pages. Dropped on an Opportunity it lists that gift's refund requests; dropped on a Campaign it lists every refund request raised against the campaign, and only then does it print the donor's gift alongside — on a gift page that column would repeat the record the reader is already looking at. GuestRequestController.getRequests decides the scope from the record id and returns it, so the component never guesses from the object it happens to be sitting on.
The panel filters to Refund_Request. Attendee-naming requests share the object but not the work: they resolve themselves and nobody reviews them, so mixing them in would bury the two rows a person actually has to decide about.
@api properties: recordId
Record action on Opportunity that re-sends the donation or event-ticket receipt to the donor on the record. Confirms the resolved recipient address and template first, and is unavailable for gifts that have not settled or were fully refunded.
Docs note
Opportunity RecordAction quick action that re-sends a donation or event-ticket receipt to the donor already on the record.
"I never got my receipt" is the most common inbound donor request, and until this existed the only answer was to re-trigger the automated path or mail a copy by hand.
The panel is confirm-then-send rather than one-click: it shows exactly which address the mail will reach and which template it will use, because the recipient is derived (Person Account orgs resolve it through the Account, not Opportunity.ContactId) and staff should see the resolved answer before an email leaves the org.
Unlike lexManageRecurringGift this panel closes on success — a receipt send is one-shot, so leaving it open would only invite an accidental duplicate.
recordId via setter, not a plain @api field read in connectedCallback: the platform assigns it AFTER connectedCallback for actions launched from the action overflow, so a connectedCallback load calls Apex with a null id. Same fix as lexManageRecurringGift.
@api properties: field, recordId
Record action on Contact and Account that emails a donor a fresh sign-in link to the donor portal. Confirms the resolved address first, then shows the link that was sent so staff can read it out to a donor on the phone.
Docs note
Contact / Account RecordAction quick action that issues a donor a fresh magic-link sign-in to the donor portal. The portal is passwordless by design, so the only way in is a link the donor requests themselves — which strands anyone whose link expired, who deleted the mail, or who is on the phone with staff right now. This is the staff-side door into that same flow.
Confirm-then-issue rather than one-click: the panel shows the exact address the mail will reach before it leaves, because in a Person Account org the address lives on the Account and staff should see which one the server resolved.
Two ways out, because the support call has two shapes. Send Link emails it. Show Link mints the same link and puts it on screen with a copy button, for the donor already on the phone — and for the donor with no address on file at all, who can be handed a link but never mailed one. Both mint, so the confirm step warns about rotation before either.
The panel does NOT close afterwards. It stays open showing the exact link that was issued, with a copy button — reading the link out is the whole point of a staff-side issue.
That link carries a live token, so it is a bearer credential: it is shown only once it exists (never before — there is nothing to show), never logged, and gone as soon as the panel closes. Issuing is one-shot — a second press would mint a new token and silently invalidate the link just handed over — so both buttons are replaced by Done rather than left pressable.
recordId via setter, not a plain @api field read in connectedCallback: the platform assigns it AFTER connectedCallback for actions launched from the action overflow, so a connectedCallback load calls Apex with a null id. Same fix as lexResendReceipt.
@api properties: field, recordId
Record action (screen action) on Recurring_Donation__c that cancels an active recurring donation after the user enters a required cancellation reason.
Docs note
Recurring_Donation__c RecordAction quick action (headless-launched, closes itself via CloseActionScreenEvent). Calls StopRecurringDonationController.stopRecurringDonation, the same controller admFundraiserDetail's drawer calls for its inline row-action stop, so cancelling from either entry point behaves identically. FIELD_CONTACT/FIELD_ACCOUNT are read (never both populated) to resolve the donor name across the standard-Contact vs. Person-Account dual-lookup model.
@api properties: recordId
Adm (internal fundraisers-console)#
Campaign "Documents" editor — create a document, attach the file donors download, publish or unpublish it, and switch the whole Documents section on or off.
Docs note
Mounted two ways: as the lexCampaignDocuments record-page shim on Campaign_Detail, and as a related list in the admFundraiserDetail drawer. Both pass record-id and nothing else.
Documents are independent of updates — a campaign can carry files with no update posts at all — and admCampaignUpdatesTab is the separate editor for those. Neither panel can see or edit the other's rows; CampaignPostController enforces that server-side.
The two-step "create the row, then attach the file" flow is not a style choice: lightning-file-upload attaches to a record that already exists, so there is no record to bind to until the draft is saved.
That is also why a new document is always created as a draft — between the two steps it is a document with no file, and the service refuses to publish one because the public card is an anchor around File_URL__c and would be dead.
Publish is blocked, not merely discouraged, while the row has no file — the server refuses it too, and a button that only ever produced an error would be a worse way to say the same thing.
The switch writes Campaign.Show_Documents__c, which is what publicFundraisingExperience reads to decide whether the Documents tab exists at all. Publishing a document while this is off puts it nowhere, so the caption says so rather than leaving it to be found.
Publishing sends the whole row back, not a one-field patch: CampaignPostController.savePost is an upsert of the form, so a partial payload would blank every field it omitted.
The upload itself is what stamps File_URL__c / File_Type__c / File_Size__c onto the row — the ContentVersion trigger does it via CampaignPostService.stampFromVersions, because a site guest cannot read a Salesforce file and the public card needs a plain URL. So the only thing to do here is re-read the list and let the row report what landed.
@api properties: recordId
Drawer "FAQs" tab — pick reusable FAQ templates or create new ones, reorder, and save the campaign's selection.
Docs note
Mounted as one tab of the admFundraiserDetail slide-in drawer (<c-adm-campaign-faq-tab record-id={recordId}>), not a standalone route. Calls three CampaignFaqController methods: getFaqLibrary (all reusable templates), getCampaignFaqs (this campaign's current selection), saveCampaignFaqs (persists the whole ordered list in one call, no per-row DML). Sibling tabs in the same drawer: admCustomQuestionsTab (identical pattern over Custom_Question templates), admEventCheckIn, admEventTicketsTab.
@api properties: recordId
Campaign "Updates" editor — write, publish and delete the plain-text progress posts that appear in the Updates timeline on the public page, and switch that whole section on or off.
Docs note
Mounted two ways: as the lexCampaignUpdates record-page shim on Campaign_Detail, and as a related list in the admFundraiserDetail drawer. Both pass record-id and nothing else. Updates are TEXT ONLY and have no relationship to documents — admCampaignDocumentsTab is the separate editor for files, and neither panel can see or edit the other's rows. That independence is enforced server-side by CampaignPostController, which takes the post type from this component (never from the admin) and from the stored row on every edit. The body is a plain textarea, not a rich-text editor: pfSectionTabs renders it inside a white-space: pre-line block, so line breaks survive and markup would not.
The switch writes Campaign.Show_Updates__c, which is what publicFundraisingExperience reads to decide whether the Updates tab exists at all. Publishing a post while this is off puts it nowhere, so the caption says so rather than leaving the admin to discover it.
Publishing sends the whole row back, not a one-field patch: CampaignPostController.savePost is an upsert of the form, so a partial payload would blank every field it omitted.
@api properties: recordId
Drawer "Questions" tab — pick reusable question templates or create new ones, reorder, and save the campaign's selection.
Docs note
Mounted as one tab of the admFundraiserDetail slide-in drawer (<c-adm-custom-questions-tab record-id={recordId}>), not a standalone route. Mirrors admCampaignFaqTab's library-reuse pattern one field over: CampaignQuestionController's getQuestionLibrary/getCampaignQuestions/saveCampaignQuestions instead of the FAQ controller's equivalents. The questions saved here are the same Content_Template__c records (RecordType Custom_Question) that dfForm's Step 4 renders on the guest side.
@api properties: recordId
Drawer "Check-In" tab (Event fundraisers) — lane board that flips each seat's check-in status at the door.
Docs note
Mounted as one tab of the admFundraiserDetail slide-in drawer (<c-adm-event-check-in record-id={recordId}>), shown only when the campaign's subtype is Event_Fundraiser. Calls EventCheckInController.getEventAttendees / setCheckInStatus. Cancelled (refunded) seats are pulled from the same Event_Attendee__c rows publicEventTickets reads, so a refund on the guest receipt side and a door scan here are always looking at the same status field.
a.id is searchable because the QR code on a donor's ticket encodes the attendee Id — a door attendant scanning into this box gets nothing back unless the Id matches.
@api properties: recordId
Drawer "Tickets" tab (Event fundraisers) — list + add/edit/delete ticket tiers.
Docs note
Mounted as one tab of the admFundraiserDetail slide-in drawer (<c-adm-event-tickets-tab record-id={recordId}>), Event_Fundraiser campaigns only. Calls EventTicketAdminController (getEventTickets, saveTicketType, deleteTicketType). deleteDisabled once any ticket has sold (sold > 0) — the delete is blocked client-side as a UX nicety, but the server enforces the same guard, so a stale client can't bypass it.
Which of the two deductible-split fields this org actually reads. Server-supplied (Settings__c.Event_Deductible_Mode__c) rather than assumed, because the editor's hints, its live deductible preview and its required-field gate all have to agree with EventPurchaseService — they did not, and the disagreement reached donor receipts (F-81).
Mirrors EventPurchaseService exactly — the active mode picks the field, the other one is ignored, and the result is clamped to [0, price] the same way. It used to apply "an explicit percentage overrides FMV" in both modes, which is not what the engine does, so the figure an admin previewed here could differ from the one printed on the receipt.
Highlight_Label__c is the badge copy AND the on-switch (blank = not highlighted), so the toggle seeds a default label on and clears it off. Demoting the event's OTHER tiers is the server's job (EventTicketAdminController.demoteOtherHighlights) — this modal only ever holds one tier, and load() below re-reads the list afterwards.
@api properties: recordId
Non-exposed helper that renders a small colored icon tile for a fundraiser's subtype (campaign, event, open donation) inside the internal fundraisers console.
Docs note
Composed (not inherited) into two parents: lexFundraisersConsole's list/grid rows and the admFundraiserDetail drawer header. Both pass only subtype and size; the icon/color mapping lives entirely in this component, so the two parents can never drift out of sync on what a subtype looks like.
@api properties: size, subtype
Non-exposed slide-in detail panel used by the Fundraisers console tab, showing a campaign's overview, donations, pages, teams, tickets, questions, FAQ, recurring gifts, and fund across tabs.
Docs note
Mounted by lexFundraisersConsole as <c-adm-fundraiser-detail record-id={detailId} onclose= onedit=> — the console owns detailId (which row is open) and passes nothing else down; this component self-fetches everything via FundraiserDetailController. Composes admFundraiserAvatar (header icon) plus four tab children for the drawer's Event-only and always-on sections: admEventTicketsTab, admEventCheckIn, admCustomQuestionsTab, admCampaignFaqTab. Row actions call RefundController.refundDonation and StopRecurringDonationController.stopRecurringDonation directly (same controllers as the lexRefundDonation / lexStopRecurringDonation RecordAction quick actions) so a refund or stop issued from this drawer behaves identically to one issued from the Opportunity highlights bar. Related lists (donations/recurring/pages/teams/tickets) lazy-load per-list on first tab view and support a nested paginated "view all" sheet beyond the 10-row inline cap — see the fundraiser-detail-related-lists-redesign design note.
Which of the org's configured Stripe accounts this fundraiser's gifts settle to (Campaign.Payment_Account__c). Blank is a hard blocker, not a styling choice — the guest checkout path throws before it can create a PaymentIntent — so the note says so.
mirrors the note the mock shows under the mode name — one line summarizing what a donor sees, so an admin can tell the resolved picker behavior without opening Fund below.
@api properties: recordId
Fundraisers-console related list of every request raised by visitors on one campaign — refunds and attendee-name changes together — with the complete/decline verdict and its two notes. Unfiltered, unlike the refund-only panel on the Campaign record page.
Docs note
The fundraisers-console view of everything a visitor has asked for on one campaign.
Deliberately UNFILTERED, which is what separates it from c/lexRefundRequests on the Campaign record page: that panel narrows to Refund_Request because a Lightning page is a place you go to decide one thing, while the console drawer is where someone works a whole fundraiser and needs to see that six people also asked to rename their seats.
Passing no requestType is the whole difference — GuestRequestController.getRequests already returns every type, already derives the scope from the record id, and already redacts rows whose gift the running user cannot open.
There is no console-specific Apex, and there must not be: a second read path would be a second place for the redaction rule to be got wrong.
Rows are read-only about the DONOR and actionable only about the VERDICT. Nothing here re-applies a payload — AttendeeNamingHandler and RefundRequestHandler own their writes.
@api properties: recordId, refresh
Set (settings console panels)#
Settings-console panel for creating and editing Campaign_Design__c page-design records — the block canvas and theme-token editor that drive the public donation page's layout and branding.
Docs note
The editor half of the CMDT-driven theme-token system: getThemeTokenCatalog reads the same Theme_Token__mdt catalog (via ThemeTokenCatalogService) that CampaignService. normalizeThemeTokensJson() uses as its sole allowlist when a guest page loads a design, so this panel can never offer a token the guest shell won't actually honor. Live preview renders through pfPageBlocks (<c-pf-page-blocks>), the identical component dfForm uses at runtime, so what an admin previews here is what a donor sees. Form_Builder_JSON__c (v2, the unified block list below) is the current schema; Page_Blocks_JSON__c is legacy and no longer read or written by this composer.
The public page is one canonical two-column layout now, so every design's mini thumbnail renders through the 'sidebar' renderer — the one that draws a content column beside a form column. The old four-way LAYOUT_KEY mapped the six retired Form_Layout_Mode__c values onto structurally different minis; there is nothing left for it to switch on. What still varies between designs is the ratio, which decides only which side the form column sits on (see previewSidebarRight/Left) — plus colour and type, which the thumbnail already reflects.
Derived from - and must stay identical to - the Campaign_Design__c.Font_Family__c restricted picklist. Both are restricted, so an option offered here but missing there fails the save with a validation error rather than degrading. Regenerating this list from the field metadata is the safest way to extend it.
Restricted picklist rather than a free-text font name/URL on purpose: allowing an admin to supply an arbitrary font stylesheet URL on a donor-facing guest page would be a CSS/content-injection risk, so only this pre-approved value set is selectable.
The Google_* values are the only ones that trigger a webfont load; System and Modern render with no external request (Modern names Inter, which is deliberately NOT bundled - it falls through to the system UI face). Google_Bricolage/Google_Hanken are the two halves of the Ember default brand pairing referenced in the design brand spec, offered here as opt-in choices rather than as the field default.
Widened to 38 faces on 2026-08-05, grouped by category in display order (sorted=false, so the order below IS the admin's dropdown order): grotesque sans, geometric sans, condensed sans, reading serif, slab, display serif, mono.
Adding a face means three edits in lockstep - a FAMILIES line in scripts/build/fetch-public-fonts.sh, a value in BOTH this field and Heading_Font_Family__c, and a FONT_STACKS entry in publicFundraisingExperience.js + publicEventRegistration.js. Miss any one and the value renders a fallback face with no visible error.
The public page is always two columns, so the only layout choice left is the proportion and which side the payment box sits on — one Column_Ratio__c picklist in place of the six layout modes and the separate side field, which could disagree with each other. Labels mirror the field's own picklist labels so the console and a record page read alike.
The thumbnail's mini CTA is always solid, because the rendered page's CTA is always solid — Button_Style__c stopped reaching the guest render path on 2026-08-13 (see publicFundraisingExperience.resolveButtonTokens). A card that still drew a hollow button would be advertising a treatment no donor sees.
Membership is explicit, not substring-matched: the substring test this replaced read 'Serif'/'Fraunces'/'Instrument' and so classified Newsreader, Lora, Bitter and EB Garamond as sans, previewing four serifs in the wrong category. Add new faces here.
There is no hero-only preview any more — every design previews as the two-column page.
Off for a brand-new design. Interrupting a donor is a choice an admin should make on purpose, not inherit from a default.
Settings-console panel controlling which panels appear on the magic-link donor portal and the headline/message copy shown there, all stored on Settings__c.
Settings-console panel for configuring sender/reply addresses, email branding colors, and automatic receipt/recurring-thank-you message text on Settings__c.
Docs note
Blank means "use the packaged RCPT default" — saving '' instead of null would make an admin who cleared the box look configured while behaving as unconfigured.
Settings-console panel controlling the event ticket tax-deductible calculation mode and the optional Campaign Member sync for ticket attendees on Settings__c.
Settings-console panel presenting the ordered post-install setup checklist. Each step reports its status live from the org and offers a one-click fix, a jump to the panel that owns it, or a link to the matching section of the after-you-install guide.
Docs note
The Get Started panel — the in-org half of the post-install experience. The other half is the published after-you-install page the package's postInstallUrl redirects a subscriber to right after the install finishes; every step here deep-links back to its section there. Steps come from SetupController.getSetupGuide(), which reads their state live from the org rather than from a stored completion flag, so this re-reads after every fix instead of tracking progress locally — a checklist that remembers being ticked lies the moment the org drifts.
"before this org CAN take a donation" was a promise the package does not keep. Nothing in the guest donation path consults this checklist, so a form on an unfinished org charges the card exactly as it would on a finished one — a 2026-08-17 fresh-install run read this line at "2 steps left", took a real $100 payment anyway, and lost it (the missing step was the finalizer). "Ready to take donations" says the same urgency without implying a gate that is not there.
Three outcomes, not two, because "you can finish it from this screen" was counting steps whose only in-app affordance is a link to a DIFFERENT panel — and, in the case of "Publish your donation site", to a panel that then sends you out to Experience Builder. A first-run walkthrough read "4 steps left · 4 of them you can finish from this screen" against a list where at most one had a button (found 2026-08-17).
canFix is the only honest "from this screen"; a panel link is a different, weaker promise, and gets said as one.
left excludes Optional rows. It did not until 2026-08-17, and an Optional row is not 'pass' whenever it is partly satisfied — so on an org with one required step open and the optional content-hosts row at 2-of-4, this line described TWO steps under a banner that said one, and counted the optional one as required. That is the same defect as F-10 and F-23, in a third place: this sentence must count exactly what outstanding counts.
The clauses are also phrased position-independently ("more"/"the last one" only appear once something precedes them), because whichever clause lands first is the sentence's opening and used to render lowercase mid-thought when there was no button to lead with.
When exactly one step is left the clause says "It", not "One" — the banner above has already said "One step left", and repeating the count reads as a second, different step.
The hero's fraction and the list beneath it count differently, in two ways that both flatter the org: an Optional row is displayed but never counted, and a "Partly done" row is counted as not done although it looks half-finished. So "6/7 Complete" can sit above eight rows, five of which read as finished. A first-run walkthrough took the number as its progress signal and believed the org was one step from ready while three steps were open (2026-08-17). Rather than change either rule — both are right on their own terms — the panel now states them, and only when there is actually a discrepancy to explain.
_resolveSettingsTabUrl() calls _applyPanelUrls() itself once _settingsTabUrl resolves (both its success and catch paths, below) — an extra call here ran before _settingsTabUrl could possibly be set, so it only ever applied panel URLs built from the stale/null value and was pure redundant work.
Settings-console panel that runs and displays org configuration health checks, with a one-click fix action for failing items.
Docs note
runHealthCheck/fixHealthCheckItem drive the pass/warn/fail checklist (Named Credential reachability, required permission-set assignments, scheduled-job presence, etc.). The scheduled-job breakdown and on-demand "Run a job now" controls both live on the "Scheduled Jobs" console tab (c-set-scheduled-jobs).
Settings-console panel for choosing the persisted log level, toggling the debug echo, and setting the retention window on Settings__c, plus the recent Stripe transaction errors. Reading the log entries themselves lives on the separate Diagnostic Logs tab.
Docs note
Sets the logging THRESHOLD (Log_Level__c), the System.debug echo, and the retention window. It no longer READS Log__c: the "Recent diagnostic logs" list moved to its own Diagnostic Logs tab (lexDiagnosticLogs / DiagnosticLogController) on 2026-08-15, because troubleshooting is recurring work while the level is a setting you choose once — and because ten rows squeezed under four settings controls was never enough to diagnose anything. The Recent errors list below it deliberately stayed: it reads Transaction_Log__c, the Stripe callout audit trail, which is a different object with no overlap with AppLogger's Log__c.
Settings-console panel for general organization and donation defaults, including the configured charge currency, stored on Settings__c.
Docs note
Person_Account_Enabled__c and NPSP_Enabled__c are read-only, detectOrgFeatures-populated flags (Schema.describeSObjects() detection at the org level) — this panel surfaces what was detected, it does not let an admin toggle Person Account/NPSP support on or off. All donor resolution downstream still goes through DonorResolutionService regardless of what this panel shows; these flags are informational plus the gate for showNpspSyncOptions.
The follow-up routing fields are meaningless while the checkout prompt that produces the matches is switched off, so they only appear once employer matching is enabled.
Blanking either fee-coverage field is a supported way to fall back to the packaged Stripe US standard (2.9% + $0.30), so the nulls travel deliberately — SaveGeneralSettings guards on containsKey, and a null clears the override.
Off by default; on only unlocks inline playback once the admin has hand-created the CSP Trusted Site this package deliberately does not ship (see the Story media section copy). Must be listed here explicitly — handleSave sends an allowlist, not everything in this.settings, so an omitted key here never reaches the server.
The public-page footer band. These moved here from the page-design record on 2026-08-12 — a privacy policy is a fact about the organization, so changing a campaign's theme must not change which policy its donors are pointed at. Blanks travel as nulls on purpose: an empty link is omitted from the footer, never rendered dead. Each campaign can still override its own on the campaign record.
Settings-console panel listing gateway payment accounts with add, edit, test-connection, and delete actions, above a ten-step guide that grades each account's public and admin Named Credentials and runs the on-demand restricted-key and connection checks.
Docs note
Stage 2 before a payment account exists. NOT a verdict — every check in that stage resolves its credential BY NAME and the name lives on the account, so there is nothing to grade against yet. It used to render pending / "Action needed" in coral, which told an admin who had just built all four objects correctly, six times over, that they had done it wrong (2026-08-17 walkthrough; raised again 2026-08-18: "how user know this flow, this is really confusing"). Neutral grey, and it never counts as work outstanding.
How many accounts the Accounts table will grade in the background when it is first opened. A cap rather than the whole list, because the cap is cheaper than discovering the limit: grading is one Apex round trip per account, so an org with fifty of them would otherwise fire fifty calls on a single tab click. Rows past the cap keep the neutral "Not checked yet" pill, which is the truth about them.
Where the "Do this next" card gets a step's title from. The eleven step bodies are bespoke markup with their titles written into the template, so there is no title on the payload to read — this map is the one place JS can name a step, and a title changed in the template has to be changed here too. It is deliberately titles-and-links only: everything the card SAYS about a step is the step's own oneLine, straight off the guide, so the card can never drift into being a second, staler copy of the instruction.
The rail's running order — one entry per pane, in the order an admin actually works them. keys is stage 1 and is NOT one of the eleven graded steps: there is nothing in Salesforce to grade about keys the admin has not pasted anywhere yet, so it carries no chip and no score. Everything after it is a graded step key, and the order matches the guide's own stepOrder.
Which panes carry a live-call button, so the rail can mark them and the pane can render the durable verdict under the right one. Both are keyed by Payment_Account__c Id in Apex.
The placeholder PaymentSetupGuideService puts in the "Yours" cell of the one checkbox it cannot read. Matched on rather than the row's English label because it is the narrower handle: the same row reads "No credential yet" before a credential exists, and that state has nothing to prove. See _proveCalloutsEnabled.
Where a step's venue chip gets its colour. Written here rather than inline in the template so the rail and the pane header cannot drift apart on the same step.
ungraded is presentational only and is applied by _toGuideView, never by Apex: the service graded these steps honestly as PENDING and its counts still say so. What changes here is what the admin is shown while the panel has no name to look a credential up by. Only PENDING is rewritten — a step that reached confirm, failed or shared reached it on evidence that does not depend on the account, and must keep saying so.
"Settled" is the rail's completeness test, and it is deliberately wider than done: a confirm step holds a value Salesforce cannot read back, so it will never grade done no matter what the admin does. Counting only done made the rail print "0 of 6" on a stage the readiness pill and the footnote both called finished — the exact self-contradiction this redesign exists to remove. The mark keeps its own per-status colour, so a self-confirmed step still looks different from a verified one.
The four counts behind the readiness pill and the Set-up tab's badge. This used to drive a five-band progress bar as well; the rail now shows every step's own mark, which is the same information without asking the admin to learn a colour vocabulary to read it.
confirm folds into done: the value exists, a package may not read it, and there is nothing left for the admin to do about it from here. failed keeps its own band because it is the one state worse than unfinished, and folding it into "to do" would hide a live fault. unverifiable keeps its own band too — folding it into done reported two never-pressed live-check buttons as finished work on an org with nothing configured at all, which is the opposite of what the bar is for. Every step's own chip still names its exact flavour.
Not "below" — the steps sit in the rail beside the pane now, not stacked underneath it, and a footnote that points somewhere the reader cannot look is worse than no footnote.
A secret key is never typed into this panel. The admin builds the External Credential and Named Credential by hand in Setup — once per gateway account, since an org may hold several — and this modal only points a Payment_Account__c row at one of them. The guide above the list walks that build step by step and then reads the org back to say which field is wrong.
The package deliberately provisions no credential of its own: a Named Credential created from Apex is born with callouts DISABLED and no Apex API can enable it (verified against a live org 2026-08-10), so provisioning could never finish the job — it only moved the manual step somewhere less visible. Removing it also keeps the secret out of this component entirely.
getPaymentSetupGuide grades ONE account at a time (PaymentSetupGuideService), because each Stripe account needs its own credential pair — the switcher at the top of the guide is how the admin moves between them. Guides are cached per account for the life of the panel and only re-read on Re-check, so switching back to an account already checked costs no callouts.
getNamedCredentials lists the org's Named Credentials for the picker. Only that reference is ever saved on the record.
testPaymentAccount performs a live connectivity check against the configured credential before an admin trusts it for guest checkout — the one fact ConnectApi cannot report, since a stored secret reads back as neither present nor valid. It is the guide's "Prove it works" step, and the Test button on each account row.
getGatewayCatalog supplies the processor combobox from PaymentGatewayRegistry. It replaced a hardcoded five-name list ("PayPal (coming soon)", …) that this panel then had to reject on save — an option an admin can pick and cannot use is not a feature, and the list drifted from what the package could actually connect to.
An account names TWO credentials — a public, charge-only one the site guest user holds, and an admin one for refunds and back-office jobs — so guide steps 1-5 grade each of them and hand this panel a credentials sub-row per credential, which it renders under the step's own verdict. The step chip shows the worse of the two, so the sub-rows are the only place an admin can see WHICH credential is the one at fault.
probeRestrictedKey is the "Prove the public key is restricted" button. It is deliberately on demand, exactly like Test connection: one live read-only gateway call whose answer changes only when a key changes, so running it on every panel render would spend the org's callout budget re-answering a settled question. Its verdict is folded back into the cached guide, so the step's chip stops saying "On demand" the moment there is a real answer to show.
Two chip colours exist only for this split: failed (a check that ran and came back wrong — a public key that can still issue refunds) and shared (one credential in both roles, which the package supports but never grades green).
Live-call verdicts, keyed by account Id then step key, and the reason this store exists at all: they are the only results on this page that Salesforce cannot re-derive. Re-check refetches the guide from Apex, which has never heard of the probe — so a re-read used to silently DELETE a FAILED restricted-key verdict and put the account back to "ready to take payments" with nothing in the org changed (found 2026-08-17 by a naive-admin walkthrough; the admin had been told, correctly, that an anonymous visitor could issue refunds, and the refresh button retracted it).
A safety check a refresh can retract is worse than no check, because it manufactures confidence. Verdicts now outlive every re-read and are cleared only by running the check again or by the account being deleted.
The rail is the whole navigation model: four stages, twelve entries, one status mark each, and exactly one of them open in the pane beside it. It replaces eleven <details> accordions, and that is not only a layout change — a result rendered inside a collapsed accordion body is a result nobody sees. Test connection wrote its answer into one, so pressing it looked identical to a dead button (found 2026-08-17; the admin pressed it four times across two controls, concluded it was broken, and would have phoned support while the callout was succeeding server-side every time).
On a live-call step that has already been run, the durable verdict block below says the same thing with a timestamp attached, and printing both put the identical sentence on screen twice. The timestamped one wins; the header steps aside.
Counts the ELEVEN graded steps, not the twelve panes. The rail prints each step's own number, which comes from Apex and skips the ungraded "Get your keys" prep pane — so counting panes put "10" in the rail and "11 of 12" in the header of the very same step. The prep pane has no number to agree with, so it says what it is instead.
A durable live verdict, rendered in the pane for whichever live step is open. Kept apart from the transient in-flight message so a re-read can drop "Calling Stripe…" without dropping the answer.
Only true from the second account onwards. Step 9 shows exactly one account row, and with a single account in the org that row is unambiguous — a sentence naming which account the eleven steps grade would be noise. From two accounts it is the opposite: the row on screen is one of several and nothing else on the step says why it, and not another, is the one being graded (2026-08-18, owner review).
The same row shape as accountRows, narrowed to the one account the eleven steps are grading. Step 9 renders THIS, not the full list: the step is about a single record, and a list there is an invitation to edit the wrong one (owner decision, 2026-08-18). The full list stays on the Payment accounts tab, which is the place that is about all of them.
The Accounts table's rows — one per Payment_Account__c, carrying every column the table prints. The list used to render as stacked cards, one per account, each repeating the gateway name three times and showing neither credential; the approved design (docs/designs/2026-08-17-payments-panel-redesign.html) is a table, because the four facts that separate one account from another — processor, mode, which credentials, how ready — only read as a comparison when they line up in columns.
The Label cell flags OFF, not ON. Almost every account is active, so an "Active" chip on every row is decoration that says nothing; a switched-off account is the surprise — it silently takes no money — and that is what earns the chip.
The Credentials column, deduped. When one credential carries both roles — the supported shared setup — printing its name twice reads as two credentials, which is the one thing this column exists to tell apart. showSep is precomputed because LWC cannot express "not the first item" in markup.
One account's readiness, for its row in the table. Read from the cached guide rather than recomputed, so the pill and the setup tab's own tally can never disagree — and neutral, not green, for an account nothing has graded yet: "Ready" about an ungraded account is the exact false reassurance this whole panel was rebuilt to stop printing.
Grades the accounts the table has no verdict for. Only the SELECTED account is graded on load, so without this every other row would read "Not checked yet" for as long as the org existed — on a two-account org, the whole table minus one line. Grading is a describe-and- read pass with no callouts in it and the result is cached like any other guide, so this runs once per account per page load. Sequential on purpose: a burst of parallel Apex calls buys nothing here and the table is not blocked on it.
Add account is switched off until this org holds a named credential, and that is the fix for the panel's oldest complaint: the button sat at the top of the page from the first render, so it read as the place to start — but the form behind it cannot be saved without a credential to point at, and building one is forty minutes of Setup work in another tab.
An admin who pressed it first met two empty required pickers and no explanation of what was missing. A control that cannot succeed yet must not look like it can; the reason below is rendered next to it, not just hung in a tooltip.
The one line the status bar prints, and the only summary left on the page. The panel used to open with a next-action card that restated the work in prose; the rail beside the pane now shows every step and its mark, and the pane always opens on the first unsettled one, so a card naming that same step was a third copy of the same fact and the one most likely to go stale. What is left here is the question the card was actually answering: can this org take a payment right now, yes or no.
A stage whose steps cannot be graded must not print a score. With no payment account there is no credential NAME to look anything up by, so all six build-stage steps come back PENDING — and the heading then read 0 of 6 at an admin who had just built all four objects correctly (2026-08-17 first-run walkthrough). 0 of 6 is a verdict; the panel does not have one yet, so it says so.
The mode sentence used to be inlined as "This account is in <b>{guide.mode}</b> mode", which on the org-level preview — no payment account, so no Mode__c — rendered as a blank gap mid-sentence: "This account is in mode, which is…" (found 2026-08-17 by a first-run walkthrough). There is no account to have a mode yet, so the preview says what the examples assume instead of reporting a value it does not have.
Names the step, not a tab. The form lives on step 9 of this wizard (and, for general account management, on the Payment accounts tab) — an earlier wording sent the reader scrolling for a form that was not on screen, and its replacement sent them to another tab they no longer have to visit.
The switcher flags a switched-off account, because the default selection now skips those (see _defaultAccountId). Without the flag the panel silently opens on a different account than the one whose name sorts first, and the switcher gives the admin no way to see why.
The publishable key is masked when you reopen a saved account, and typed in the clear when you first paste it. It genuinely is not a secret — it ships to every donor's browser — but the same modal has just finished telling the admin that two other keys must never be typed into a field, and nobody reading quickly separates the three (2026-08-17 walkthrough, P-16). Left in plaintext it rides along in every screen-share and support screenshot of this window, which teaches exactly the habit the rest of the page is trying to prevent.
Masking is a password input rather than a static ellipsis so the field stays editable and stays inside the required-field sweep; Reveal is offered because an admin comparing this against the Stripe dashboard must be able to read it.
Both credential pickers carry their own refresh button, and it is NOT only for the empty case. The list behind them is read when the modal opens; the normal workflow is to open the modal, discover a credential is missing, build it in Setup in another tab and come back — at which point the picker is stale but NOT empty (the other credential is in it), so the empty-state "Check again" never renders and the only way out was a full page reload. Reported 2026-08-17 against a live org. The button is always there instead.
Only Live is badged. Test is the safe, ordinary case and renders as plain text — badging both would put a second coloured chip in every row of the accounts table, competing with the Status pill, which is the column the admin is actually there to read. Decoration is reserved for the exception, never spent on the norm.
The default is the first ACTIVE account, not simply the first. The list arrives sorted by record Name, so an org running one live account plus a half-built draft would land the eleven steps on the draft whenever its name sorted first — a panel full of outstanding work for a record that takes no money, on a page whose job is to say whether donations can be taken (audit finding F-52, 2026-08-18). An inactive account is still reachable: it is one click away on the "Showing" switcher, which is what the switcher is for.
A convenience jump, no longer a required hop. Step 9 shows the account it is grading and opens the same edit modal in place, so this tab is where an admin goes to see the whole list and to add a SECOND account — not where they are sent to finish the step.
Test connection. Its answer is written to THREE places on purpose, and the reason is the defect this replaces: the result used to be a single line inside the step's collapsed accordion body, so pressing the button produced no visible change whatsoever and a successful call was indistinguishable from a dead control. It now folds into the step's own status (so the rail mark and the readiness line move), into the durable verdict store (so Re-check cannot erase it), and into the pane's result block (so the admin reads the sentence Stripe actually returned).
Records one live-call answer against the account it was asked about, and stamps it with the time it was taken. The stamp is the honest part: a verdict that survives a re-read has to say how old it is, or it becomes a claim about right now that nobody re-tested.
"Enabled for Callouts" is the one row of the checkbox table Apex cannot grade. It is not a field on the NamedCredential SObject at all — that object exposes only the three CalloutOptions* booleans, and calloutStatus lives in the Metadata API, which Apex cannot retrieve synchronously. So the row ships readable:false and wears the amber "Check yourself" chip forever, which is honest but leaves one permanently unresolvable cell in a table the admin is being asked to clear.
A live call settles it anyway, from the other direction: a credential with callouts switched off cannot reach the gateway at all, so any answer that came BACK from the gateway proves the box is ticked. Which live call counts is not interchangeable — StripeGateway routes testConnection through the ADMIN credential and probeRestrictedKey through the PUBLIC one, and this table is built from the public reading. So the probe proves it outright; the connection test proves it only in shared mode, where both roles name one credential and there is nothing to confuse.
Statuses are filtered on the same "did the gateway answer" test. For the probe, done (Stripe refused the write) and failed (Stripe allowed it) are both conclusions drawn from a real HTTP response, while an unreachable gateway grades unverifiable and a thrown callout is recorded as pending — neither is proof. For the connection test only done counts: its failed is the genuinely ambiguous case, because a blocked callout and a rejected key are exactly what this table exists to tell apart. And the pre-graded shared verdict is never proof — Apex returns it without making the callout.
The picker prints "Label (API name)", not the label alone. Every word of guidance on the tab next door — the reference table, the build steps, the grading messages — names credentials by API name (Stripe_Public), because that is what the checks read and what Setup asks for.
Salesforce's picker only knows the MasterLabel, so an admin who followed the guide exactly was shown two labels they had never been taught ("Stripe Test Public") and had to guess the mapping (2026-08-17 walkthrough, P-07). Guessing wrong swaps the admin key onto the guest-facing role, which is the one mistake on this page that has a security consequence. The API name is dropped when it is identical to the label — a bracket repeating the words beside it is noise.
Re-read the credential list every time the modal opens, not once in connectedCallback. The whole shape of this workflow is "leave the console, build a credential in Setup, come back" — so the one list that must be current is the one loaded furthest in the past. A first-run walkthrough hit exactly that (2026-08-17): both pickers insisted "No Named Credentials in this org yet" while Setup listed two and the Apex behind the picker returned both, because the panel instance predated the credentials and Lightning kept it alive. Nothing could be saved, and the org could not take a donation.
Re-validating the one field that changed is what clears the error the save attempt put under it. A blocked save calls reportValidity() on every control, and Salesforce leaves "Complete this field." rendered until something asks that control again — filling it in is not, by itself, that ask.
So a walkthrough (2026-08-17, P-06) filled in the credential it had just been told was missing and watched the red text stay put under a visibly complete field, with no way to find out whether the form would now be accepted short of pressing Save and seeing. Cheap to fix, and it costs one call on a field the admin has just touched.
Settings-console panel listing the public donation/campaign URLs (type, slug, status) with a copy-URL row action for each.
Docs note
Read-only inventory of every guest-reachable donation URL (campaigns, event registration) built from Settings__c.Experience_Site_URL__c + each record's slug — a cross-check surface, not the place slugs are edited. Complements lexEmbedBuilder (which generates the embeddable snippet for one campaign at a time) by showing every direct link across the org at once.
Settings-console panel mapping the package's logical record-type slots to the org's actual record types per object, with per-row override and reset-to-default actions.
Docs note
Maps each packaged "record type slot" (packageRTDeveloperName) to the actual Record Type an org uses for it — the admin-facing override surface for whatever RecordTypeService resolves at runtime, letting an org substitute its own existing Record Type instead of the packaged default without any Apex/trigger change.
Settings-console panel controlling the retry cadence, grace period, and dunning-email frequency for failed recurring donation payments on Settings__c.
Settings-console panel toggling whether refunds are enabled and setting the refund-eligibility window in days on Settings__c.
Docs note
Stored in the friendly sense (Donor_Refund_Requests_Enabled__c, default false) rather than inverted like the master switch, so this one reads and saves straight through.
Settings-console panel that shows every pipeline CronTrigger as a card — a family with its cadence editor, a single job read-only — explains why a family runs as several offset jobs, and lets an admin schedule missing jobs or run one on demand.
Docs note
The single (non-family) jobs take their label and description from the SAME RUNNABLE_JOBS entries that drive the "Run a job now" list, keyed by the CronTrigger name each one is scheduled under (the JOB_NAME_* constants on the Apex schedulables). One source means the status card at the top of the panel and the Run-now row further down can never disagree about what a job is called or what it does.
DonorSummaryRollupBatch is intentionally absent from this map — it is run on demand only and has no CronTrigger of its own. A cron job the map does not know (an admin's own, or a renamed constant) still renders, falling back to its raw name with no description rather than disappearing.
The last three all run on their own schedule once installed — the two Stripe jobs hourly, campaign totals nightly. They are offered on demand anyway because the hour after an admin connects Stripe is exactly when they need to confirm reconciliation works, chasing a specific missing refund should not mean waiting out a cron cycle, and a campaign total that looks wrong is worth being able to correct on the spot rather than tomorrow morning. The two Stripe jobs no-op safely when Stripe is not connected yet.
"Schedule missing jobs" reuses SetupController.fixHealthCheckItem('scheduled_jobs') — the same action behind the Health tab's Fix button and the Get Started step — rather than calling scheduleJobFamily per family. That entry point runs PostInstallHandler.scheduleJobs(), which is idempotent (its scheduleIfAbsent helper no-ops on an existing job name) and covers the single jobs (payment sweep, refund reconciliation, campaign rollups) that have no family card here.
It exists on this tab because the cadence editor is rendered per family and a family with zero CronTriggers renders nothing: an org whose jobs were never scheduled (source deployed rather than package installed) or wholly aborted had no control on this tab at all, only a sentence pointing at another one.
A single job's cadence line is derived from the CronExpression the platform reports, never from a hard-coded string next to the job name. The CRON_* constants in Apex are only the value used at install time — an admin who reschedules a job in Setup, or a future release that shifts an offset, would otherwise leave this panel confidently stating a time the job no longer runs at. Salesforce cron is sec min hour day-of-month month day-of-week [year]; only the two shapes this pipeline actually schedules (hourly at a fixed minute, daily at a fixed time) get prose, and anything else falls back to showing the raw expression.
This tab reuses the existing SetupController.getScheduledJobStatus read path unchanged — the flat CronTrigger list already carries everything (name, state, next/previous fire) needed to group and label it here; only the cadence-edit actions (abortJobFamily / scheduleJobFamily) are new. Cadence editing is 2 separate Apex round-trips rather than one combined action: aborting and rescheduling a CronTrigger of the same name within a single Apex transaction is not a documented-safe sequence, whereas 2 separate transactions mirrors the abort-then-recreate sequence this pipeline already relies on (PostInstallHandler).
A single job gets the same card as a family — friendly label, what it does, when it runs — because "why is RefundReconcile in a plain list while Donation Finalizer gets a card?" has no answer an admin can act on: the difference is only that a sub-hourly family needs many CronTriggers.
What it deliberately does NOT get is the cadence dropdown, because SetupController.ScheduleJobFamily only accepts the two family keys in CADENCE_FAMILIES and rejects anything else — offering a control the backend would refuse is worse than none. These jobs are rescheduled from Setup > Scheduled Jobs, which is why the raw CronTrigger name stays on the card: it is the string the admin has to match over there.
Settings-console panel that walks an admin through getting a public donation site live: an eight-step checklist, machine-checked where possible, with Setup deep links, the site picker, a per-page build table, and the embedding switch.
Docs note
The Campaign-access step (5) deliberately links to Permission Sets rather than to Profiles: the Lightning Setup node for the profile list is EnhancedProfiles only when the Enhanced Profile User Interface is switched on, so a profile deep link is a dead end in some orgs. A permission set granting Campaign read and assigned to the guest user satisfies the check exactly the same way, and the step's body still spells out the profile route as an option.
The step asks for the Campaign OBJECT grant only, and says so precisely, because the two halves of Campaign access reach a subscriber org by different routes. sf package version create strips STANDARD object and field permissions out of a managed permission set and still reports Success, but it keeps grants on CUSTOM fields — so the 58 Campaign.*__c grants on Fundraising_GuestDonor do arrive, while the object grant and the two stock fields the public pages read (Status, End Date) do not. A guest profile normally reads those two stock fields already, which is why this is one manual step and not three; the step names them as a fallback to check rather than as work to do. Wording corrected 2026-08-19 (audit F-76), when it still lumped all three together and implied the packaged set covered the fields.
BLOCKED is not a weaker UNVERIFIABLE — it is the opposite claim. UNVERIFIABLE means Salesforce exposes no way to read this, ever, so the admin must confirm it themselves. BLOCKED means this app CAN read it and will, as soon as a prerequisite is met. Three of these steps (guest permission set, Campaign access, Stripe credential) are per-guest-user and so cannot be read until a site is chosen; they used to borrow UNVERIFIABLE's "Can't verify" chip, which told the admin to go and hand-verify something the panel was about to check for them (2026-08-17 walkthrough, F-07).
One row can be honestly wrong, and the panel says so rather than hiding it. The Stripe guest-credential line reads its principal grant through ConnectApi, which some orgs refuse to a packaged caller (audit F-58) — indistinguishable from a credential that is genuinely not granted, so a correctly configured org can see "Can't verify" here forever. The step's body states that limit and points at Get Started step 5, which proves the same two grants by SOQL and is not affected. Raised 2026-08-19 as audit F-77; the code-side fix (rebuild this read on the SetupEntityAccess query F-76 added) is an open decision, not shipped.
Three states, not two. A site can be DISCOVERED (its name or URL prefix matches the convention this package seeds) without ever having been CHOSEN, and discovery saves nothing — no Network Id, no site URL, so every public donation link below is still dead. Reporting that as Done is what let the panel show a green step 2 beside "No site URL yet" and "Not set yet — choose your site in step 2 above" at the same moment (2026-08-17 naive-admin walkthrough, finding 12).
The address shown is the real public base URL rather than Network.UrlPathPrefix, which is a path fragment Setup renders with a suffix donors never type (finding 10).
Graded on the site's STATUS, which is activation — publishing in Experience Builder pushes content live and leaves the status exactly where it was. The old copy said "Status is Live — the site is published", which welded the two together and sent an admin who had published correctly looking for a fault (2026-08-17 walkthrough). Both actions are required; only activation is what this line can see.
stripeCred is returned OUTSIDE the checklist, and that placement is the fix for a cross-panel sequencing bug rather than a cosmetic choice. Get Started sends the admin to this panel at its step 3 ("your donation site") and to Payments at its step 4; while the grant was numbered step 6 of this checklist, the site checklist was instructing the admin to do step 4's work — with a permission set and a credential that do not exist yet — in the middle of step 3.
It stays CHECKED here, because the grant lands on this site's guest user and this panel is the only place that reads that user; it is no longer numbered, no longer tallied, and no longer carries the procedure. Payments owns the procedure (its "Let your public site use the credential" step); this is a mirror of the verdict. Callers must keep it out of buildTally() — see the guard there.
The Site & Domain panel, rendered as a seven-step setup checklist (enable, site, guestAccess, permSet, campaignAccess, pages, publish) plus one un-numbered, un-tallied mirror row beneath it (paymentGrant). Each step is a native <details> collapsed by default; its status comes from buildSteps(), which is the single place a Done/Pending/Can't verify decision is made, so the progress tally and the per-step chips cannot disagree.
getExperienceSiteStatus supplies digitalExperiencesEnabled, exists, status and the composed pageUrls (SetupController.withPublicPageUrls); checkGuestPermissionSet supplies the permSet step and, nested under its 'stripeCredential' key, the stripeCred step (CommunityService.checkStripeCredentialAccess — corrected 2026-08-10, previously shipped as permanently "Can't verify" on the false claim that the grant is unreadable from Apex), and nested under its 'objectAccess' key, the campaignAccess step (CommunityService.checkGuestObjectAccess, added 2026-08-16).
campaignAccess and stripeCred grade the two grants this package CANNOT ship: a 2GP managed permission set may carry neither a standard object permission nor an external-credential principal grant, so Fundraising_GuestDonor's Campaign grant works in a scratch org where the source is deployed and is silently stripped from every installed version. Both are therefore the admin's to make by hand, and this panel's job is to read back whether they did.
guestAccess and pages have no API behind them and are permanently "Can't verify" — the panel says so rather than inventing a check.
listExperienceSites/saveExperienceSite back the site picker inside the site step (docs/plan-dynamic-experience-site-selection-2026-08-03.md): any Experience Cloud site the admin can see is selectable, not just one matching the packaged "Fundraising" convention — the chosen Network.Id is what SetupController.resolveExperienceSite() resolves against from then on.
When no site exists yet, createExperienceSite fetches the live "create site" CLI command from CommunityService — the server stays the single source of truth for that string; CLI_FALLBACK is shown only if that call fails, so the copy-command button is never left blank.
embedEnabled is presented inverted from its storage field (Embed_Disabled__c, default false) so the UI reads as an opt-in "Allow embedding" toggle; setPublicLinks and lexEmbedBuilder both consume the resulting Experience_Site_URL__c once it is set here.
The stripeCred step grades the PUBLIC payment credential only, and that is deliberate, not an oversight (2026-08-11 credential split). CommunityService.configuredNamedCredentialNames() feeds the check from Payment_Account__c.Named_Credential__c and never from Admin_Named_Credential__c, because on this step "assigned to the guest user" is the SUCCESS condition: a guest user holding the restricted, charge-only key is exactly what public donations need.
Fold the admin credential into the same check and a guest user holding the FULL key — the very exposure the split exists to prevent — would light this step green. The opposite fault is therefore invisible here by design and is caught in the Payments panel instead (PaymentSetupGuideService step 8, which reports it as a failure); the mirror row's copy says so and points the admin there.
Labelled with the public address donors actually visit, falling back to the URL path prefix only when ConnectApi reports no site URL. The prefix alone is not an address — Salesforce may store something other than what the admin typed, so a picker labelled with it reads as a mismatch the admin cannot explain (2026-08-17 walkthrough, finding 10).
persisted is why this is not just an Id comparison. When no site has been saved the panel falls back to CommunityService's name/prefix discovery and the picker pre-selects what it found — so selection and current site matched, the button rendered disabled, and step 2's own instruction ("pick it from the list, and click Use this site") could not be carried out at all (2026-08-17 naive-admin walkthrough, finding 11). Nothing is saved in that state, so there is always something to persist.
onError sets _sitesError rather than leaving the picker's failure indistinguishable from "this org genuinely has no sites" (I7, 2026-08-10 final review) — a failed load without this rendered an empty combobox with no toast, no error text and the explanatory "no sites found" hint suppressed (it only appears once _sitesLoaded is true).
Clears _guestCheck and sets _guestCheckPending BEFORE the call fires, regardless of whether networkId is truthy — previously the clear only happened on the falsy branch, so a re-check dispatched for a newly selected site left the PREVIOUS site's payload (and its green Done chips) on screen for the whole in-flight window, and permanently if the call then failed (I5). onError now resolves to the same honest {verified:false} shape a successful-but-unverified response would produce, so a failed re-check degrades to "Can't verify" rather than staying silently stale.
requestedNetworkId is captured at dispatch time and compared against the live selectedNetworkId when the response lands, so a stale response from a superseded selection (rapid re-selection, out-of-order network replies) is discarded instead of overwriting a newer in-flight or completed check.
Pf (public guest helper blocks)#
Non-exposed JS-only utility module (no markup) that reads the checkout URL's query string and returns the marketing-attribution parameters every guest checkout surface sends with its order, so UTM and other tracking tags reach the donation record.
Docs note
These mirror AttributionCapture's server-side caps. Both layers enforce them — the client so an absurd URL doesn't bloat the Apex request, the server because a client cap is a courtesy, not a control (anything can POST to the guest endpoint).
Functional and personal keys the public pages use to carry their own state, plus the redirect/receipt tokens. Kept in sync with AttributionCapture.EXCLUDED_KEYS in Apex — stripping them here as well keeps them out of the network payload entirely, which matters for the credential-ish ones (token, client secrets) more than for the merely noisy ones.
The full-width band beneath both columns of the public page: the organisation's mark and footer text, tap-to-act email, phone and (for events) venue rows, and a legal line with hours, tax id, policy links, socials and copyright.
Docs note
Guest helper for the public two-column page shell. It takes the contact and identity maps CampaignService already resolved through their per-campaign overrides and never queries. It sits OUTSIDE the two columns so it stays full width at every column ratio — that placement is the whole reason it is a separate bundle rather than part of the story column. Its legal line also carries the secure-payment notice, the only place after the trust bar that a donor is told who processes the card. The band is always light: a dark contact block was rejected because this is the reachable-human block on a page that asks a stranger for money.
The full-width band under both columns — the last thing on the public page and the only place a donor is told a human exists. It is a non-exposed guest helper: the shell owns the payload and hands down contact and identity already resolved through their campaign overrides (CampaignService.resolveContact / resolveIdentity), so this bundle never queries.
Two tap targets and no form. A guest contact form means spam, a Case object, an owner and a reply SLA — and a promise of a reply nobody is on the hook for is worse than a phone number.
Four lines of copy on two axes: what the page sells (a gift or a seat) and where the donor is standing (before the ask, or on the confirmation after it). The post-gift wording is not cosmetic — "Questions before you give?" printed under a completed receipt asks a donor to reconsider a decision they already made, and reads as though the page has not noticed the payment went through.
Both props default to {} rather than undefined, because the template reads nested keys directly and LWC does not guard the intermediate — one campaign saved before these fields existed would otherwise blank the whole page rather than just this band.
Spaces, brackets and dashes are how a phone number should READ, and exactly what a tel: URI must not contain — so the displayed value and the dialled one are formatted separately from the same field.
A map search rather than a dead '#'. The venue row only ever renders for an event, where "how do I get there" is the question, and a maps query on the name plus address is the one destination that works without the package storing coordinates.
An unset network is omitted rather than rendered as a dead link — an org with only a Facebook page should show one icon, not three, two of which go nowhere.
@api properties: contact, ein, identity, isEvent, isPostGift, orgName
Non-exposed donor-portal block listing each tax year the donor gave in (count and total) with a per-year statement download. Authorized by the portal's magic-link token, never a record id, so a donor can only ever produce their own statement.
Docs note
Donor-portal block for the annual giving statement, rendered inside publicDonorDashboard. Addressed by the dashboard's magic-link token — never by a record id — so a donor can only ever produce their OWN statement, and DonorPortalController re-validates the token on every call rather than trusting anything this component sends.
The document itself is built by c/uiStatementPrint, the same module the staff record action (lexGivingStatement) prints from: a donor comparing their download against the copy a fundraiser printed must not find two differently-worded tax documents.
years is an optional passthrough: getDashboard already returns the year list, so the parent hands it over and this component skips its own round trip. Left unset (embedded anywhere else) it still loads the list itself from the token alone.
@api properties: currencyCode, token, years
Campaign header with goal progress bar for Experience Cloud
Docs note
Composed by publicFundraisingExperience as the shell's hero block and imported directly by dfForm (the 3-step donation canvas can render its own hero header without going through the shell). Self-fetches via CampaignController.getCampaignBySlug rather than receiving campaign data as a prop, so either parent only needs to pass campaignSlug. Emits a bubbling, composed donate CustomEvent on CTA click — the host page/shell decides what "donate" means (scroll to an embedded dfForm, or navigate to one) rather than this component owning navigation.
the gradient runs light-to-dark, matching the direction of the with-image scrim above, because .hero-body is bottom-aligned — its copy always lands on the dark end and takes its ink from --pf-ui-on-dark. Ending on the undarkened brand colour (as this did before) put light text on a light primary such as Ember coral.
Subtype is NOT re-decided here. CampaignService.PROGRESS_BAR_SUBTYPES is the one place that rule lives, and its answer arrives as the showProgressBar prop; this getter only adds the local guard that the campaign actually has a goal to draw against. Until 2026-08-18 (F-42) it excluded Open_Donation on its own, which is how the hero ended up hiding a bar the donation form on the same page was drawing.
@api properties: before, campaignSlug, ctaLabel, eyebrow, hideCta, showProgressBar
Runtime renderer for ordered page content blocks (rich text, image, testimonial, video, gallery, FAQ)
Docs note
Composed by both setDesigns (the internal page-design editor's live preview) and dfForm (the actual guest-facing content-block rendering inside the 3-step canvas) as <c-pf-page-blocks blocks-json= campaign-faqs=>, so a design change previews and renders through the identical sanitization/allow-listing logic in this one component — no drift between editor preview and what a donor actually sees.
@api properties: blocks, blocksJson, campaignFaqs
Renders a campaign's documents, updates and FAQ on the public page as one tabbed well with a per-tab count, a sticky tab bar and a travelling selection pill.
Docs note
Guest helper for the public two-column page shell. A tab appears only when its section toggle is on AND it has content behind it, so the bar never promises an empty panel. Every panel that has content stays in the DOM and is hidden with the hidden attribute, which keeps the documents and answers reachable for search engines, print and screen readers whichever tab the donor left open. Document links are ContentDistribution public URLs minted by CampaignPostService.
Documents, updates and FAQ are reference material a donor consults rather than reads, so they share one well instead of stacking into ~1,900px of scroll that pushes the ask column against an empty page. The tab bar carries a count per tab so nothing is hidden without a signal. Non-exposed guest helper: everything it renders arrives already resolved from CampaignService/CampaignPostService, so it never queries.
Webfonts arrive after first paint and every tab label changes width when they do, so the pill measured during the first render lands a pixel or two off the tab it is meant to sit under. Re-measuring once the face the tabs are actually drawn in has loaded is the fix; a resize needs the same treatment for the same reason.
The pill is positioned from the live geometry of the active tab, never from a fixed percentage — label widths differ per language and per count, so a hard-coded offset lands on the wrong tab.
Every panel that has content stays in the DOM and is hidden with the hidden attribute rather than removed, so the documents and answers a search engine, a printer or a screen reader should see are all present regardless of which tab the donor left open.
@api properties: defaultTab, documents, faqs, meta, sections, updates
Renders a campaign's narrative on the public page as a stacked, full-width editorial section with a numbered heading, an optional drop cap and pull quotes, and a fade clamp with a read-more toggle for long stories.
Docs note
Guest helper for the public two-column page shell; it takes the story text already resolved by CampaignService and never queries. The narrative is delegated to c-ui-markdown, which is shared with the authoring console's preview; this bundle owns only the section chrome — heading, ledger rule, fade clamp and the read-more toggle.
The section shell around a story: heading, ledger rule, fade clamp and read-more toggle. The narrative itself is rendered by c-ui-markdown, which is shared with the authoring console's live preview so the admin sees exactly what a donor will.
The old dual path (lightning-formatted-rich-text for markup, hand-split paragraphs for plain text) is gone. Story__c holds Markdown now, and the plain-text stories that predate it — blank-line paragraphs, a leading ">" pull quote — are already valid Markdown, so they render unchanged with no migration. A story that still contains stored HTML renders as literal text rather than markup, which is the correct outcome: markup was never a supported input.
The heading is a prop, not a literal, so a campaign whose narrative is not a "story" (a grant report, a case for support) can title the section for what it actually is.
There is deliberately no section number alongside it. Numbering only earns its keep when a reader can count — and the sections below the story are tabbed, so at most one of them is on screen at a time. A lone "01" over the only numbered section on the page labels a sequence that does not exist.
The clamp stays on until the measurement below has run, because the measurement needs it: scrollHeight only exceeds clientHeight while the max-height is applied. Once measured, a story that fits drops the class — otherwise its last paragraph kept the fade mask with no toggle to reveal anything, so short stories rendered as if they were cut off when they were complete.
Whether the story overflows the clamp is a question about rendered text, not about character count — a 900-character story in a large-type design can be taller than a 1,500-character one. So it is measured once per story, after the first paint, and the _measured latch keeps that measurement from re-entering the render loop it triggers.
@api properties: heading, inlineVideoEnabled, story
CSS-only shared style module (no JS/markup) defining the token-consuming card, field, and label primitives reused across all guest-facing public LWCs.
Docs note
Pulled in via @import by pfHero, pfPageBlocks, publicEventRegistration, dfDesignationPicker, and dfForm — the single point where the shared pf-ui-* primitive tokens turn into actual card/field/label rules, so a guest-page bundle never redefines these locally (avoids the per-component duplicate token systems the theme-token rules prohibit).
Trust strip (organisation mark and name, campaign name, tax ID and payment-security badges) heading the guest donation and event ticket pages. Rendered by its parent form, not dropped on a page, so it is deliberately not exposed to Experience Builder.
Docs note
The slim, always-visible trust strip that heads the guest donation and event-ticket pages. Presentational only — it holds no state, makes no Apex call and is never dropped on a page from Experience Builder (isExposed=false). Its parent (dfForm today, publicEventRegistration from Task 11) already reads the org identity it needs from UiConfigController.getUiConfig and passes it down, so composing it costs no extra round-trip.
Every rule it renders against lives in c/pfStyles (.pf-trustbar, .pf-badge) rather than in this bundle: the strip is one of the primitives shared across both guest pages, and the 2026-08-05 redesign keeps all such shared rules in the one file so a theme token added there reaches every consumer at once. The @import below is what pulls them into this bundle's scoped stylesheet.
@api properties: campaignName, ein, orgMarkUrl, orgName
Df (donation form family)#
Non-exposed donation-form field letting a donor direct their gift to a single fund or split it across several. Selecting funds divides the gift evenly; editing one fund rebalances the rest, so the allocation always sums to the gift total.
Docs note
Composed by dfForm as <c-df-designation-picker designations= default-designation-id= total= onchange=>. In Selectable mode dfForm renders it INLINE at the admin's block position; in All_Active mode it sits behind the optional "+ Direct my gift" pill (see dfForm's _addonTypes). Assigned/None never render it at all — there is nothing to choose.
The parent rides the emitted selection through as optionsJson.designationId or optionsJson.designations on createDonorAndIntent; server-side DesignationSplits.parse() re-normalizes the split against the captured Stripe total and creates one Donation_Designation__c per fund. This component's arithmetic is a UX convenience — never the source of truth.
ALLOCATION MODEL (2026-08-06 redesign). The previous control was a repeating row builder: "+ Add fund", pick a fund, type an amount, three interactions per fund, with the gift unbalanced the whole time and an "Allocated $0.00 of $100.00" message reporting it.
This version holds one invariant instead — the parts ALWAYS sum to the gift — so the invalid state is unreachable and there is no error to display.
Selecting a fund gives it an even share; editing one fund takes the difference out of the others in proportion to what they currently hold.
Amounts are held in integer cents so repeated rebalancing can't accumulate float drift, and split in whole units whenever the gift is a whole-unit amount ($100 across three funds is 34/33/33, not 33.34/33.33/33.33).
@api properties: defaultDesignationId, designations, total
Three-step donation canvas (Compose, Payment, Thank You) for Experience Cloud campaigns
Docs note
Mounted by the publicFundraisingExperience shell as the guest donation canvas; the shell resolves record-type/subtype/slug and passes campaignSlug (+ builderConfig, thankYouCta, and the widget-handoff initialAmount/Email/Name) down as @api props. Composes <c-df-designation-picker> for the designation field block and <c-pf-page-blocks> (via oneBlockArray) for the thank-you step's content blocks, so sanitization stays in one place. There is no layout mode any more: the 2026-08-12 redesign made the public page a single two-column grid owned by publicFundraisingExperience, and this component is the ask in its payment rail. The six dfLayoutXxx CSS-import skins and the Form_Layout_Mode__c field that chose between them were retired with it.
THIS COMPONENT IS ONLY THE ASK. Two things it used to render moved out in the same redesign, for the same reason — it no longer owns the page, so page-level material does not belong in the rail:
· The FAQ answer shelf and its "?" signpost. FAQs are now one tab of <c-pf-section-tabs> in the wide column. Keeping the shelf as well would have printed the same answers twice on one screen. The campaignFaqs @api went with it.
· The interleaved content blocks (richText/image/video/testimonial/gallery) an admin could place between field blocks. They still render, and still in stored order — the shell puts them below the story in the wide column (see its pageContentBlocks getter). Nothing was dropped from the product; the blocks changed column, not existence.
The thank-you step keeps its own content blocks: that is a different surface, shown after the gift, and it has no wide column to move them to.
Stripe.js iframe/postMessage contract (see the lwc-conventions skill for the general pattern): the iframe (src={stripeIframeSrc}, resolved server-side) mounts once on the compose→pay transition and stays mounted through processing (showStripeIframe spans steps 3-4) so stripe.confirmPayment() never loses its JS context. _handlePostMessage strictly checks event.origin === ALLOWED_ORIGIN (window.location.origin) before acting on STRIPE_READY, IFRAME_HEIGHT, PAYMENT_PROCESSING, PAYMENT_SUCCESS, SETUP_SUCCESS, PAYMENT_ERROR. Outbound: handleIframeLoad sends INIT_STRIPE once _clientSecret is set; submitPayment always sends CONFIRM_PAYMENT (the server only ever mints a PaymentIntent, never a SetupIntent — see DonationService.createDonorAndIntent) with an isRecurring flag, so stripePayment.page knows whether to reply PAYMENT_SUCCESS or SETUP_SUCCESS+paymentMethodId. Same target origin — never '*'.
Server-authoritative finalize: createDonorAndIntent (DonationFormController) inserts a Donation_Staging__c row and returns an opaque accessToken — no Opportunity exists yet. The browser is never trusted to finalize the gift; after Stripe confirms client-side, _onPaymentSuccess polls checkPaymentStatus (Apex → Stripe, authoritative) while the system-context scheduler (StagingReconciliationBatch) is the actual backstop that creates the donation record if the poll window closes first.
Donor-covered processing fee. _feeCoverage is the server's offer description ({percent, fixed}) or null when this campaign doesn't offer it — the presence of the object IS the gate, so the checkbox cannot be made to appear client-side. coverFees is the donor's yes/no and the ONLY part of this the browser sends; Apex recomputes the amount from its own settings, so the estimate below is display-only and a tampered figure changes nothing about what is charged.
Server-resolved, never derived from EndDate in the browser: whether an ended campaign still takes gifts is a per-campaign admin choice (Campaign.Accept_Donations_After_End_Date__c), and the same rule has to hold on the write path anyway. Defaults to open when the key is absent so an older cached payload cannot close a live campaign.
The gift is confirmed; leave the form. A routed thank-you page is what lets analytics count a conversion — an in-form step change produces no pageview and no distinct URL. The ACCESS token goes in the URL because the receipt token does not exist yet (the reconciler writes the donation a beat later); publicThankYou polls for it and rewrites the address bar to ?token= the moment it does. Navigating now rather than after the poll is what makes the conversion pageview fire at t=0.
The native "leave site?" dialog, for the one case it is genuinely right: a charge is mid-flight. This is NOT the exit-intent prompt — see _handleExitIntent for why that cannot be driven from this event.
Pointer-based exit intent: the cursor crossing out through the TOP edge of the viewport is the one signal that reliably means "heading for the tab bar / address bar" rather than "reaching for the scrollbar or another window". relatedTarget === null distinguishes leaving the document from merely moving between elements inside it.
This deliberately does NOT use beforeunload, which specs/09 originally called for. A beforeunload handler can only ever produce the browser's own generic dialog — custom in-page UI is ignored, and preventDefault() does not change that (MDN, Window beforeunload). By the time that event fires the page is already leaving, so there is no moment left in which to render a modal. Verified 2026-08-06.
Pointer-only is also deliberate. Touch devices have no cursor and therefore no honest equivalent signal; the usual substitutes (trapping the back button, guessing from scroll velocity) fire on people who were not leaving, and a prompt that interrupts a donor mid-gift costs more than it recovers.
Compose and Payment share ONE canvas. Every layout mode keys its grid off .form-step[data-step="single"] — the story column, momentum rail, gradient hero and summary rail all hang off that one selector — so a payment step rendered in its own <div> inherited none of it and the page visibly lost its layout the instant the donor pressed Continue. Rendering both steps inside the same canvas keeps the chrome mounted and swaps only the form column beneath it, which is what a donor expects mid-checkout.
The c/pfTrustbar strip this block used to feed moved out to the page host on 2026-08-12 (publicFundraisingExperience): the bar is the whole page's site header, and a strip nested in this component's shadow DOM could only ever escape its container by having the host publish negative-margin values for it. The host renders it directly now, so only the EIN survives here — the thank-you step still badges it beside the receipt line.
The sticky action bar restates the running total and the primary CTA at the bottom of the viewport so a donor who has scrolled past the button never has to scroll back to give. It is suppressed on the thank-you step (nothing left to submit) and while the processing overlay is up (the CTA would be a dead control under a scrim).
Deliberately NOT gated on "an amount is chosen", the way the redesign plan first drafted it: the Continue button is the only way forward, and hiding it whenever the amount is empty or out of bounds would leave the donor with a validation message and no control. It stays visible and disabled instead (composePrepareDisabled), which is what the pre-redesign markup did.
The admin's Show_Progress_Bar__c veto is honoured first (2026-08-18, F-42): before that this form drew its own readout regardless, so switching the bar off left the hero and the embed hiding a progress figure the form on the same page was still showing.
The caption row under the filament carries "N% funded" and the donor count. It is gated separately from the readout as a whole because a campaign can publish a donor count with no goal and no raised figure — that campaign gets the caption row on its own, with the label, figure and filament all suppressed, rather than an empty ruled box.
Singular at exactly one donor. The count is public-facing copy, and "1 donors" is the kind of detail that reads as an unattended page on the one campaign it happens to.
The filament's bead sits ON the progress point, so it shares goalPercent with the lit segment's width. Its own translate(-50%) is in CSS, not here — an inline style is the only way to pass a runtime percentage through, and keeping it to the single positional value means the theme still owns the bead's size, colour and glow.
The two waits this form can be in. They used to share one full-viewport blurred scrim, which was wrong for both of them: the fetch does not need to block anything (the host has already painted the hero and the story around this card), and the payment confirmation needs to block THIS CARD, not the campaign the donor is reading. Splitting them lets the fetch render as an in-card skeleton and the confirmation as a veil bounded by the card.
showProcessingOverlay survives as the union of the two, because the sticky action bar is suppressed in both — under a veil the CTA is a dead control, and during the fetch there is no amount for it to submit.
isLoading is c/uiBase's flag for EVERY callApex, not just the first fetch — so it is true again on createDonorAndIntent, markSubmitted, checkPaymentStatus and the rest, long after the campaign is on screen. That distinction is the whole reason there are two waits here:
• the FIRST fetch (isLoading with no campaign yet) has nothing to cover — the canvas is already suppressed, because isComposeStep/isPaymentStep both require a campaign — so it draws the card's silhouette IN the empty card; • every LATER call is working on a form the donor is looking at, so it veils that card rather than replacing it.
Getting this wrong is visible: gating the skeleton on isLoading alone put a placeholder card on top of a fully rendered step 1 on every mid-flow call.
"Keep this page open" is the only instruction that matters at step 4 — the donor's card is already at the gateway, and closing the tab mid-confirmation is the one action that can leave them wondering whether they were charged. Every other call is short and needs no instruction, only an acknowledgement that the press registered.
Mirrors FeeCoverageService.computeFee exactly — a TRUE gross-up, not gift × percent. The processor takes its cut of the total it captures, so covering 2.9% + $0.30 on a $100 gift costs $3.30, not $3.20; charging the naive figure leaves the cause short. This is a display estimate only. Apex recomputes the real figure from Settings__c and never trusts a number from this component.
No amount and no arrow in this string. The CTA used to read "Continue to payment · $100.00 →", which is the pattern for a button standing on its own — but this one lives in the action bar, directly under "Total today $100.00" set in 25px figure type, so the echo restated the line above it and cost the button roughly 90px it does not have. In the rail the button measures 308px, and the echoed label wrapped, orphaning the arrow onto a second line and growing the button to 80px beside a 53px ghost. The arrow now lives in the markup as .pf-btn__arrow.
Assigned/None modes still populate designations with the single resolved fund (so summary/allocation logic keeps working) but must never show a picker UI for it — there is nothing for the donor to choose. Only Selectable/All_Active render the picker.
The single biggest scroll saving in the 2026-08-05 redesign. Designation, tribute, employer match and custom questions used to render fully expanded and inline, always — roughly 700px of fields most donors never touch, sitting between the amount they chose and the button they came to press. step1Groups folds them into ONE "+" pill row that expands in place. Nothing is removed; it is only deferred until asked for.
Admin block ORDER is preserved: the pill row renders at the position of the first optional block in the design's stored list, and the pills within it keep their stored relative order, so a design that puts tribute before designation still reads that way. The required blocks (amount, frequency, donor info) and every content block stay exactly where the admin put them.
Two blocks can opt OUT of the pill row and render inline instead. Both exceptions exist for the same reason: a pill is an invitation to skip, so anything the campaign has decided the donor should actually answer must not be behind one.
· custom_questions — the moment any question is marked required. A required field behind a collapsed pill is a form a donor can fail to complete without ever seeing why.
· designation, when Designation_Mode__c is Selectable — the admin curated a fund list specifically so this campaign's donors would choose from it, which is a different intent from All_Active (every fund in the org offered as a convenience). Selectable therefore keeps the picker at the admin's block position; All_Active stays a pill. Assigned/None never reach here at all — isSectionVisible drops them (showDesignationPicker).
'true'/'false' strings, not booleans. aria-checked is a regular attribute, not a boolean one — a radio that is NOT selected must still carry aria-checked="false", and a JS false would drop the attribute entirely and leave the group unreadable.
The segmented control's thumb is positioned by index, not by which button carries .is-selected — CSS cannot select a parent's sibling by class, and three cross-fading backgrounds would not slide. This is the only value the template has to hand down, so it ships as a single custom property.
Reassurance copy for the chosen frequency. The old design put a fixed note inside each option ("Once a year"), which could only restate its own label. Reading the donor's selected amount lets this line answer the question that actually decides a recurring gift — what does it come to over a year — so monthly is quoted with its annualised total rather than left to be guessed.
Unticking "send an e-card" drops the address as well as the flag. The address input is only rendered while ecard is true (showEcardEmail), so keeping a typed value in state would ship an address the donor can no longer see and has explicitly declined to use. ReceiptService.buildTributeNotification gates on the flag too — see gap-audit §1.1.
Stripe's Payment Element measures the width of the document it mounts into ONCE, and it mounts the moment this INIT_STRIPE message lands. If the iframe is still zero-width at that instant the element lays out at zero and never recovers — it renders as a sliver of stacked labels inside a correctly-sized frame, which is what the payment step did the first time it moved into the layout grid (frame 716px, Stripe drawn at ~0).
The frame's load can beat its own layout, so waiting for a non-zero measured width before posting is the guarantee. Two consecutive non-zero frames, because the first non-zero frame can still be an intermediate track size while the grid resolves. The 30-frame cap (~0.5s) means a genuinely hidden frame still gets its INIT rather than silently never initialising.
The Payment Element lives in a Visualforce iframe, so it inherits none of the page's theme tokens — without this it renders in Stripe's own blue-on-white default in the middle of a themed card. The Appearance API is the only channel across that boundary, and it takes literals: it rejects rgba() and var() in colour keys and silently ignores a fontSizeBase under 16px. So the tokens are resolved against the host element here and sent as computed values rather than handed through as custom properties.
Every fallback is the token's own designed default, not a neutral, so a design record that overrides nothing still gets the Ember palette rather than Stripe's.
@api properties: before, builderConfig, campaignSlug, initialAmount, initialEmail, initialName, props, showProgressBar, thankYouCta, went
Ui (shared primitives)#
Non-exposed JS-only utility module (no markup) that turns a raw Apex or DML failure from a controller response envelope into one readable reason per failure plus the field API names it cited, so a console can say what to fix.
Docs note
Consumed by lexFundraisersConsole's wizard save-failure panel. Lives here rather than in uiBase because uiBase is a base class (callApex/toast plumbing) — this is a pure function any console can import without inheriting from it, following the uiMoney precedent for JS-only shared utilities.
Salesforce status codes are SCREAMING_SNAKE and always followed by ", " before the human message. Six-char minimum keeps the pattern off ordinary uppercase words in prose.
Non-exposed base class for internal Lightning Experience LWCs, providing a shared Apex-call wrapper for the success/data/error envelope, loading spinner, and error toasts.
Docs note
Extended throughout the internal adm/set/lex* bundles (and by the public 3-step canvas dfForm) as the standard callApex wrapper.
Shared service module: the closed allowlist that turns a design record's restricted font picklist token into a CSS font-family stack. Guest pages resolve fonts through it so a design record's field value is never interpolated raw into an inline style.
Docs note
The canonical home for the guest-page font allowlist.
Font_Family__c and Heading_Font_Family__c on Campaign_Design__c are RESTRICTED picklists, and the values they hold are tokens (Google_Lora), not CSS. This module is the closed allowlist that turns a token into a font-family stack. Nothing else may build one: a design record's raw field value must never be interpolated into an inline style, or a crafted record becomes a CSS injection on a public page. resolveFontStack is therefore the only export that callers should use, and it looks the token up with Object.prototype.hasOwnProperty.call — a bare property read would resolve 'constructor' or 'toString' to an inherited Object.prototype member and hand that back as a font stack.
The map must stay in step with the picklist value sets on both fields and with the FAMILIES list in scripts/build/fetch-public-fonts.sh — an entry here whose family has no @font-face rule in the PublicFonts static resource silently falls through to the next name in its own stack. 'Modern' is the one deliberate exception: it names no bundled face and resolves to the system UI stack by design.
publicFundraisingExperience and publicEventRegistration still carry private copies of this map that have already drifted apart from each other on Serif/Modern/System. They should be migrated onto this module — that migration changes how those two pages render three picklist values, so it is a deliberate change, not a refactor to slip into an unrelated PR.
Token in, CSS font-family stack out. Anything unrecognised — blank, 'Inherit', a value an older package version wrote, or an outright injection attempt — returns fallback, so the caller always has a safe string and never has to test the result.
Renders a Markdown story as headings, emphasis, links, lists, pull quotes, rules, images and video, using only escaped text bindings. Shared by the public campaign page and the authoring console's live preview so the two can never diverge.
Docs note
Takes Markdown through @api value and a facade/iframe choice through @api inline-video-enabled. It never queries and never touches innerHTML: uiMarkdownParser.js turns the source into a token array which this bundle's template walks with for:each and lwc:if, so every author-supplied string lands in an auto-escaped LWC text node. That is the whole XSS defence, and it is structural — there is no sanitizer to keep current.
The only component that knows what a story block looks like. It takes Markdown and renders the token array from uiMarkdownParser through for:each/lwc:if — there is no innerHTML here and there must never be one, because that is the whole XSS story for this feature. Every string the parser produced lands in a {value} text binding so the LWC engine escapes it; anything the parser could not validate is already literal text by the time it reaches this template.
Facade unless the org opted in AND created its own CSP Trusted Site. Resolved server-side and handed down, so this component never reads Settings. The setter is a strict allowlist rather than a truthiness check, so any unexpected value lands on the facade — the side that loads nothing from YouTube until a donor clicks.
@api properties: inlineVideoEnabled, value
Story authoring surface for the internal console: a textarea with a caret-aware formatting toolbar, inline prompts for links, images and video, a byte counter against the field limit, and a live preview using the same renderer as the public page.
Docs note
Uses a raw textarea rather than lightning-textarea because caret-aware insertion needs selectionStart/selectionEnd, which a base component does not expose. That also puts it outside the wizard's reportValidity() sweep, so it exposes an @api reportValidity() the step calls explicitly. Nothing in this bundle imports Apex — images and video posters are external URLs the admin pastes, so there is no upload path to authorise. Every link/image/video label and destination the popovers are about to insert is re-checked against uiMarkdownParser's own construct grammar before insertion (see labelError / destinationError / posterError in the .js) — safeUrl/safeImageUrl only validate scheme, not shape, so a value that passes them can still fail to round-trip through the parser and render as literal Markdown source on the donor's page.
A raw <textarea>, not lightning-textarea, and deliberately so: caret-aware insertion needs selectionStart/selectionEnd, and a base component does not expose its inner element (reaching into another component's shadow root is not permitted). contenteditable is never used — that is where a WYSIWYG editor would need a third-party library, and this feature has none.
There is no upload affordance. Images and video posters are external URLs the admin pastes, exactly as the Hero image field on the same wizard step has always worked.
The textarea carries no content binding at all (no value={...} attribute/property, no child text node) — renderedCallback() below is the only thing that ever writes area.value, and it only writes when the DOM value has drifted from _value. This sidesteps an unverified question about whether the template compiler resolves value on a raw <textarea> as a property or an inert content attribute (HTMLTextAreaElement has no value content attribute, so getting this wrong would silently render an empty box). commit() sets area.value synchronously for the same reason: no re-render is guaranteed to run before the caret needs to move.
These three mirror uiMarkdownParser's own construct grammar (INLINE_LINK / IMAGE_LINE's shared destination class, and VIDEO_LINE's stricter poster class) so the editor never emits Markdown its own preview — and the public page's parser — would refuse to match. safeUrl/ safeImageUrl only check the scheme; they do not check shape, so a value that passes them can still break the block/inline grammar and round-trip back as literal Markdown source instead of a link, image or video. Keep DESTINATION_VALID in lockstep with uiMarkdownParser.js's INLINE_LINK/IMAGE_LINE destination group if either changes.
The poster sits in VIDEO_LINE's second destination group, [^)\s]+ — which excludes only the closing paren and whitespace. An opening paren is legal there and round-trips, so rejecting it too would turn away addresses the story format actually accepts.
Bold and Italic wrap whatever the author selected, and that selection is Markdown SOURCE, not rendered text — so an asterisk inside it would close the emphasis early and strand the rest of the selection as literal text on the donor's page. The parser treats a backslash-escaped delimiter as content (see findDelimiter), so escaping is a lossless repair rather than a refusal: the reader sees exactly the characters the author selected.
An asterisk that is ALREADY escaped is left alone — re-escaping it would change \* from "a literal asterisk" into "a backslash followed by an asterisk", which is not what the author wrote. Any backslash is copied together with the character behind it for the same reason.
Forwarded to the preview's c/uiMarkdown untouched. Preview fidelity is the pane's whole purpose, and without this an org that enabled inline playback would show its author a facade card while donors got a player — the one difference an author cannot discover by looking. The editor never reads Settings itself; the host passes down what the server resolved. Same strict allowlist as uiMarkdown's own setter, so an attribute string coerces the same way in both.
Cancelling must release the range openPopover captured. replaceSelection prefers _selection over the live caret, so a stale one survives the popover it was taken for: select a phrase, open Link, Cancel, click elsewhere, then Divider — and the rule lands on the abandoned range, deleting the phrase at an offset the user is no longer looking at. A textarea edit is not undoable through the component, so that deletion is permanent.
The stopPropagation is load-bearing, not defensive tidying. lightning-input dispatches its change as bubbles + composed, so without this it escapes this component's shadow root and arrives at the host's own onchange handler — which reasonably assumes any change from <c-ui-markdown-editor> is the story value and reads event.detail.value. The popover field's partial text would then overwrite the whole story on every keystroke, invisibly, because the popover is layered over the textarea. Any new listener added inside this component must do the same: this bundle emits exactly one change to the outside world, from commit().
Prefixes EVERY line the selection touches, not just the one the caret sits on. Selecting three lines and clicking Bulleted list is the ordinary way anyone writes a list, and every Markdown editor an author has met behaves this way. prefix may be a function of the line's index within the selection, which is how the ordered list counts up.
Blank lines inside a multi-line selection are skipped: a lone - renders as an empty bullet and a lone > as an empty quote line, neither of which the author asked for. A single line is always prefixed even when empty — that is the "start a list here" case.
@api properties: inlineVideoEnabled, label, maxBytes, reportValidity, value
Shared service module: turns a campaign story's Markdown into a plain token array, validating every link, image and video destination it emits. The public renderer and the authoring console's preview both parse through it, so the two cannot diverge.
Docs note
Markdown -> plain token array. A JS-only service bundle, like c/uiFontStacks. Deliberately NOT a Markdown-to-HTML converter: c-ui-markdown renders these tokens through for:each/lwc:if bindings, so every author string lands in an auto-escaped LWC text node. There is no innerHTML and no lwc:dom="manual" anywhere in this feature, which is what makes stored-XSS structurally impossible rather than sanitizer- dependent. CampaignService.normalizeStoryMarkdown is the server-side authority; the gates here are defence in depth and the editor's live preview.
Keep this file import-free. scripts/lint/markdown-parser-check.mjs loads it as a data: URL module to test the shipped source directly, and a data: module cannot resolve bare specifiers. npm run lint:markdown fails loudly if an import appears.
Reference-link syntax ([label]: dest, [text][label], [text][], bare [label], and their ![...] image forms) is DELIBERATELY never resolved anywhere in this file. The Apex sanitizer's own neutralization step can emit a bare [x] when it defuses a hostile nested construct (see CampaignService.cls's normalizeStoryMarkup header) — if this parser resolved shortcut references, that defused text would turn back into a live link on every neutralized input, not just crafted ones. INLINE_LINK only matches when [...] is immediately followed by (...), so every reference-link shape simply falls through to literal text. Do not add reference-link resolution here.
Also assume attribute-bearing HTML tag text reaches this file unstripped. CampaignService's sanitizer has parked, known gaps (<img src="x"onerror=alert(1)>, <svg onload = alert(1)> — see task-5-review.md P-1/P-2) that survive every round of the Apex scanner rewrite. This file has no <...> handling at all — every < is a literal character, always — so that is what actually stops those two shapes from becoming a live tag, not the Apex strip.
Reusable Ember-branded modal shell (header, body, footer, progress) used by the Settings dialogs.
Docs note
Composed (as <c-ui-modal>) by the Settings console panels for their add/edit dialogs (e.g. setPayments' account editor, setRecordTypes' override editor) and by admEventTicketsTab's ticket-type add/edit modal — the one modal chrome shared across the internal console, kept deliberately separate from the guest-facing Stripe payment iframe (which is never a modal — it is a stable sibling element, see dfForm's doc note on why it must not remount).
@api properties: flush, progress, size, subtitle, title, variant
Non-exposed JS-only utility module (no markup) providing shared currency-aware money formatting and the org's configured charge currency for every donation surface.
Docs note
The currency half of this module (formatMoney/currencyOf) is imported by most money-displaying bundles — dfDesignationPicker, pfHero, publicThankYou, publicDonorDashboard, the lex* record-page cards, admEventTicketsTab, admFundraiserDetail — each pairing it with its own @wire(getUiConfig) per the LWC1503 static-analysis constraint documented above (the wire adapter itself can't be re-exported through a shared module). Notably NOT imported by dfForm, publicEventRegistration, publicCampaignGallery, lexGivingStatement, uiStatementPrint or lexDonationMonitor: those six build their own Intl.NumberFormat inline and so do not get the RangeError guard below, which exists because Intl throws on an unrecognised Default_Currency__c. Folding them in is worth doing; until then, do not describe this module as universal.
Shared service module: maps an org-default design record onto the --pf-* custom properties the public pages read, and writes them onto the caller's host element. Holds the hex and font-token gates that stop design data reaching CSS raw.
Docs note
Turns a Campaign_Design__c slice into the --pf-* custom properties the public pages already read, and writes them onto the calling component's own host element.
WHY A SHARED MODULE. Six public pages need the identical mapping (the directory, the donor portal, the thank-you page, the receipt, the ticket wallet, the event registration page). Six private copies would be six chances for the colour gate below to be softened in one of them, and a design record is admin-authored data that ends up inside a style attribute — so the gate is the security boundary, not a tidiness preference.
WHY THE HOST ELEMENT AND NOT A DIV INSIDE. Every one of those pages declares its own primitives on :host as --dp-canvas: var(--pf-brand-bg, #FBFAF6). A custom property is substituted on the element that DECLARES it, so --pf-brand-bg has to be set on the host or above it. Setting it on a child would leave :host resolving the fallback and the page would render its hard-coded palette while looking, in DevTools, like it had been themed.
The second gate. Apex's CampaignService.safeHex has already run on every colour, but this is the side that actually writes into a style attribute: a value that is not a plain hex literal never reaches CSS, so no future change to the Apex path can turn a design record into a style injection on a public page.
Radius_Preset__c is a restricted picklist of tokens, never CSS. Looked up with hasOwnProperty.call — a bare property read would resolve 'constructor' to an inherited Object.prototype member and hand that back as a radius scale.
Spacing_Density__c, Shadow_Style__c and Content_Max_Width__c — the three geometry presets. Like the radii these are restricted picklists resolved to fixed literals, never CSS from the record. They were missing here entirely until 2026-08-14, which is why a themed page got the org's colours on the package's shipped shadow, gutters and column width.
Theme_Tokens_JSON__c — the per-token overrides an admin saves in the page-design editor, and the last word on any token they name. Token NAMES are allowlisted server-side against the Theme_Token__mdt catalog by CampaignService.normalizeThemeTokensJson, which is the sole validation authority; what arrives here is already filtered, so this only has to hold the value-level floor — a string, non-blank — and never throw on malformed JSON, since a bad record must degrade to the unoverridden theme rather than leave the page unpainted.
Last-resort palette: what a page paints before its theme call returns, and if the org has no default design record at all. Ember, the package's own brand.
The design record carries eleven colours, two font tokens and a radius preset. Anything the record does NOT carry — the portal's moss/honey semantics, the error reds, shadows, easing — is deliberately left unset so each page keeps the value its own design chose. An admin picks a palette, not a whole design system, and inventing the rest from their primary is how a "success" pill ends up the same colour as a "failed" one.
Two values are DERIVED rather than read, because the slice has no field for them: the button hover shade and the soft surface tint. Both are built with color-mix from values that have already passed safeColor, so nothing unvalidated enters the string.
Call once the theme payload has landed. Safe to call again — every property is overwritten, none accumulate — and safe to call with null, which leaves the page on its own fallbacks.
Shared primitive that renders a QR symbol as inline SVG with no external library or network request. Used by the public tickets sheet so each seat carries a code the check-in desk can scan, on screen or on paper. Byte mode, ECC level M, versions 1-3.
Docs note
A dependency-free QR encoder rendered as an inline SVG path. It exists because the ticket sheet needs a code that can be scanned at a door, and a managed package cannot pull a QR library off a CDN — Experience Cloud CSP blocks it, and an AppExchange package must not depend on an external host for a page that has to work on a phone with no signal.
Scope is byte mode at error-correction level M, versions 1 to 3. That covers 44 data codewords, far more than the 18-character record Id this actually carries, and each of those versions is a SINGLE error-correction block — from version 4 up, level M splits the payload across blocks that must be interleaved, which is the bulk of a general encoder. Anything longer than version 3 renders nothing rather than a code that cannot be read.
Correctness was established by decode round-trip, not by eyeballing: every payload was encoded under all eight masks, rasterised, and read back with an independent decoder.
@api properties: alternativeText, value
Non-exposed JS-only utility module (no markup) that clears the Experience Builder section padding wrapping a full-page guest component, so pages built to reach the browser edge are not framed by a gutter and their full-bleed regions are not clipped.
Docs note
The one place the package fights Experience Builder's own section padding.
An Enhanced LWR page wraps every component an admin drops onto it in a community_layout-section.comm-section-container, and that element carries its own padding (16px by default, whatever the admin set in Builder otherwise). For an ordinary component that padding is the point. For a component that IS the page — the guest donation shell, the thank-you page — it renders as a pale frame around all four edges of a design that was built to reach the glass, and it also clips the calc(50% - 50vw) + 100vw full-bleed the trust bar and the contact band rely on.
The section is outside our shadow root, so no stylesheet we ship can reach it. Setting the properties imperatively is the only route, and it is why this is a JS module rather than CSS.
Scoped by walking UP from the calling component's own host to its enclosing sections — never by Builder section id. Those ids (section-eb11 …) belong to the org the site was authored in and silently match nothing in a subscriber org, so a managed package must resolve the target from its own position in the DOM.
Builder re-renders its section chrome after our first paint, restoring the padding we just cleared, so one pass at connectedCallback is not enough. Twelve passes at 350ms covers a little over four seconds — long past the last Builder repaint on a cold load, and it stops on its own rather than leaving an interval running for the life of the page.
One pass. Exported for a caller that wants to re-apply on a specific event of its own (a route change, a payload swap) rather than only on the timer below.
Call from connectedCallback and keep the returned function; call it from disconnectedCallback. Not optional — an interval that outlives its component keeps a reference to it alive and goes on writing styles onto a section the component has left.
Reusable Ember-branded segmented control replacing lightning-radio-group type="button", whose selected-option color is a hardcoded SLDS blue.
@api properties: label, options, value
Non-exposed JS-only utility module (no markup) that builds and prints the annual giving statement document. Shared by the staff record action and the donor-portal download so both produce a byte-identical tax document.
Docs note
Shared print builder for the annual giving statement. Both the staff record action (lexGivingStatement) and the donor-portal block (pfGivingStatement) print the SAME document from this one module — a donor comparing the copy they downloaded against the one a fundraiser printed for them must not find two differently-worded tax documents.
Every node is built with createElement/textContent, never innerHTML or document.write: donor names, campaign names, and admin-configured legal text all land in this document, and none of them may be interpreted as markup.
Reusable Ember-branded switch replacing lightning-input type="toggle".
Docs note
Composed (as <c-ui-toggle>) across the Settings console panels (e.g. setOrganization's NPSP sync switches, setSite's embed-enabled toggle) and admEventTicketsTab's active-ticket switch — anywhere a boolean setting needs on-brand styling that lightning-input's toggle variant can't provide (see the class-doc note on the hardcoded SLDS blue it replaces).
@api properties: activeText, checked, disabled, inactiveText, label