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 = " " 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.