Files
sure/app/javascript/controllers/goal_pledge_preview_controller.js
Guillem Arias b22a1644e2 fix(goals/pledge-modal): use StyledFormBuilder + restore live preview
V2 rebuilt the pledge create modal but bypassed the DS form helpers
inherited from `StyledFormBuilder`, lost the inline impact preview
from V1's contribution form, and shipped a goal-level "transfer vs
manual_save" toggle that broke on mixed-funding goals.

- Manual `form-field/__body/__label/__input` div-wrapping for the
  account select → idiomatic `f.select :account_id, choices,
  { label: t(".account_label") }`. The builder applies the required
  marker, error state, and inline-label handling automatically; the
  hand-built version drifted from that path and applied
  `form-field__input` directly onto the select element, where the
  builder picks the correct input class per field type.

- Hand-rolled `<div class="form-error">` + `<p>` loop for errors →
  `render "shared/form_errors", model: @pledge` (the shared partial
  with the destructive-icon prefix). Matches V1's contribution modal
  and the rest of the codebase.

- Drop `class: "btn btn--primary"` on `f.submit` → bare
  `f.submit t(".submit")`. The builder's `submit` is wired to
  `DS::Button.new(text:, full_width: true)`; the explicit class was
  redundant.

- Drop the duplicate "Cancel" button. DS::Dialog already renders an
  X in the header; the in-form ghost Cancel was a second close
  affordance with no analogue in the new-goal stepper or V1's
  contribution form.

- Drop `data: { turbo_frame: "_top" }` on submit. Success already
  flows through the controller's `turbo_stream.action(:redirect, …)`
  and on 422 the modal frame is the right swap target; the explicit
  `_top` was at best redundant and at worst a future Turbo footgun.

- Wire `data-controller="goal-pledge-preview"` on the form and add
  an inline preview `<p>` below the amount field. As the user types
  the amount, the line updates to "Reaches 75% — $3,750 of $5,000."
  or "Hits your $5,000 target — goal reached." Mirrors V1's
  contribution preview that V2 dropped on the floor.

- Rename `goal_contribution_preview_controller.js` →
  `goal_pledge_preview_controller.js`. Pure rename; the controller
  was already domain-neutral.

- Per-account pledge kind. The controller's `default_kind_for(goal)`
  picked `transfer` whenever the goal had ANY connected account —
  meaning a goal that linked a Plaid checking account AND a manual
  cash envelope routed every pledge as `transfer`, including those
  the user submitted against the manual account. The reconciler
  would then watch for a Transaction that never arrives. Replace
  with `kind_for_account(account)` that picks per-account: manual →
  `manual_save`, anything else → `transfer`.

- `new` action now respects `?account_id=…` query params and
  preselects that account (helpful for the catch-up callout's
  inline "Save $X/mo" CTA, which can target a specific account).

Locale: drop the hardcoded "(±5 days, ±$0.50 or ±1%)" tolerance
copy from the helper text — that detail belongs in docs, not in a
modal that fires on every pledge create. Currency-aware copy lands
in commit I. Drop the now-unused `cancel:` key. Add the three
preview templates (`preview_zero`, `preview_nonzero`,
`preview_reached`) consumed by the Stimulus controller.
2026-05-14 19:41:30 +02:00

66 lines
2.0 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"];
static values = {
currentBalance: Number,
targetAmount: Number,
currency: String,
templateZero: String,
templateNonzero: String,
templateReached: String,
};
connect() {
this.update();
}
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()}`;
}
}
}