diff --git a/app/javascript/controllers/goal_contribution_preview_controller.js b/app/javascript/controllers/goal_contribution_preview_controller.js new file mode 100644 index 000000000..714c586f0 --- /dev/null +++ b/app/javascript/controllers/goal_contribution_preview_controller.js @@ -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()}`; + } + } +} diff --git a/app/views/goal_contributions/new.html.erb b/app/views/goal_contributions/new.html.erb index 730ec7f49..80554abb1 100644 --- a/app/views/goal_contributions/new.html.erb +++ b/app/views/goal_contributions/new.html.erb @@ -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" + } %> + +
<%= f.select :account_id, options_from_collection_for_select(@goal.linked_accounts, :id, :name, @contribution.account_id), diff --git a/config/locales/views/goal_contributions/en.yml b/config/locales/views/goal_contributions/en.yml index a26bee626..b8faf827a 100644 --- a/config/locales/views/goal_contributions/en.yml +++ b/config/locales/views/goal_contributions/en.yml @@ -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: