mirror of
https://github.com/we-promise/sure.git
synced 2026-05-30 15:59:02 +00:00
fix(savings): DS conformance pass on stepper, ring, card, status pill
- StatusPill: use functional `text-success` / `text-warning` tokens with matching icon colors and `px-2 py-1`, mirroring `app/views/budget_categories/_budget_category.html.erb:29-43`. - ProgressRing: rework center text to match `_budget_donut.html.erb` (small "Saved" label, `text-3xl font-medium` headline, "of $X" underline). Stroke color now derives from goal.status (yellow when behind, blue on track, green reached, gray for no-date). - GoalCard bar: track height + transition match budget category bar (`h-1.5`, `transition-all duration-500`, `inline-size`). - Index/show layouts: render page header inline (`<h1>` + actions). The default application layout doesn't yield `:page_actions`, so the CTA + kebab menu wouldn't appear when emitted via `content_for`. - Stepper review summary: target the actual form inputs by `name` rather than relying on the `data-target` Stimulus attribute, since `money_field` puts the attribute on the wrapper. Step 1 validation scoped to the step 1 panel. - Demo generator: filter Depository accounts via `where(accountable_type: "Depository")` — Rails delegated_type generates the `depository?` predicate, not a `.depository` scope.
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -127,3 +127,7 @@ logs/security/
|
||||
|
||||
# Added by codex
|
||||
.codex
|
||||
|
||||
# Ignore Playwright artifacts
|
||||
playwright-report/
|
||||
.playwright-mcp
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
<span class="text-secondary"><%= goal.current_balance_money.format %></span>
|
||||
<span class="text-secondary">/ <%= goal.target_amount_money.format %></span>
|
||||
</div>
|
||||
<div class="mt-2 h-2 w-full rounded-full bg-container-inset overflow-hidden">
|
||||
<div class="h-full <%= bar_color_class %>" style="width: <%= progress_percent %>%"></div>
|
||||
<div class="mt-2 h-1.5 w-full rounded-full bg-container-inset overflow-hidden">
|
||||
<div class="h-full <%= bar_color_class %> rounded-full transition-all duration-500" style="inline-size: <%= progress_percent %>%"></div>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center justify-between text-xs text-secondary tabular-nums">
|
||||
<span><%= progress_percent %>%</span>
|
||||
|
||||
@@ -21,10 +21,11 @@ class Savings::GoalCardComponent < ApplicationComponent
|
||||
end
|
||||
|
||||
def bar_color_class
|
||||
case progress_percent
|
||||
when 0...25 then "bg-gray-400"
|
||||
when 25...75 then "bg-blue-500"
|
||||
else "bg-green-600"
|
||||
case goal.status
|
||||
when :reached then "bg-green-500"
|
||||
when :behind then "bg-yellow-500"
|
||||
when :on_track then "bg-blue-500"
|
||||
else "bg-gray-400"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
transform="rotate(-90 <%= Savings::ProgressRingComponent::SIZE / 2.0 %> <%= Savings::ProgressRingComponent::SIZE / 2.0 %>)" />
|
||||
</svg>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center text-center">
|
||||
<span class="text-2xl font-semibold text-primary tabular-nums"><%= percent %>%</span>
|
||||
<span class="text-xs text-secondary tabular-nums mt-1"><%= current_label %></span>
|
||||
<span class="text-xs text-secondary tabular-nums">of <%= target_label %></span>
|
||||
<span class="text-secondary text-sm mb-1"><%= t("savings_goals.show.ring.saved") %></span>
|
||||
<span class="text-3xl font-medium text-primary tabular-nums privacy-sensitive"><%= current_label %></span>
|
||||
<span class="text-secondary text-sm mt-1 tabular-nums">of <%= target_label %></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Savings::ProgressRingComponent < ApplicationComponent
|
||||
SIZE = 180
|
||||
SIZE = 220
|
||||
STROKE = 14
|
||||
RADIUS = (SIZE - STROKE) / 2.0
|
||||
CIRCUMFERENCE = 2 * Math::PI * RADIUS
|
||||
@@ -19,10 +19,11 @@ class Savings::ProgressRingComponent < ApplicationComponent
|
||||
end
|
||||
|
||||
def stroke_color
|
||||
case percent
|
||||
when 0...25 then "var(--color-gray-400)"
|
||||
when 25...75 then "var(--color-blue-500)"
|
||||
else "var(--color-green-600)"
|
||||
case goal.status
|
||||
when :reached then "var(--color-green-500)"
|
||||
when :behind then "var(--color-yellow-500)"
|
||||
when :on_track then "var(--color-blue-500)"
|
||||
else "var(--color-gray-400)"
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium <%= classes %>">
|
||||
<%= helpers.icon(icon_name, size: "xs", color: "current") %>
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium whitespace-nowrap <%= classes %>">
|
||||
<%= helpers.icon(icon_name, size: "sm", color: icon_color) %>
|
||||
<%= label %>
|
||||
</span>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
class Savings::StatusPillComponent < ApplicationComponent
|
||||
VARIANTS = {
|
||||
on_track: { classes: "bg-green-600/10 text-green-700", icon: "check" },
|
||||
behind: { classes: "bg-yellow-500/10 text-yellow-700", icon: "alert-triangle" },
|
||||
reached: { classes: "bg-green-600/10 text-green-700", icon: "circle-check-big" },
|
||||
no_target_date: { classes: "bg-container-inset text-secondary", icon: "calendar-off" }
|
||||
on_track: { classes: "bg-green-500/10 text-success", icon: "check-circle", icon_color: "green" },
|
||||
behind: { classes: "bg-yellow-500/10 text-warning", icon: "alert-triangle", icon_color: "yellow" },
|
||||
reached: { classes: "bg-green-500/10 text-success", icon: "circle-check-big", icon_color: "green" },
|
||||
no_target_date: { classes: "bg-surface-inset text-secondary", icon: "calendar-off", icon_color: "default" }
|
||||
}.freeze
|
||||
|
||||
def initialize(goal:)
|
||||
@@ -26,6 +26,10 @@ class Savings::StatusPillComponent < ApplicationComponent
|
||||
variant[:icon]
|
||||
end
|
||||
|
||||
def icon_color
|
||||
variant[:icon_color]
|
||||
end
|
||||
|
||||
def classes
|
||||
variant[:classes]
|
||||
end
|
||||
|
||||
@@ -52,10 +52,13 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
validateStep1() {
|
||||
const requiredInputs = this.step1PanelTarget.querySelectorAll(
|
||||
'input[name="savings_goal[name]"], input[name="savings_goal[target_amount]"]'
|
||||
);
|
||||
let ok = true;
|
||||
this.step1FieldTargets.forEach((field) => {
|
||||
if (!field.checkValidity()) {
|
||||
field.reportValidity();
|
||||
requiredInputs.forEach((input) => {
|
||||
if (typeof input.checkValidity === "function" && !input.checkValidity()) {
|
||||
input.reportValidity();
|
||||
ok = false;
|
||||
}
|
||||
});
|
||||
@@ -95,11 +98,13 @@ export default class extends Controller {
|
||||
updateReview() {
|
||||
if (!this.hasReviewPanelTarget) return;
|
||||
|
||||
if (this.hasReviewNameTarget && this.hasNameFieldTarget) {
|
||||
this.reviewNameTarget.textContent = this.nameFieldTarget.value || "—";
|
||||
if (this.hasReviewNameTarget) {
|
||||
const nameInput = this.element.querySelector('input[name="savings_goal[name]"]');
|
||||
this.reviewNameTarget.textContent = nameInput?.value || "—";
|
||||
}
|
||||
if (this.hasReviewAmountTarget && this.hasTargetAmountFieldTarget) {
|
||||
this.reviewAmountTarget.textContent = this.targetAmountFieldTarget.value || "—";
|
||||
if (this.hasReviewAmountTarget) {
|
||||
const amountInput = this.element.querySelector('input[name="savings_goal[target_amount]"]');
|
||||
this.reviewAmountTarget.textContent = amountInput?.value || "—";
|
||||
}
|
||||
if (this.hasReviewDateTarget) {
|
||||
const dateInput = this.element.querySelector('input[type="date"][name="savings_goal[target_date]"]');
|
||||
|
||||
@@ -1279,7 +1279,7 @@ class Demo::Generator
|
||||
end
|
||||
|
||||
def generate_savings_goals!(family)
|
||||
depository_accounts = family.accounts.depository.visible.to_a
|
||||
depository_accounts = family.accounts.where(accountable_type: "Depository").visible.to_a
|
||||
return if depository_accounts.empty?
|
||||
|
||||
currency = depository_accounts.first.currency
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<%= content_for :page_title, t(".title") %>
|
||||
<%= content_for :page_actions do %>
|
||||
<% if @linkable_account_count > 0 %>
|
||||
<%= render DS::Link.new(
|
||||
text: t(".new_goal"),
|
||||
variant: "primary",
|
||||
href: new_savings_goal_path,
|
||||
icon: "plus",
|
||||
frame: :modal
|
||||
) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<div class="space-y-4">
|
||||
<header class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-lg font-semibold text-primary"><%= t(".title") %></h1>
|
||||
<% if @linkable_account_count > 0 %>
|
||||
<%= render DS::Link.new(
|
||||
text: t(".new_goal"),
|
||||
variant: "primary",
|
||||
href: new_savings_goal_path,
|
||||
icon: "plus",
|
||||
frame: :modal
|
||||
) %>
|
||||
<% end %>
|
||||
</header>
|
||||
|
||||
<% if @savings_goals.empty? && @counts["all"].zero? %>
|
||||
<%= render "empty_state", linkable_account_count: @linkable_account_count %>
|
||||
<% else %>
|
||||
<div class="space-y-4">
|
||||
<% if @savings_goals.empty? && @counts["all"].zero? %>
|
||||
<%= render "empty_state", linkable_account_count: @linkable_account_count %>
|
||||
<% else %>
|
||||
<nav class="flex items-center gap-1 overflow-x-auto" role="tablist">
|
||||
<% SavingsGoalsController::STATE_FILTERS.each do |state| %>
|
||||
<% active = state == @state_filter %>
|
||||
@@ -39,5 +39,5 @@
|
||||
<p class="text-sm text-secondary"><%= t(".empty_filtered", state: t(".tabs.#{@state_filter}").downcase) %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -1,73 +1,80 @@
|
||||
<%= content_for :page_title, @savings_goal.name %>
|
||||
<%= content_for :page_actions do %>
|
||||
<%= render DS::Menu.new do |menu| %>
|
||||
<% menu.with_item(
|
||||
variant: "link",
|
||||
text: t(".edit"),
|
||||
icon: "pencil",
|
||||
href: edit_savings_goal_path(@savings_goal),
|
||||
data: { turbo_frame: :modal }
|
||||
) %>
|
||||
<% if @savings_goal.may_pause? %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
text: t(".pause"),
|
||||
icon: "pause",
|
||||
href: pause_savings_goal_path(@savings_goal),
|
||||
method: :patch
|
||||
) %>
|
||||
<% end %>
|
||||
<% if @savings_goal.may_resume? %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
text: t(".resume"),
|
||||
icon: "play",
|
||||
href: resume_savings_goal_path(@savings_goal),
|
||||
method: :patch
|
||||
) %>
|
||||
<% end %>
|
||||
<% if @savings_goal.may_complete? %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
text: t(".complete"),
|
||||
icon: "circle-check-big",
|
||||
href: complete_savings_goal_path(@savings_goal),
|
||||
method: :patch
|
||||
) %>
|
||||
<% end %>
|
||||
<% if @savings_goal.may_archive? %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
text: t(".archive"),
|
||||
icon: "archive",
|
||||
href: archive_savings_goal_path(@savings_goal),
|
||||
method: :patch
|
||||
) %>
|
||||
<% end %>
|
||||
<% if @savings_goal.may_unarchive? %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
text: t(".unarchive"),
|
||||
icon: "archive-restore",
|
||||
href: unarchive_savings_goal_path(@savings_goal),
|
||||
method: :patch
|
||||
) %>
|
||||
<% end %>
|
||||
<% if @savings_goal.archived? %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
text: t(".delete"),
|
||||
icon: "trash-2",
|
||||
href: savings_goal_path(@savings_goal),
|
||||
method: :delete,
|
||||
destructive: true,
|
||||
confirm: CustomConfirm.for_resource_deletion(@savings_goal.name, high_severity: true)
|
||||
) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<header class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<%= render Savings::GoalAvatarComponent.new(goal: @savings_goal, size: "md") %>
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-lg font-semibold text-primary truncate"><%= @savings_goal.name %></h1>
|
||||
<p class="text-xs text-secondary"><%= t("savings_goals.states.#{@savings_goal.state}") %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= render DS::Menu.new do |menu| %>
|
||||
<% menu.with_item(
|
||||
variant: "link",
|
||||
text: t(".edit"),
|
||||
icon: "pencil",
|
||||
href: edit_savings_goal_path(@savings_goal),
|
||||
data: { turbo_frame: :modal }
|
||||
) %>
|
||||
<% if @savings_goal.may_pause? %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
text: t(".pause"),
|
||||
icon: "pause",
|
||||
href: pause_savings_goal_path(@savings_goal),
|
||||
method: :patch
|
||||
) %>
|
||||
<% end %>
|
||||
<% if @savings_goal.may_resume? %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
text: t(".resume"),
|
||||
icon: "play",
|
||||
href: resume_savings_goal_path(@savings_goal),
|
||||
method: :patch
|
||||
) %>
|
||||
<% end %>
|
||||
<% if @savings_goal.may_complete? %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
text: t(".complete"),
|
||||
icon: "circle-check-big",
|
||||
href: complete_savings_goal_path(@savings_goal),
|
||||
method: :patch
|
||||
) %>
|
||||
<% end %>
|
||||
<% if @savings_goal.may_archive? %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
text: t(".archive"),
|
||||
icon: "archive",
|
||||
href: archive_savings_goal_path(@savings_goal),
|
||||
method: :patch
|
||||
) %>
|
||||
<% end %>
|
||||
<% if @savings_goal.may_unarchive? %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
text: t(".unarchive"),
|
||||
icon: "archive-restore",
|
||||
href: unarchive_savings_goal_path(@savings_goal),
|
||||
method: :patch
|
||||
) %>
|
||||
<% end %>
|
||||
<% if @savings_goal.archived? %>
|
||||
<% menu.with_item(
|
||||
variant: "button",
|
||||
text: t(".delete"),
|
||||
icon: "trash-2",
|
||||
href: savings_goal_path(@savings_goal),
|
||||
method: :delete,
|
||||
destructive: true,
|
||||
confirm: CustomConfirm.for_resource_deletion(@savings_goal.name, high_severity: true)
|
||||
) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</header>
|
||||
|
||||
<section class="bg-container rounded-xl shadow-border-xs p-6">
|
||||
<div class="flex flex-col md:flex-row items-start md:items-center gap-6">
|
||||
<div>
|
||||
@@ -75,14 +82,6 @@
|
||||
</div>
|
||||
|
||||
<div class="flex-1 space-y-3 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= render Savings::GoalAvatarComponent.new(goal: @savings_goal, size: "lg") %>
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-lg font-semibold text-primary truncate"><%= @savings_goal.name %></h1>
|
||||
<p class="text-xs text-secondary"><%= t("savings_goals.states.#{@savings_goal.state}") %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<p class="text-xs text-secondary"><%= t(".stats.current") %></p>
|
||||
|
||||
@@ -51,6 +51,8 @@ en:
|
||||
funding_accounts_heading: Funding accounts
|
||||
no_contributions_yet: No contributions yet.
|
||||
confirm_delete_contribution: Delete this contribution?
|
||||
ring:
|
||||
saved: Saved
|
||||
source:
|
||||
initial: Initial
|
||||
manual: Manual
|
||||
|
||||
Reference in New Issue
Block a user