mirror of
https://github.com/we-promise/sure.git
synced 2026-05-29 23:39:03 +00:00
feat(goal_contributions/new): live impact preview below amount field
Add-contribution modal previously offered zero feedback on what the
typed amount would do to goal progress. Now renders "Currently X%
saved (Y of Z)." at rest and updates on each keystroke to
"Will bring you to X% saved (Y of Z)." or "Will reach your Z target."
when the contribution would close the gap.
- New goal_contribution_preview_controller.js consumes current balance
+ target + currency + three localized template strings as Stimulus
values. Intl.NumberFormat for currency formatting (locale-correct
out of the box; fallback to currencyValue prefix on environments
that don't support it).
- ERB form-level data-controller wires the values; amount input uses
amount_data: to thread the Stimulus target / action through the
money_field helper.
- Locale: goal_contributions.new.preview_{zero,nonzero,reached} with
{percent}, {current}, {newTotal}, {target} placeholders.
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Live impact preview for the add-contribution 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()}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,27 @@
|
||||
|
||||
<%= styled_form_with model: @contribution,
|
||||
url: goal_contributions_path(@goal),
|
||||
class: "space-y-3" do |f| %>
|
||||
class: "space-y-3",
|
||||
data: {
|
||||
controller: "goal-contribution-preview",
|
||||
goal_contribution_preview_current_balance_value: @goal.current_balance.to_f,
|
||||
goal_contribution_preview_target_amount_value: @goal.target_amount.to_f,
|
||||
goal_contribution_preview_currency_value: @goal.currency,
|
||||
goal_contribution_preview_template_zero_value: t(".preview_zero"),
|
||||
goal_contribution_preview_template_nonzero_value: t(".preview_nonzero"),
|
||||
goal_contribution_preview_template_reached_value: t(".preview_reached")
|
||||
} do |f| %>
|
||||
<%= f.money_field :amount,
|
||||
label: t(".amount"),
|
||||
hide_currency: true,
|
||||
autofocus: true %>
|
||||
autofocus: true,
|
||||
amount_data: {
|
||||
goal_contribution_preview_target: "amountInput",
|
||||
action: "input->goal-contribution-preview#update"
|
||||
} %>
|
||||
|
||||
<p class="text-xs text-secondary tabular-nums -mt-1"
|
||||
data-goal-contribution-preview-target="preview"></p>
|
||||
|
||||
<%= f.select :account_id,
|
||||
options_from_collection_for_select(@goal.linked_accounts, :id, :name, @contribution.account_id),
|
||||
|
||||
@@ -9,6 +9,9 @@ en:
|
||||
contributed_at: Date
|
||||
notes: Notes (optional)
|
||||
submit: Save contribution
|
||||
preview_zero: "Currently {percent}% saved ({current} of {target})."
|
||||
preview_nonzero: "Will bring you to {percent}% saved ({newTotal} of {target})."
|
||||
preview_reached: "Will reach your {target} target."
|
||||
create:
|
||||
success: Contribution saved.
|
||||
destroy:
|
||||
|
||||
Reference in New Issue
Block a user