Files
sure/app/javascript/controllers/goal_pledge_preview_controller.js
Guillem Arias 4a8f6557e6 fix(goals/pledge-modal): helper text reacts to selected account
The helper paragraph above the amount field was painted off
`@goal.any_connected_account?` — a goal-level decision that fires
once at modal render and never updates. On a mixed-funding goal
(one connected + one manual account linked), the helper read the
"transfer" copy regardless of which account the user picked from
the dropdown, even though the saved pledge's `kind` is decided
per-account by `kind_for_account` in the controller. Stale UI vs.
correct data.

Render both helper paragraphs hidden, then toggle the right one
visible via Stimulus on `change->goal-pledge-preview#accountChanged`.
The select renders its `<option>` tags with `data-manual="true|false"`
from `Account#manual?`; the controller reads that attribute off
the currently-selected option and flips `hidden` on the two helper
targets.

`connect()` also calls `accountChanged()` so the initial paint
matches the preselected account from `?account_id=…` (the catch-up
callout's amount-specific deep link).

Switch from `options_from_collection_for_select` to
`options_for_select` with the `[label, value, { data: { manual: } }]`
3-tuple form — Rails supports per-option data attributes there but
not on the collection helper.
2026-05-14 20:37:16 +02:00

88 lines
2.9 KiB
JavaScript

import { Controller } from "@hotwired/stimulus";
// Live impact preview for the record-pledge modal. Reads current balance +
// target amount from values and updates a preview sentence each keystroke.
// Template strings come from ERB so the wording stays localized.
export default class extends Controller {
static targets = [
"amountInput",
"preview",
"accountSelect",
"helperConnected",
"helperManual",
];
static values = {
currentBalance: Number,
targetAmount: Number,
currency: String,
templateZero: String,
templateNonzero: String,
templateReached: String,
};
connect() {
this.update();
this.accountChanged();
}
// Helper text reacts to the currently-selected account, not the goal as a
// whole. A mixed-funding goal (one connected account + one manual) used to
// paint the "connected" helper even if the user then picked the manual
// account from the dropdown — the saved pledge would be `kind: manual_save`
// (correct, per `kind_for_account` in the controller) but the helper read
// "transfer-style" copy until submission.
accountChanged() {
if (!this.hasAccountSelectTarget) return;
if (!this.hasHelperConnectedTarget || !this.hasHelperManualTarget) return;
const opt = this.accountSelectTarget.selectedOptions[0];
const isManual = opt?.dataset.manual === "true";
this.helperConnectedTarget.hidden = isManual;
this.helperManualTarget.hidden = !isManual;
}
update() {
if (!this.hasPreviewTarget) return;
const amount = this.#amountValue();
const newTotal = this.currentBalanceValue + amount;
const target = this.targetAmountValue;
const reached = newTotal >= target && target > 0;
const percent = target > 0 ? Math.min(100, Math.round((newTotal / target) * 100)) : 0;
let text;
if (reached) {
text = this.templateReachedValue.replace("{target}", this.#money(target));
} else if (amount === 0) {
text = this.templateZeroValue
.replaceAll("{percent}", percent.toString())
.replaceAll("{current}", this.#money(this.currentBalanceValue))
.replaceAll("{target}", this.#money(target));
} else {
text = this.templateNonzeroValue
.replaceAll("{percent}", percent.toString())
.replaceAll("{newTotal}", this.#money(newTotal))
.replaceAll("{target}", this.#money(target));
}
this.previewTarget.textContent = text;
}
#amountValue() {
if (!this.hasAmountInputTarget) return 0;
const parsed = Number.parseFloat(this.amountInputTarget.value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
#money(value) {
try {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency: this.currencyValue || "USD",
maximumFractionDigits: 0,
}).format(value);
} catch {
return `${this.currencyValue || "$"}${Math.round(value).toLocaleString()}`;
}
}
}