diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index af71fcd1a..f4de696c2 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -44,9 +44,9 @@ module SettingsHelper } end - def settings_section(title:, subtitle: nil, collapsible: false, open: true, &block) + def settings_section(title:, subtitle: nil, collapsible: false, open: true, auto_open_param: nil, &block) content = capture(&block) - render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content, collapsible: collapsible, open: open } + render partial: "settings/section", locals: { title: title, subtitle: subtitle, content: content, collapsible: collapsible, open: open, auto_open_param: auto_open_param } end def settings_nav_footer diff --git a/app/javascript/controllers/auto_open_controller.js b/app/javascript/controllers/auto_open_controller.js new file mode 100644 index 000000000..de59f108e --- /dev/null +++ b/app/javascript/controllers/auto_open_controller.js @@ -0,0 +1,29 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="auto-open" +// Auto-opens a
element based on URL param +// Use data-auto-open-param-value="paramName" to open when ?paramName=1 is in URL +export default class extends Controller { + static values = { param: String }; + + connect() { + if (!this.hasParamValue || !this.paramValue) return; + + const params = new URLSearchParams(window.location.search); + if (params.get(this.paramValue) === "1") { + this.element.open = true; + + // Clean up the URL param after opening + params.delete(this.paramValue); + const newUrl = params.toString() + ? `${window.location.pathname}?${params.toString()}${window.location.hash}` + : `${window.location.pathname}${window.location.hash}`; + window.history.replaceState({}, "", newUrl); + + // Scroll into view after opening + requestAnimationFrame(() => { + this.element.scrollIntoView({ behavior: "smooth", block: "start" }); + }); + } + } +} diff --git a/app/javascript/controllers/lazy_load_controller.js b/app/javascript/controllers/lazy_load_controller.js index f210abfa6..93bea5c30 100644 --- a/app/javascript/controllers/lazy_load_controller.js +++ b/app/javascript/controllers/lazy_load_controller.js @@ -3,11 +3,26 @@ import { Controller } from "@hotwired/stimulus"; // Connects to data-controller="lazy-load" // Used with
elements to lazy-load content when expanded // Use data-action="toggle->lazy-load#toggled" on the
element +// Optional: data-lazy-load-auto-open-param-value="paramName" to auto-open when ?paramName=1 is in URL export default class extends Controller { static targets = ["content", "loading", "frame"]; - static values = { url: String, loaded: Boolean }; + static values = { url: String, loaded: Boolean, autoOpenParam: String }; connect() { + // Check if we should auto-open based on URL param + if (this.hasAutoOpenParamValue && this.autoOpenParamValue) { + const params = new URLSearchParams(window.location.search); + if (params.get(this.autoOpenParamValue) === "1") { + this.element.open = true; + // Clean up the URL param after opening + params.delete(this.autoOpenParamValue); + const newUrl = params.toString() + ? `${window.location.pathname}?${params.toString()}${window.location.hash}` + : `${window.location.pathname}${window.location.hash}`; + window.history.replaceState({}, "", newUrl); + } + } + // If already open on connect (browser restored state), load immediately if (this.element.open && !this.loadedValue) { this.load(); diff --git a/app/views/coinbase_items/_coinbase_item.html.erb b/app/views/coinbase_items/_coinbase_item.html.erb index eca7c6d6e..4a8d39b7a 100644 --- a/app/views/coinbase_items/_coinbase_item.html.erb +++ b/app/views/coinbase_items/_coinbase_item.html.erb @@ -1,6 +1,21 @@ <%# locals: (coinbase_item:) %> <%= tag.div id: dom_id(coinbase_item) do %> + <%# Compute unlinked count early so it's available for both menu and bottom section %> + <% unlinked_count = if defined?(@coinbase_unlinked_count_map) && @coinbase_unlinked_count_map + @coinbase_unlinked_count_map[coinbase_item.id] || 0 + else + begin + coinbase_item.coinbase_accounts + .left_joins(:account_provider) + .where(account_providers: { id: nil }) + .count + rescue => e + Rails.logger.warn("Coinbase card: unlinked_count fallback failed: #{e.class} - #{e.message}") + 0 + end + end %> +
@@ -55,15 +70,25 @@ href: settings_providers_path, frame: "_top" ) %> - <% elsif Rails.env.development? %> + <% else %> <%= icon( "refresh-cw", as_button: true, - href: sync_coinbase_item_path(coinbase_item) + href: sync_coinbase_item_path(coinbase_item), + disabled: coinbase_item.syncing? ) %> <% end %> <%= render DS::Menu.new do |menu| %> + <% if unlinked_count.to_i > 0 %> + <% menu.with_item( + variant: "link", + text: t(".import_wallets_menu"), + icon: "plus", + href: setup_accounts_coinbase_item_path(coinbase_item), + frame: :modal + ) %> + <% end %> <% menu.with_item( variant: "button", text: t(".delete"), @@ -93,20 +118,6 @@ provider_item: coinbase_item ) %> - <%# Compute unlinked Coinbase accounts (no AccountProvider link) %> - <% unlinked_count = if defined?(@coinbase_unlinked_count_map) && @coinbase_unlinked_count_map - @coinbase_unlinked_count_map[coinbase_item.id] || 0 - else - begin - coinbase_item.coinbase_accounts - .left_joins(:account_provider) - .where(account_providers: { id: nil }) - .count - rescue => e - 0 - end - end %> - <% if unlinked_count.to_i > 0 && coinbase_item.accounts.empty? %> <%# No accounts imported yet - show prominent setup prompt %>
@@ -120,16 +131,6 @@ frame: :modal ) %>
- <% elsif unlinked_count.to_i > 0 %> - <%# Some accounts imported, more available - show subtle link %> -
- <%= link_to setup_accounts_coinbase_item_path(coinbase_item), - data: { turbo_frame: :modal }, - class: "flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors" do %> - <%= icon "plus", size: "sm" %> - <%= t(".more_wallets_available", count: unlinked_count) %> - <% end %> -
<% elsif coinbase_item.accounts.empty? && coinbase_item.coinbase_accounts.none? %> <%# No coinbase_accounts at all - waiting for sync %>
diff --git a/app/views/settings/_section.html.erb b/app/views/settings/_section.html.erb index 0c9a4f586..bb5873839 100644 --- a/app/views/settings/_section.html.erb +++ b/app/views/settings/_section.html.erb @@ -1,6 +1,8 @@ -<%# locals: (title:, subtitle: nil, content:, collapsible: false, open: true) %> +<%# locals: (title:, subtitle: nil, content:, collapsible: false, open: true, auto_open_param: nil) %> <% if collapsible %> -
class="group bg-container shadow-border-xs rounded-xl p-4"> +
+ class="group bg-container shadow-border-xs rounded-xl p-4" + <%= "data-controller=\"auto-open\" data-auto-open-param-value=\"#{h(auto_open_param)}\"".html_safe if auto_open_param.present? %>>
<%= icon "chevron-right", class: "text-secondary group-open:transform group-open:rotate-90 transition-transform" %> diff --git a/app/views/settings/providers/_snaptrade_panel.html.erb b/app/views/settings/providers/_snaptrade_panel.html.erb index 572cd5d28..314213acc 100644 --- a/app/views/settings/providers/_snaptrade_panel.html.erb +++ b/app/views/settings/providers/_snaptrade_panel.html.erb @@ -54,31 +54,26 @@
<% if items&.any? %> <% item = items.first %> -
-
- <% if item.user_registered? %> -
-

- <%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %> - <% if item.unlinked_accounts_count > 0 %> - (<%= t("providers.snaptrade.needs_setup", count: item.unlinked_accounts_count) %>) - <% end %> -

- <% else %> -
-

<%= t("providers.snaptrade.status_needs_registration") %>

- <% end %> -
-
- <% if item.user_registered? %> -
- - <%= t("providers.snaptrade.manage_connections") %> - <%= icon "chevron-right", class: "w-3 h-3 transition-transform group-open:rotate-90" %> + data-lazy-load-url-value="<%= connections_snaptrade_item_path(item) %>" + data-lazy-load-auto-open-param-value="manage"> + +
+
+

+ <%= t("providers.snaptrade.status_connected", count: item.snaptrade_accounts.count) %> + <% if item.unlinked_accounts_count > 0 %> + (<%= t("providers.snaptrade.needs_setup", count: item.unlinked_accounts_count) %>) + <% end %> +

+
+ + <%= t("providers.snaptrade.manage_connections") %> + <%= icon "chevron-right", class: "w-3 h-3 transition-transform group-open:rotate-90" %> +
@@ -86,7 +81,6 @@ <%= t("providers.snaptrade.connection_limit_info") %>

- <%# Loading state - replaced by fetched content %>
<%= icon "loader-2", class: "w-4 h-4 animate-spin" %> <%= t("providers.snaptrade.loading_connections") %> @@ -96,6 +90,11 @@
+ <% else %> +
+
+

<%= t("providers.snaptrade.status_needs_registration") %>

+
<% end %> <% else %>
diff --git a/app/views/settings/providers/show.html.erb b/app/views/settings/providers/show.html.erb index 4a3bd6964..c7e922f68 100644 --- a/app/views/settings/providers/show.html.erb +++ b/app/views/settings/providers/show.html.erb @@ -67,7 +67,7 @@ <% end %> - <%= settings_section title: "SnapTrade (beta)", collapsible: true, open: false do %> + <%= settings_section title: "SnapTrade (beta)", collapsible: true, open: false, auto_open_param: "manage" do %> <%= render "settings/providers/snaptrade_panel" %> diff --git a/app/views/simplefin_items/_simplefin_item.html.erb b/app/views/simplefin_items/_simplefin_item.html.erb index 530841a2b..cf6c8da35 100644 --- a/app/views/simplefin_items/_simplefin_item.html.erb +++ b/app/views/simplefin_items/_simplefin_item.html.erb @@ -1,6 +1,21 @@ <%# locals: (simplefin_item:) %> <%= tag.div id: dom_id(simplefin_item) do %> + <%# Compute unlinked count early so it's available for both menu and bottom section %> + <% unlinked_count = if defined?(@simplefin_unlinked_count_map) && @simplefin_unlinked_count_map + @simplefin_unlinked_count_map[simplefin_item.id] || 0 + else + begin + simplefin_item.simplefin_accounts + .left_joins(:account, :account_provider) + .where(accounts: { id: nil }, account_providers: { id: nil }) + .count + rescue => e + Rails.logger.warn("SimpleFin card: unlinked_count fallback failed: #{e.class} - #{e.message}") + 0 + end + end %> +
@@ -16,31 +31,9 @@ <% end %>
- <%# Compute unlinked count early for badge display %> - <% header_unlinked_count = if defined?(@simplefin_unlinked_count_map) && @simplefin_unlinked_count_map - @simplefin_unlinked_count_map[simplefin_item.id] || 0 - else - begin - simplefin_item.simplefin_accounts - .left_joins(:account, :account_provider) - .where(accounts: { id: nil }, account_providers: { id: nil }) - .count - rescue => e - 0 - end - end %> -
<%= tag.p simplefin_item.name, class: "font-medium text-primary" %> - <% if header_unlinked_count.to_i > 0 %> - <%= link_to setup_accounts_simplefin_item_path(simplefin_item), - data: { turbo_frame: :modal }, - class: "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning hover:bg-warning/20 transition-colors" do %> - <%= icon "alert-circle", size: "xs" %> - <%= header_unlinked_count %> <%= header_unlinked_count == 1 ? "account" : "accounts" %> need setup - <% end %> - <% end %> <% if simplefin_item.scheduled_for_deletion? %>

<%= t(".deletion_in_progress") %>

<% end %> @@ -49,25 +42,25 @@

<%= simplefin_item.institution_summary %>

- <%# Extra inline badges from latest sync stats %> + <%# Extra inline badges from latest sync stats - only show warnings %> <% stats = (@simplefin_sync_stats_map || {})[simplefin_item.id] || {} %> - <% if stats.present? %> + <% has_warnings = stats["accounts_skipped"].to_i > 0 || + stats["rate_limited"].present? || + stats["rate_limited_at"].present? || + stats["total_errors"].to_i > 0 || + (stats["errors"].is_a?(Array) && stats["errors"].any?) %> + <% if has_warnings %>
- <% if stats["unlinked_accounts"].to_i > 0 %> - <%= render DS::Tooltip.new(text: "Accounts need setup", icon: "link-2", size: "sm") %> - Unlinked: <%= stats["unlinked_accounts"].to_i %> - <% end %> - <% if stats["accounts_skipped"].to_i > 0 %> - <%= render DS::Tooltip.new(text: "Some accounts were skipped due to errors during sync", icon: "alert-triangle", size: "sm", color: "warning") %> - Skipped: <%= stats["accounts_skipped"].to_i %> + <%= render DS::Tooltip.new(text: t(".accounts_skipped_tooltip"), icon: "alert-triangle", size: "sm", color: "warning") %> + <%= t(".accounts_skipped_label", count: stats["accounts_skipped"].to_i) %> <% end %> <% if stats["rate_limited"].present? || stats["rate_limited_at"].present? %> <% ts = stats["rate_limited_at"] %> <% ago = (ts.present? ? (begin; time_ago_in_words(Time.parse(ts)); rescue StandardError; nil; end) : nil) %> <%= render DS::Tooltip.new( - text: (ago ? "Rate limited (" + ago + " ago)" : "Rate limited recently"), + text: (ago ? t(".rate_limited_ago", time: ago) : t(".rate_limited_recently")), icon: "clock", size: "sm", color: "warning" @@ -80,10 +73,6 @@ <%= render DS::Tooltip.new(text: tooltip_text, icon: "alert-octagon", size: "sm", color: "warning") %> <% end %> <% end %> - - <% if stats["total_accounts"].to_i > 0 %> - Total: <%= stats["total_accounts"].to_i %> - <% end %>
<% end %> <% end %> @@ -163,15 +152,25 @@ href: edit_simplefin_item_path(simplefin_item), frame: "modal" ) %> - <% elsif Rails.env.development? %> + <% else %> <%= icon( "refresh-cw", as_button: true, - href: sync_simplefin_item_path(simplefin_item) + href: sync_simplefin_item_path(simplefin_item), + disabled: simplefin_item.syncing? ) %> <% end %> <%= render DS::Menu.new do |menu| %> + <% if unlinked_count.to_i > 0 %> + <% menu.with_item( + variant: "link", + text: t(".setup_accounts_menu"), + icon: "settings", + href: setup_accounts_simplefin_item_path(simplefin_item), + frame: :modal + ) %> + <% end %> <% menu.with_item( variant: "button", text: t(".delete"), @@ -205,23 +204,8 @@ institutions_count: simplefin_item.connected_institutions.size ) %> - <%# Compute unlinked SimpleFin accounts (no legacy account and no AccountProvider link) - # Prefer controller-provided map; fallback to a local query so the card stays accurate after Turbo broadcasts %> - <% unlinked_count = if defined?(@simplefin_unlinked_count_map) && @simplefin_unlinked_count_map - @simplefin_unlinked_count_map[simplefin_item.id] || 0 - else - begin - simplefin_item.simplefin_accounts - .left_joins(:account, :account_provider) - .where(accounts: { id: nil }, account_providers: { id: nil }) - .count - rescue => e - Rails.logger.warn("SimpleFin card: unlinked_count fallback failed: #{e.class} - #{e.message}") - 0 - end - end %> - - <% if unlinked_count.to_i > 0 %> + <% if unlinked_count.to_i > 0 && simplefin_item.accounts.empty? %> + <%# No accounts imported yet - show prominent setup prompt %>

<%= t(".setup_needed") %>

<%= t(".setup_description") %>

diff --git a/app/views/snaptrade_items/_snaptrade_item.html.erb b/app/views/snaptrade_items/_snaptrade_item.html.erb index ffa141edf..cc4e27ea1 100644 --- a/app/views/snaptrade_items/_snaptrade_item.html.erb +++ b/app/views/snaptrade_items/_snaptrade_item.html.erb @@ -6,12 +6,12 @@
<%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %> -
+
<% if snaptrade_item.logo.attached? %> <%= image_tag snaptrade_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %> <% else %>
- <%= tag.p snaptrade_item.name.first.upcase, class: "text-primary text-xs font-medium" %> + <%= tag.p snaptrade_item.name.first.upcase, class: "text-success text-xs font-medium" %>
<% end %>
@@ -21,14 +21,6 @@
<%= tag.p snaptrade_item.name, class: "font-medium text-primary" %> - <% if unlinked_count > 0 %> - <%= link_to setup_accounts_snaptrade_item_path(snaptrade_item), - data: { turbo_frame: :modal }, - class: "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-warning/10 text-warning hover:bg-warning/20 transition-colors" do %> - <%= icon "alert-circle", size: "xs" %> - <%= t(".accounts_need_setup", count: unlinked_count) %> - <% end %> - <% end %> <% if snaptrade_item.scheduled_for_deletion? %>

<%= t(".deletion_in_progress") %>

<% end %> @@ -89,6 +81,21 @@ icon: "plus", href: connect_snaptrade_item_path(snaptrade_item) ) %> + <% if unlinked_count > 0 %> + <% menu.with_item( + variant: "link", + text: t(".setup_accounts_menu"), + icon: "settings", + href: setup_accounts_snaptrade_item_path(snaptrade_item), + frame: :modal + ) %> + <% end %> + <% menu.with_item( + variant: "link", + text: t(".manage_connections"), + icon: "cable", + href: settings_providers_path(manage: "1") + ) %> <% menu.with_item( variant: "button", text: t(".delete"), @@ -105,13 +112,6 @@
<% if snaptrade_item.accounts.any? %> <%= render "accounts/index/account_groups", accounts: snaptrade_item.accounts %> -
- <%= link_to connect_snaptrade_item_path(snaptrade_item), - class: "text-sm text-secondary hover:text-primary flex items-center gap-1 transition-colors" do %> - <%= icon "plus", size: "sm" %> - <%= t(".add_another_brokerage") %> - <% end %> -
<% end %> <%# Sync summary (collapsible) - using shared ProviderSyncSummary component %> @@ -124,7 +124,8 @@ activities_pending: activities_pending ) %> - <% if unlinked_count > 0 %> + <% if unlinked_count > 0 && snaptrade_item.accounts.empty? %> + <%# No accounts imported yet - show prominent setup prompt %>

<%= t(".setup_needed") %>

<%= t(".setup_description") %>

diff --git a/config/locales/views/coinbase_items/en.yml b/config/locales/views/coinbase_items/en.yml index 9b8ee8c2f..3fec937e4 100644 --- a/config/locales/views/coinbase_items/en.yml +++ b/config/locales/views/coinbase_items/en.yml @@ -47,6 +47,7 @@ en: setup_needed: Wallets ready to import setup_description: Select which Coinbase wallets you want to track. setup_action: Import Wallets + import_wallets_menu: Import Wallets more_wallets_available: one: "%{count} more wallet available to import" other: "%{count} more wallets available to import" diff --git a/config/locales/views/simplefin_items/en.yml b/config/locales/views/simplefin_items/en.yml index 76f44d177..216f9cc2d 100644 --- a/config/locales/views/simplefin_items/en.yml +++ b/config/locales/views/simplefin_items/en.yml @@ -65,6 +65,14 @@ en: setup_needed: New accounts ready to set up setup_description: Choose account types for your newly imported SimpleFIN accounts. setup_action: Set Up New Accounts + setup_accounts_menu: Set Up Accounts + more_accounts_available: + one: "%{count} more account available to set up" + other: "%{count} more accounts available to set up" + accounts_skipped_tooltip: "Some accounts were skipped due to errors during sync" + accounts_skipped_label: "Skipped: %{count}" + rate_limited_ago: "Rate limited (%{time} ago)" + rate_limited_recently: "Rate limited recently" status: Last synced %{timestamp} ago status_never: Never synced status_with_summary: "Last synced %{timestamp} ago • %{summary}" diff --git a/config/locales/views/snaptrade_items/en.yml b/config/locales/views/snaptrade_items/en.yml index 44fc65721..779c8a93c 100644 --- a/config/locales/views/snaptrade_items/en.yml +++ b/config/locales/views/snaptrade_items/en.yml @@ -94,6 +94,11 @@ en: setup_needed: "Accounts need setup" setup_description: "Some accounts from SnapTrade need to be linked to Sure accounts." setup_action: "Setup Accounts" + setup_accounts_menu: "Set Up Accounts" + manage_connections: "Manage Connections" + more_accounts_available: + one: "%{count} more account available to set up" + other: "%{count} more accounts available to set up" no_accounts_title: "No accounts discovered" no_accounts_description: "Connect a brokerage to import your investment accounts."