Lunchflow fix (#307)

* Fix lunch flow pre-loading and UX

* Small UX fixes

- Proper closing of modal on cancel
- Preload on new account already

* Review comments

* Fix json error

* Delete .claude/settings.local.json

Signed-off-by: soky srm <sokysrm@gmail.com>

* Lunch Flow brand (again :-)

* FIX process only linked accounts

* FIX disable accounts with no name

* Fix string normalization

---------

Signed-off-by: soky srm <sokysrm@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
soky srm
2025-11-10 21:32:55 +01:00
committed by GitHub
parent eae532714b
commit c6771ebaab
15 changed files with 271 additions and 73 deletions

View File

@@ -11,6 +11,10 @@ class AccountsController < ApplicationController
render layout: "settings"
end
def new
@show_lunchflow_link = family.can_connect_lunchflow?
end
def sync_all
family.sync_later
redirect_to accounts_path, notice: "Syncing accounts..."

View File

@@ -69,31 +69,6 @@ module AccountableResource
@show_us_link = Current.family.can_connect_plaid_us?
@show_eu_link = Current.family.can_connect_plaid_eu?
@show_lunchflow_link = Current.family.can_connect_lunchflow?
# Preload Lunchflow accounts if available and cache them
if @show_lunchflow_link
cache_key = "lunchflow_accounts_#{Current.family.id}"
@lunchflow_accounts = Rails.cache.fetch(cache_key, expires_in: 5.minutes) do
begin
lunchflow_provider = Provider::LunchflowAdapter.build_provider
if lunchflow_provider.present?
accounts_data = lunchflow_provider.get_accounts
accounts_data[:accounts] || []
else
[]
end
rescue Provider::Lunchflow::LunchflowError => e
Rails.logger.error("Failed to preload Lunchflow accounts: #{e.message}")
[]
rescue StandardError => e
Rails.logger.error("Unexpected error preloading Lunchflow accounts: #{e.class}: #{e.message}")
Rails.logger.error(e.backtrace.join("\n"))
[]
end
end
end
end
def accountable_type

View File

@@ -9,6 +9,43 @@ class LunchflowItemsController < ApplicationController
def show
end
# Preload Lunchflow accounts in background (async, non-blocking)
def preload_accounts
begin
cache_key = "lunchflow_accounts_#{Current.family.id}"
# Check if already cached
cached_accounts = Rails.cache.read(cache_key)
if cached_accounts.present?
render json: { success: true, has_accounts: cached_accounts.any?, cached: true }
return
end
# Fetch from API
lunchflow_provider = Provider::LunchflowAdapter.build_provider
unless lunchflow_provider.present?
render json: { success: false, error: "no_api_key", has_accounts: false }
return
end
accounts_data = lunchflow_provider.get_accounts
available_accounts = accounts_data[:accounts] || []
# Cache the accounts for 5 minutes
Rails.cache.write(cache_key, available_accounts, expires_in: 5.minutes)
render json: { success: true, has_accounts: available_accounts.any?, cached: false }
rescue Provider::Lunchflow::LunchflowError => e
Rails.logger.error("Lunchflow preload error: #{e.message}")
render json: { success: false, error: e.message, has_accounts: false }
rescue StandardError => e
Rails.logger.error("Unexpected error preloading Lunchflow accounts: #{e.class}: #{e.message}")
render json: { success: false, error: "unexpected_error", has_accounts: false }
end
end
# Fetch available accounts from Lunchflow API and show selection UI
def select_accounts
begin
@@ -75,12 +112,20 @@ class LunchflowItemsController < ApplicationController
created_accounts = []
already_linked_accounts = []
invalid_accounts = []
selected_account_ids.each do |account_id|
# Find the account data from API response
account_data = accounts_data[:accounts].find { |acc| acc[:id].to_s == account_id.to_s }
next unless account_data
# Validate account name is not blank (required by Account model)
if account_data[:name].blank?
invalid_accounts << account_id
Rails.logger.warn "LunchflowItemsController - Skipping account #{account_id} with blank name"
next
end
# Create or find lunchflow_account
lunchflow_account = lunchflow_item.lunchflow_accounts.find_or_initialize_by(
account_id: account_id.to_s
@@ -117,7 +162,17 @@ class LunchflowItemsController < ApplicationController
lunchflow_item.sync_later if created_accounts.any?
# Build appropriate flash message
if created_accounts.any? && already_linked_accounts.any?
if invalid_accounts.any? && created_accounts.empty? && already_linked_accounts.empty?
# All selected accounts were invalid (blank names)
redirect_to new_account_path, alert: t(".invalid_account_names", count: invalid_accounts.count)
elsif invalid_accounts.any? && (created_accounts.any? || already_linked_accounts.any?)
# Some accounts were created/already linked, but some had invalid names
redirect_to return_to || accounts_path,
alert: t(".partial_invalid",
created_count: created_accounts.count,
already_linked_count: already_linked_accounts.count,
invalid_count: invalid_accounts.count)
elsif created_accounts.any? && already_linked_accounts.any?
redirect_to return_to || accounts_path,
notice: t(".partial_success",
created_count: created_accounts.count,
@@ -243,6 +298,12 @@ class LunchflowItemsController < ApplicationController
return
end
# Validate account name is not blank (required by Account model)
if account_data[:name].blank?
redirect_to accounts_path, alert: t(".invalid_account_name")
return
end
# Create or find lunchflow_account
lunchflow_account = lunchflow_item.lunchflow_accounts.find_or_initialize_by(
account_id: lunchflow_account_id.to_s

View File

@@ -0,0 +1,89 @@
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="lunchflow-preload"
export default class extends Controller {
static targets = ["link", "spinner"];
static values = {
accountableType: String,
returnTo: String,
};
connect() {
this.preloadAccounts();
}
async preloadAccounts() {
try {
// Show loading state if we have a link target (on method selector page)
if (this.hasLinkTarget) {
this.showLoading();
}
// Fetch accounts in background to populate cache
const url = new URL(
"/lunchflow_items/preload_accounts",
window.location.origin
);
if (this.hasAccountableTypeValue) {
url.searchParams.append("accountable_type", this.accountableTypeValue);
}
if (this.hasReturnToValue) {
url.searchParams.append("return_to", this.returnToValue);
}
const csrfToken = document.querySelector('[name="csrf-token"]');
const headers = {
Accept: "application/json",
};
if (csrfToken) {
headers["X-CSRF-Token"] = csrfToken.content;
}
const response = await fetch(url, { headers });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.success && data.has_accounts) {
// Accounts loaded successfully, enable the link
if (this.hasLinkTarget) {
this.hideLoading();
}
} else if (!data.has_accounts) {
// No accounts available, hide the link entirely
if (this.hasLinkTarget) {
this.linkTarget.style.display = "none";
}
} else {
// Error occurred
if (this.hasLinkTarget) {
this.hideLoading();
}
console.error("Failed to preload Lunchflow accounts:", data.error);
}
} catch (error) {
// On error, still enable the link so user can try
if (this.hasLinkTarget) {
this.hideLoading();
}
console.error("Error preloading Lunchflow accounts:", error);
}
}
showLoading() {
this.linkTarget.classList.add("pointer-events-none", "opacity-50");
if (this.hasSpinnerTarget) {
this.spinnerTarget.classList.remove("hidden");
}
}
hideLoading() {
this.linkTarget.classList.remove("pointer-events-none", "opacity-50");
if (this.hasSpinnerTarget) {
this.spinnerTarget.classList.add("hidden");
}
}
}

View File

@@ -37,7 +37,8 @@ class LunchflowItem < ApplicationRecord
return [] if lunchflow_accounts.empty?
results = []
lunchflow_accounts.joins(:account).each do |lunchflow_account|
# Only process accounts that are linked and have active status
lunchflow_accounts.joins(:account).merge(Account.visible).each do |lunchflow_account|
begin
result = LunchflowAccount::Processor.new(lunchflow_account).process
results << { lunchflow_account_id: lunchflow_account.id, success: true, result: result }
@@ -55,7 +56,8 @@ class LunchflowItem < ApplicationRecord
return [] if accounts.empty?
results = []
accounts.each do |account|
# Only schedule syncs for active accounts
accounts.visible.each do |account|
begin
account.sync_later(
parent_sync: parent_sync,

View File

@@ -24,31 +24,39 @@ class LunchflowItem::Importer
# Continue with import even if snapshot storage fails
end
# Step 2: Import accounts
accounts_imported = 0
# Step 2: Update only previously selected accounts (don't create new ones)
accounts_updated = 0
accounts_failed = 0
if accounts_data[:accounts].present?
# Get all existing lunchflow account IDs for this item (normalize to strings for comparison)
existing_account_ids = lunchflow_item.lunchflow_accounts.pluck(:account_id).map(&:to_s)
accounts_data[:accounts].each do |account_data|
account_id = account_data[:id]&.to_s
next unless account_id.present?
# Only update if this account was previously selected (exists in our DB)
next unless existing_account_ids.include?(account_id)
begin
import_account(account_data)
accounts_imported += 1
accounts_updated += 1
rescue => e
accounts_failed += 1
account_id = account_data[:id] || "unknown"
Rails.logger.error "LunchflowItem::Importer - Failed to import account #{account_id}: #{e.message}"
# Continue importing other accounts even if one fails
Rails.logger.error "LunchflowItem::Importer - Failed to update account #{account_id}: #{e.message}"
# Continue updating other accounts even if one fails
end
end
end
Rails.logger.info "LunchflowItem::Importer - Imported #{accounts_imported} accounts (#{accounts_failed} failed)"
Rails.logger.info "LunchflowItem::Importer - Updated #{accounts_updated} accounts (#{accounts_failed} failed)"
# Step 3: Fetch transactions for each account
# Step 3: Fetch transactions only for linked accounts with active status
transactions_imported = 0
transactions_failed = 0
lunchflow_item.lunchflow_accounts.each do |lunchflow_account|
lunchflow_item.lunchflow_accounts.joins(:account).merge(Account.visible).each do |lunchflow_account|
begin
result = fetch_and_store_transactions(lunchflow_account)
if result[:success]
@@ -63,11 +71,11 @@ class LunchflowItem::Importer
end
end
Rails.logger.info "LunchflowItem::Importer - Completed import for item #{lunchflow_item.id}: #{accounts_imported} accounts, #{transactions_imported} transactions"
Rails.logger.info "LunchflowItem::Importer - Completed import for item #{lunchflow_item.id}: #{accounts_updated} accounts updated, #{transactions_imported} transactions"
{
success: accounts_failed == 0 && transactions_failed == 0,
accounts_imported: accounts_imported,
accounts_updated: accounts_updated,
accounts_failed: accounts_failed,
transactions_imported: transactions_imported,
transactions_failed: transactions_failed
@@ -123,16 +131,23 @@ class LunchflowItem::Importer
account_id = account_data[:id]
# Validate required account_id to prevent duplicate creation
# Validate required account_id
if account_id.blank?
Rails.logger.warn "LunchflowItem::Importer - Skipping account with missing ID"
raise ArgumentError, "Account ID is required"
end
lunchflow_account = lunchflow_item.lunchflow_accounts.find_or_initialize_by(
# Only find existing accounts, don't create new ones during sync
lunchflow_account = lunchflow_item.lunchflow_accounts.find_by(
account_id: account_id.to_s
)
# Skip if account wasn't previously selected
unless lunchflow_account
Rails.logger.debug "LunchflowItem::Importer - Skipping unselected account #{account_id}"
return
end
begin
lunchflow_account.upsert_lunchflow_snapshot!(account_data)
lunchflow_account.save!

View File

@@ -13,7 +13,7 @@ class LunchflowItem::Syncer
# Phase 2: Check account setup status and collect sync statistics
sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text)
total_accounts = lunchflow_item.lunchflow_accounts.count
linked_accounts = lunchflow_item.lunchflow_accounts.joins(:account)
linked_accounts = lunchflow_item.lunchflow_accounts.joins(:account).merge(Account.visible)
unlinked_accounts = lunchflow_item.lunchflow_accounts.includes(:account).where(accounts: { id: nil })
# Store sync statistics for display

View File

@@ -21,10 +21,10 @@ class Provider::Lunchflow
handle_response(response)
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.error "Lunchflow API: GET /accounts failed: #{e.class}: #{e.message}"
Rails.logger.error "Lunch Flow API: GET /accounts failed: #{e.class}: #{e.message}"
raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed)
rescue => e
Rails.logger.error "Lunchflow API: Unexpected error during GET /accounts: #{e.class}: #{e.message}"
Rails.logger.error "Lunch Flow API: Unexpected error during GET /accounts: #{e.class}: #{e.message}"
raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed)
end
@@ -52,10 +52,10 @@ class Provider::Lunchflow
handle_response(response)
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.error "Lunchflow API: GET #{path} failed: #{e.class}: #{e.message}"
Rails.logger.error "Lunch Flow API: GET #{path} failed: #{e.class}: #{e.message}"
raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed)
rescue => e
Rails.logger.error "Lunchflow API: Unexpected error during GET #{path}: #{e.class}: #{e.message}"
Rails.logger.error "Lunch Flow API: Unexpected error during GET #{path}: #{e.class}: #{e.message}"
raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed)
end
@@ -71,10 +71,10 @@ class Provider::Lunchflow
handle_response(response)
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.error "Lunchflow API: GET #{path} failed: #{e.class}: #{e.message}"
Rails.logger.error "Lunch Flow API: GET #{path} failed: #{e.class}: #{e.message}"
raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed)
rescue => e
Rails.logger.error "Lunchflow API: Unexpected error during GET #{path}: #{e.class}: #{e.message}"
Rails.logger.error "Lunch Flow API: Unexpected error during GET #{path}: #{e.class}: #{e.message}"
raise LunchflowError.new("Exception during GET request: #{e.message}", :request_failed)
end
@@ -93,7 +93,7 @@ class Provider::Lunchflow
when 200
JSON.parse(response.body, symbolize_names: true)
when 400
Rails.logger.error "Lunchflow API: Bad request - #{response.body}"
Rails.logger.error "Lunch Flow API: Bad request - #{response.body}"
raise LunchflowError.new("Bad request to Lunchflow API: #{response.body}", :bad_request)
when 401
raise LunchflowError.new("Invalid API key", :unauthorized)
@@ -104,7 +104,7 @@ class Provider::Lunchflow
when 429
raise LunchflowError.new("Rate limit exceeded. Please try again later.", :rate_limited)
else
Rails.logger.error "Lunchflow API: Unexpected response - Code: #{response.code}, Body: #{response.body}"
Rails.logger.error "Lunch Flow API: Unexpected response - Code: #{response.code}, Body: #{response.body}"
raise LunchflowError.new("Failed to fetch data: #{response.code} #{response.message} - #{response.body}", :fetch_failed)
end
end

View File

@@ -6,12 +6,12 @@ class Provider::LunchflowAdapter < Provider::Base
# Register this adapter with the factory
Provider::Factory.register("LunchflowAccount", self)
# Configuration for Lunchflow
# Configuration for Lunch Flow
configure do
description <<~DESC
Setup instructions:
1. Visit [Lunchflow](https://www.lunchflow.app) to get your API key
2. Enter your API key below to enable Lunchflow bank data sync
1. Visit [Lunch Flow](https://www.lunchflow.app) to get your API key
2. Enter your API key below to enable Lunch Flow bank data sync
3. Choose the appropriate environment (production or staging)
DESC
@@ -20,21 +20,21 @@ class Provider::LunchflowAdapter < Provider::Base
required: true,
secret: true,
env_key: "LUNCHFLOW_API_KEY",
description: "Your Lunchflow API key for authentication"
description: "Your Lunch Flow API key for authentication"
field :base_url,
label: "Base URL",
required: false,
env_key: "LUNCHFLOW_BASE_URL",
default: "https://lunchflow.app/api/v1",
description: "Base URL for Lunchflow API"
description: "Base URL for Lunch Flow API"
end
def provider_name
"lunchflow"
end
# Build a Lunchflow provider instance with configured credentials
# Build a Lunch Flow provider instance with configured credentials
# @return [Provider::Lunchflow, nil] Returns nil if API key is not configured
def self.build_provider
api_key = config_value(:api_key)
@@ -46,7 +46,7 @@ class Provider::LunchflowAdapter < Provider::Base
# Reload Lunchflow configuration when settings are updated
def self.reload_configuration
# Lunchflow doesn't need to configure Rails.application.config like Plaid does
# Lunch Flow doesn't need to configure Rails.application.config like Plaid does
# The configuration is read dynamically via config_value(:api_key) and config_value(:base_url)
# This method exists to be called by the settings controller after updates
# No action needed here since values are fetched on-demand
@@ -65,7 +65,7 @@ class Provider::LunchflowAdapter < Provider::Base
end
def institution_domain
# Lunchflow may provide institution metadata in account data
# Lunch Flow may provide institution metadata in account data
metadata = provider_account.institution_metadata
return nil unless metadata.present?
@@ -77,7 +77,7 @@ class Provider::LunchflowAdapter < Provider::Base
begin
domain = URI.parse(url).host&.gsub(/^www\./, "")
rescue URI::InvalidURIError
Rails.logger.warn("Invalid institution URL for Lunchflow account #{provider_account.id}: #{url}")
Rails.logger.warn("Invalid institution URL for Lunch Flow account #{provider_account.id}: #{url}")
end
end

View File

@@ -1,5 +1,8 @@
<%= render layout: "accounts/new/container", locals: { title: t(".title") } do %>
<div class="text-sm">
<div class="text-sm"
<% if @show_lunchflow_link %>
data-controller="lunchflow-preload"
<% end %>>
<% unless params[:classification] == "liability" %>
<%= render "account_type", accountable: Depository.new %>
<%= render "account_type", accountable: Investment.new %>

View File

@@ -1,7 +1,14 @@
<%# locals: (path:, accountable_type:, show_us_link: true, show_eu_link: true, show_lunchflow_link: false) %>
<%= render layout: "accounts/new/container", locals: { title: t(".title"), back_path: new_account_path } do %>
<div class="text-sm">
<div class="text-sm"
<% if show_lunchflow_link %>
data-controller="lunchflow-preload"
data-lunchflow-preload-accountable-type-value="<%= h(accountable_type) %>"
<% if params[:return_to] %>
data-lunchflow-preload-return-to-value="<%= h(params[:return_to]) %>"
<% end %>
<% end %>>
<%= link_to path, class: "flex items-center gap-4 w-full text-center text-primary focus:outline-hidden focus:bg-surface border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-surface rounded-lg p-2" do %>
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
<%= icon("keyboard") %>
@@ -39,12 +46,18 @@
class: "text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-surface border border-transparent focus:border focus:border-primary px-2 hover:bg-surface rounded-lg p-2",
data: {
turbo_frame: "modal",
turbo_action: "advance"
turbo_action: "advance",
lunchflow_preload_target: "link"
} do %>
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
<%= icon("link-2") %>
</span>
<%= t("accounts.new.method_selector.lunchflow_entry") %>
<span class="flex items-center gap-2">
<%= t("accounts.new.method_selector.lunchflow_entry") %>
<span data-lunchflow-preload-target="spinner" class="hidden">
<%= icon("loader-2", class: "animate-spin") %>
</span>
</span>
<% end %>
<% end %>

View File

@@ -15,15 +15,28 @@
<div class="space-y-2">
<% @available_accounts.each do |account| %>
<label class="flex items-start gap-3 p-3 border border-primary rounded-lg hover:bg-subtle cursor-pointer transition-colors">
<%= check_box_tag "account_ids[]", account[:id], false, class: "mt-1" %>
<% has_blank_name = account[:name].blank? %>
<label class="flex items-start gap-3 p-3 border <%= has_blank_name ? 'border-error bg-error/5' : 'border-primary' %> rounded-lg <%= has_blank_name ? 'cursor-not-allowed opacity-60' : 'hover:bg-subtle cursor-pointer' %> transition-colors">
<%= check_box_tag "account_ids[]", account[:id], false, disabled: has_blank_name, class: "mt-1" %>
<div class="flex-1">
<div class="font-medium text-sm text-primary">
<%= account[:name] %>
<div class="font-medium text-sm <%= has_blank_name ? 'text-error' : 'text-primary' %>">
<% if has_blank_name %>
<%= t(".no_name_placeholder") %>
<% else %>
<%= account[:name] %>
<% end %>
</div>
<div class="text-xs text-secondary mt-1">
<% if account[:iban].present? %>
<%= account[:iban] %> •
<% end %>
<%= account[:institution_name] %> • <%= account[:currency] %> • <%= account[:status] %>
</div>
<% if has_blank_name %>
<div class="text-xs text-error mt-1">
<%= t(".configure_name_in_lunchflow") %>
</div>
<% end %>
</div>
</label>
<% end %>
@@ -32,7 +45,7 @@
<div class="flex gap-2 justify-end pt-4">
<%= link_to t(".cancel"), @return_to || new_account_path,
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover",
data: { turbo_frame: "_top" } %>
data: { turbo_frame: "_top", action: "DS--dialog#close" } %>
<%= submit_tag t(".link_accounts"),
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover disabled:button-bg-disabled" %>
</div>

View File

@@ -15,15 +15,28 @@
<div class="space-y-2">
<% @available_accounts.each do |account| %>
<label class="flex items-start gap-3 p-3 border border-primary rounded-lg hover:bg-subtle cursor-pointer transition-colors">
<%= radio_button_tag "lunchflow_account_id", account[:id], false, class: "mt-1" %>
<% has_blank_name = account[:name].blank? %>
<label class="flex items-start gap-3 p-3 border <%= has_blank_name ? 'border-error bg-error/5' : 'border-primary' %> rounded-lg <%= has_blank_name ? 'cursor-not-allowed opacity-60' : 'hover:bg-subtle cursor-pointer' %> transition-colors">
<%= radio_button_tag "lunchflow_account_id", account[:id], false, disabled: has_blank_name, class: "mt-1" %>
<div class="flex-1">
<div class="font-medium text-sm text-primary">
<%= account[:name] %>
<div class="font-medium text-sm <%= has_blank_name ? 'text-error' : 'text-primary' %>">
<% if has_blank_name %>
<%= t(".no_name_placeholder") %>
<% else %>
<%= account[:name] %>
<% end %>
</div>
<div class="text-xs text-secondary mt-1">
<% if account[:iban].present? %>
<%= account[:iban] %> •
<% end %>
<%= account[:institution_name] %> • <%= account[:currency] %> • <%= account[:status] %>
</div>
<% if has_blank_name %>
<div class="text-xs text-error mt-1">
<%= t(".configure_name_in_lunchflow") %>
</div>
<% end %>
</div>
</label>
<% end %>
@@ -32,7 +45,7 @@
<div class="flex gap-2 justify-end pt-4">
<%= link_to t(".cancel"), @return_to || accounts_path,
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-primary button-bg-secondary hover:button-bg-secondary-hover",
data: { turbo_frame: "_top" } %>
data: { turbo_frame: "_top", action: "DS--dialog#close" } %>
<%= submit_tag t(".link_account"),
class: "inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-lg text-inverse bg-inverse hover:bg-inverse-hover disabled:button-bg-disabled" %>
</div>