PageSourceSearch

https://sifrcrm.up.railway.app/assets/controllers/embed_donation_f…e79653cc435d5034a3f17c3dd0708e13500e796a.js

js sifrcrm.up.railway.app collected 2026-10-03 12:15:21 UTC 59,270 bytes, 1,502 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2import { tipBreakdown, tipOptions, coverFeeTipCents, DEFAULT_OPTION, PERCENT_THRESHOLD_CENTS, TipError } from "utils/tip_calculator"
3
4// Existing embed fallback amounts, used when a fundraiser configures no
5// suggested choices at all. Unchanged since before MSJD-666's structured rows.
6const FALLBACK_AMOUNT_CENTS = [2500, 5000, 10000]
7
8/*
9 * embed-donation-flow
10 *
11 * Wizard brain for the organization donation iFrame.
12 * Screens: 1=fund-selection, 2a=contact, 2b=payment, 3=thank-you, 4=employer-match
13 *
14 * Architecture decision: when advancing to Screen 2b (payment), the flow
15 * controller serialises the current wizard state into
16 * data-embed-payment-snapshot-value on the payment section's root element.
17 * The embed-payment controller reads that snapshot on connect so it has
18 * everything it needs to POST to payment_intents without any shared closure.
19 */
20export default class extends Controller {
21  static targets = [
22    // screens
23    "screenFundSelection",
24    "screenContact",
25    "screenPayment",
26    "screenThankYou",
27    "screenEmployerMatch",
28
29    // screen-1 elements
30    "fundraiserSelect",
31    "fundraiserSelectBlock",
32    "intervalRow",
33    "frequencyRow",
34    "installmentRow",
35    "installmentMonthly",
36    "installmentEndDate",
37    "installmentCount",
38    "installmentDecrement",
39    "installmentIncrement",
40    "installmentMinWarning",
41    "amountButtonsSection",
42    "amountButtonsRow",
43    "amountButton",
44    "amountDescriptionArea",
45    "amountDescriptionCallout",
46    "amountDescriptionTitle",
47    "amountDescription",
48    "amountDescriptionMeasure",
49    "amountInput",
50    "amountTotalHint",
51    "amountError",
52    "donateButton",
53    "tipSelect",
54    "tipCustomWrapper",
55    "tipCustomInput",
56    "tipDeclineCheckbox",
57    "tipHelper",
58    "tipError",
59    "tipLine",
60
61    // screen-2a elements
62    "firstName",
63    "lastName",
64    "email",
65    "phone",
66    "anonymousCheckbox",
67    "nameFields",
68    "phoneField",
69    "consentMasjid",
70    "consentPlatform",
71    "contactError",
72    "continueContactButton",
73
74    // step indicator
75    "stepItem",
76
77    // modal shell (screens 2-5) + slots screen 1 moves between
78    "modal",
79    "modalBackdrop",
80    "modalClose",
81    "pageSlot",
82    "modalSlot",
83
84    // page-flow wrapper measured for iframe auto-resize
85    "pageContent",
86
87    // fundraiser panel (screens 2-4, left column)
88    "panelImage",
89    "panelImagePlaceholder",
90    "panelName",
91    "panelDescription",
92
93    // screen-3
94    "thankYouAmount",
95
96    // screen-4
97    "employerName",
98    "workEmail",
99    "employerMatchError",
100    "submitMatchButton",
101    "matchSubmittedMessage",
102  ]
103
104  static values = {
105    fundraisers:     Array,
106    preferredSlug:   String,
107    preselectedSlug: String,
108    locked:          Boolean,
109    paymentUrl:      String,
110    finalizeUrl:     String,
111    employerMatchUrl: String,
112    publishableKey:  String,
113    stripeAccount:   String,
114    currency:        String,
115    // MSJD-711: set (launch_mode=modal) when /embed/v1.js opens this flow
116    // from a donation button, card or sticky button. Only honoured when framed.
117    launchMode:      Boolean,
118  }
119
120  // ─── lifecycle ────────────────────────────────────────────────────────────
121
122  connect() {
123    this.currentScreen = 1
124    this.selectedInterval = null
125    this.selectedFrequency = null
126    this.selectedInstallmentMonths = null
127    this.lastPaymentIntentId = null
128
129    // MSJD-666: the choices currently rendered (normalized from the fundraiser
130    // JSON) and the id of the one the donor has selected, if any. Typing a
131    // custom amount clears the id even when the number matches a choice.
132    this.suggestionEntries = []
133    this.selectedSuggestedAmountId = null
134
135    // Tip selector state (see the tip section). Middle tier preselected.
136    this.tipOption = DEFAULT_OPTION
137    this.tipCustomCents = null
138    this.tipInvalid = false
139    this.tipErrorMessage = null
140    if (this.hasTipSelectTarget) this.tipSelectTarget.value = this.tipOption
141
142    // Resolve starting fundraiser
143    const preselected = this.fundraisersValue.find(
144      f => f.slug === this.preselectedSlugValue
145    )
146    const preferred = preselected ||
147      this.fundraisersValue.find(f => f.slug === this.preferredSlugValue) ||
148      this.fundraisersValue[0]
149
150    this.currentFundraiser = preferred || null
151
152    if (this.lockedValue) {
153      // Locked mode (per-fundraiser iframe): the dropdown is not in the DOM at all.
154      // The context block (name/image/description) is already rendered server-side
155      // and will be populated by updateFundraiserPanel() below.
156      // Nothing extra to do for the select element.
157    } else if (preselected && this.hasPreselectedSlugValue && this.preselectedSlugValue) {
158      // Org mode: a ?fundraiser= query param was given — hide the select block
159      this.fundraiserSelectBlockTargets.forEach(el => el.classList.add("hidden"))
160      // Also sync the hidden select value so the DOM reflects the preselection
161      if (this.hasFundraiserSelectTarget) {
162        this.fundraiserSelectTarget.value = preselected.slug
163      }
164    } else if (this.hasFundraiserSelectTarget) {
165      // Org mode default: set the dropdown to the preferred value
166      this.fundraiserSelectTarget.value = preferred?.slug || ""
167    }
168
169    this.renderIntervalRow()
170    this.updateFundraiserPanel()
171    this._renderTip()
172    this.goToScreen(1, { inModal: this._launched() })
173    this._setupAutoResize()
174    this._setupDescriptionResizeObserver()
175    this._setupLauncherBridge()
176  }
177
178  disconnect() {
179    if (this._onParentMessage) {
180      window.removeEventListener("message", this._onParentMessage)
181      this._onParentMessage = null
182    }
183    if (this._resizeObserver) {
184      this._resizeObserver.disconnect()
185      this._resizeObserver = null
186    }
187    if (this._descriptionResizeObserver) {
188      this._descriptionResizeObserver.disconnect()
189      this._descriptionResizeObserver = null
190    }
191  }
192
193  // ─── screen navigation ────────────────────────────────────────────────────
194
195  goToScreen(n, { inModal = false } = {}) {
196    // Screen 2b mounts Stripe Elements. embed-payment (and, through it,
197    // Stripe.js) is pinned preload: false and registered here rather than in
198    // app/javascript/embed.js, so a visitor who never opens the wizard never
199    // downloads ~150 KB of Stripe (MSJD-479). Fetch starts the moment the modal
200    // opens — two user steps before the payment screen — so the definition has
201    // landed by the time the donor gets there.
202    if (n >= 2 || inModal) this._ensurePaymentController()
203
204    // Screen 1 (fund selection) normally lives on the page behind the modal.
205    // With inModal: true it is moved into the modal slot instead, so users
206    // can adjust their donation without the modal closing. Screens 2-5
207    // always live inside the modal overlay.
208    const modalScreens = [
209      this.screenContactTarget,
210      this.screenPaymentTarget,
211      this.screenThankYouTarget,
212      this.screenEmployerMatchTarget,
213    ]
214    modalScreens.forEach((el, i) => {
215      el.classList.toggle("hidden", i + 2 !== n)
216    })
217
218    const showFundSelectionInModal = n === 1 && inModal
219    if (showFundSelectionInModal) {
220      if (this.hasModalSlotTarget) this.modalSlotTarget.appendChild(this.screenFundSelectionTarget)
221      if (this.hasModalTarget) this.modalTarget.classList.remove("hidden")
222    } else {
223      if (this.hasPageSlotTarget && this.screenFundSelectionTarget.parentElement !== this.pageSlotTarget) {
224        this.pageSlotTarget.appendChild(this.screenFundSelectionTarget)
225      }
226      if (this.hasModalTarget) this.modalTarget.classList.toggle("hidden", n === 1)
227    }
228
229    this.currentScreen = n
230    this.updateStepIndicator(n)
231    this._notifyHostOfModalState()
232  }
233
234  // Close the modal back to fund selection on the page. All wizard state
235  // (amount, donation type, contact fields) is retained for when it reopens.
236  closeModal() {
237    if (this.hasModalTarget && this.modalTarget.classList.contains("hidden")) return
238    if (this._launched()) {
239      // Launched from a host control: there is no page behind the modal, so
240      // closing just hides it (the host then hides this iframe) and keeps the
241      // current step and entered values for when the donor reopens it.
242      this.modalTarget.classList.add("hidden")
243      this._notifyHostOfModalState()
244      return
245    }
246    this.goToScreen(1)
247  }
248
249  // ─── host launcher bridge (MSJD-711/712/715) ──────────────────────────────
250  // Launch mode only means something inside an iframe the host helper
251  // created; opened directly (unframed) the flow behaves as a normal page.
252  _launched() {
253    return this.launchModeValue && window.parent !== window
254  }
255
256  _setupLauncherBridge() {
257    if (!this._launched()) return
258    this._onParentMessage = (event) => {
259      // Commands only ever come from the embedding page; they control
260      // presentation (reopen), never payment submission.
261      if (event.source !== window.parent) return
262      if ((event.data || {}).type !== "sifr:embed:resume") return
263      this.goToScreen(this.currentScreen, { inModal: this.currentScreen === 1 })
264      this._focusModal()
265    }
266    window.addEventListener("message", this._onParentMessage)
267    try {
268      window.parent.postMessage({ type: "sifr:embed:ready" }, "*")
269    } catch (_e) {
270      // Cross-origin postMessage with "*" should not throw; guard regardless.
271    }
272    this._focusModal()
273  }
274
275  _focusModal() {
276    if (this.hasModalCloseTarget) this.modalCloseTarget.focus({ preventScroll: true })
277  }
278
279  // Escape closes the modal — unless it was pressed inside a form field.
280  // Browsers pass the Escape keydown through to the page when dismissing
281  // their native autofill dropdown, which would otherwise close the modal
282  // mid-form.
283  escapePressed(event) {
284    const tag = event.target?.tagName
285    if (tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA") return
286    this.closeModal()
287  }
288
289
290  // When embedded with the /embed/v1.js host script, the parent page expands
291  // the iframe to full viewport while the modal is open. Without the script
292  // (or when not iframed) this is a harmless no-op.
293  _notifyHostOfModalState() {
294    if (window.parent === window) return
295    const open = this.hasModalTarget && !this.modalTarget.classList.contains("hidden")
296    if (open === this._lastNotifiedModalOpen) return
297    this._lastNotifiedModalOpen = open
298    try {
299      window.parent.postMessage({ type: open ? "sifr:embed:modal-open" : "sifr
299:embed:modal-close" }, "*")
300    } catch (_e) {
301      // Cross-origin postMessage with "*" should not throw; guard regardless.
302    }
303  }
304
305  // ─── iframe auto-resize ─────────────────────────────────────────────────────
306  // The page-flow content (screen 1 + footer) changes height: selecting an
307  // Installment plan reveals the stepper, and a long fundraiser description
308  // makes the locked context block taller. The iframe is embedded with a fixed
309  // height, so taller content would otherwise overflow into an internal
310  // scrollbar. We measure the content and report it to the /embed/v1.js host
311  // script, which fits the iframe to it. A ResizeObserver catches every reflow.
312  // Harmless no-op when not iframed (no host script can act on the message).
313  _setupAutoResize() {
314    if (window.parent === window) return
315    this._reportHeight()
316    if (typeof ResizeObserver === "undefined") return
317    const target = this.hasPageContentTarget ? this.pageContentTarget : this.element
318    this._resizeObserver = new ResizeObserver(() => this._reportHeight())
319    this._resizeObserver.observe(target)
320  }
321
322  _reportHeight() {
323    if (window.parent === window) return
324    const el = this.hasPageContentTarget ? this.pageContentTarget : this.element
325    const height = Math.ceil(el.getBoundingClientRect().height)
326    if (height <= 0 || height === this._lastReportedHeight) return
327    this._lastReportedHeight = height
328    try {
329      window.parent.postMessage({ type: "sifr:embed:resize", height }, "*")
330    } catch (_e) {
331      // postMessage with "*" should not throw; guard regardless.
332    }
333  }
334
335  updateStepIndicator(screen) {
336    // Map wizard screens → active step (1-4 visible steps).
337    // Thank-you (4) and employer match (5) show the whole journey complete.
338    const stepMap = { 1: 1, 2: 2, 3: 3, 4: 5, 5: 5 }
339    const activeStep = stepMap[screen] || 1
340    // The indicator is rendered once per screen, so style by each item's own
341    // data-step-index — positional forEach indexes would span all copies.
342    this.stepItemTargets.forEach(el => {
343      const index = parseInt(el.dataset.stepIndex, 10)
344      const done = index < activeStep - 1
345      const active = index === activeStep - 1
346      el.querySelector("[data-step-circle]")?.classList.toggle("embed-selected", done || active)
347      el.querySelector(".step-check")?.classList.toggle("hidden", !done)
348      el.querySelector(".step-number")?.classList.toggle("hidden", done)
349      el.querySelector(".step-label")?.classList.toggle("text-gray-900", done || active)
350    })
351  }
352
353  // ─── screen 1: fund selection ─────────────────────────────────────────────
354
355  fundraiserChanged(event) {
356    const slug = event.target.value
357    this.currentFundraiser = this.fundraisersValue.find(f => f.slug === slug) || null
358    // renderIntervalRow re-renders the amount buttons — preselecting the middle
359    // suggested amount for the newly chosen fundraiser, or entering fixed-amount
360    // mode (chips hidden, input locked) when it has a fixed amount.
361    this.renderIntervalRow()
362    this.updateFundraiserPanel()
363    this._renderTip()
364  }
365
366  renderIntervalRow() {
367    const f = this.currentFundraiser
368    if (!f || !this.hasIntervalRowTarget) return
369
370    // Filter out 'Last 10 Nights' (already done server-side, but be safe)
371    const intervals = (f.donation_intervals || []).filter(i => i !== "Last 10 Nights")
372
373    const container = this.intervalRowTarget
374    container.innerHTML = ""
375
376    if (intervals.length === 1) {
377      // Single interval → full-width non-toggle label
378      const singleInterval = intervals[0]
379      // If it's Recurring with exactly 1 frequency, collapse label into freq name
380      const label = (singleInterval === "Recurring" && (f.recurring_intervals || []).length === 1)
381        ? this._capitalise(f.recurring_intervals[0])
382        : singleInterval
383
384      const btn = this._makeIntervalDisplay(label)
385      container.appendChild(btn)
386      this._selectInterval(singleInterval)
387
388      // Even with a single interval, Recurring (>1 frequency) needs its
389      // frequency picker and Installment needs its stepper — and both must set
390      // the selected frequency. _renderFrequencyRow() handles all three cases
391      // (Recurring → frequency row, Installment → stepper, One-Time → hides both).
392      this._renderFrequencyRow()
393    } else {
394      // Multiple intervals → toggleable buttons
395      intervals.forEach(interval => {
396        const btn = this._makeIntervalButton(interval)
397        container.appendChild(btn)
398      })
399
400      // Auto-select: One-Time preferred, else first
401      const autoSelect = intervals.includes("One-Time") ? "One-Time" : intervals[0]
402      this._selectInterval(autoSelect)
403      this._highlightInterval(autoSelect)
404      this._renderFrequencyRow()
405    }
406
407    this._renderAmountButtons()
408    this._renderInstallmentRow()
409  }
410
411  selectInterval(event) {
412    const interval = event.currentTarget.dataset.interval
413    this._selectInterval(interval)
414    this._highlightInterval(interval)
415    this._renderFrequencyRow()
416  }
417
418  _selectInterval(interval) {
419    this.selectedInterval = interval
420    this.selectedFrequency = null
421    this.selectedInstallmentMonths = null
422    if (this.hasFrequencyRowTarget) this.frequencyRowTarget.classList.add("hidden")
423    if (this.hasInstallmentRowTarget) this.installmentRowTarget.classList.add("hidden")
424    if (this.hasDonateButtonTarget) this.donateButtonTarget.disabled = false
425    this._renderTip()
426  }
427
428  _highlightInterval(active) {
429    this.intervalRowTarget.querySelectorAll("[data-interval]").forEach(btn => {
430      btn.classList.toggle("embed-selected", btn.dataset.interval === active)
431    })
432  }
433
434  _renderFrequencyRow() {
435    const f = this.currentFundraiser
436    if (!this.hasFrequencyRowTarget) return
437
438    if (this.selectedInterval === "Recurring") {
439      const freqs = f?.recurring_intervals || []
440      const row = this.frequencyRowTarget
441      row.innerHTML = ""
442
443      if (freqs.length === 1) {
444        // Single frequency: auto-select it, replace "Recurring" label in interval row
445        this.selectedFrequency = freqs[0]
446        // Mutate the interval row button label to show the frequency name
447        const btn = this.intervalRowTarget.querySelector("[data-interval='Recurring']")
448        if (btn) btn.textContent = this._capitalise(freqs[0])
449        row.classList.add("hidden")
450      } else {
451        // Multiple frequencies → toggle buttons
452        freqs.forEach(freq => {
453          const btn = this._makeFrequencyButton(freq)
454          row.appendChild(btn)
455        })
456        // Auto-select first
457        this.selectedFrequency = freqs[0]
458        this._highlightFrequency(freqs[0])
459        row.classList.remove("hidden")
460      }
461
462      if (this.hasInstallmentRowTarget) this.installmentRowTarget.classList.add("hidden")
463    } else if (this.selectedInterval === "Installment") {
464      if (this.hasFrequencyRowTarget) this.frequencyRowTarget.classList.add("hidden")
465      if (this.hasInstallmentRowTarget) {
466        this.installmentRowTarget.classList.remove("hidden")
467        this.selectedFrequency = "monthly"
468        this.selectedInstallmentMonths = this.selectedInstallmentMonths || 6
469        this._renderInstallmentRow()
470      }
471    } else {
472      if (this.hasFrequencyRowTarget) this.frequencyRowTarget.classList.add("hidden")
473      if (this.hasInstallmentRowTarget) this.installmentRowTarget.classList.add("hidden")
474    }
475    this._renderTip()
476  }
477
478  selectFrequency(event) {
479    const freq = event.currentTarget.dataset.freq
480    this.selectedFrequency = freq
481    this._highlightFrequency(freq)
482    this._renderTip()
483  }
484
485  _highlightFrequency(active) {
486    if (!this.hasFrequencyRowTarget) return
487    this.frequencyRowTarget.querySelectorAll("[data-freq]").forEach(btn => {
488      btn.classList.toggle("embed-selected", btn.dataset.freq === active)
489    })
490  }
491
492  incrementInstallments(event) {
493    if (event) event.preventDefault()
494    const current = this.selectedInstallmentMonths || 6
495    const next = Math.min(current + 1, 36)
496    if (next === current) return
497    this.selectedInstallmentMonths = next
498    this._renderInstallmentRow()
499    this._renderTip()
500  }
501
502  decrementInstallments(event) {
503    if (event) event.preventDefault()
504    const current = this.selectedInstallmentMonths || 6
505    const next = Math.max(current - 1, 2)
506    if (next === current) return
507    this.selectedInstallmentMonths = next
508    this._renderInstallmentRow()
509    this._renderTip()
510  }
511
512  _currentAmountCents() {
513    const raw = this.hasAmountInputTarget ? parseFloat(this.amountInputTarget.value) : NaN
514    return isNaN(raw) || raw <= 0 ? 0 : Math.round(raw * 100)
515  }
516
517  _renderInstallmentRow() {
518    if (!this.hasInstallmentRowTarget || this.selectedInterval !== "Installment") return
519    const count = this.selectedInstallmentMonths || 6
520
521    if (this.hasInstallmentCountTarget) this.installmentCountTarget.textContent = String(count)
522    if (this.hasInstallmentDecrementTarget) this.installmentDecrementTarget.disabled = count <= 2
523    if (this.hasInstallmentIncrementTarget) this.installmentIncrementTarget.disabled = count >= 36
524
525    const amountCents = this._currentAmountCents()
526    const monthlyCents = amountCents > 0 ? Math.round(amountCents / count) : 0
527
528    if (this.hasInstallmentMonthlyTarget) {
529      this.installmentMonthlyTarget.textContent = amountCents > 0
530        ? `$${(monthlyCents / 100).toFixed(2)} / month`
531        : "Choose monthly amount"
532    }
533
534    if (this.hasInstallmentEndDateTarget) {
535      if (amountCents > 0) {
536        // Last charge fires (count - 1) months from now (the first charges today).
537        // Clamp the day to the target month's last valid day — JS setMonth
538        // overflows by default (Jan 31 + 1mo → Mar 3).
539        const start = new Date()
540        const startDay = start.getDate()
541        const target = new Date(start.getFullYear(), start.getMonth() + (count - 1), 1)
542        const lastDay = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate()
543        target.setDate(Math.min(startDay, lastDay))
544        const formatted = target.toLocaleDateString("en-US", { month: "short", day: "
544numeric", year: "numeric" })
545        this.installmentEndDateTarget.textContent = `Last payment ${formatted}`
546      } else {
547        this.installmentEndDateTarget.innerHTML = "&nbsp;"
548      }
549    }
550
551    const belowMin = amountCents > 0 && monthlyCents < 50
552    if (this.hasInstallmentMinWarningTarget) {
553      this.installmentMinWarningTarget.classList.toggle("hidden", !belowMin)
554    }
555    if (this.hasDonateButtonTarget) this.donateButtonTarget.disabled = belowMin
556  }
557
558  selectAmount(event) {
559    this._applySelection(event.currentTarget.dataset.suggestionId || null)
560  }
561
562  amountTyped() {
563    // Clearing by id, not by value: a donor who types 25 when a $25 choice
564    // exists has still made their own choice, and no description applies.
565    this.clearAmountSelection()
566    this._clearAmountError()
567    this._renderInstallmentRow()
568    this._renderTip()
569  }
570
571  clearAmountSelection() {
572    this.selectedSuggestedAmountId = null
573    this._amountChoiceButtons().forEach(btn => {
574      btn.classList.remove("embed-selected")
575      btn.setAttribute("aria-pressed", "false")
576    })
577    this._renderSelectedDescription(null)
578  }
579
580  // ─── suggested amount choices (MSJD-666) ──────────────────────────────────
581  //
582  // The fundraiser JSON carries structured `suggested_amounts` rows plus a
583  // `default_suggested_amount_id`. Older cached responses have neither, so
584  // they are normalized from the legacy `suggested_amounts_cents` array into
585  // display-only choices with synthetic ids and no invented copy.
586
587  _suggestionEntries(f) {
588    const normalize = (cents, index, prefix) => ({
589      id: `${prefix}-${index}`,
590      title: "",
591      description: "",
592      amount_cents: cents,
593    })
594    const fallback = () => FALLBACK_AMOUNT_CENTS.map((cents, i) => normalize(cents, i, "fallback"))
595
596    if (!f) return fallback()
597
598    if (Array.isArray(f.suggested_amounts)) {
599      // An explicitly empty array means "no choices configured" — never
600      // resurrect the stale legacy array, but the embed keeps its own
601      // $25/$50/$100 fallback (with no titles or descriptions).
602      const structured = f.suggested_amounts.map(entry => ({
603        id: String(entry.id),
604        title: entry.title == null ? "" : String(entry.title),
605        description: entry.description == null ? "" : String(entry.description),
606        amount_cents: parseInt(entry.amount_cents, 10),
607      })).filter(entry => Number.isFinite(entry.amount_cents) && entry.amount_cents > 0)
608
609      return structured.length === 0 ? fallback() : structured
610    }
611
612    // Key absent entirely (cached response predating MSJD-666).
613    const legacy = (f.suggested_amounts_cents || [])
614      .map(cents => parseInt(cents, 10))
615      .filter(cents => Number.isFinite(cents) && cents > 0)
616    if (legacy.length === 0) return fallback()
617    return legacy.map((cents, i) => normalize(cents, i, "legacy"))
618  }
619
620  // Explicit administrator default wins; otherwise today's middle preselection.
621  // A synthetic id can never match a real default, so normalized/fallback
622  // choices always land on the middle one.
623  _defaultSuggestionId(f, entries) {
624    if (entries.length === 0) return null
625    const configured = f?.default_suggested_amount_id
626    if (configured != null && configured !== "") {
627      const match = entries.find(entry => entry.id === String(configured))
628      if (match) return match.id
629    }
630    return entries[Math.floor(entries.length / 2)].id
631  }
632
633  // MSJD-664 gave every migrated legacy amount an auto-generated title
634  // (SuggestedAmount.neutral_title), which inside a chip would only repeat the
635  // amount printed above it. Suppress that one string for display — the JSON
636  // and the admin editor keep it — and only when it names the row's OWN
637  // amount. Anything the administrator actually wrote, including a title of
638  // the same shape for a different amount or in a different case, is shown as
639  // authored.
640  _neutralTitle(cents) {
641    // Integer math, mirroring Ruby's format('Donation of $%.2f', cents / 100):
642    // two decimals, no thousands separator, exact for every integer cent value.
643    const whole = Math.trunc(Math.abs(cents) / 100)
644    const fraction = String(Math.abs(cents) % 100).padStart(2, "0")
645    return `Donation of $${cents < 0 ? "-" : ""}${whole}.${fraction}`
646  }
647
648  _displayTitle(entry) {
649    const title = entry.title || ""
650    return title === this._neutralTitle(entry.amount_cents) ? "" : title
651  }
652
653  // What a chosen suggestion puts in the amount input. Whole dollars keep the
654  // short form the wizard has always used ("25"); a fractional amount shows
655  // both decimals ("25.50"), never the "25.5" a float toString() would give.
656  // Integer math only — 2550 / 100 is not exactly representable as a double.
657  // Donor-typed values are never rewritten, and everything downstream still
658  // parseFloat()s this back to the same cents.
659  _amountInputValue(cents) {
660    const sign = cents < 0 ? "-" : ""
661    const whole = Math.trunc(Math.abs(cents) / 100)
662    const remainder = Math.abs(cents) % 100
663    if (remainder === 0) return `${sign}${whole}`
664    return `${sign}${whole}.${String(remainder).padStart(2, "0")}`
665  }
666
667  _amountChoiceButtons() {
668    if (!this.hasAmountButtonsRowTarget) return []
669    // Query the row directly — Stimulus target registration is async after the
670    // buttons are inserted, so amountButtonTargets is empty on this tick.
671    return Array.from(this.amountButtonsRowTarget.querySelectorAll("button"))
672  }
673
674  _renderAmountButtons() {
675    if (!this.hasAmountButtonsRowTarget) return
676    const f = this.currentFundraiser
677
678    // Fixed Amount mode (parity with mySifr): no suggested chips, the amount
679    // input carries the fixed amount and is not editable.
680    const fixedCents = f?.fixed_amount_cents || 0
681    if (fixedCents > 0) {
682      this._enterFixedAmountMode(fixedCents)
683      return
684    }
685    this._exitFixedAmountMode()
686
687    // Switching fundraiser must not flash the previous fundraiser's message.
688    this._resetDescription()
689
690    const entries = this._suggestionEntries(f)
691    this.suggestionEntries = entries
692
693    const row = this.amountButtonsRowTarget
694    row.innerHTML = ""
695    const cols = entries.length % 3 === 1 ? 2 : 3
696    row.style.setProperty("--amount-cols", String(cols))
697    entries.forEach(entry => row.appendChild(this._makeAmountChoice(entry)))
698
699    // Order matters. Applying the selection BEFORE the slot is measured keeps
700    // the default chip's themed fill out of `transition-colors`: measuring
701    // forces a style/layout flush, and a flush between "chip inserted white"
702    // and "chip marked selected" makes the browser animate the fill, so the
703    // widget's first paint would show a half-faded chip with unreadable text.
704    this._buildDescriptionCopies(entries)
705    this._applySelection(this._defaultSuggestionId(f, entries))
706    this._measureDescriptionSlotWhenReady(entries)
707  }
708
709  _makeAmountChoice(entry) {
710    const button = document.createElement("button")
711    button.type = "button"
712    button.dataset.suggestionId = String(entry.id)
713    button.dataset.cents = String(entry.amount_cents)
714    button.dataset.embedDonationFlowTarget = "amountButton"
715    button.dataset.action = "click->embed-donation-flow#selectAmount"
716    button.className = "embed-amount-choice flex flex-col items-center justify-center gap-0.5 rounded-xl border border-gray-300 bg-white px-2 py-3 text-center text-gray-800 transition-colors hover:border-gray-400"
717    button.setAttribute("aria-pressed", "false")
718
719    const amount = document.createElement("span")
720    amount.className = "text-sm font-semibold leading-tight tabular-nums"
721    amount.textContent = this._formatDollars(entry.amount_cents)
722
723    // textContent, never innerHTML: titles and descriptions are administrator
724    // free text served straight out of the fundraiser JSON.
725    const label = this._displayTitle(entry)
726    const title = document.createElement("span")
727    title.className = "embed-amount-choice-title text-[11px] font-medium leading-snug"
728    title.textContent = label
729    // display: none, so the flex gap does not reserve a row for it either.
730    if (label === "") title.classList.add("hidden")
731
732    button.append(amount, title)
733    return button
734  }
735
736  // One writer for every selection path (initial load, chip click, fundraiser
737  // switch): input value, highlight, aria-pressed, description, tip and
738  // installment display all move together.
739  _applySelection(suggestionId) {
740    const entry = suggestionId == null
741      ? null
742      : (this.suggestionEntries || []).find(item => item.id === String(suggestionId)) || null
743
744    this.selectedSuggestedAmountId = entry ? entry.id : null
745
746    this._amountChoiceButtons().forEach(btn => {
747      const active = entry != null && btn.dataset.suggestionId === entry.id
748      btn.classList.toggle("embed-selected", active)
749      btn.setAttribute("aria-pressed", active ? "true" : "false")
750    })
751
752    if (entry && this.hasAmountInputTarget) {
753      this.amountInputTarget.value = this._amountInputValue(entry.amount_cents)
754    }
755
756    this._renderSelectedDescription(entry)
757    this._clearAmountError()
758    this._renderInstallmentRow()
759    this._renderTip()
760  }
761
762  // What the callout would say for an entry: its displayable title (an
763  // auto-generated one counts as untitled) and its description, either of
764  // which may be absent. An entry with neither gets no callout at all — the
765  // chip already carries the amount.
766  _calloutFor(entry) {
767    if (!entry) return { title: "", description: "" }
768    return {
769      title: this._displayTitle(entry),
770      description: (entry.description || "").trim(),
771    }
772  }
773
774  _renderSelectedDescription(entry) {
775    if (!this.hasAmountDescriptionCalloutTarget) return
776    const { title, description } = this._calloutFor(entry)
777
778    if (this.hasAmountDescriptionTitleTarget) {
779      this.amountDescriptionTitleTarget.textContent = title
780      this.amountDescriptionTitleTarget.classList.toggle("hidden", title === "")
781    }
782    if (this.hasAmountDescriptionTarget) {
783      this.amountDescriptionTarget.textContent = description
784      this.amountDescriptionTarget.classList.toggle("hidden", description === "")
785    }
786    // Nothing to say → no empty styled panel. The slot keeps its reserved
787    // height either way, so the iframe does not jump.
788    this.amountDescriptionCalloutTarget.classList.toggle(
789      "hidden", title === "" && description === ""
790    )
791  }
792
793  _configuredCallouts(entries) {
794    return entries
795      .map(entry => this._calloutFor(entry))
796      .filter(callout => callout.title !== "" || callout.description !== "")
797  }
798
799  // Invisible, never-announced copies of every callout THIS fundraiser can
800  // show — the slot reserves the tallest. Same markup and classes as the real
801  // callout, so the measured height is the rendered height. Pure DOM writes,
802  // no layout reads.
803  _buildDescriptionCopies(entries) {
804    if (!this.hasAmountDescriptionAreaTarget) return
805    const callouts = this._configuredCallouts(entries)
806
807    // No choice has anything to say → no slot at all, nothing reserved.
808    this.amountDescriptionAreaTarget.classList.toggle("hidden", callouts.length === 0)
809    if (callouts.length === 0) this.amountDescriptionAreaTarget.style.minHeight = ""
810
811    if (!this.hasAmountDescriptionMeasureTarget) return
812    const measure = this.amountDescriptionMeasureTarget
813    measure.textContent = ""
814    callouts.forEach(({ title, description }) => {
815      const copy = document.createElement("div")
816      copy.className = "embed-amount-description"
817      if (title !== "") {
818        const titleCopy = document.createElement("strong")
819        titleCopy.className = "embed-amount-description-title"
820        titleCopy.textContent = title
821        copy.appendChild(titleCopy)
822      }
823      if (description !== "") {
824        const textCopy = document.createElement("span")
825        textCopy.className = "embed-amount-description-text"
826        textCopy.textContent = description
827        copy.appendChild(textCopy)
828      }
829      measure.appendChild(copy)
830    })
831  }
832
833  _measureDescriptionSlotWhenReady(entries) {
834    if (this._configuredCallouts(entries).length === 0) return
835
836    this._measureDescriptionSlot()
837    // Web fonts land after first paint and change the wrapped height; remeasure
838    // once they are ready (no-op in browsers without the Font Loading API).
839    if (!this._descriptionFontsAwaited && document.fonts && document.fonts.ready) {
840      this._descriptionFontsAwaited = true
841      document.fonts.ready
842        .then(() => this._measureDescriptionSlot())
843        .catch(() => {})
844    }
845  }
846
847  _measureDescriptionSlot() {
848    if (!this.hasAmountDescriptionAreaTarget || !this.hasAmountDescriptionMeasureTarget) return
849    const area = this.amountDescriptionAreaTarget
850    if (area.classList.contains("hidden")) return
851
852    const heights = Array.from(this.amountDescriptionMeasureTarget.children)
853      .map(copy => copy.getBoundingClientRect().height)
854    if (heights.length === 0) return
855
856    // Zero means the slot is off-layout right now (screen 1 mid-move between
857    // the page and the modal). Keep the height already reserved rather than
858    // dropping it — a later width change remeasures it for real.
859    const tallest = Math.ceil(Math.max(...heights))
860    if (tallest > 0) area.style.minHeight = `${tallest}px`
861  }
862
863  // Width-only: reacting to height would feed the min-height we just set back
864  // into the observer. Disconnected in disconnect().
865  _setupDescriptionResizeObserver() {
866    if (this._descriptionResizeObserver) return
867    if (typeof ResizeObserver === "undefined") return
868    if (!this.hasAmountButtonsSectionTarget) return
869
870    this._lastDescriptionWidth = null
871    this._descriptionResizeObserver = new ResizeObserver(entries => {
872      const width = Math.round(entries[0].contentRect.width)
873      if (width === 0 || width === this._lastDescriptionWidth) return
874      this._lastDescriptionWidth = width
875      this._measureDescriptionSlot()
876    })
877    this._descriptionResizeObserver.observe(this.amountButtonsSectionTarget)
878  }
879
880  _resetDescription() {
881    this.selectedSuggestedAmountId = null
882    if (this.hasAmountDescriptionTitleTarget) {
883      this.amountDescriptionTitleTarget.textContent = ""
884      this.amountDescriptionTitleTarget.classList.add("hidden")
885    }
886    if (this.hasAmountDescriptionTarget) {
887      this.amountDescriptionTarget.textContent = ""
888      this.amountDescriptionTarget.classList.add("hidden")
889    }
890    if (this.hasAmountDescriptionCalloutTarget) {
891      this.amountDescriptionCalloutTarget.classList.add("hidden")
892    }
893    if (this.hasAmountDescriptionMeasureTarget) {
894      this.amountDescriptionMeasureTarget.textContent = ""
895    }
896    if (this.hasAmountDescriptionAreaTarget) {
897      this.amountDescriptionAreaTarget.classList.add("hidden")
898      this.amountDescriptionAreaTarget.style.minHeight = ""
899    }
900  }
901
902  _enterFixedAmountMode(fixedCents) {
903    if (this.hasAmountButtonsSectionTarget) {
904      this.amountButtonsSectionTarget.classList.add("hidden")
905    }
906    this.amountButtonsRowTarget.innerHTML = ""
907    this.suggestionEntries = []
908    this._resetDescription()
909    if (this.hasAmountInputTarget) {
910      this.amountInputTarget.value = (fixedCents / 100).toFixed(2)
911      this.amountInputTarget.readOnly = true
912      this.amountInputTarget.classList.add("bg-gray-100", "cursor-not-allowed")
913    }
914    this._clearAmountError()
915    this._renderTip()
916  }
917
918  _exitFixedAmountMode() {
919    if (this.hasAmountButtonsSectionTarget) {
920      this.amountButtonsSectionTarget.classList.remove("hidden")
921    }
922    if (this.hasAmountInputTarget) {
923      this.amountInputTarget.readOnly = false
924      this.amountInputTarget.classList.remove("bg-gray-100", "cursor-not-allowed")
925    }
926  }
927
928  // ─── tip / fee cover (screen 1) ───────────────────────────────────────────
929  //
930  // The amount input holds the BASE gift and is never rewritten. The selector
931  // adds a contribution on top; utils/tip_calculator.js (mirror of
932  // DonorTipCalculator) owns the math. Payment method is unknown on Screen 1,
933  // so everything here assumes Card (highest fee, most common); the payment
934  // screen recomputes for the chosen method and the server refuses a total
935  // the donor never saw.
936
937  changeTipOption(event) {
938    this.tipOption = event.target.value
939    const custom = this.tipOption === "custom"
940    if (this.hasTipCustomWrapperTarget) this.tipCustomWrapperTarget.classList.toggle("hidden", !custom)
941    if (!custom && this.hasTipDeclineCheckboxTarget) this.tipDeclineCheckboxTarget.checked = false
942    if (custom && this.hasTipCustomInputTarget) {
943      this.tipCustomInputTarget.disabled = false
944      if (this.tipCustomInputTarget.value) {
945        this.tipCustomCents = Math.round((parseFloat(this.tipCustomInputTarget.value) || 0) * 100)
946      } else {
947        this._prefillCustomWithFloor()
948      }
949    }
950    this._renderTip()
951  }
952
953  changeTipCustom(event) {
954    this.tipCustomAutofilled = false
955    this.tipCustomCents = Math.round((parseFloat(event.target.value) || 0) * 100)
956    this._renderTip()
957  }
958
959  toggleTipDecline(event) {
960    const declined = event.target.checked
961    this.tipOption = declined ? "none" : "custom"
962    if (this.hasTipCustomInputTarget) this.tipCustomInputTarget.disabled = declined
963    this._renderTip()
964  }
965
966  // A value the widget wrote itself keeps tracking the floor as the amount or
967  // frequency changes (see _renderTip); one the donor typed is theirs.
968  _prefillCustomWithFloor() {
969    this.tipCustomAutofilled = true
970    const baseCents = this._perCycleBaseCents()
971    if (baseCents <= 0) {
972      // Nothing to derive a floor from yet -- filled in once an amount exists.
973      this.tipCustomInputTarget.value = ""
974      this.tipCustomCents = null
975      return
976    }
977    const floor = coverFeeTipCents(baseCents, "card", this._isSubscription())
978    this.tipCustomInputTarget.value = (floor / 100).toFixed(2)
979    this.tipCustomCents = floor
980  }
981
982  // Per-cycle breakdown for the current selection, or null without an amount.
983  // A custom tip below the floor sets tipInvalid and is DISPLAYED at the floor
984  // so the totals stay meaningful while the donor fixes it.
985  _currentTipBreakdown() {
986    const baseCents = this._perCycleBaseCents()
987    if (baseCents <= 0) {
988      // No amount means no tip to validate -- a stale error/line from a
989      // prior amount must not linger once the donor clears the field.
990      this.tipInvalid = false
991      this.tipErrorMessage = null
992      return null
993    }
994    const args = {
995      baseCents,
996      paymentMethod: "card",
997      subscription: this._isSubscription(),
998      tipOption: this.tipOption,
999      customCents: this.tipCustomCents,
1000    }
1001    try {
1002      const b = tipBreakdown(args)
1003      this.tipInvalid = false
1004      this.tipErrorMessage = null
1005      return b
1006    } catch (e) {
1007      if (!(e instanceof TipError)) throw e
1008      this.tipInvalid = true
1009      this.tipErrorMessage = e.message
1010      return tipBreakdown({ ...args, customCents: coverFeeTipCents(baseCents, "card", args.subscription) })
1011    }
1012  }
1013
1014  _masjidName() {
1015    return (this.hasTipHelperTarget && this.tipHelperTarget.dataset.masjidName) || "this organization"
1016  }
1017
1018  _renderTip() {
1019    if (this.tipOption === "custom" && this.tipCustomAutofilled && this.hasTipCustomInputTarget) {
1020      this._prefillCustomWithFloor()
1021    }
1022    const baseCents = this._perCycleBaseCents()
1023    const subscription = this._isSubscription()
1024    const b = this._currentTipBreakdown()
1025    const money = (cents) => `$${(cents / 100).toFixed(2)}`
1026
1027    // Option labels carry the live amount ("10% ($10.00)"); keys stay stable
1028    // so the donor's tier survives the amount crossing the $25 threshold.
1029    if (this.hasTipSelectTarget) {
1030      tipOptions(baseCents, "card", subscription).forEach(opt => {
1031        const node = this.tipSelectTarget.querySelector(`option[value="${opt.key}"]`)
1032        if (!node) return
1033        const showAmount = opt.tipCents != null && baseCents > 0 &&
1034          (opt.key === "cover_fee" || baseCents >= PERCENT_THRESHOLD_CENTS)
1035        node.textContent = showAmount ? `${opt.label} (${money(opt.tipCents)})` : opt.label
1036      })
1037    }
1038
1039    if (this.hasTipHelperTarget) {
1040      this.tipHelperTarget.textContent = this.tipOption === "none"
1041        ? `Sifr charges ${this._masjidName()} no platform fees. Processing fees will come out of your gift.`
1042        : `Sifr charges ${this._masjidName()} no platform fees — your tip keeps it that way.`
1043    }
1044
1045    if (this.hasTipErrorTarget) {
1046      this.tipErrorTarget.textContent = this.tipInvalid ? this.tipErrorMessage : ""
1047      this.tipErrorTarget.classList.toggle("hidden", !this.tipInvalid)
1048    }
1049
1050    if (this.hasTipCustomInputTarget) {
1051      if (this.tipInvalid) {
1052        this.tipCustomInputTarget.setAttribute("aria-invalid", "true")
1053      } else {
1054        this.tipCustomInputTarget.removeAttribute("aria-invalid")
1055      }
1056    }
1057
1058    // Per-cycle total as a hint inside the amount box — display only; the
1059    // input keeps the base gift, which is what we send to the server.
1060    const showHint = !!b && b.contributionCents > 0
1061    if (this.hasAmountTotalHintTarget) {
1062      // Installments charge this per month, not once — say so next to the
1063      // pledge total the donor typed (PR #172 review).
1064      const perCycle = this.selectedInterval === "Installment" ? "/mo" : ""
1065      this.amountTotalHintTarget.textContent = showHint ? `→ ${money(b.totalCents)}${perCycle}` : ""
1066      this.amountTotalHintTarget.classList.toggle("hidden", !showHint)
1067    }
1068    if (this.hasAmountInputTarget) {
1069      this.amountInputTarget.classList.toggle("pr-32", showHint)
1070      this.amountInputTarget.classList.toggle("pr-16", !showHint)
1071    }
1072
1073    if (!this.hasTipLineTarget) return
1074    if (!b) {
1075      this.tipLineTarget.textContent = ""
1076      this.tipLineTarget.classList.add("hidden")
1077      return
1078    }
1079    this.tipLineTarget.textContent = this._tipLineText(b, money)
1080    this.tipLineTarget.classList.remove("hidden")
1081  }
1082
1083  _tipLineText(b, money) {
1084    const total = money(b.totalCents)
1085    let give
1086    if (this.selectedInterval === "Recurring") {
1087      give = `You'll give ${total} ${this._frequencyLabel()}`
1088    } else if (this.selectedInterval === "Installment") {
1089      give = `You'll give ${total}/mo for ${this.selectedInstallmentMonths || 6} months`
1090    } else {
1091      give = `You'll give ${total}`
1092    }
1093    if (b.tipped) return `Tip ${money(b.contributionCents)} · ${give}`
1094    if (b.tipOption === "cover_fee") return `Covers ${money(b.contributionCents)} processing fee · ${give}`
1095    return `${give} · ${this._masjidName()} covers processing fees`
1096  }
1097
1098  _perCycleBaseCents() {
1099    const base = this._currentAmountCents()
1100    if (this.selectedInterval === "Installment") {
1101      const months = this.selectedInstallmentMonths || 6
1102      return Math.round(base / months)
1103    }
1104    return base
1105  }
1106
1107  _isSubscription() {
1108    return this.selectedInterval === "Recurring" || this.selectedInterval === "Installment"
1109  }
1110
1111  _frequencyLabel() {
1112    const labels = { monthly: "monthly", weekly: "weekly", daily: "daily" }
1113    return labels[this.selectedFrequency] || "monthly"
1114  }
1115
1116  donate(event) {
1117    event.preventDefault()
1118    this._clearAmountError()
1119    const raw = this.hasAmountInputTarget ? this.amountInputTarget.value.trim() : ""
1120    const parsed = parseFloat(raw)
1121    if (!raw || isNaN(parsed) || parsed <= 0) {
1122      this._showAmountError("Please enter a valid donation amount.")
1123      return
1124    }
1125    this.donationAmountDollars = parsed
1126    this.donationAmountCents = Math.round(parsed * 100)
1127    if (this.selectedInterval === "Installment") {
1128      const months = this.selectedInstallmentMonths || 6
1129      if (Math.round(this.donationAmountCents / months) < 50) {
1130        this._showAmountError("Monthly amount is below the $0.50 minimum — increase your donation or reduce installments.")
1131        return
1132      }
1133    }
1134
1135    // A custom tip below the floor is shown inline by _renderTip; don't advance.
1136    this._renderTip()
1137    if (this.tipInvalid) return
1138
1139    // Two-step entry. The first DONATE press (fund selection still on the page)
1140    // opens the modal with fund selection moved inside it, so the donor confirms
1141    // their gift on Step 1 with the wizard progress visible. The second press
1142    // (fund selection already in the modal) advances to the contact step.
1143    const fundSelectionInModal =
1144      this.hasModalSlotTarget &&
1145      this.screenFundSelectionTarget.parentElement === this.modalSlotTarget
1146
1147    if (fundSelectionInModal) {
1148      this.goToScreen(2)
1149    } else {
1150      this.goToScreen(1, { inModal: true })
1151    }
1152  }
1153
1154  // ─── screen 2a: contact ───────────────────────────────────────────────────
1155
1156  // Anonymous = hidden from the public only. The donor is still linked for
1157  // the organization by email alone, so email stays required while name and
1158  // phone are hidden and never sent (see continueToPayment).
1159  anonymousToggled(event) {
1160    const anon = event.target.checked
1161    if (this.hasNameFieldsTarget) {
1162      this.nameFieldsTarget.classList.toggle("hidden", anon)
1163      this.nameFieldsTarget.querySelectorAll("input").forEach(input => {
1164        input.required = !anon
1165      })
1166    }
1167    if (this.hasPhoneFieldTarget) {
1168      this.phoneFieldTarget.classList.toggle("hidden", anon)
1169    }
1170  }
1171
1172  continueToPayment(event) {
1173    event.preventDefault()
1174    this._clearContactError()
1175
1176    const anon = this.hasAnonymousCheckboxTarget && this.anonymousCheckboxTarget.checked
1177    // Anonymous donors send email only; ignore anything typed before hiding.
1178    const firstName = !anon && this.hasFirstNameTarget ? this.firstNameTarget.value.trim() : ""
1179    const lastName  = !anon && this.hasLastNameTarget  ? this.lastNameTarget.value.trim()  : ""
1180    const email     = this.hasEmailTarget              ? this.emailTarget.value.trim()     : ""
1181    const phone     = !anon && this.hasPhoneTarget     ? this.phoneTarget.value.trim()     : ""
1182
1183    if (!anon && !firstName) {
1184      this._showContactError("First name is required.")
1185      return
1186    }
1187    if (!anon && !lastName) {
1188      this._showContactError("Last name is required.")
1189      return
1190    }
1191    if (!email) {
1192      this._showContactError("Email address is required.")
1193      return
1194    }
1195    if (!this._validEmail(email)) {
1196      this._showContactError("Please enter a valid email address.")
1197      return
1198    }
1199
1200    this.contactData = { firstName, lastName, email, phone, anonymous: anon }
1201    this._writePaymentSnapshot()
1202    this.goToScreen(3)
1203  }
1204
1205  // Thank-you → "Donate again": start another gift at Step 1 inside the open
1206  // modal, keeping the donor's previous choices and details as a starting
1207  // point. embed-payment has already rotated its attempt id on success, so
1208  // the next gift is a new PaymentIntent, never a replay of this one.
1209  donateAgain() {
1210    this.lastPaymentIntentId = null
1211    if (this.hasThankYouAmountTarget) this.thankYouAmountTarget.textContent = "—"
1212    this.goToScreen(1, { inModal: true })
1213    if (this._launched()) this._focusModal()
1214  }
1215
1216  backToFundSelection() {
1217    // In-modal back: show fund selection inside the open modal so the user
1218    // can adjust amount/type and continue without losing their place.
1219    this.goToScreen(1, { inModal: true })
1220  }
1221
1222  backToContact() {
1223    this.goToScreen(2)
1224  }
1225
1226  // ─── screen 3: payment (owned by embed-payment controller) ────────────────
1227
1228  // Load + register embed-payment on demand. Stimulus connects the already
1229  // rendered [data-controller="embed-payment"] element as soon as the
1230  // definition lands, and _writePaymentSnapshot() has by then put the snapshot
1231  // on that element, so connect() mounts Stripe with the right params. Memoised
1232  // so repeated screen changes import once; a failed fetch clears the memo so
1233  // the next transition retries.
1234  _ensurePaymentController() {
1235    if (this._paymentControllerPromise) return this._paymentControllerPromise
1236
1237    this._paymentControllerPromise = import("controllers/embed_payment_controller")
1238      .then(({ default: PaymentController }) => {
1239        this.application.register("embed-payment", PaymentController)
1240      })
1241      .catch(() => {
1242        this._paymentControllerPromise = null
1243      })
1244
1245    return this._paymentControllerPromise
1246  }
1247
1248  // Called by embed-payment controller via a custom event when payment succeeds
1249  paymentSucceeded(event) {
1250    const { paymentIntentId, amountCents, chargedCents, donationType, frequency, installmentMonths } = event.detail
1251    this.lastPaymentIntentId = paymentIntentId
1252    if (this.hasThankYouAmountTarget) {
1253      this.thankYouAmountTarget.textContent = this._thankYouLine({
1254        chargedCents: chargedCents || amountCents || this.donationAmountCents,
1255        donationType, frequency, installmentMonths,
1256      })
1257    }
1258    this.goToScreen(4)
1259  }
1260
1261  _thankYouLine({ chargedCents, donationType, frequency, installmentMonths }) {
1262    const amount = `$${(chargedCents / 100).toFixed(2)}`
1263    if (donationType === "recurring") {
1264      const labels = { monthly: "monthly", weekly: "weekly", daily: "daily" }
1265      return `You'll be charged ${amount} ${labels[frequency] || "monthly"}`
1266    }
1267    if (donationType === "installment") {
1268      return `You'll be charged ${amount}/mo for ${installmentMonths} months`
1269    }
1270    return `You donated ${amount}`
1271  }
1272
1273  // ─── screen 4: thank you ──────────────────────────────────────────────────
1274
1275  goToEmployerMatch() {
1276    this.goToScreen(5)
1277  }
1278
1279  // ─── screen 5: employer match ─────────────────────────────────────────────
1280
1281  cancelEmployerMatch() {
1282    this.goToScreen(4)
1283  }
1284
1285  async submitEmployerMatch(event) {
1286    event.preventDefault()
1287    this._clearEmployerMatchError()
1288
1289    const employerName = this.hasEmployerNameTarget ? this.employerNameTarget.value.trim() : ""
1290    const workEmail    = this.hasWorkEmailTarget    ? this.workEmailTarget.value.trim()    : ""
1291
1292    if (!employerName) {
1293      this._showEmployerMatchError("Employer name is required.")
1294      return
1295    }
1296    if (!workEmail || !this._validEmail(workEmail)) {
1297      this._showEmployerMatchError("Please enter a valid work email address.")
1298      return
1299    }
1300
1301    if (this.hasSubmitMatchButtonTarget) {
1302      this.submitMatchButtonTarget.disabled = true
1303      this.submitMatchButtonTarget.textContent = "Submitting…"
1304    }
1305
1306    try {
1307      const csrf = document.querySelector("meta[name='csrf-token']")?.content || ""
1308      const res = await fetch(this.employerMatchUrlValue, {
1309        method: "POST",
1310        headers: {
1311          "Content-Type": "application/json",
1312          "X-CSRF-Token": csrf,
1313        },
1314        body: JSON.stringify({
1315          payment_intent_id: this.lastPaymentIntentId,
1316          employer_name: employerName,
1317          work_email: workEmail,
1318        }),
1319      })
1320
1321      if (!res.ok) {
1322        const data = await res.json().catch(() => ({}))
1323        this._showEmployerMatchError(data.error || "Submission failed. Please try again.")
1324        if (this.hasSubmitMatchButtonTarget) {
1325          this.submitMatchButtonTarget.disabled = false
1326          this.submitMatchButtonTarget.textContent = "Submit"
1327        }
1328        return
1329      }
1330
1331      // Show submitted state, then return to thank-you after a moment
1332      if (this.hasMatchSubmittedMessageTarget) {
1333        this.matchSubmittedMessageTarget.classList.remove("hidden")
1334      }
1335      if (this.hasSubmitMatchButtonTarget) {
1336        this.submitMatchButtonTarget.classList.add("hidden")
1337      }
1338      setTimeout(() => this.goToScreen(4), 2000)
1339    } catch (err) {
1340      this._showEmployerMatchError("Network error. Please check your connection and try again.")
1341      if (this.hasSubmitMatchButtonTarget) {
1342        this.submitMatchButtonTarget.disabled = false
1343        this.submitMatchButtonTarget.textContent = "Submit"
1344      }
1345    }
1346  }
1347
1348  // ─── fundraiser panel ─────────────────────────────────────────────────────
1349
1350  updateFundraiserPanel() {
1351    const f = this.currentFundraiser
1352    if (!f) return
1353    // Update ALL panel instances (one per screen) so whichever screen is
1354    // currently visible always shows the correct fundraiser data.
1355    this.panelImageTargets.forEach(img => {
1356      img.src = f.image_url || ""
1357      img.alt = f.name || ""
1358      img.classList.toggle("hidden", !f.image_url)
1359    })
1360    // Hide the placeholder when an image is shown — leaving both visible
1361    // stretches the aspect-video box and renders a gray strip under the image.
1362    this.panelImagePlaceholderTargets.forEach(el => {
1363      el.classList.toggle("hidden", !!f.image_url)
1364    })
1365    this.panelNameTargets.forEach(el => {
1366      el.textContent = f.name || ""
1367    })
1368    this.panelDescriptionTargets.forEach(el => {
1369      el.textContent = f.description || ""
1370    })
1371  }
1372
1373  // ─── payment snapshot (written to DOM so embed-payment can read it) ────────
1374
1375  _writePaymentSnapshot() {
1376    const snapshot = {
1377      fundraiserId:        this.currentFundraiser?.id,
1378      fundraiserSlug:      this.currentFundraiser?.slug,
1379      amountCents:         this.donationAmountCents,
1380      donationType:        this._donationType(),
1381      frequency:           this.selectedFrequency,
1382      installmentMonths:   this.selectedInstallmentMonths,
1383      tipOption:           this.tipOption,
1384      tipCustomCents:      this.tipOption === "custom" ? this.tipCustomCents : null,
1385      anonymous:           this.contactData?.anonymous || false,
1386      contact: {
1387        first_name:   this.contactData?.firstName || "",
1388        last_name:    this.contactData?.lastName  || "",
1389        email:        this.contactData?.email     || "",
1390        phone_number: this.contactData?.phone     || "",
1391      },
1392      consents: {
1393        masjid:    this.hasConsentMasjidTarget    ? this.consentMasjidTarget.checked    : false,
1394        platform:  this.hasConsentPlatformTarget  ? this.consentPlatformTarget.checked  : false,
1395      },
1396      paymentUrl:   this.paymentUrlValue,
1397      finalizeUrl:  this.finalizeUrlValue,
1398      publishableKey: this.publishableKeyValue,
1399      stripeAccount:  this.stripeAccountValue,
1400      currency:       this.currencyValue || "usd",
1401    }
1402
1403    // Write into the payment section root element so embed-payment can read it
1404    if (this.hasScreenPaymentTarget) {
1405      // Find the child that has data-controller="embed-payment"
1406      const paymentEl = this.screenPaymentTarget.querySelector("[data-controller='embed-payment']")
1407      if (paymentEl) {
1408        paymentEl.dataset.embedPaymentSnapshotValue = JSON.stringify(snapshot)
1409      }
1410    }
1411  }
1412
1413  _donationType() {
1414    if (this.selectedInterval === "Recurring")    return "recurring"
1415    if (this.selectedInterval === "Installment")  return "installment"
1416    return "one_time"
1417  }
1418
1419  // ─── helpers ──────────────────────────────────────────────────────────────
1420
1421  _makeIntervalDisplay(label) {
1422    const el = document.createElement("div")
1423    el.className = "w-full text-center py-3 rounded-xl border border-gray-300 bg-white text-gray-700 font-semibold text-sm"
1424    el.textContent = label
1425    return el
1426  }
1427
1428  _makeIntervalButton(interval) {
1429    const btn = document.createElement("button")
1430    btn.type = "button"
1431    btn.dataset.interval = interval
1432    btn.dataset.action = "click->embed-donation-flow#selectInterval"
1433    btn.className = "flex-1 px-2 py-3 rounded-xl border border-gray-300 bg-white text-gray-800 font-semibold text-sm hover:border-gray-400 transition-colors"
1434    btn.textContent = interval
1435    return btn
1436  }
1437
1438  _makeFrequencyButton(freq) {
1439    const btn = document.createElement("button")
1440    btn.type = "button"
1441    btn.dataset.freq = freq
1442    btn.dataset.action = "click->embed-donation-flow#selectFrequency"
1443    btn.className = "flex-1 px-2 py-3 rounded-xl border border-gray-300 bg-white text-gray-800 font-semibold text-sm hover:border-gray-400 transition-colors"
1444    btn.textContent = this._capitalise(freq)
1445    return btn
1446  }
1447
1448  _formatDollars(cents) {
1449    const dollars = cents / 100
1450    return `$${dollars % 1 === 0 ? dollars.toFixed(0) : dollars.toFixed(2)}`
1451  }
1452
1453  _capitalise(str) {
1454    return str.charAt(0).toUpperCase() + str.slice(1)
1455  }
1456
1457  _validEmail(email) {
1458    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
1459  }
1460
1461  _showAmountError(msg) {
1462    if (this.hasAmountErrorTarget) {
1463      this.amountErrorTarget.textContent = msg
1464      this.amountErrorTarget.classList.remove("hidden")
1465    }
1466  }
1467
1468  _clearAmountError() {
1469    if (this.hasAmountErrorTarget) {
1470      this.amountErrorTarget.textContent = ""
1471      this.amountErrorTarget.classList.add("hidden")
1472    }
1473  }
1474
1475  _showContactError(msg) {
1476    if (this.hasContactErrorTarget) {
1477      this.contactErrorTarget.textContent = msg
1478      this.contactErrorTarget.classList.remove("hidden")
1479    }
1480  }
1481
1482  _clearContactError() {
1483    if (this.hasContactErrorTarget) {
1484      this.contactErrorTarget.textContent = ""
1485      this.contactErrorTarget.classList.add("hidden")
1486    }
1487  }
1488
1489  _showEmployerMatchError(msg) {
1490    if (this.hasEmployerMatchErrorTarget) {
1491      this.employerMatchErrorTarget.textContent = msg
1492      this.employerMatchErrorTarget.classList.remove("hidden")
1493    }
1494  }
1495
1496  _clearEmployerMatchError() {
1497    if (this.hasEmployerMatchErrorTarget) {
1498      this.employerMatchErrorTarget.textContent = ""
1499      this.employerMatchErrorTarget.classList.add("hidden")
1500    }
1501  }
1502};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.