diff --git a/.cursor/rules/project-conventions.mdc b/.cursor/rules/project-conventions.mdc
index 33906c22..5a312840 100644
--- a/.cursor/rules/project-conventions.mdc
+++ b/.cursor/rules/project-conventions.mdc
@@ -53,6 +53,7 @@ This codebase adopts a "skinny controller, fat models" convention. Furthermore,
- Use Turbo streams to enhance functionality, but do not solely depend on it
- Format currencies, numbers, dates, and other values server-side, then pass to Stimulus controllers for display only
- Keep client-side code for where it truly shines. For example, @bulk_select_controller.js is a case where server-side solutions would degrade the user experience significantly. When bulk-selecting entries, client-side solutions are the way to go and Stimulus provides the right toolset to achieve this.
+- Always use the `icon` helper in [application_helper.rb](mdc:app/helpers/application_helper.rb) for icons. NEVER use `lucide_icon` helper directly.
The Hotwire suite (Turbo/Stimulus) works very well with these native elements and we optimize for this.
diff --git a/Gemfile b/Gemfile
index f5d73296..26b29fc9 100644
--- a/Gemfile
+++ b/Gemfile
@@ -19,9 +19,11 @@ gem "propshaft"
gem "tailwindcss-rails"
gem "lucide-rails", github: "maybe-finance/lucide-rails"
-# Hotwire
+# Hotwire + UI
gem "stimulus-rails"
gem "turbo-rails"
+gem "view_component"
+gem "lookbook", ">= 2.3.7"
gem "hotwire_combobox"
diff --git a/Gemfile.lock b/Gemfile.lock
index 9fb79093..2e64fa11 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -85,8 +85,8 @@ GEM
public_suffix (>= 2.0.2, < 7.0)
ast (2.4.3)
aws-eventstream (1.3.2)
- aws-partitions (1.1073.0)
- aws-sdk-core (3.221.0)
+ aws-partitions (1.1093.0)
+ aws-sdk-core (3.222.3)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -133,21 +133,23 @@ GEM
logger (~> 1.5)
chunky_png (1.4.0)
climate_control (1.2.0)
- concurrent-ruby (1.3.5)
- connection_pool (2.5.2)
+ concurrent-ruby (1.3.4)
+ connection_pool (2.5.3)
crack (1.0.0)
bigdecimal
rexml
crass (1.0.6)
+ css_parser (1.21.1)
+ addressable
csv (3.3.4)
date (3.4.1)
debug (1.10.0)
irb (~> 1.10)
reline (>= 0.3.8)
docile (1.4.1)
- dotenv (3.1.7)
- dotenv-rails (3.1.7)
- dotenv (= 3.1.7)
+ dotenv (3.1.8)
+ dotenv-rails (3.1.8)
+ dotenv (= 3.1.8)
railties (>= 6.1)
drb (2.2.1)
erb_lint (0.9.0)
@@ -171,14 +173,14 @@ GEM
net-http (>= 0.5.0)
faraday-retry (2.3.1)
faraday (~> 2.0)
- ffi (1.17.1-aarch64-linux-gnu)
- ffi (1.17.1-aarch64-linux-musl)
- ffi (1.17.1-arm-linux-gnu)
- ffi (1.17.1-arm-linux-musl)
- ffi (1.17.1-arm64-darwin)
- ffi (1.17.1-x86_64-darwin)
- ffi (1.17.1-x86_64-linux-gnu)
- ffi (1.17.1-x86_64-linux-musl)
+ ffi (1.17.2-aarch64-linux-gnu)
+ ffi (1.17.2-aarch64-linux-musl)
+ ffi (1.17.2-arm-linux-gnu)
+ ffi (1.17.2-arm-linux-musl)
+ ffi (1.17.2-arm64-darwin)
+ ffi (1.17.2-x86_64-darwin)
+ ffi (1.17.2-x86_64-linux-gnu)
+ ffi (1.17.2-x86_64-linux-musl)
foreman (0.88.1)
globalid (1.2.1)
activesupport (>= 6.1)
@@ -194,6 +196,8 @@ GEM
rails (>= 7.0.7.2)
stimulus-rails (>= 1.2)
turbo-rails (>= 1.2)
+ htmlbeautifier (1.4.3)
+ htmlentities (4.3.4)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
i18n-tasks (1.0.15)
@@ -221,7 +225,7 @@ GEM
activesupport (> 4.0)
jwt (~> 2.0)
io-console (0.8.0)
- irb (1.15.1)
+ irb (1.15.2)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
@@ -255,6 +259,18 @@ GEM
loofah (2.24.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
+ lookbook (2.3.9)
+ activemodel
+ css_parser
+ htmlbeautifier (~> 1.3)
+ htmlentities (~> 4.3.4)
+ marcel (~> 1.0)
+ railties (>= 5.0)
+ redcarpet (~> 3.5)
+ rouge (>= 3.26, < 5.0)
+ view_component (>= 2.0)
+ yard (~> 0.9)
+ zeitwerk (~> 2.5)
mail (2.8.1)
mini_mime (>= 0.1.1)
net-imap
@@ -262,6 +278,7 @@ GEM
net-smtp
marcel (1.0.4)
matrix (0.4.2)
+ method_source (1.1.0)
mini_magick (5.2.0)
benchmark
logger
@@ -273,7 +290,7 @@ GEM
multipart-post (2.4.1)
net-http (0.6.0)
uri
- net-imap (0.5.6)
+ net-imap (0.5.7)
date
net-protocol
net-pop (0.1.2)
@@ -283,28 +300,28 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.4)
- nokogiri (1.18.6-aarch64-linux-gnu)
+ nokogiri (1.18.8-aarch64-linux-gnu)
racc (~> 1.4)
- nokogiri (1.18.6-aarch64-linux-musl)
+ nokogiri (1.18.8-aarch64-linux-musl)
racc (~> 1.4)
- nokogiri (1.18.6-arm-linux-gnu)
+ nokogiri (1.18.8-arm-linux-gnu)
racc (~> 1.4)
- nokogiri (1.18.6-arm-linux-musl)
+ nokogiri (1.18.8-arm-linux-musl)
racc (~> 1.4)
- nokogiri (1.18.6-arm64-darwin)
+ nokogiri (1.18.8-arm64-darwin)
racc (~> 1.4)
- nokogiri (1.18.6-x86_64-darwin)
+ nokogiri (1.18.8-x86_64-darwin)
racc (~> 1.4)
- nokogiri (1.18.6-x86_64-linux-gnu)
+ nokogiri (1.18.8-x86_64-linux-gnu)
racc (~> 1.4)
- nokogiri (1.18.6-x86_64-linux-musl)
+ nokogiri (1.18.8-x86_64-linux-musl)
racc (~> 1.4)
octokit (10.0.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
pagy (9.3.4)
- parallel (1.26.3)
- parser (3.3.7.2)
+ parallel (1.27.0)
+ parser (3.3.8.0)
ast (~> 2.4.1)
racc
pg (1.5.9)
@@ -382,7 +399,7 @@ GEM
ffi (~> 1.0)
rbs (3.9.2)
logger
- rdoc (6.13.0)
+ rdoc (6.13.1)
psych (>= 4.0.0)
redcarpet (3.6.1)
redis (5.4.0)
@@ -390,15 +407,16 @@ GEM
redis-client (0.24.0)
connection_pool
regexp_parser (2.10.0)
- reline (0.6.0)
+ reline (0.6.1)
io-console (~> 0.5)
rexml (3.4.1)
rotp (6.3.0)
- rqrcode (3.0.0)
+ rouge (4.5.2)
+ rqrcode (3.1.0)
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.0.0)
- rubocop (1.74.0)
+ rubocop (1.75.4)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -406,20 +424,21 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
- rubocop-ast (>= 1.38.0, < 2.0)
+ rubocop-ast (>= 1.44.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
- rubocop-ast (1.41.0)
+ rubocop-ast (1.44.1)
parser (>= 3.3.7.2)
- rubocop-performance (1.24.0)
+ prism (~> 1.4)
+ rubocop-performance (1.25.0)
lint_roller (~> 1.1)
- rubocop (>= 1.72.1, < 2.0)
+ rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
- rubocop-rails (2.30.3)
+ rubocop-rails (2.31.0)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
- rubocop (>= 1.72.1, < 2.0)
+ rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-rails-omakase (1.1.0)
rubocop (>= 1.72)
@@ -479,18 +498,18 @@ GEM
sorbet-runtime (0.5.12043)
stimulus-rails (1.3.4)
railties (>= 6.0.0)
- stringio (3.1.5)
+ stringio (3.1.7)
stripe (15.0.0)
- tailwindcss-rails (4.2.1)
+ tailwindcss-rails (4.2.2)
railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0)
- tailwindcss-ruby (4.0.15)
- tailwindcss-ruby (4.0.15-aarch64-linux-gnu)
- tailwindcss-ruby (4.0.15-aarch64-linux-musl)
- tailwindcss-ruby (4.0.15-arm64-darwin)
- tailwindcss-ruby (4.0.15-x86_64-darwin)
- tailwindcss-ruby (4.0.15-x86_64-linux-gnu)
- tailwindcss-ruby (4.0.15-x86_64-linux-musl)
+ tailwindcss-ruby (4.1.4)
+ tailwindcss-ruby (4.1.4-aarch64-linux-gnu)
+ tailwindcss-ruby (4.1.4-aarch64-linux-musl)
+ tailwindcss-ruby (4.1.4-arm64-darwin)
+ tailwindcss-ruby (4.1.4-x86_64-darwin)
+ tailwindcss-ruby (4.1.4-x86_64-linux-gnu)
+ tailwindcss-ruby (4.1.4-x86_64-linux-musl)
terminal-table (4.0.0)
unicode-display_width (>= 1.1.1, < 4)
thor (1.3.2)
@@ -508,6 +527,10 @@ GEM
vcr (6.3.1)
base64
vernier (1.7.0)
+ view_component (3.22.0)
+ activesupport (>= 5.2.0, < 8.1)
+ concurrent-ruby (= 1.3.4)
+ method_source (~> 1.0)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
@@ -524,6 +547,7 @@ GEM
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
+ yard (0.9.37)
zeitwerk (2.7.2)
PLATFORMS
@@ -564,6 +588,7 @@ DEPENDENCIES
jwt
letter_opener
logtail-rails
+ lookbook (>= 2.3.7)
lucide-rails!
mocha
octokit
@@ -596,6 +621,7 @@ DEPENDENCIES
tzinfo-data
vcr
vernier
+ view_component
web-console
webmock
diff --git a/app/assets/tailwind/maybe-design-system.css b/app/assets/tailwind/maybe-design-system.css
index fc7a67ac..d29b5c04 100644
--- a/app/assets/tailwind/maybe-design-system.css
+++ b/app/assets/tailwind/maybe-design-system.css
@@ -5,6 +5,12 @@
One-off styling (3rd party overrides, etc.) should be done in the application.css file.
*/
+@import './maybe-design-system/background-utils.css';
+@import './maybe-design-system/foreground-utils.css';
+@import './maybe-design-system/text-utils.css';
+@import './maybe-design-system/border-utils.css';
+@import './maybe-design-system/component-utils.css';
+
@custom-variant theme-dark (&:where([data-theme=dark], [data-theme=dark] *));
@theme {
@@ -18,6 +24,7 @@
--color-success: var(--color-green-600);
--color-warning: var(--color-yellow-600);
--color-destructive: var(--color-red-600);
+ --color-shadow: --alpha(var(--color-black) / 6%);
/* Gray scale */
--color-gray-25: #FAFAFA;
@@ -231,262 +238,25 @@
}
}
-/* Custom shadow borders used for surfaces / containers */
-@utility shadow-border-xs {
- box-shadow: var(--shadow-xs), 0px 0px 0px 1px var(--color-alpha-black-50);
-}
-
-@utility shadow-border-sm {
- box-shadow: var(--shadow-sm), 0px 0px 0px 1px var(--color-alpha-black-50);
-}
-
-@utility shadow-border-md {
- box-shadow: var(--shadow-md), 0px 0px 0px 1px var(--color-alpha-black-50);
-}
-
-@utility shadow-border-lg {
- box-shadow: var(--shadow-lg), 0px 0px 0px 1px var(--color-alpha-black-50);
-}
-
-@utility shadow-border-xl {
- box-shadow: var(--shadow-xl), 0px 0px 0px 1px var(--color-alpha-black-50);
-}
-
-/* Design system color utilities */
-@utility text-primary {
- @apply text-gray-900;
-
- @variant theme-dark {
- @apply text-white;
- }
-}
-
-@utility text-secondary {
- @apply text-gray-500;
-
- @variant theme-dark {
- @apply text-gray-400;
- }
-}
-
-@utility text-subdued {
- @apply text-gray-400;
-
- @variant theme-dark {
- @apply text-gray-600;
- }
-}
-
-@utility text-link {
- @apply text-blue-600;
-
- @variant theme-dark {
- @apply text-blue-500;
- }
-}
-
-@utility bg-surface {
- @apply bg-gray-50;
-
- @variant theme-dark {
- @apply bg-black;
- }
-}
-
-@utility bg-surface-hover {
- @apply bg-gray-100;
-
- @variant theme-dark {
- @apply bg-gray-800;
- }
-}
-
-@utility bg-surface-inset {
- @apply bg-gray-100;
-
- @variant theme-dark {
- @apply bg-gray-900;
- }
-}
-
-@utility bg-surface-inset-hover {
- @apply bg-gray-200;
-
- @variant theme-dark {
- @apply bg-gray-800;
- }
-}
-
-@utility bg-container {
- @apply bg-white;
-
- @variant theme-dark {
- @apply bg-gray-900;
- }
-}
-
-@utility bg-container-hover {
- @apply bg-gray-50;
-
- @variant theme-dark {
- @apply bg-gray-800;
- }
-}
-
-@utility bg-container-inset {
- @apply bg-gray-50;
-
- @variant theme-dark {
- @apply bg-gray-800;
- }
-}
-
-@utility bg-container-inset-hover {
- @apply bg-gray-100;
-
- @variant theme-dark {
- @apply bg-gray-700;
- }
-}
-
-@utility bg-inverse {
- @apply bg-gray-800;
-
- @variant theme-dark {
- @apply bg-white;
- }
-}
-
-@utility bg-inverse-hover {
- @apply bg-gray-700;
-
- @variant theme-dark {
- @apply bg-gray-100;
- }
-}
-
-@utility bg-overlay {
- background-color: rgba(var(--color-gray-100), 0.5);
-
- @variant theme-dark {
- background-color: var(--color-alpha-black-900);
- }
-}
-
-@utility border-primary {
- @apply border-alpha-black-300;
-
- @variant theme-dark {
- @apply border-alpha-white-400;
- }
-}
-
-@utility border-secondary {
- @apply border-alpha-black-200;
-
- @variant theme-dark {
- @apply border-alpha-white-300;
- }
-}
-
-@utility border-tertiary {
- @apply border-alpha-black-100;
-
- @variant theme-dark {
- @apply border-alpha-white-200;
- }
-}
-
-@utility border-subdued {
- @apply border-alpha-black-50;
-
- @variant theme-dark {
- @apply border-alpha-white-100;
- }
-}
-
-@utility border-solid {
- @apply border-black;
-
- @variant theme-dark {
- @apply border-white;
- }
-}
-
-@utility border-destructive {
- @apply border-red-500;
-
- @variant theme-dark {
- @apply border-red-400;
- }
-}
-
-/* Foreground Colors */
-@utility fg-gray {
- @apply text-gray-500;
-
- @variant theme-dark {
- @apply text-gray-400;
- }
-}
-
-@utility fg-contrast {
- @apply text-gray-400;
-
- @variant theme-dark {
- @apply text-gray-500;
- }
-}
-
-@utility fg-inverse {
- @apply text-white;
-
- @variant theme-dark {
- @apply text-gray-900;
- }
-}
-
-@utility fg-primary {
- @apply text-gray-900;
-
- @variant theme-dark {
- @apply text-white;
- }
-}
-
-@utility fg-primary-variant {
- @apply text-gray-800;
-
- @variant theme-dark {
- @apply text-gray-50;
- }
-}
-
-@utility fg-secondary {
- @apply text-gray-50;
-
- @variant theme-dark {
- @apply text-gray-700;
- }
-}
-
-@utility fg-secondary-variant {
- @apply text-gray-100;
-
- @variant theme-dark {
- @apply text-gray-600;
- }
-}
-
-@utility fg-subdued {
- @apply text-gray-400;
-
- @variant theme-dark {
- @apply text-gray-500;
- }
+/* Specific override for strong tags in prose under dark mode */
+.prose:where([data-theme=dark], [data-theme=dark] *) strong {
+ color: theme(colors.white) !important;
}
@layer base {
+ [data-theme="dark"] {
+ --color-success: var(--color-green-500);
+ --color-warning: var(--color-yellow-400);
+ --color-destructive: var(--color-red-400);
+ --color-shadow: --alpha(#000000 / 8%);
+
+ --shadow-xs: 0px 1px 2px 0px --alpha(var(--color-white) / 8%);
+ --shadow-sm: 0px 1px 6px 0px --alpha(var(--color-white) / 8%);
+ --shadow-md: 0px 4px 8px -2px --alpha(var(--color-white) / 8%);
+ --shadow-lg: 0px 12px 16px -4px --alpha(var(--color-white) / 8%);
+ --shadow-xl: 0px 20px 24px -4px --alpha(var(--color-white) / 8%);
+ }
+
button {
@apply cursor-pointer focus-visible:outline-gray-900;
}
@@ -495,6 +265,12 @@
@apply text-gray-200;
}
+ /* We control the sizing through DialogComponent, so reset this value */
+ dialog:modal {
+ max-width: 100dvw;
+ max-height: 100dvh;
+ }
+
details>summary::-webkit-details-marker {
@apply hidden;
}
@@ -515,85 +291,6 @@
}
@layer components {
- /* Buttons */
- .btn {
- @apply inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:cursor-not-allowed focus:outline-gray-500;
- @apply transition-all duration-300;
- }
-
- .btn--primary {
- @apply button-bg-primary text-white disabled:text-gray-400;
- @apply hover:button-bg-primary-hover;
- @apply disabled:button-bg-disabled disabled:hover:button-bg-disabled;
-
- @variant theme-dark {
- @apply button-bg-primary fg-primary;
- @apply hover:button-bg-primary-hover;
- @apply disabled:button-bg-disabled disabled:hover:button-bg-disabled;
- }
- }
-
- .btn--secondary {
- @apply button-bg-secondary text-primary;
- @apply hover:button-bg-secondary-hover;
-
- @variant theme-dark {
- @apply text-white;
- background-color: var(--color-gray-700);
- &:hover {
- background-color: var(--color-gray-800);
- }
- }
- }
-
- .btn--outline {
- @apply border border-alpha-black-200 text-primary disabled:button-bg-disabled disabled:hover:button-bg-disabled disabled:text-gray-400;
-
- &:hover {
- background-color: var(--color-gray-100);
- }
-
- @variant theme-dark {
- @apply border-alpha-white-300 text-white disabled:button-bg-disabled disabled:hover:button-bg-disabled disabled:text-gray-600;
-
- &:hover {
- background-color: var(--color-gray-800);
- }
- }
- }
-
- .btn--ghost {
- @apply border border-transparent text-primary;
-
- &:hover {
- background-color: var(--color-gray-100)
- }
-
- @variant theme-dark {
- @apply fg-primary;
-
- &:hover {
- background-color: var(--color-gray-900);
- }
- }
- }
-
- .btn--outline-destructive {
- @apply border border-red-500 text-red-500 hover:bg-gray-50;
-
- @variant theme-dark {
- @apply border-red-400 text-red-400 hover:button-bg-destructive-hover;
- }
- }
-
- .btn--destructive {
- @apply button-bg-destructive text-white hover:button-bg-destructive-hover disabled:button-bg-disabled disabled:hover:button-bg-disabled disabled:text-red-400;
-
- @variant theme-dark {
- @apply button-bg-destructive text-white hover:button-bg-destructive-hover disabled:button-bg-disabled disabled:hover:button-bg-disabled;
- }
- }
-
/* Forms */
.form-field {
@apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-container border-secondary shadow-xs w-full;
@@ -706,19 +403,6 @@
}
}
- /* Switches */
- .switch {
- @apply block bg-gray-100 w-9 h-5 rounded-full cursor-pointer;
- @apply after:content-[''] after:block after:absolute after:top-0.5 after:left-0.5 after:bg-white after:w-4 after:h-4 after:rounded-full;
- @apply after:transition-transform after:duration-300 after:ease-in-out;
- @apply peer-checked:bg-green-600 peer-checked:after:translate-x-4;
- @apply transition-colors duration-300;
-
- @variant theme-dark {
- background-color: var(--color-gray-700);
- }
- }
-
/* Tooltips */
.tooltip {
@apply hidden absolute;
@@ -733,120 +417,5 @@
}
}
-@layer utilities {
- /* Specific override for strong tags in prose under dark mode */
- .prose:where([data-theme=dark], [data-theme=dark] *) strong {
- color: theme(colors.white) !important;
- }
-}
-/* Button Backgrounds */
-@utility button-bg-primary {
- @apply bg-gray-900;
- /* Maps to fg-primary light */
-
- @variant theme-dark {
- @apply bg-white;
- /* Maps to fg-primary dark */
- }
-}
-
-@utility button-bg-primary-hover {
- @apply bg-gray-800;
- /* Maps to fg-primary-variant light */
-
- @variant theme-dark {
- @apply bg-gray-50;
- /* Maps to fg-primary-variant dark */
- }
-}
-
-@utility button-bg-secondary {
- @apply bg-gray-50; /* Maps to fg-secondary light */
-
- @variant theme-dark {
- @apply bg-gray-700; /* Maps to fg-secondary dark */
- }
-}
-
-@utility button-bg-secondary-hover {
- @apply bg-gray-100; /* Maps to fg-secondary-variant light */
-
- @variant theme-dark {
- @apply bg-gray-600; /* Maps to fg-secondary-variant dark */
- }
-}
-
-@utility button-bg-disabled {
- @apply bg-gray-50;
-
- @variant theme-dark {
- @apply bg-gray-700;
- }
-}
-
-@utility button-bg-destructive {
- @apply bg-red-500;
-
- @variant theme-dark {
- @apply bg-red-400;
- }
-}
-
-@utility button-bg-destructive-hover {
- @apply bg-red-600;
-
- @variant theme-dark {
- @apply bg-red-500;
- }
-}
-
-@utility button-bg-ghost-hover {
- @apply bg-gray-50;
-
- @variant theme-dark {
- @apply bg-gray-800 fg-inverse;
- }
-}
-
-@utility button-bg-outline-hover {
- @apply bg-gray-100;
-
- @variant theme-dark {
- @apply bg-gray-700;
- }
-}
-
-/* Tab Styles */
-@utility tab-item-active {
- @apply bg-white;
-
- @variant theme-dark {
- @apply bg-gray-700;
- }
-}
-
-@utility tab-item-hover {
- @apply bg-gray-200;
-
- @variant theme-dark {
- @apply bg-gray-800;
- }
-}
-
-@utility tab-bg-group {
- @apply bg-gray-50;
-
- @variant theme-dark {
- @apply bg-alpha-black-700;
- }
-}
-
-@utility bg-nav-indicator {
- @apply bg-black;
-
- @variant theme-dark {
- @apply bg-white;
- }
-}
diff --git a/app/assets/tailwind/maybe-design-system/background-utils.css b/app/assets/tailwind/maybe-design-system/background-utils.css
new file mode 100644
index 00000000..1c7bc56a
--- /dev/null
+++ b/app/assets/tailwind/maybe-design-system/background-utils.css
@@ -0,0 +1,87 @@
+@utility bg-surface {
+ @apply bg-gray-50;
+
+ @variant theme-dark {
+ @apply bg-black;
+ }
+}
+
+@utility bg-surface-hover {
+ @apply bg-gray-100;
+
+ @variant theme-dark {
+ @apply bg-gray-900;
+ }
+}
+
+@utility bg-surface-inset {
+ @apply bg-gray-100;
+
+ @variant theme-dark {
+ @apply bg-gray-800;
+ }
+}
+
+@utility bg-surface-inset-hover {
+ @apply bg-gray-200;
+
+ @variant theme-dark {
+ @apply bg-gray-800;
+ }
+}
+
+@utility bg-container {
+ @apply bg-white;
+
+ @variant theme-dark {
+ @apply bg-gray-900;
+ }
+}
+
+@utility bg-container-hover {
+ @apply bg-gray-50;
+
+ @variant theme-dark {
+ @apply bg-gray-800;
+ }
+}
+
+@utility bg-container-inset {
+ @apply bg-gray-50;
+
+ @variant theme-dark {
+ @apply bg-gray-800;
+ }
+}
+
+@utility bg-container-inset-hover {
+ @apply bg-gray-100;
+
+ @variant theme-dark {
+ @apply bg-gray-700;
+ }
+}
+
+@utility bg-inverse {
+ @apply bg-gray-800;
+
+ @variant theme-dark {
+ @apply bg-white;
+ }
+}
+
+@utility bg-inverse-hover {
+ @apply bg-gray-700;
+
+ @variant theme-dark {
+ @apply bg-gray-100;
+ }
+}
+
+@utility bg-overlay {
+ background-color: --alpha(var(--color-gray-100) / 50%);
+
+ @variant theme-dark {
+ background-color: var(--color-alpha-black-900);
+ }
+}
\ No newline at end of file
diff --git a/app/assets/tailwind/maybe-design-system/border-utils.css b/app/assets/tailwind/maybe-design-system/border-utils.css
new file mode 100644
index 00000000..94c54a55
--- /dev/null
+++ b/app/assets/tailwind/maybe-design-system/border-utils.css
@@ -0,0 +1,88 @@
+/* Custom shadow borders used for surfaces / containers */
+@utility shadow-border-xs {
+ box-shadow: var(--shadow-xs), 0px 0px 0px 1px var(--color-alpha-black-50);
+
+ @variant theme-dark {
+ box-shadow: var(--shadow-xs), 0px 0px 0px 1px var(--color-alpha-white-50);
+ }
+}
+
+@utility shadow-border-sm {
+ box-shadow: var(--shadow-sm), 0px 0px 0px 1px var(--color-alpha-black-50);
+
+ @variant theme-dark {
+ box-shadow: var(--shadow-sm), 0px 0px 0px 1px var(--color-alpha-white-50);
+ }
+}
+
+@utility shadow-border-md {
+ box-shadow: var(--shadow-md), 0px 0px 0px 1px var(--color-alpha-black-50);
+
+ @variant theme-dark {
+ box-shadow: var(--shadow-md), 0px 0px 0px 1px var(--color-alpha-white-50);
+ }
+}
+
+@utility shadow-border-lg {
+ box-shadow: var(--shadow-lg), 0px 0px 0px 1px var(--color-alpha-black-50);
+
+ @variant theme-dark {
+ box-shadow: var(--shadow-lg), 0px 0px 0px 1px var(--color-alpha-white-50);
+ }
+}
+
+@utility shadow-border-xl {
+ box-shadow: var(--shadow-xl), 0px 0px 0px 1px var(--color-alpha-black-50);
+
+ @variant theme-dark {
+ box-shadow: var(--shadow-xl), 0px 0px 0px 1px var(--color-alpha-white-50);
+ }
+}
+
+@utility border-primary {
+ @apply border-alpha-black-300;
+
+ @variant theme-dark {
+ @apply border-alpha-white-400;
+ }
+}
+
+@utility border-secondary {
+ @apply border-alpha-black-200;
+
+ @variant theme-dark {
+ @apply border-alpha-white-300;
+ }
+}
+
+@utility border-tertiary {
+ @apply border-alpha-black-100;
+
+ @variant theme-dark {
+ @apply border-alpha-white-200;
+ }
+}
+
+@utility border-subdued {
+ @apply border-alpha-black-50;
+
+ @variant theme-dark {
+ @apply border-alpha-white-100;
+ }
+}
+
+@utility border-solid {
+ @apply border-black;
+
+ @variant theme-dark {
+ @apply border-white;
+ }
+}
+
+@utility border-destructive {
+ @apply border-red-500;
+
+ @variant theme-dark {
+ @apply border-red-400;
+ }
+}
\ No newline at end of file
diff --git a/app/assets/tailwind/maybe-design-system/component-utils.css b/app/assets/tailwind/maybe-design-system/component-utils.css
new file mode 100644
index 00000000..597b5092
--- /dev/null
+++ b/app/assets/tailwind/maybe-design-system/component-utils.css
@@ -0,0 +1,109 @@
+/* Button Backgrounds */
+@utility button-bg-primary {
+ @apply bg-gray-900;
+ /* Maps to fg-primary light */
+
+ @variant theme-dark {
+ @apply bg-white;
+ /* Maps to fg-primary dark */
+ }
+}
+
+@utility button-bg-primary-hover {
+ @apply bg-gray-800;
+ /* Maps to fg-primary-variant light */
+
+ @variant theme-dark {
+ @apply bg-gray-50;
+ /* Maps to fg-primary-variant dark */
+ }
+}
+
+@utility button-bg-secondary {
+ @apply bg-gray-50; /* Maps to fg-secondary light */
+
+ @variant theme-dark {
+ @apply bg-gray-700; /* Maps to fg-secondary dark */
+ }
+}
+
+@utility button-bg-secondary-hover {
+ @apply bg-gray-100; /* Maps to fg-secondary-variant light */
+
+ @variant theme-dark {
+ @apply bg-gray-600; /* Maps to fg-secondary-variant dark */
+ }
+}
+
+@utility button-bg-disabled {
+ @apply bg-gray-50;
+
+ @variant theme-dark {
+ @apply bg-gray-700;
+ }
+}
+
+@utility button-bg-destructive {
+ @apply bg-red-500;
+
+ @variant theme-dark {
+ @apply bg-red-400;
+ }
+}
+
+@utility button-bg-destructive-hover {
+ @apply bg-red-600;
+
+ @variant theme-dark {
+ @apply bg-red-500;
+ }
+}
+
+@utility button-bg-ghost-hover {
+ @apply bg-gray-50;
+
+ @variant theme-dark {
+ @apply bg-gray-800 fg-inverse;
+ }
+}
+
+@utility button-bg-outline-hover {
+ @apply bg-gray-100;
+
+ @variant theme-dark {
+ @apply bg-gray-700;
+ }
+}
+
+/* Tab Styles */
+@utility tab-item-active {
+ @apply bg-white;
+
+ @variant theme-dark {
+ @apply bg-gray-700;
+ }
+}
+
+@utility tab-item-hover {
+ @apply bg-gray-200;
+
+ @variant theme-dark {
+ @apply bg-gray-800;
+ }
+}
+
+@utility tab-bg-group {
+ @apply bg-gray-50;
+
+ @variant theme-dark {
+ @apply bg-alpha-black-700;
+ }
+}
+
+@utility bg-nav-indicator {
+ @apply bg-black;
+
+ @variant theme-dark {
+ @apply bg-white;
+ }
+}
\ No newline at end of file
diff --git a/app/assets/tailwind/maybe-design-system/foreground-utils.css b/app/assets/tailwind/maybe-design-system/foreground-utils.css
new file mode 100644
index 00000000..6bc76aa1
--- /dev/null
+++ b/app/assets/tailwind/maybe-design-system/foreground-utils.css
@@ -0,0 +1,63 @@
+@utility fg-gray {
+ @apply text-gray-500;
+
+ @variant theme-dark {
+ @apply text-gray-400;
+ }
+}
+
+@utility fg-contrast {
+ @apply text-gray-400;
+
+ @variant theme-dark {
+ @apply text-gray-500;
+ }
+}
+
+@utility fg-inverse {
+ @apply text-white;
+
+ @variant theme-dark {
+ @apply text-gray-900;
+ }
+}
+
+@utility fg-primary {
+ @apply text-gray-900;
+
+ @variant theme-dark {
+ @apply text-white;
+ }
+}
+
+@utility fg-primary-variant {
+ @apply text-gray-800;
+
+ @variant theme-dark {
+ @apply text-gray-50;
+ }
+}
+
+@utility fg-secondary {
+ @apply text-gray-50;
+
+ @variant theme-dark {
+ @apply text-gray-700;
+ }
+}
+
+@utility fg-secondary-variant {
+ @apply text-gray-100;
+
+ @variant theme-dark {
+ @apply text-gray-600;
+ }
+}
+
+@utility fg-subdued {
+ @apply text-gray-400;
+
+ @variant theme-dark {
+ @apply text-gray-500;
+ }
+}
\ No newline at end of file
diff --git a/app/assets/tailwind/maybe-design-system/text-utils.css b/app/assets/tailwind/maybe-design-system/text-utils.css
new file mode 100644
index 00000000..1a35dfff
--- /dev/null
+++ b/app/assets/tailwind/maybe-design-system/text-utils.css
@@ -0,0 +1,39 @@
+@utility text-primary {
+ @apply text-gray-900;
+
+ @variant theme-dark {
+ @apply text-white;
+ }
+}
+
+@utility text-inverse {
+ @apply text-white;
+
+ @variant theme-dark {
+ @apply text-gray-900;
+ }
+}
+
+@utility text-secondary {
+ @apply text-gray-500;
+
+ @variant theme-dark {
+ @apply text-gray-400;
+ }
+}
+
+@utility text-subdued {
+ @apply text-gray-400;
+
+ @variant theme-dark {
+ @apply text-gray-600;
+ }
+}
+
+@utility text-link {
+ @apply text-blue-600;
+
+ @variant theme-dark {
+ @apply text-blue-500;
+ }
+}
\ No newline at end of file
diff --git a/app/components/button_component.html.erb b/app/components/button_component.html.erb
new file mode 100644
index 00000000..e0c5e017
--- /dev/null
+++ b/app/components/button_component.html.erb
@@ -0,0 +1,13 @@
+<%= container do %>
+ <% if icon && (icon_position != "right") %>
+ <%= lucide_icon(icon, class: icon_classes) %>
+ <% end %>
+
+ <% unless icon_only? %>
+ <%= text %>
+ <% end %>
+
+ <% if icon && icon_position == "right" %>
+ <%= lucide_icon(icon, class: icon_classes) %>
+ <% end %>
+<% end %>
diff --git a/app/components/button_component.rb b/app/components/button_component.rb
new file mode 100644
index 00000000..36600a3c
--- /dev/null
+++ b/app/components/button_component.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+# An extension to `button_to` helper. All options are passed through to the `button_to` helper with some additional
+# options available.
+class ButtonComponent < ButtonishComponent
+ attr_reader :confirm
+
+ def initialize(confirm: nil, **opts)
+ super(**opts)
+ @confirm = confirm
+ end
+
+ def container(&block)
+ if href.present?
+ button_to(href, **merged_opts, &block)
+ else
+ content_tag(:button, **merged_opts, &block)
+ end
+ end
+
+ private
+ def merged_opts
+ merged_opts = opts.dup || {}
+ extra_classes = merged_opts.delete(:class)
+ href = merged_opts.delete(:href)
+ data = merged_opts.delete(:data) || {}
+
+ if confirm.present?
+ data = data.merge(turbo_confirm: confirm.to_data_attribute)
+ end
+
+ if frame.present?
+ data = data.merge(turbo_frame: frame)
+ end
+
+ merged_opts.merge(
+ class: class_names(container_classes, extra_classes),
+ data: data
+ )
+ end
+end
diff --git a/app/components/buttonish_component.rb b/app/components/buttonish_component.rb
new file mode 100644
index 00000000..4743616e
--- /dev/null
+++ b/app/components/buttonish_component.rb
@@ -0,0 +1,148 @@
+class ButtonishComponent < ViewComponent::Base
+ VARIANTS = {
+ primary: {
+ container_classes: "text-inverse bg-inverse hover:bg-inverse-hover disabled:bg-gray-500 theme-dark:disabled:bg-gray-400",
+ icon_classes: "fg-inverse"
+ },
+ secondary: {
+ container_classes: "text-secondary bg-gray-50 theme-dark:bg-gray-700 hover:bg-gray-100 theme-dark:hover:bg-gray-600 disabled:bg-gray-200 theme-dark:disabled:bg-gray-600",
+ icon_classes: "fg-primary"
+ },
+ destructive: {
+ container_classes: "text-inverse bg-red-500 theme-dark:bg-red-400 hover:bg-red-600 theme-dark:hover:bg-red-500 disabled:bg-red-200 theme-dark:disabled:bg-red-600",
+ icon_classes: "fg-white"
+ },
+ outline: {
+ container_classes: "text-primary border border-secondary bg-transparent hover:bg-surface-hover",
+ icon_classes: "fg-gray"
+ },
+ outline_destructive: {
+ container_classes: "text-destructive border border-secondary bg-transparent hover:bg-gray-100 theme-dark:hover:bg-gray-700",
+ icon_classes: "fg-gray"
+ },
+ ghost: {
+ container_classes: "text-primary bg-transparent hover:bg-gray-100 theme-dark:hover:bg-gray-700",
+ icon_classes: "fg-gray"
+ },
+ icon: {
+ container_classes: "hover:bg-gray-100 theme-dark:hover:bg-gray-700",
+ icon_classes: "fg-gray"
+ },
+ icon_inverse: {
+ container_classes: "bg-inverse hover:bg-inverse-hover",
+ icon_classes: "fg-inverse"
+ }
+ }.freeze
+
+ SIZES = {
+ sm: {
+ container_classes: "px-2 py-1",
+ icon_container_classes: "inline-flex items-center justify-center w-8 h-8",
+ radius_classes: "rounded-md",
+ text_classes: "text-sm",
+ icon_classes: "w-4 h-4"
+ },
+ md: {
+ container_classes: "px-3 py-2",
+ icon_container_classes: "inline-flex items-center justify-center w-9 h-9",
+ radius_classes: "rounded-lg",
+ text_classes: "text-sm",
+ icon_classes: "w-5 h-5"
+ },
+ lg: {
+ container_classes: "px-4 py-3",
+ icon_container_classes: "inline-flex items-center justify-center w-10 h-10",
+ radius_classes: "rounded-xl",
+ text_classes: "text-base",
+ icon_classes: "w-6 h-6"
+ }
+ }.freeze
+
+ attr_reader :variant, :size, :href, :icon, :icon_position, :text, :full_width, :extra_classes, :frame, :opts
+
+ def initialize(variant: :primary, size: :md, href: nil, text: nil, icon: nil, icon_position: :left, full_width: false, frame: nil, **opts)
+ @variant = variant.to_s.underscore.to_sym
+ @size = size.to_sym
+ @href = href
+ @icon = icon
+ @icon_position = icon_position.to_sym
+ @text = text
+ @full_width = full_width
+ @extra_classes = opts.delete(:class)
+ @frame = frame
+ @opts = opts
+ end
+
+ def call
+ raise NotImplementedError, "ButtonishComponent is an abstract class and cannot be instantiated directly."
+ end
+
+ def container_classes(override_classes = nil)
+ class_names(
+ "font-medium whitespace-nowrap",
+ merged_base_classes,
+ full_width ? "w-full justify-center" : nil,
+ container_size_classes,
+ size_data.dig(:text_classes),
+ variant_data.dig(:container_classes)
+ )
+ end
+
+ def container_size_classes
+ icon_only? ? size_data.dig(:icon_container_classes) : size_data.dig(:container_classes)
+ end
+
+ def icon_classes
+ class_names(
+ size_data.dig(:icon_classes),
+ variant_data.dig(:icon_classes)
+ )
+ end
+
+ def icon_only?
+ variant.in?([ :icon, :icon_inverse ])
+ end
+
+ private
+ def variant_data
+ self.class::VARIANTS.dig(variant)
+ end
+
+ def size_data
+ self.class::SIZES.dig(size)
+ end
+
+ # Make sure that user can override common classes like `hidden`
+ def merged_base_classes
+ base_display_classes = "inline-flex items-center gap-1"
+ base_radius_classes = size_data.dig(:radius_classes)
+
+ extra_classes_list = (extra_classes || "").split
+
+ has_display_override = extra_classes_list.any? { |c| permitted_display_override_classes.include?(c) }
+ has_radius_override = extra_classes_list.any? { |c| permitted_radius_override_classes.include?(c) }
+
+ base_classes = []
+
+ unless has_display_override
+ base_classes << base_display_classes
+ end
+
+ unless has_radius_override
+ base_classes << base_radius_classes
+ end
+
+ class_names(
+ base_classes,
+ extra_classes
+ )
+ end
+
+ def permitted_radius_override_classes
+ [ "rounded-full" ]
+ end
+
+ def permitted_display_override_classes
+ [ "hidden", "flex" ]
+ end
+end
diff --git a/app/components/dialog_component.html.erb b/app/components/dialog_component.html.erb
new file mode 100644
index 00000000..8b2a67be
--- /dev/null
+++ b/app/components/dialog_component.html.erb
@@ -0,0 +1,38 @@
+
+ <%= tag.dialog class: "w-full h-full bg-transparent theme-dark:backdrop:bg-alpha-black-900 backdrop:bg-overlay #{drawer? ? "lg:p-3" : "lg:p-1"}", **merged_opts do %>
+ <%= tag.div class: dialog_outer_classes do %>
+ <%= tag.div class: dialog_inner_classes, data: { dialog_target: "content" } do %>
+
+ <% if header? %>
+ <%= header %>
+ <% end %>
+
+ <% if body? %>
+
+ <%= body %>
+
+ <% if sections.any? %>
+
+ <% sections.each do |section| %>
+ <%= section %>
+ <% end %>
+
+ <% end %>
+
+ <% end %>
+
+ <%# Optional, for customizing dialogs %>
+ <%= content %>
+
+
+ <% if actions? %>
+
+ <% actions.each do |action| %>
+ <%= action %>
+ <% end %>
+
+ <% end %>
+ <% end %>
+ <% end %>
+ <% end %>
+
diff --git a/app/components/dialog_component.rb b/app/components/dialog_component.rb
new file mode 100644
index 00000000..16f04bdc
--- /dev/null
+++ b/app/components/dialog_component.rb
@@ -0,0 +1,105 @@
+class DialogComponent < ViewComponent::Base
+ renders_one :header, ->(title: nil, subtitle: nil, hide_close_icon: false, **opts, &block) do
+ content_tag(:header, class: "px-4 flex flex-col gap-2", **opts) do
+ title_div = content_tag(:div, class: "flex items-center justify-between gap-2") do
+ title = content_tag(:h2, title, class: class_names("font-medium text-primary", drawer? ? "text-lg" : "")) if title
+ close_icon = render ButtonComponent.new(variant: "icon", class: "ml-auto", icon: "x", tabindex: "-1", data: { action: "dialog#close" }) unless hide_close_icon
+ safe_join([ title, close_icon ].compact)
+ end
+
+ subtitle = content_tag(:p, subtitle, class: "text-sm text-secondary") if subtitle
+
+ block_content = capture(&block) if block
+
+ safe_join([ title_div, subtitle, block_content ].compact)
+ end
+ end
+
+ renders_one :body
+
+ renders_many :actions, ->(cancel_action: false, **button_opts) do
+ merged_opts = if cancel_action
+ button_opts.merge(type: "button", data: { action: "modal#close" })
+ else
+ button_opts
+ end
+
+ render ButtonComponent.new(**merged_opts)
+ end
+
+ renders_many :sections, ->(title:, **disclosure_opts, &block) do
+ render DisclosureComponent.new(title: title, align: :right, **disclosure_opts) do
+ block.call
+ end
+ end
+
+ attr_reader :variant, :auto_open, :reload_on_close, :frame, :width, :opts
+
+ VARIANTS = %w[modal drawer].freeze
+ WIDTHS = {
+ sm: "lg:max-w-[300px]",
+ md: "lg:max-w-[550px]",
+ lg: "lg:max-w-[700px]",
+ full: "lg:max-w-full"
+ }.freeze
+
+ def initialize(variant: "modal", auto_open: true, reload_on_close: false, frame: nil, width: "md", **opts)
+ @variant = variant.to_sym
+ @auto_open = auto_open
+ @reload_on_close = reload_on_close
+ @frame = frame
+ @width = width.to_sym
+ @opts = opts
+ end
+
+ def frame
+ @frame || variant
+ end
+
+ def dialog_outer_classes
+ variant_classes = if drawer?
+ "items-end justify-end"
+ else
+ "items-center justify-center"
+ end
+
+ class_names(
+ "flex h-full w-full",
+ variant_classes
+ )
+ end
+
+ def dialog_inner_classes
+ variant_classes = if drawer?
+ "lg:w-[550px] h-full"
+ else
+ class_names(
+ "max-h-full",
+ WIDTHS[width]
+ )
+ end
+
+ class_names(
+ "flex flex-col bg-container lg:rounded-xl lg:shadow-border-xs w-full overflow-hidden",
+ variant_classes
+ )
+ end
+
+ def merged_opts
+ merged_opts = opts.dup
+ data = merged_opts.delete(:data) || {}
+
+ data[:controller] = [ "dialog", "hotkey", data[:controller] ].compact.join(" ")
+ data[:dialog_auto_open_value] = auto_open
+ data[:dialog_reload_on_close_value] = reload_on_close
+ data[:action] = [ "mousedown->dialog#clickOutside", data[:action] ].compact.join(" ")
+ data[:hotkey] = "esc:dialog#close"
+ merged_opts[:data] = data
+
+ merged_opts
+ end
+
+ def drawer?
+ variant == :drawer
+ end
+end
diff --git a/app/javascript/controllers/modal_controller.js b/app/components/dialog_controller.js
similarity index 55%
rename from app/javascript/controllers/modal_controller.js
rename to app/components/dialog_controller.js
index 242c0247..8d746ad9 100644
--- a/app/javascript/controllers/modal_controller.js
+++ b/app/components/dialog_controller.js
@@ -1,19 +1,24 @@
import { Controller } from "@hotwired/stimulus";
-// Connects to data-controller="modal"
+// Connects to data-controller="dialog"
export default class extends Controller {
+ static targets = ["content"]
+
static values = {
+ autoOpen: { type: Boolean, default: false },
reloadOnClose: { type: Boolean, default: false },
};
connect() {
if (this.element.open) return;
- this.element.showModal();
+ if (this.autoOpenValue) {
+ this.element.showModal();
+ }
}
-
- // Hide the dialog when the user clicks outside of it
+
+ // If the user clicks anywhere outside of the visible content, close the dialog
clickOutside(e) {
- if (e.target === this.element) {
+ if (!this.contentTarget.contains(e.target)) {
this.close();
}
}
diff --git a/app/components/disclosure_component.html.erb b/app/components/disclosure_component.html.erb
new file mode 100644
index 00000000..554342d1
--- /dev/null
+++ b/app/components/disclosure_component.html.erb
@@ -0,0 +1,25 @@
+>
+ <%= tag.summary class: class_names(
+ "px-3 py-2 rounded-xl cursor-pointer flex items-center justify-between bg-surface"
+ ) do %>
+
+ <% if align == :left %>
+ <%= lucide_icon "chevron-right", class: "fg-gray w-5 h-5 group-open:transform group-open:rotate-90" %>
+ <% end %>
+
+ <%= tag.span class: class_names("font-medium", align == :left ? "text-sm text-primary" : "text-xs uppercase text-secondary") do %>
+ <%= title %>
+ <% end %>
+
+
+ <% if align == :right %>
+ <%= lucide_icon "chevron-down", class: "fg-gray w-5 h-5 group-open:transform group-open:rotate-180" %>
+ <% elsif summary_content? %>
+ <%= summary_content %>
+ <% end %>
+ <% end %>
+
+
+ <%= content %>
+
+
diff --git a/app/components/disclosure_component.rb b/app/components/disclosure_component.rb
new file mode 100644
index 00000000..013e3e9d
--- /dev/null
+++ b/app/components/disclosure_component.rb
@@ -0,0 +1,12 @@
+class DisclosureComponent < ViewComponent::Base
+ renders_one :summary_content
+
+ attr_reader :title, :align, :open, :opts
+
+ def initialize(title:, align: "right", open: false, **opts)
+ @title = title
+ @align = align.to_sym
+ @open = open
+ @opts = opts
+ end
+end
diff --git a/app/components/filled_icon_component.html.erb b/app/components/filled_icon_component.html.erb
new file mode 100644
index 00000000..49adba9e
--- /dev/null
+++ b/app/components/filled_icon_component.html.erb
@@ -0,0 +1,8 @@
+<%= tag.div style: transparent? ? container_styles : nil,
+ class: container_classes do %>
+ <% if icon %>
+ <%= helpers.icon(icon, size: icon_size, color: "current") %>
+ <% elsif text %>
+ <%= tag.span text.first, class: text_classes %>
+ <% end %>
+<% end %>
diff --git a/app/components/filled_icon_component.rb b/app/components/filled_icon_component.rb
new file mode 100644
index 00000000..e9c3ce68
--- /dev/null
+++ b/app/components/filled_icon_component.rb
@@ -0,0 +1,97 @@
+class FilledIconComponent < ViewComponent::Base
+ attr_reader :icon, :text, :hex_color, :size, :rounded, :variant
+
+ VARIANTS = %i[default text surface container].freeze
+
+ SIZES = {
+ sm: {
+ container_size: "w-6 h-6",
+ container_radius: "rounded-md",
+ icon_size: "sm",
+ text_size: "text-xs"
+ },
+ md: {
+ container_size: "w-8 h-8",
+ container_radius: "rounded-lg",
+ icon_size: "md",
+ text_size: "text-xs"
+ },
+ lg: {
+ container_size: "w-9 h-9",
+ container_radius: "rounded-xl",
+ icon_size: "lg",
+ text_size: "text-sm"
+ }
+ }.freeze
+
+ def initialize(variant: :default, icon: nil, text: nil, hex_color: nil, size: "md", rounded: false)
+ @variant = variant.to_sym
+ @icon = icon
+ @text = text
+ @hex_color = hex_color
+ @size = size.to_sym
+ @rounded = rounded
+ end
+
+ def container_classes
+ class_names(
+ "flex justify-center items-center",
+ size_classes,
+ radius_classes,
+ transparent? ? "border" : solid_bg_class
+ )
+ end
+
+ def icon_size
+ SIZES[size][:icon_size]
+ end
+
+ def text_classes
+ class_names(
+ "text-center font-medium uppercase",
+ SIZES[size][:text_size]
+ )
+ end
+
+ def container_styles
+ <<~STYLE.strip
+ background-color: #{transparent_bg_color};
+ border-color: #{transparent_border_color};
+ color: #{custom_fg_color};
+ STYLE
+ end
+
+ def transparent?
+ variant.in?(%i[default text])
+ end
+
+ private
+ def solid_bg_class
+ case variant
+ when :surface
+ "bg-surface-inset"
+ when :container
+ "bg-container-inset"
+ end
+ end
+
+ def size_classes
+ SIZES[size][:container_size]
+ end
+
+ def radius_classes
+ rounded ? "rounded-full" : SIZES[size][:container_radius]
+ end
+
+ def custom_fg_color
+ hex_color || "var(--color-gray-500)"
+ end
+
+ def transparent_bg_color
+ "color-mix(in oklab, #{custom_fg_color} 10%, transparent)"
+ end
+
+ def transparent_border_color
+ "color-mix(in oklab, #{custom_fg_color} 10%, transparent)"
+ end
+end
diff --git a/app/components/link_component.html.erb b/app/components/link_component.html.erb
new file mode 100644
index 00000000..707c3d9f
--- /dev/null
+++ b/app/components/link_component.html.erb
@@ -0,0 +1,13 @@
+<%= link_to href, **merged_opts do %>
+ <% if icon && (icon_position != "right") %>
+ <%= lucide_icon(icon, class: icon_classes) %>
+ <% end %>
+
+ <% unless icon_only? %>
+ <%= text %>
+ <% end %>
+
+ <% if icon && icon_position == "right" %>
+ <%= lucide_icon(icon, class: icon_classes) %>
+ <% end %>
+<% end %>
diff --git a/app/components/link_component.rb b/app/components/link_component.rb
new file mode 100644
index 00000000..4bbe10e5
--- /dev/null
+++ b/app/components/link_component.rb
@@ -0,0 +1,31 @@
+# An extension to `link_to` helper. All options are passed through to the `link_to` helper with some additional
+# options available.
+class LinkComponent < ButtonishComponent
+ attr_reader :frame
+
+ VARIANTS = VARIANTS.reverse_merge(
+ default: {
+ container_classes: "",
+ icon_classes: "fg-gray"
+ }
+ ).freeze
+
+ def merged_opts
+ merged_opts = opts.dup || {}
+ data = merged_opts.delete(:data) || {}
+
+ if frame
+ data = data.merge(turbo_frame: frame)
+ end
+
+ merged_opts.merge(
+ class: class_names(container_classes, extra_classes),
+ data: data
+ )
+ end
+
+ private
+ def container_size_classes
+ super unless variant == :default
+ end
+end
diff --git a/app/components/menu_component.html.erb b/app/components/menu_component.html.erb
new file mode 100644
index 00000000..527e5e36
--- /dev/null
+++ b/app/components/menu_component.html.erb
@@ -0,0 +1,27 @@
+<%= tag.div data: { controller: "menu", menu_placement_value: placement, menu_offset_value: offset, testid: testid } do %>
+ <% if variant == :icon %>
+ <%= render ButtonComponent.new(variant: "icon", icon: icon_vertical ? "more-vertical" : "more-horizontal", data: { menu_target: "button" }) %>
+ <% elsif variant == :button %>
+ <%= button %>
+ <% elsif variant == :avatar %>
+
+
+ <%= render "settings/user_avatar", avatar_url: avatar_url %>
+
+
+ <% end %>
+
+
+
+ <%= header %>
+
+ <%= tag.div class: class_names("py-1" => !no_padding) do %>
+ <% items.each do |item| %>
+ <%= item %>
+ <% end %>
+
+ <%= custom_content %>
+ <% end %>
+
+
+<% end %>
diff --git a/app/components/menu_component.rb b/app/components/menu_component.rb
new file mode 100644
index 00000000..012b2f62
--- /dev/null
+++ b/app/components/menu_component.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+class MenuComponent < ViewComponent::Base
+ attr_reader :variant, :avatar_url, :placement, :offset, :icon_vertical, :no_padding, :testid
+
+ renders_one :button, ->(**button_options, &block) do
+ options_with_target = button_options.merge(data: { menu_target: "button" })
+
+ if block
+ content_tag(:button, **options_with_target, &block)
+ else
+ ButtonComponent.new(**options_with_target)
+ end
+ end
+
+ renders_one :header, ->(&block) do
+ content_tag(:div, class: "border-b border-tertiary", &block)
+ end
+
+ renders_one :custom_content
+
+ renders_many :items, MenuItemComponent
+
+ VARIANTS = %i[icon button avatar].freeze
+
+ def initialize(variant: "icon", avatar_url: nil, placement: "bottom-end", offset: 12, icon_vertical: false, no_padding: false, testid: nil)
+ @variant = variant.to_sym
+ @avatar_url = avatar_url
+ @placement = placement
+ @offset = offset
+ @icon_vertical = icon_vertical
+ @no_padding = no_padding
+ @testid = testid
+
+ raise ArgumentError, "Invalid variant: #{@variant}" unless VARIANTS.include?(@variant)
+ end
+end
diff --git a/app/javascript/controllers/menu_controller.js b/app/components/menu_controller.js
similarity index 100%
rename from app/javascript/controllers/menu_controller.js
rename to app/components/menu_controller.js
diff --git a/app/components/menu_item_component.html.erb b/app/components/menu_item_component.html.erb
new file mode 100644
index 00000000..bd62bad7
--- /dev/null
+++ b/app/components/menu_item_component.html.erb
@@ -0,0 +1,12 @@
+<% if variant == :divider %>
+
+<% else %>
+
+ <%= wrapper do %>
+ <% if icon %>
+ <%= lucide_icon(icon, class: destructive? ? "text-destructive" : "fg-gray") %>
+ <% end %>
+ <%= tag.span(text, class: text_classes) %>
+ <% end %>
+
+<% end %>
diff --git a/app/components/menu_item_component.rb b/app/components/menu_item_component.rb
new file mode 100644
index 00000000..c029afa7
--- /dev/null
+++ b/app/components/menu_item_component.rb
@@ -0,0 +1,57 @@
+class MenuItemComponent < ViewComponent::Base
+ VARIANTS = %i[link button divider].freeze
+
+ attr_reader :variant, :text, :icon, :href, :method, :destructive, :confirm, :opts
+
+ def initialize(variant:, text: nil, icon: nil, href: nil, method: :post, destructive: false, confirm: nil, **opts)
+ @variant = variant.to_sym
+ @text = text
+ @icon = icon
+ @href = href
+ @method = method.to_sym
+ @destructive = destructive
+ @opts = opts
+ @confirm = confirm
+ raise ArgumentError, "Invalid variant: #{@variant}" unless VARIANTS.include?(@variant)
+ end
+
+ def wrapper(&block)
+ if variant == :button
+ button_to href, method: method, class: container_classes, **merged_button_opts, &block
+ elsif variant == :link
+ link_to href, class: container_classes, **opts, &block
+ else
+ nil
+ end
+ end
+
+ def text_classes
+ [
+ "text-sm",
+ destructive? ? "text-destructive" : "text-primary"
+ ].join(" ")
+ end
+
+ def destructive?
+ method == :delete || destructive
+ end
+
+ private
+ def container_classes
+ [
+ "flex items-center gap-2 p-2 rounded-md w-full",
+ destructive? ? "hover:bg-red-tint-5 theme-dark:hover:bg-red-tint-10" : "hover:bg-container-hover"
+ ].join(" ")
+ end
+
+ def merged_button_opts
+ merged_opts = opts.dup || {}
+ data = merged_opts.delete(:data) || {}
+
+ if confirm.present?
+ data = data.merge(turbo_confirm: confirm.to_data_attribute)
+ end
+
+ merged_opts.merge(data: data)
+ end
+end
diff --git a/app/components/tab_component.rb b/app/components/tab_component.rb
new file mode 100644
index 00000000..fc084a1a
--- /dev/null
+++ b/app/components/tab_component.rb
@@ -0,0 +1,12 @@
+class TabComponent < ViewComponent::Base
+ attr_reader :id, :label
+
+ def initialize(id:, label:)
+ @id = id
+ @label = label
+ end
+
+ def call
+ content
+ end
+end
diff --git a/app/components/tabs/nav_component.rb b/app/components/tabs/nav_component.rb
new file mode 100644
index 00000000..2c4e81ca
--- /dev/null
+++ b/app/components/tabs/nav_component.rb
@@ -0,0 +1,29 @@
+class Tabs::NavComponent < ViewComponent::Base
+ erb_template <<~ERB
+ <%= tag.nav class: classes do %>
+ <% btns.each do |btn| %>
+ <%= btn %>
+ <% end %>
+ <% end %>
+ ERB
+
+ renders_many :btns, ->(id:, label:, classes: nil, &block) do
+ content_tag(
+ :button, label, id: id,
+ type: "button",
+ class: class_names(btn_classes, id == active_tab ? active_btn_classes : inactive_btn_classes, classes),
+ data: { id: id, action: "tabs#show", tabs_target: "navBtn" },
+ &block
+ )
+ end
+
+ attr_reader :active_tab, :classes, :active_btn_classes, :inactive_btn_classes, :btn_classes
+
+ def initialize(active_tab:, classes: nil, active_btn_classes: nil, inactive_btn_classes: nil, btn_classes: nil)
+ @active_tab = active_tab
+ @classes = classes
+ @active_btn_classes = active_btn_classes
+ @inactive_btn_classes = inactive_btn_classes
+ @btn_classes = btn_classes
+ end
+end
diff --git a/app/components/tabs/panel_component.rb b/app/components/tabs/panel_component.rb
new file mode 100644
index 00000000..3c34932a
--- /dev/null
+++ b/app/components/tabs/panel_component.rb
@@ -0,0 +1,11 @@
+class Tabs::PanelComponent < ViewComponent::Base
+ attr_reader :tab_id
+
+ def initialize(tab_id:)
+ @tab_id = tab_id
+ end
+
+ def call
+ content
+ end
+end
diff --git a/app/components/tabs_component.html.erb b/app/components/tabs_component.html.erb
new file mode 100644
index 00000000..4ec901fa
--- /dev/null
+++ b/app/components/tabs_component.html.erb
@@ -0,0 +1,17 @@
+<%= tag.div data: {
+ controller: "tabs",
+ testid: testid,
+ tabs_url_param_key_value: url_param_key,
+ tabs_nav_btn_active_class: active_btn_classes,
+ tabs_nav_btn_inactive_class: inactive_btn_classes
+} do %>
+ <% if unstyled? %>
+ <%= content %>
+ <% else %>
+ <%= nav %>
+
+ <% panels.each do |panel| %>
+ <%= panel %>
+ <% end %>
+ <% end %>
+<% end %>
diff --git a/app/components/tabs_component.rb b/app/components/tabs_component.rb
new file mode 100644
index 00000000..4017b308
--- /dev/null
+++ b/app/components/tabs_component.rb
@@ -0,0 +1,65 @@
+class TabsComponent < ViewComponent::Base
+ renders_one :nav, ->(classes: nil) do
+ Tabs::NavComponent.new(
+ active_tab: active_tab,
+ active_btn_classes: active_btn_classes,
+ inactive_btn_classes: inactive_btn_classes,
+ btn_classes: base_btn_classes,
+ classes: unstyled? ? classes : class_names(nav_container_classes, classes)
+ )
+ end
+
+ renders_many :panels, ->(tab_id:, &block) do
+ content_tag(
+ :div,
+ class: ("hidden" unless tab_id == active_tab),
+ data: { id: tab_id, tabs_target: "panel" },
+ &block
+ )
+ end
+
+ VARIANTS = {
+ default: {
+ active_btn_classes: "bg-white theme-dark:bg-gray-700 text-primary shadow-sm",
+ inactive_btn_classes: "text-secondary hover:bg-surface-inset-hover",
+ base_btn_classes: "w-full inline-flex justify-center items-center text-sm font-medium px-2 py-1 rounded-md transition-colors duration-200",
+ nav_container_classes: "flex bg-surface-inset p-1 rounded-lg mb-4"
+ }
+ }
+
+ attr_reader :active_tab, :url_param_key, :variant, :testid
+
+ def initialize(active_tab:, url_param_key: nil, variant: :default, active_btn_classes: "", inactive_btn_classes: "", testid: nil)
+ @active_tab = active_tab
+ @url_param_key = url_param_key
+ @variant = variant.to_sym
+ @active_btn_classes = active_btn_classes
+ @inactive_btn_classes = inactive_btn_classes
+ @testid = testid
+ end
+
+ def active_btn_classes
+ unstyled? ? @active_btn_classes : VARIANTS.dig(variant, :active_btn_classes)
+ end
+
+ def inactive_btn_classes
+ unstyled? ? @inactive_btn_classes : VARIANTS.dig(variant, :inactive_btn_classes)
+ end
+
+ private
+ def unstyled?
+ variant == :unstyled
+ end
+
+ def base_btn_classes
+ unless unstyled?
+ VARIANTS.dig(variant, :base_btn_classes)
+ end
+ end
+
+ def nav_container_classes
+ unless unstyled?
+ VARIANTS.dig(variant, :nav_container_classes)
+ end
+ end
+end
diff --git a/app/components/tabs_controller.js b/app/components/tabs_controller.js
new file mode 100644
index 00000000..32f18d08
--- /dev/null
+++ b/app/components/tabs_controller.js
@@ -0,0 +1,42 @@
+import { Controller } from "@hotwired/stimulus";
+
+// Connects to data-controller="tabs--components"
+export default class extends Controller {
+ static classes = ["navBtnActive", "navBtnInactive"];
+ static targets = ["panel", "navBtn"];
+ static values = { urlParamKey: String };
+
+ connect() {
+ console.log("tabs controller connected");
+ }
+
+ show(e) {
+ const btn = e.target.closest("button");
+ const selectedTabId = btn.dataset.id;
+
+ this.navBtnTargets.forEach((navBtn) => {
+ if (navBtn.dataset.id === selectedTabId) {
+ navBtn.classList.add(...this.navBtnActiveClasses);
+ navBtn.classList.remove(...this.navBtnInactiveClasses);
+ } else {
+ navBtn.classList.add(...this.navBtnInactiveClasses);
+ navBtn.classList.remove(...this.navBtnActiveClasses);
+ }
+ });
+
+ this.panelTargets.forEach((panel) => {
+ if (panel.dataset.id === selectedTabId) {
+ panel.classList.remove("hidden");
+ } else {
+ panel.classList.add("hidden");
+ }
+ });
+
+ // Update URL with the selected tab
+ if (this.urlParamKeyValue) {
+ const url = new URL(window.location.href);
+ url.searchParams.set(this.urlParamKeyValue, selectedTabId);
+ window.history.replaceState({}, "", url);
+ }
+ }
+}
diff --git a/app/components/toggle_component.html.erb b/app/components/toggle_component.html.erb
new file mode 100644
index 00000000..6845686c
--- /dev/null
+++ b/app/components/toggle_component.html.erb
@@ -0,0 +1,5 @@
+
+ <%= hidden_field_tag name, unchecked_value, id: nil %>
+ <%= check_box_tag name, checked_value, checked, class: "sr-only peer", disabled: disabled, id: id, **opts %>
+ <%= label_tag name, " ".html_safe, class: label_classes, for: id %>
+
diff --git a/app/components/toggle_component.rb b/app/components/toggle_component.rb
new file mode 100644
index 00000000..e3af85a8
--- /dev/null
+++ b/app/components/toggle_component.rb
@@ -0,0 +1,26 @@
+class ToggleComponent < ViewComponent::Base
+ attr_reader :id, :name, :checked, :disabled, :checked_value, :unchecked_value, :opts
+
+ def initialize(id:, name: nil, checked: false, disabled: false, checked_value: "1", unchecked_value: "0", **opts)
+ @id = id
+ @name = name
+ @checked = checked
+ @disabled = disabled
+ @checked_value = checked_value
+ @unchecked_value = unchecked_value
+ @opts = opts
+ end
+
+ def label_classes
+ class_names(
+ "block w-9 h-5 cursor-pointer",
+ "rounded-full bg-gray-100 theme-dark:bg-gray-700",
+ "transition-colors duration-300",
+ "after:content-[''] after:block after:bg-white after:absolute after:rounded-full",
+ "after:top-0.5 after:left-0.5 after:w-4 after:h-4",
+ "after:transition-transform after:duration-300 after:ease-in-out",
+ "peer-checked:bg-green-600 peer-checked:after:translate-x-4",
+ "peer-disabled:opacity-70 peer-disabled:cursor-not-allowed"
+ )
+ end
+end
diff --git a/app/controllers/chats_controller.rb b/app/controllers/chats_controller.rb
index 61909200..6b8b494a 100644
--- a/app/controllers/chats_controller.rb
+++ b/app/controllers/chats_controller.rb
@@ -1,8 +1,6 @@
class ChatsController < ApplicationController
include ActionView::RecordIdentifier
- guard_feature unless: -> { Current.user.ai_enabled? }
-
before_action :set_chat, only: [ :show, :edit, :update, :destroy ]
def index
diff --git a/app/controllers/lookbooks_controller.rb b/app/controllers/lookbooks_controller.rb
new file mode 100644
index 00000000..6dc06d7e
--- /dev/null
+++ b/app/controllers/lookbooks_controller.rb
@@ -0,0 +1,3 @@
+class LookbooksController < Lookbook::PreviewController
+ layout "lookbooks"
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 316e7900..0db4c9e5 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -1,12 +1,30 @@
module ApplicationHelper
include Pagy::Frontend
- def icon(key, size: "md", color: "current")
- render partial: "shared/icon", locals: { key:, size:, color: }
+ def styled_form_with(**options, &block)
+ options[:builder] = StyledFormBuilder
+ form_with(**options, &block)
end
- def icon_custom(key, size: "md", color: "current")
- render partial: "shared/icon_custom", locals: { key:, size:, color: }
+ def icon(key, size: "md", color: "default", custom: false, as_button: false, **opts)
+ extra_classes = opts.delete(:class)
+ sizes = { xs: "w-3 h-3", sm: "w-4 h-4", md: "w-5 h-5", lg: "w-6 h-6", xl: "w-7 h-7", "2xl": "w-8 h-8" }
+ colors = { default: "fg-gray", success: "text-success", warning: "text-warning", destructive: "text-destructive", current: "text-current" }
+
+ icon_classes = class_names(
+ "shrink-0",
+ sizes[size.to_sym],
+ colors[color.to_sym],
+ extra_classes
+ )
+
+ if custom
+ inline_svg_tag("#{key}.svg", class: icon_classes, **opts)
+ elsif as_button
+ render ButtonComponent.new(variant: "icon", class: extra_classes, icon: key, size: size, type: "button", **opts)
+ else
+ lucide_icon(key, class: icon_classes, **opts)
+ end
end
# Convert alpha (0-1) to 8-digit hex (00-FF)
@@ -31,60 +49,10 @@ module ApplicationHelper
turbo_stream_from Current.family if Current.family
end
- ##
- # Helper to open a centered and overlayed modal with custom contents
- #
- # @example Basic usage
- # <%= modal classes: "custom-class" do %>
- # Content here
- # <% end %>
- #
- def modal(reload_on_close: false, overflow_visible: false, &block)
- content = capture &block
- render partial: "shared/modal", locals: { content:, reload_on_close:, overflow_visible: }
- end
-
- ##
- # Helper to open a drawer on the right side of the screen with custom contents
- #
- # @example Basic usage
- # <%= drawer do %>
- # Content here
- # <% end %>
- #
- def drawer(reload_on_close: false, &block)
- content = capture &block
- render partial: "shared/drawer", locals: { content:, reload_on_close: }
- end
-
- def disclosure(title, default_open: true, &block)
- content = capture &block
- render partial: "shared/disclosure", locals: { title: title, content: content, open: default_open }
- end
-
def page_active?(path)
current_page?(path) || (request.path.start_with?(path) && path != "/")
end
- def mixed_hex_styles(hex)
- color = hex || "#1570EF" # blue-600
-
- <<-STYLE.strip
- background-color: color-mix(in srgb, #{color} 10%, white);
- border-color: color-mix(in srgb, #{color} 30%, white);
- color: #{color};
- STYLE
- end
-
- def circle_logo(name, hex: nil, size: "md")
- render partial: "shared/circle_logo", locals: { name: name, hex: hex, size: size }
- end
-
- def return_to_path(params, fallback = root_path)
- uri = URI.parse(params[:return_to] || fallback)
- uri.relative? ? uri.path : root_path
- end
-
# Wrapper around I18n.l to support custom date formats
def format_date(object, format = :default, options = {})
date = object.to_date
@@ -144,49 +112,6 @@ module ApplicationHelper
markdown.render(text).html_safe
end
- # Determines the starting widths of each panel depending on the user's sidebar preferences
- def app_sidebar_config(user)
- left_sidebar_showing = user.show_sidebar?
- right_sidebar_showing = user.show_ai_sidebar?
-
- content_max_width = if !left_sidebar_showing && !right_sidebar_showing
- 1024 # 5xl
- elsif left_sidebar_showing && !right_sidebar_showing
- 896 # 4xl
- else
- 768 # 3xl
- end
-
- left_panel_min_width = 320
- left_panel_max_width = 320
- right_panel_min_width = 400
- right_panel_max_width = 550
-
- left_panel_width = left_sidebar_showing ? left_panel_min_width : 0
- right_panel_width = if right_sidebar_showing
- left_sidebar_showing ? right_panel_min_width : right_panel_max_width
- else
- 0
- end
-
- {
- left_panel: {
- is_open: left_sidebar_showing,
- initial_width: left_panel_width,
- min_width: left_panel_min_width,
- max_width: left_panel_max_width
- },
- right_panel: {
- is_open: right_sidebar_showing,
- initial_width: right_panel_width,
- min_width: right_panel_min_width,
- max_width: right_panel_max_width,
- overflow: right_sidebar_showing ? "auto" : "hidden"
- },
- content_max_width: content_max_width
- }
- end
-
private
def calculate_total(item, money_method, negate)
items = item.reject { |i| i.respond_to?(:entryable) && i.entryable.transfer? }
diff --git a/app/helpers/custom_confirm.rb b/app/helpers/custom_confirm.rb
new file mode 100644
index 00000000..bf40f449
--- /dev/null
+++ b/app/helpers/custom_confirm.rb
@@ -0,0 +1,51 @@
+# The shape of data expected by `confirm_dialog_controller.js` to override the
+# default browser confirm API via Turbo.
+class CustomConfirm
+ class << self
+ def for_resource_deletion(resource_name, high_severity: false)
+ new(
+ destructive: true,
+ high_severity: high_severity,
+ title: "Delete #{resource_name}?",
+ body: "Are you sure you want to delete #{resource_name}? This is not reversible.",
+ btn_text: "Delete #{resource_name}"
+ )
+ end
+ end
+
+ def initialize(title: default_title, body: default_body, btn_text: default_btn_text, destructive: false, high_severity: false)
+ @title = title
+ @body = body
+ @btn_text = btn_text
+ @btn_variant = derive_btn_variant(destructive, high_severity)
+ end
+
+ def to_data_attribute
+ {
+ title: title,
+ body: body,
+ confirmText: btn_text,
+ variant: btn_variant
+ }
+ end
+
+ private
+ attr_reader :title, :body, :btn_text, :btn_variant
+
+ def derive_btn_variant(destructive, high_severity)
+ return "primary" unless destructive
+ high_severity ? "destructive" : "outline-destructive"
+ end
+
+ def default_title
+ "Are you sure?"
+ end
+
+ def default_body
+ "This is not reversible."
+ end
+
+ def default_btn_text
+ "Confirm"
+ end
+end
diff --git a/app/helpers/forms_helper.rb b/app/helpers/forms_helper.rb
deleted file mode 100644
index dfa5c3b5..00000000
--- a/app/helpers/forms_helper.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-module FormsHelper
- def styled_form_with(**options, &block)
- options[:builder] = StyledFormBuilder
- form_with(**options, &block)
- end
-
- def modal_form_wrapper(title:, subtitle: nil, overflow_visible: false, &block)
- content = capture &block
-
- render partial: "shared/modal_form", locals: { title:, subtitle:, content:, overflow_visible: }
- end
-
- def period_select(form:, selected:, classes: "border border-secondary bg-container-inset rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0")
- periods_for_select = Period.all.map { |period| [ period.label_short, period.key ] }
-
- form.select(:period, periods_for_select, { selected: selected.key }, class: classes, data: { "auto-submit-form-target": "auto" })
- end
-
- def currencies_for_select
- Money::Currency.all_instances.sort_by { |currency| [ currency.priority, currency.name ] }
- end
-end
diff --git a/app/helpers/menus_helper.rb b/app/helpers/menus_helper.rb
deleted file mode 100644
index 0ddc9e5f..00000000
--- a/app/helpers/menus_helper.rb
+++ /dev/null
@@ -1,47 +0,0 @@
-module MenusHelper
- def contextual_menu(icon: "more-horizontal", id: nil, &block)
- tag.div id: id, data: { controller: "menu" } do
- concat contextual_menu_icon(icon)
- concat contextual_menu_content(&block)
- end
- end
-
- def contextual_menu_modal_action_item(label, url, icon: "pencil-line", turbo_frame: :modal, class_name: nil)
- link_to url, class: "flex items-center rounded-md text-primary hover:bg-container-hover p-2 gap-2 #{class_name}", data: { action: "click->menu#close", turbo_frame: turbo_frame } do
- concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-secondary"))
- concat(tag.span(label, class: "text-sm"))
- end
- end
-
- def contextual_menu_item(label, url:, icon:, turbo_frame: nil)
- link_to url, class: "flex items-center rounded-md text-primary hover:bg-container-hover p-2 gap-2", data: { action: "click->menu#close", turbo_frame: turbo_frame } do
- concat(lucide_icon(icon, class: "shrink-0 w-5 h-5 text-secondary"))
- concat(tag.span(label, class: "text-sm"))
- end
- end
-
- def contextual_menu_destructive_item(label, url, turbo_confirm: true, turbo_frame: nil)
- button_to url,
- method: :delete,
- class: "flex items-center w-full rounded-md text-red-500 hover:bg-red-500/5 p-2 gap-2",
- data: { turbo_confirm: turbo_confirm, turbo_frame: } do
- concat(lucide_icon("trash-2", class: "shrink-0 w-5 h-5"))
- concat(tag.span(label, class: "text-sm"))
- end
- end
-
- private
- def contextual_menu_icon(icon)
- tag.button class: "w-9 h-9 flex justify-center items-center hover:bg-surface-hover rounded-lg cursor-pointer focus:outline-none focus-visible:outline-none", data: { menu_target: "button" } do
- concat lucide_icon("more-vertical", class: "w-5 h-5 text-secondary md:hidden")
- concat lucide_icon(icon, class: "w-5 h-5 text-secondary hidden md:block")
- end
- end
-
- def contextual_menu_content(&block)
- tag.div class: "min-w-[200px] p-1 z-50 shadow-border-xs bg-container rounded-lg hidden",
- data: { menu_target: "content" } do
- capture(&block)
- end
- end
-end
diff --git a/app/helpers/styled_form_builder.rb b/app/helpers/styled_form_builder.rb
index 7a817578..1ddd445a 100644
--- a/app/helpers/styled_form_builder.rb
+++ b/app/helpers/styled_form_builder.rb
@@ -48,19 +48,46 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
}
end
- def submit(value = nil, options = {})
- default_options = {
- data: { turbo_submits_with: "Submitting..." },
- class: "btn btn--primary w-full justify-center"
- }
+ # A custom styled "toggle" switch input. Underlying input is a `check_box` (uses same API)
+ def toggle(method, options = {}, checked_value = "1", unchecked_value = "0")
+ if object
+ id = "#{object.id}_#{object_name}_#{method}"
+ name = "#{object_name}[#{method}]"
+ checked = object.send(method)
+ else
+ id = "#{method}_toggle_id"
+ name = method
+ checked = options[:checked]
+ end
- merged_options = default_options.merge(options)
+ @template.render(
+ ToggleComponent.new(
+ id: id,
+ name: name,
+ checked: checked,
+ disabled: options[:disabled],
+ checked_value: checked_value,
+ unchecked_value: unchecked_value,
+ **options
+ )
+ )
+ end
+
+ def submit(value = nil, options = {})
+ # Rails superclass logic to extract the submit text
value, options = nil, value if value.is_a?(Hash)
- super(value, merged_options)
+ value ||= submit_default_value
+
+ @template.render(
+ ButtonComponent.new(
+ text: value,
+ data: (options[:data] || {}).merge({ turbo_submits_with: "Submitting..." }),
+ full_width: true
+ )
+ )
end
private
-
def build_styled_field(label, field, options, remove_padding_right: false)
if options[:inline]
label + field
diff --git a/app/helpers/transactions_helper.rb b/app/helpers/transactions_helper.rb
index dd729c47..173306b9 100644
--- a/app/helpers/transactions_helper.rb
+++ b/app/helpers/transactions_helper.rb
@@ -1,13 +1,13 @@
module TransactionsHelper
def transaction_search_filters
[
- { key: "account_filter", icon: "layers" },
- { key: "date_filter", icon: "calendar" },
- { key: "type_filter", icon: "tag" },
- { key: "amount_filter", icon: "hash" },
- { key: "category_filter", icon: "shapes" },
- { key: "tag_filter", icon: "tags" },
- { key: "merchant_filter", icon: "store" }
+ { key: "account_filter", label: "Account", icon: "layers" },
+ { key: "date_filter", label: "Date", icon: "calendar" },
+ { key: "type_filter", label: "Type", icon: "tag" },
+ { key: "amount_filter", label: "Amount", icon: "hash" },
+ { key: "category_filter", label: "Category", icon: "shapes" },
+ { key: "tag_filter", label: "Tag", icon: "tags" },
+ { key: "merchant_filter", label: "Merchant", icon: "store" }
]
end
diff --git a/app/javascript/controllers/account_collapse_controller.js b/app/javascript/controllers/account_collapse_controller.js
deleted file mode 100644
index 11c51cde..00000000
--- a/app/javascript/controllers/account_collapse_controller.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import { Controller } from "@hotwired/stimulus";
-
-// Connects to data-controller="account-collapse"
-export default class extends Controller {
- static values = { type: String };
- initialToggle = false;
- STORAGE_NAME = "accountCollapseStates";
-
- connect() {
- this.element.addEventListener("toggle", this.onToggle);
- this.updateFromLocalStorage();
- }
-
- disconnect() {
- this.element.removeEventListener("toggle", this.onToggle);
- }
-
- onToggle = () => {
- if (this.initialToggle) {
- this.initialToggle = false;
- return;
- }
-
- const items = this.getItemsFromLocalStorage();
- if (items.has(this.typeValue)) {
- items.delete(this.typeValue);
- } else {
- items.add(this.typeValue);
- }
- localStorage.setItem(this.STORAGE_NAME, JSON.stringify([...items]));
- };
-
- updateFromLocalStorage() {
- const items = this.getItemsFromLocalStorage();
-
- if (items.has(this.typeValue)) {
- this.initialToggle = true;
- this.element.setAttribute("open", "");
- }
- }
-
- getItemsFromLocalStorage() {
- try {
- const items = localStorage.getItem(this.STORAGE_NAME);
- return new Set(items ? JSON.parse(items) : []);
- } catch (error) {
- console.error("Error parsing items from localStorage:", error);
- return new Set();
- }
- }
-}
diff --git a/app/javascript/controllers/app_layout_controller.js b/app/javascript/controllers/app_layout_controller.js
new file mode 100644
index 00000000..cc15c78c
--- /dev/null
+++ b/app/javascript/controllers/app_layout_controller.js
@@ -0,0 +1,56 @@
+import { Controller } from "@hotwired/stimulus";
+
+// Connects to data-controller="dialog"
+export default class extends Controller {
+ static targets = ["leftSidebar", "rightSidebar", "mobileSidebar"];
+ static classes = [
+ "expandedSidebar",
+ "collapsedSidebar",
+ "expandedTransition",
+ "collapsedTransition",
+ ];
+
+ openMobileSidebar() {
+ this.mobileSidebarTarget.classList.remove("hidden");
+ }
+
+ closeMobileSidebar() {
+ this.mobileSidebarTarget.classList.add("hidden");
+ }
+
+ toggleLeftSidebar() {
+ const isOpen = this.leftSidebarTarget.classList.contains("w-full");
+ this.#updateUserPreference("show_sidebar", !isOpen);
+ this.#toggleSidebarWidth(this.leftSidebarTarget, isOpen);
+ }
+
+ toggleRightSidebar() {
+ const isOpen = this.rightSidebarTarget.classList.contains("w-full");
+ this.#updateUserPreference("show_ai_sidebar", !isOpen);
+ this.#toggleSidebarWidth(this.rightSidebarTarget, isOpen);
+ }
+
+ #toggleSidebarWidth(el, isCurrentlyOpen) {
+ if (isCurrentlyOpen) {
+ el.classList.remove(...this.expandedSidebarClasses);
+ el.classList.add(...this.collapsedSidebarClasses);
+ } else {
+ el.classList.add(...this.expandedSidebarClasses);
+ el.classList.remove(...this.collapsedSidebarClasses);
+ }
+ }
+
+ #updateUserPreference(field, value) {
+ fetch(`/users/${this.userIdValue}`, {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content,
+ Accept: "application/json",
+ },
+ body: new URLSearchParams({
+ [`user[${field}]`]: value,
+ }).toString(),
+ });
+ }
+}
diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js
index f898dcad..ae24872d 100644
--- a/app/javascript/controllers/application.js
+++ b/app/javascript/controllers/application.js
@@ -6,52 +6,14 @@ const application = Application.start();
application.debug = false;
window.Stimulus = application;
-Turbo.config.forms.confirm = (message) => {
- const dialog = document.getElementById("turbo-confirm");
-
- try {
- const { title, body, accept, acceptClass } = JSON.parse(message);
-
- if (title) {
- document.getElementById("turbo-confirm-title").innerHTML = title;
- }
-
- if (body) {
- document.getElementById("turbo-confirm-body").innerHTML = body;
- }
-
- if (accept) {
- document.getElementById("turbo-confirm-accept").innerHTML = accept;
- }
-
- if (acceptClass) {
- document.getElementById("turbo-confirm-accept").className = acceptClass;
- }
- } catch (e) {
- document.getElementById("turbo-confirm-title").innerText = message;
- }
-
- dialog.showModal();
-
- return new Promise((resolve) => {
- dialog.addEventListener(
- "close",
- () => {
- const confirmed = dialog.returnValue === "confirm";
-
- if (!confirmed) {
- document.getElementById("turbo-confirm-title").innerHTML =
- "Are you sure?";
- document.getElementById("turbo-confirm-body").innerHTML =
- "You will not be able to undo this decision";
- document.getElementById("turbo-confirm-accept").innerHTML = "Confirm";
- }
-
- resolve(confirmed);
- },
- { once: true },
+Turbo.config.forms.confirm = (data) => {
+ const confirmDialogController =
+ application.getControllerForElementAndIdentifier(
+ document.getElementById("confirm-dialog"),
+ "confirm-dialog",
);
- });
+
+ return confirmDialogController.handleConfirm(data);
};
export { application };
diff --git a/app/javascript/controllers/bulk_select_controller.js b/app/javascript/controllers/bulk_select_controller.js
index a1e40157..0851da7a 100644
--- a/app/javascript/controllers/bulk_select_controller.js
+++ b/app/javascript/controllers/bulk_select_controller.js
@@ -7,7 +7,7 @@ export default class extends Controller {
"group",
"selectionBar",
"selectionBarText",
- "bulkEditDrawerTitle",
+ "bulkEditDrawerHeader",
];
static values = {
singularLabel: String,
@@ -25,8 +25,9 @@ export default class extends Controller {
document.removeEventListener("turbo:load", this._updateView);
}
- bulkEditDrawerTitleTargetConnected(element) {
- element.innerText = `Edit ${
+ bulkEditDrawerHeaderTargetConnected(element) {
+ const headingTextEl = element.querySelector("h2");
+ headingTextEl.innerText = `Edit ${
this.selectedIdsValue.length
} ${this._pluralizedResourceName()}`;
}
diff --git a/app/javascript/controllers/color_avatar_controller.js b/app/javascript/controllers/color_avatar_controller.js
index 49d9caeb..351bc437 100644
--- a/app/javascript/controllers/color_avatar_controller.js
+++ b/app/javascript/controllers/color_avatar_controller.js
@@ -21,8 +21,8 @@ export default class extends Controller {
handleColorChange(e) {
const color = e.currentTarget.value;
- this.avatarTarget.style.backgroundColor = `color-mix(in srgb, ${color} 10%, white)`;
- this.avatarTarget.style.borderColor = `color-mix(in srgb, ${color} 10%, white)`;
+ this.avatarTarget.style.backgroundColor = `color-mix(in srgb, ${color} 10%, transparent)`;
+ this.avatarTarget.style.borderColor = `color-mix(in srgb, ${color} 10%, transparent)`;
this.avatarTarget.style.color = color;
}
}
diff --git a/app/javascript/controllers/confirm_dialog_controller.js b/app/javascript/controllers/confirm_dialog_controller.js
new file mode 100644
index 00000000..e66269a6
--- /dev/null
+++ b/app/javascript/controllers/confirm_dialog_controller.js
@@ -0,0 +1,59 @@
+import { Controller } from "@hotwired/stimulus";
+
+// Connects to data-controller="confirm-dialog"
+// See javascript/controllers/application.js for how this is wired up
+export default class extends Controller {
+ static targets = ["title", "subtitle", "confirmButton"];
+
+ handleConfirm(rawData) {
+ const data = this.#normalizeRawData(rawData);
+
+ this.#prepareDialog(data);
+
+ this.element.showModal();
+
+ return new Promise((resolve) => {
+ this.element.addEventListener(
+ "close",
+ () => {
+ const isConfirmed = this.element.returnValue === "confirm";
+ resolve(isConfirmed);
+ },
+ { once: true },
+ );
+ });
+ }
+
+ #prepareDialog(data) {
+ const variant = data.variant || "primary";
+
+ this.confirmButtonTargets.forEach((button) => {
+ if (button.dataset.variant === variant) {
+ button.removeAttribute("hidden");
+ } else {
+ button.setAttribute("hidden", true);
+ }
+
+ button.textContent = data.confirmText || "Confirm";
+ });
+
+ this.titleTarget.textContent = data.title || "Are you sure?";
+ this.subtitleTarget.innerHTML =
+ data.body || "This action cannot be undone.";
+ }
+
+ // If data is a string, it's the title. Otherwise, return the parsed object.
+ #normalizeRawData(rawData) {
+ try {
+ const parsed = JSON.parse(rawData);
+
+ if (typeof parsed === "boolean") {
+ return { title: "Are you sure?" };
+ }
+
+ return parsed;
+ } catch (e) {
+ return { title: rawData };
+ }
+ }
+}
diff --git a/app/javascript/controllers/deletion_controller.js b/app/javascript/controllers/deletion_controller.js
index cd49065d..ec4bc9f2 100644
--- a/app/javascript/controllers/deletion_controller.js
+++ b/app/javascript/controllers/deletion_controller.js
@@ -1,30 +1,28 @@
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
- static targets = ["replacementField", "submitButton"];
- static classes = ["dangerousAction", "safeAction"];
+ static targets = [
+ "replacementField",
+ "destructiveSubmitButton",
+ "safeSubmitButton",
+ ];
+
static values = {
submitTextWhenReplacing: String,
submitTextWhenNotReplacing: String,
};
- updateSubmitButton() {
+ chooseSubmitButton() {
if (this.replacementFieldTarget.value) {
- this.submitButtonTarget.value = this.submitTextWhenReplacingValue;
- this.#markSafe();
+ this.destructiveSubmitButtonTarget.hidden = true;
+ this.safeSubmitButtonTarget.textContent =
+ this.submitTextWhenReplacingValue;
+ this.safeSubmitButtonTarget.hidden = false;
} else {
- this.submitButtonTarget.value = this.submitTextWhenNotReplacingValue;
- this.#markDangerous();
+ this.destructiveSubmitButtonTarget.textContent =
+ this.submitTextWhenNotReplacingValue;
+ this.destructiveSubmitButtonTarget.hidden = false;
+ this.safeSubmitButtonTarget.hidden = true;
}
}
-
- #markSafe() {
- this.submitButtonTarget.classList.remove(...this.dangerousActionClasses);
- this.submitButtonTarget.classList.add(...this.safeActionClasses);
- }
-
- #markDangerous() {
- this.submitButtonTarget.classList.remove(...this.safeActionClasses);
- this.submitButtonTarget.classList.add(...this.dangerousActionClasses);
- }
}
diff --git a/app/javascript/controllers/intercom_controller.js b/app/javascript/controllers/intercom_controller.js
new file mode 100644
index 00000000..f22d1db8
--- /dev/null
+++ b/app/javascript/controllers/intercom_controller.js
@@ -0,0 +1,8 @@
+import { Controller } from "@hotwired/stimulus";
+
+// Connects to data-controller="intercom"
+export default class extends Controller {
+ show() {
+ Intercom("show");
+ }
+}
diff --git a/app/javascript/controllers/preserve_scroll_controller.js b/app/javascript/controllers/preserve_scroll_controller.js
deleted file mode 100644
index c2110fd1..00000000
--- a/app/javascript/controllers/preserve_scroll_controller.js
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- https://dev.to/konnorrogers/maintain-scroll-position-in-turbo-without-data-turbo-permanent-2b1i
- modified to add support for horizontal scrolling
- */
-if (!window.scrollPositions) {
- window.scrollPositions = {};
-}
-
-function preserveScroll() {
- document.querySelectorAll("[data-preserve-scroll]").forEach((element) => {
- scrollPositions[element.id] = {
- top: element.scrollTop,
- left: element.scrollLeft
- };
- });
-}
-
-function restoreScroll(event) {
- document.querySelectorAll("[data-preserve-scroll]").forEach((element) => {
- if (scrollPositions[element.id]) {
- element.scrollTop = scrollPositions[element.id].top;
- element.scrollLeft = scrollPositions[element.id].left;
- }
- });
-
- if (!event.detail.newBody) return;
- // event.detail.newBody is the body element to be swapped in.
- // https://turbo.hotwired.dev/reference/events
- event.detail.newBody.querySelectorAll("[data-preserve-scroll]").forEach((element) => {
- if (scrollPositions[element.id]) {
- element.scrollTop = scrollPositions[element.id].top;
- element.scrollLeft = scrollPositions[element.id].left;
- }
- });
-}
-
-window.addEventListener("turbo:before-cache", preserveScroll);
-window.addEventListener("turbo:before-render", restoreScroll);
-window.addEventListener("turbo:render", restoreScroll);
diff --git a/app/javascript/controllers/rule/actions_controller.js b/app/javascript/controllers/rule/actions_controller.js
index 815e027e..f4de40eb 100644
--- a/app/javascript/controllers/rule/actions_controller.js
+++ b/app/javascript/controllers/rule/actions_controller.js
@@ -8,6 +8,7 @@ export default class extends Controller {
remove(e) {
if (e.params.destroy) {
this.destroyFieldTarget.value = true;
+ this.element.classList.add("hidden");
} else {
this.element.remove();
}
diff --git a/app/javascript/controllers/sidebar_controller.js b/app/javascript/controllers/sidebar_controller.js
deleted file mode 100644
index a46794e3..00000000
--- a/app/javascript/controllers/sidebar_controller.js
+++ /dev/null
@@ -1,86 +0,0 @@
-import { Controller } from "@hotwired/stimulus";
-
-// Connects to data-controller="sidebar"
-export default class extends Controller {
- static values = {
- userId: String,
- config: Object,
- };
-
- static targets = ["leftPanel", "leftPanelMobile", "rightPanel", "content"];
-
- initialize() {
- this.leftPanelOpen = this.configValue.left_panel.is_open;
- this.rightPanelOpen = this.configValue.right_panel.is_open;
- }
-
- toggleLeftPanel() {
- this.leftPanelOpen = !this.leftPanelOpen;
- this.#updatePanelWidths();
- this.#persistPreference("show_sidebar", this.leftPanelOpen);
- }
-
- toggleLeftPanelMobile() {
- if (this.leftPanelOpen) {
- this.leftPanelMobileTarget.classList.remove("hidden");
- this.leftPanelOpen = false;
- } else {
- this.leftPanelMobileTarget.classList.add("hidden");
- this.leftPanelOpen = true;
- }
- }
-
- toggleRightPanel() {
- this.rightPanelOpen = !this.rightPanelOpen;
- this.#updatePanelWidths();
- this.#persistPreference("show_ai_sidebar", this.rightPanelOpen);
- }
-
- #updatePanelWidths() {
- this.leftPanelTarget.style.width = `${this.#leftPanelWidth()}px`;
- this.rightPanelTarget.style.width = `${this.#rightPanelWidth()}px`;
- this.rightPanelTarget.style.overflow = this.#rightPanelOverflow();
- }
-
- #leftPanelWidth() {
- if (this.leftPanelOpen) {
- return this.configValue.left_panel.min_width;
- }
-
- return 0;
- }
-
- #rightPanelWidth() {
- if (this.rightPanelOpen) {
- if (this.leftPanelOpen) {
- return this.configValue.right_panel.min_width;
- }
-
- return this.configValue.right_panel.max_width;
- }
-
- return 0;
- }
-
- #rightPanelOverflow() {
- if (this.rightPanelOpen) {
- return "auto";
- }
-
- return "hidden";
- }
-
- #persistPreference(field, value) {
- fetch(`/users/${this.userIdValue}`, {
- method: "PATCH",
- headers: {
- "Content-Type": "application/x-www-form-urlencoded",
- "X-CSRF-Token": document.querySelector('[name="csrf-token"]').content,
- Accept: "application/json",
- },
- body: new URLSearchParams({
- [`user[${field}]`]: value,
- }).toString(),
- });
- }
-}
diff --git a/app/javascript/controllers/tabs_controller.js b/app/javascript/controllers/tabs_controller.js
deleted file mode 100644
index 1e1cd614..00000000
--- a/app/javascript/controllers/tabs_controller.js
+++ /dev/null
@@ -1,74 +0,0 @@
-import { Controller } from "@hotwired/stimulus";
-
-// Connects to data-controller="tabs"
-export default class extends Controller {
- static classes = ["active", "inactive"];
- static targets = ["btn", "tab"];
- static values = { defaultTab: String, localStorageKey: String };
-
- connect() {
- const selectedTab = this.hasLocalStorageKeyValue
- ? this.getStoredTab() || this.defaultTabValue
- : this.defaultTabValue;
-
- this.updateClasses(selectedTab);
- document.addEventListener("turbo:load", this.onTurboLoad);
- }
-
- disconnect() {
- document.removeEventListener("turbo:load", this.onTurboLoad);
- }
-
- select(event) {
- const element = event.target.closest("[data-id]");
- if (element) {
- const selectedId = element.dataset.id;
- this.updateClasses(selectedId);
- if (this.hasLocalStorageKeyValue) {
- this.storeTab(selectedId);
- }
- }
- }
-
- onTurboLoad = () => {
- const selectedTab = this.hasLocalStorageKeyValue
- ? this.getStoredTab() || this.defaultTabValue
- : this.defaultTabValue;
-
- this.updateClasses(selectedTab);
- };
-
- getStoredTab() {
- const tabs = JSON.parse(localStorage.getItem("tabs") || "{}");
- return tabs[this.localStorageKeyValue];
- }
-
- storeTab(selectedId) {
- const tabs = JSON.parse(localStorage.getItem("tabs") || "{}");
- tabs[this.localStorageKeyValue] = selectedId;
- localStorage.setItem("tabs", JSON.stringify(tabs));
- }
-
- updateClasses = (selectedId) => {
- this.btnTargets.forEach((btn) => {
- btn.classList.remove(...this.activeClasses);
- btn.classList.remove(...this.inactiveClasses);
- });
-
- this.tabTargets.forEach((tab) => tab.classList.add("hidden"));
-
- this.btnTargets.forEach((btn) => {
- if (btn.dataset.id === selectedId) {
- btn.classList.add(...this.activeClasses);
- } else {
- btn.classList.add(...this.inactiveClasses);
- }
- });
-
- this.tabTargets.forEach((tab) => {
- if (tab.id === selectedId) {
- tab.classList.remove("hidden");
- }
- });
- };
-}
diff --git a/app/javascript/controllers/theme_controller.js b/app/javascript/controllers/theme_controller.js
index d01edf7f..b9c0782d 100644
--- a/app/javascript/controllers/theme_controller.js
+++ b/app/javascript/controllers/theme_controller.js
@@ -1,73 +1,87 @@
-import { Controller } from "@hotwired/stimulus"
+import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
- static values = { userPreference: String }
+ static values = { userPreference: String };
connect() {
- this.applyTheme()
- this.startSystemThemeListener()
+ this.applyTheme();
+ this.startSystemThemeListener();
}
disconnect() {
- this.stopSystemThemeListener()
+ this.stopSystemThemeListener();
}
// Called automatically by Stimulus when the userPreferenceValue changes (e.g., after form submit/page reload)
userPreferenceValueChanged() {
- this.applyTheme()
+ this.applyTheme();
}
// Called when a theme radio button is clicked
updateTheme(event) {
- const selectedTheme = event.currentTarget.value
+ const selectedTheme = event.currentTarget.value;
if (selectedTheme === "system") {
- this.setTheme(this.systemPrefersDark())
+ this.setTheme(this.systemPrefersDark());
} else if (selectedTheme === "dark") {
- this.setTheme(true)
+ this.setTheme(true);
} else {
- this.setTheme(false)
+ this.setTheme(false);
}
}
// Applies theme based on the userPreferenceValue (from server)
applyTheme() {
if (this.userPreferenceValue === "system") {
- this.setTheme(this.systemPrefersDark())
+ this.setTheme(this.systemPrefersDark());
} else if (this.userPreferenceValue === "dark") {
- this.setTheme(true)
+ this.setTheme(true);
} else {
- this.setTheme(false)
+ this.setTheme(false);
}
}
// Sets or removes the data-theme attribute
setTheme(isDark) {
if (isDark) {
- document.documentElement.setAttribute("data-theme", "dark")
+ document.documentElement.setAttribute("data-theme", "dark");
} else {
- document.documentElement.removeAttribute("data-theme")
+ document.documentElement.removeAttribute("data-theme");
}
}
systemPrefersDark() {
- return window.matchMedia("(prefers-color-scheme: dark)").matches
+ return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
handleSystemThemeChange = (event) => {
// Only apply system theme changes if the user preference is currently 'system'
if (this.userPreferenceValue === "system") {
- this.setTheme(event.matches)
+ this.setTheme(event.matches);
}
+ };
+
+ toDark() {
+ this.setTheme(true);
+ }
+
+ toLight() {
+ this.setTheme(false);
}
startSystemThemeListener() {
- this.darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
- this.darkMediaQuery.addEventListener("change", this.handleSystemThemeChange)
+ this.darkMediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
+ this.darkMediaQuery.addEventListener(
+ "change",
+ this.handleSystemThemeChange,
+ );
}
stopSystemThemeListener() {
if (this.darkMediaQuery) {
- this.darkMediaQuery.removeEventListener("change", this.handleSystemThemeChange)
+ this.darkMediaQuery.removeEventListener(
+ "change",
+ this.handleSystemThemeChange,
+ );
}
}
-}
\ No newline at end of file
+}
diff --git a/app/javascript/controllers/time_series_chart_controller.js b/app/javascript/controllers/time_series_chart_controller.js
index 664e989d..57cac880 100644
--- a/app/javascript/controllers/time_series_chart_controller.js
+++ b/app/javascript/controllers/time_series_chart_controller.js
@@ -95,7 +95,8 @@ export default class extends Controller {
.attr("cx", this._d3InitialContainerWidth / 2)
.attr("cy", this._d3InitialContainerHeight / 2)
.attr("r", 4)
- .style("fill", "var(--color-gray-400)");
+ .attr("class", "fg-subdued")
+ .style("fill", "currentColor");
}
_drawChart() {
@@ -151,7 +152,8 @@ export default class extends Controller {
.append("stop")
.attr("class", "end-color")
.attr("offset", "100%")
- .attr("stop-color", "var(--color-gray-300)");
+ .attr("class", "fg-subdued")
+ .attr("stop-color", "currentColor");
}
_setTrendlineSplitAt(percent) {
@@ -191,7 +193,7 @@ export default class extends Controller {
// Style ticks
this._d3Group
.selectAll(".tick text")
- .style("fill", "var(--color-gray-500)")
+ .attr("class", "fg-gray")
.style("font-size", "12px")
.style("font-weight", "500")
.attr("text-anchor", "middle")
@@ -258,14 +260,10 @@ export default class extends Controller {
this._d3Tooltip = d3
.select(`#${this.element.id}`)
.append("div")
- .style("position", "absolute")
- .style("padding", "8px")
- .style("font", "14px Inter, sans-serif")
- .style("background", "var(--color-white)")
- .style("border", "1px solid var(--color-alpha-black-100)")
- .style("border-radius", "10px")
- .style("pointer-events", "none")
- .style("opacity", 0); // Starts as hidden
+ .attr(
+ "class",
+ "bg-container text-sm font-sans absolute p-2 border border-secondary rounded-lg pointer-events-none opacity-0",
+ );
}
_trackMouseForShowingTooltip() {
@@ -273,6 +271,7 @@ export default class extends Controller {
this._d3Group
.append("rect")
+ .attr("class", "bg-container")
.attr("width", this._d3ContainerWidth)
.attr("height", this._d3ContainerHeight)
.attr("fill", "none")
@@ -308,12 +307,12 @@ export default class extends Controller {
// Guideline
this._d3Group
.append("line")
- .attr("class", "guideline")
+ .attr("class", "guideline fg-subdued")
.attr("x1", this._d3XScale(d.date))
.attr("y1", 0)
.attr("x2", this._d3XScale(d.date))
.attr("y2", this._d3ContainerHeight)
- .attr("stroke", "var(--color-gray-300)")
+ .attr("stroke", "currentColor")
.attr("stroke-dasharray", "4, 4");
// Big circle
@@ -353,7 +352,6 @@ export default class extends Controller {
this._d3Group.selectAll(".guideline").remove();
this._d3Group.selectAll(".data-point-circle").remove();
this._d3Tooltip.style("opacity", 0);
-
this._setTrendlineSplitAt(1);
}
});
@@ -364,23 +362,17 @@ export default class extends Controller {
${datum.date_formatted}
-
-
-
- ${datum.trend.previous.amount === datum.trend.current.amount ? `
-
- ` : Number(datum.trend.previous.amount) < Number(datum.trend.current.amount) ? `
-
- ` : `
-
- `}
+
+
+
+ ${this._getTrendIcon(datum)}
${this._extractFormattedValue(datum.trend.current)}
${
datum.trend.value === 0
- ? `
`
+ ? `
`
: `
${this._extractFormattedValue(datum.trend.value)} (${datum.trend.percent_formatted})
@@ -391,6 +383,23 @@ export default class extends Controller {
`;
}
+ _getTrendIcon(datum) {
+ const isIncrease =
+ Number(datum.trend.previous.amount) < Number(datum.trend.current.amount);
+ const isDecrease =
+ Number(datum.trend.previous.amount) > Number(datum.trend.current.amount);
+
+ if (isIncrease) {
+ return ` `;
+ }
+
+ if (isDecrease) {
+ return ` `;
+ }
+
+ return ` `;
+ }
+
_getDatumValue = (datum) => {
return this._extractNumericValue(datum.trend.current);
};
diff --git a/app/models/balance_sheet.rb b/app/models/balance_sheet.rb
index 21b4aeca..c289f86f 100644
--- a/app/models/balance_sheet.rb
+++ b/app/models/balance_sheet.rb
@@ -26,13 +26,15 @@ class BalanceSheet
ClassificationGroup.new(
key: "asset",
display_name: "Assets",
- icon: "blocks",
+ icon: "plus",
+ total_money: total_assets_money,
account_groups: account_groups("asset")
),
ClassificationGroup.new(
key: "liability",
display_name: "Debts",
- icon: "scale",
+ icon: "minus",
+ total_money: total_liabilities_money,
account_groups: account_groups("liability")
)
]
@@ -75,7 +77,7 @@ class BalanceSheet
end
private
- ClassificationGroup = Struct.new(:key, :display_name, :icon, :account_groups, keyword_init: true)
+ ClassificationGroup = Struct.new(:key, :display_name, :icon, :total_money, :account_groups, keyword_init: true)
AccountGroup = Struct.new(:key, :name, :accountable_type, :classification, :total, :total_money, :weight, :accounts, :color, :missing_rates?, keyword_init: true)
def active_accounts
diff --git a/app/models/family.rb b/app/models/family.rb
index caaa4134..1ab64523 100644
--- a/app/models/family.rb
+++ b/app/models/family.rb
@@ -146,6 +146,10 @@ class Family < ApplicationRecord
false
end
+ def missing_data_provider?
+ requires_data_provider? && Provider::Registry.get_provider(:synth).nil?
+ end
+
def primary_user
users.order(:created_at).first
end
diff --git a/app/models/period.rb b/app/models/period.rb
index 2cceb743..2fbcd30b 100644
--- a/app/models/period.rb
+++ b/app/models/period.rb
@@ -84,6 +84,10 @@ class Period
def all
PERIODS.map { |key, period| from_key(key) }
end
+
+ def as_options
+ all.map { |period| [ period.label_short, period.key ] }
+ end
end
PERIODS.each do |key, period|
diff --git a/app/views/accounts/_account.html.erb b/app/views/accounts/_account.html.erb
index 07236d6c..07ffd3d5 100644
--- a/app/views/accounts/_account.html.erb
+++ b/app/views/accounts/_account.html.erb
@@ -25,7 +25,7 @@
<% unless account.scheduled_for_deletion? %>
<%= link_to edit_account_path(account, return_to: return_to), data: { turbo_frame: :modal }, class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center" do %>
- <%= lucide_icon "pencil-line", class: "w-4 h-4 text-secondary" %>
+ <%= icon("pencil-line", size: "sm") %>
<% end %>
<% end %>
@@ -35,7 +35,9 @@
<% unless account.scheduled_for_deletion? %>
- <%= render "shared/toggle_form", model: account, attribute: :is_active, turbo_frame: "_top" %>
+ <%= styled_form_with model: account, data: { turbo_frame: "_top", controller: "auto-submit-form" } do |f| %>
+ <%= f.toggle :is_active, { data: { auto_submit_form_target: "auto" } } %>
+ <% end %>
<% end %>
diff --git a/app/views/accounts/_account_sidebar_tabs.html.erb b/app/views/accounts/_account_sidebar_tabs.html.erb
index 49d6ebaf..1ed98300 100644
--- a/app/views/accounts/_account_sidebar_tabs.html.erb
+++ b/app/views/accounts/_account_sidebar_tabs.html.erb
@@ -1,88 +1,91 @@
-<%# locals: (family:) %>
+<%# locals: (family:, active_account_group_tab:) %>
-<% if family.requires_data_provider? && Provider::Registry.get_provider(:synth).nil? %>
-
-
-
- <%= icon "triangle-alert", size: "sm" %>
-
Missing historical data
+
+ <% if family.missing_data_provider? %>
+
+
+
+ <%= icon "triangle-alert", size: "sm", color: "warning" %>
+
Missing historical data
+
+
+ <%= icon("chevron-down", color: "warning", class: "group-open:transform group-open:rotate-180") %>
+
+
+
Maybe uses Synth API to fetch historical exchange rates, security prices, and more. This data is required to calculate accurate historical account balances.
+
+
+ <%= link_to "Add your Synth API key here.", settings_hosting_path, class: "text-yellow-600 underline" %>
+
+
+ <% end %>
- <%= lucide_icon "chevron-down", class: "text-yellow-600 group-open:transform group-open:rotate-180 w-5" %>
-
-
-
Maybe uses Synth API to fetch historical exchange rates, security prices, and more. This data is required to calculate accurate historical account balances.
-
-
- <%= link_to "Add your Synth API key here.", settings_hosting_path, class: "text-yellow-600 underline" %>
-
-
-
-<% end %>
-
-
-
-
- Assets
-
-
-
- Debts
-
-
-
- All
-
-
-
-
- <%= link_to new_account_path(step: "method_select", classification: "asset"),
- class: "flex items-center gap-3 btn btn--ghost text-secondary mb-1",
- data: { turbo_frame: "modal" } do %>
- <%= icon("plus") %>
-
New asset
+ <%= render TabsComponent.new(active_tab: active_account_group_tab, url_param_key: "account_group_tab", testid: "account-sidebar-tabs") do |tabs| %>
+ <% tabs.with_nav do |nav| %>
+ <% nav.with_btn(id: "assets", label: "Assets") %>
+ <% nav.with_btn(id: "debts", label: "Debts") %>
+ <% nav.with_btn(id: "all", label: "All") %>
<% end %>
-
- <% family.balance_sheet.account_groups("asset").each do |group| %>
- <%= render "accounts/accountable_group", account_group: group %>
- <% end %>
-
-
+ <% tabs.with_panel(tab_id: "assets") do %>
+
+ <%= render LinkComponent.new(
+ text: "New asset",
+ variant: "ghost",
+ href: new_account_path(step: "method_select", classification: "asset"),
+ icon: "plus",
+ frame: :modal,
+ full_width: true,
+ class: "justify-start"
+ ) %>
-
- <%= link_to new_account_path(step: "method_select", classification: "liability"),
- class: "flex items-center gap-3 btn btn--ghost text-secondary mb-1",
- data: { turbo_frame: "modal" } do %>
- <%= icon("plus") %>
-
New debt
+
+ <% family.balance_sheet.account_groups("asset").each do |group| %>
+ <%= render "accounts/accountable_group", account_group: group %>
+ <% end %>
+
+
<% end %>
-
- <% family.balance_sheet.account_groups("liability").each do |group| %>
- <%= render "accounts/accountable_group", account_group: group %>
- <% end %>
-
-
+ <% tabs.with_panel(tab_id: "debts") do %>
+
+ <%= render LinkComponent.new(
+ text: "New debt",
+ variant: "ghost",
+ href: new_account_path(step: "method_select", classification: "liability"),
+ icon: "plus",
+ frame: :modal,
+ full_width: true,
+ class: "justify-start"
+ ) %>
-
- <%= link_to new_account_path(step: "method_select"),
- class: "flex items-center gap-3 btn btn--ghost text-secondary mb-1",
- data: { turbo_frame: "modal" } do %>
- <%= icon("plus") %>
-
New account
+
+ <% family.balance_sheet.account_groups("liability").each do |group| %>
+ <%= render "accounts/accountable_group", account_group: group %>
+ <% end %>
+
+
<% end %>
-
- <% family.balance_sheet.account_groups.each do |group| %>
- <%= render "accounts/accountable_group", account_group: group %>
- <% end %>
-
-
+ <% tabs.with_panel(tab_id: "all") do %>
+
+ <%= render LinkComponent.new(
+ text: "New account",
+ variant: "ghost",
+ full_width: true,
+ href: new_account_path(step: "method_select"),
+ icon: "plus",
+ frame: :modal,
+ class: "justify-start"
+ ) %>
+
+
+ <% family.balance_sheet.account_groups.each do |group| %>
+ <%= render "accounts/accountable_group", account_group: group %>
+ <% end %>
+
+
+ <% end %>
+ <% end %>
diff --git a/app/views/accounts/_account_type.html.erb b/app/views/accounts/_account_type.html.erb
index 63622859..9eb98d6e 100644
--- a/app/views/accounts/_account_type.html.erb
+++ b/app/views/accounts/_account_type.html.erb
@@ -1,9 +1,11 @@
<%# locals: (accountable:) %>
<%= link_to new_polymorphic_path(accountable, step: "method_select", return_to: params[:return_to]),
- class: "flex items-center gap-4 w-full text-center focus:outline-hidden hover:bg-surface-hover focus:bg-surface-hover fg-contrast hover:fg-primary focus:fg-primary border border-transparent block px-2 rounded-lg p-2" do %>
-
- <%= lucide_icon(accountable.icon, style: "color: #{accountable.color}", class: "w-5 h-5") %>
-
+ class: "flex items-center gap-4 w-full text-center focus:outline-hidden hover:bg-surface-hover focus:bg-surface-hover fg-primary border border-transparent block px-2 rounded-lg p-2" do %>
+ <%= render FilledIconComponent.new(
+ icon: accountable.icon,
+ hex_color: accountable.color,
+ ) %>
+
<%= accountable.display_name.singularize %>
<% end %>
diff --git a/app/views/accounts/_accountable_group.html.erb b/app/views/accounts/_accountable_group.html.erb
index 3e2a26ee..a3939727 100644
--- a/app/views/accounts/_accountable_group.html.erb
+++ b/app/views/accounts/_accountable_group.html.erb
@@ -1,11 +1,7 @@
<%# locals: (account_group:) %>
-
-
- <%= lucide_icon("chevron-right", class: "group-open:rotate-90 text-secondary w-5 h-5") %>
-
- <%= tag.p account_group.name, class: "text-sm font-medium" %>
-
+<%= render DisclosureComponent.new(title: account_group.name, align: :left) do |disclosure| %>
+ <% disclosure.with_summary_content do %>
<%= tag.p format_money(account_group.total_money), class: "text-sm font-medium text-primary" %>
@@ -15,11 +11,11 @@
<% end %>
-
+ <% end %>
<% account_group.accounts.each do |account| %>
- <%= link_to account_path(account), class: "block flex items-center gap-2 btn btn--ghost", title: account.name do %>
+ <%= link_to account_path(account), class: "block flex items-center gap-2 px-3 py-2 hover:bg-surface-hover", title: account.name do %>
<%= render "accounts/logo", account: account, size: "sm", color: account_group.color %>
@@ -40,10 +36,13 @@
<% end %>
- <%= link_to new_polymorphic_path(account_group.key, step: "method_select"),
- class: "flex items-center gap-3 btn btn--ghost text-secondary",
- data: { turbo_frame: "modal" } do %>
- <%= icon("plus") %>
-
New <%= account_group.name.downcase.singularize %>
- <% end %>
-
+ <%= render LinkComponent.new(
+ href: new_polymorphic_path(account_group.key, step: "method_select"),
+ text: "New #{account_group.name.downcase.singularize}",
+ icon: "plus",
+ full_width: true,
+ variant: "ghost",
+ frame: :modal,
+ class: "justify-start"
+ ) %>
+<% end %>
diff --git a/app/views/accounts/_empty.html.erb b/app/views/accounts/_empty.html.erb
index 6a1b6c89..a1eef715 100644
--- a/app/views/accounts/_empty.html.erb
+++ b/app/views/accounts/_empty.html.erb
@@ -3,9 +3,10 @@
<%= tag.p t(".no_accounts"), class: "text-primary mb-1 font-medium" %>
<%= tag.p t(".empty_message"), class: "text-secondary mb-4" %>
- <%= link_to new_account_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %>
- <%= lucide_icon("plus", class: "w-5 h-5") %>
-
<%= t(".new_account") %>
- <% end %>
+ <%= render LinkComponent.new(
+ text: t(".new_account"),
+ href: new_account_path,
+ frame: :modal
+ ) %>
diff --git a/app/views/accounts/_logo.html.erb b/app/views/accounts/_logo.html.erb
index 9286a0f3..8eec2153 100644
--- a/app/views/accounts/_logo.html.erb
+++ b/app/views/accounts/_logo.html.erb
@@ -12,5 +12,5 @@
<% elsif account.logo.attached? %>
<%= image_tag account.logo, class: "shrink-0 rounded-full #{size_classes[size]}" %>
<% else %>
- <%= circle_logo(account.name, hex: color || account.accountable.color, size: size) %>
+ <%= render FilledIconComponent.new(variant: :text, hex_color: color || account.accountable.color, text: account.name, size: size, rounded: true) %>
<% end %>
diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb
index fcdca138..4893e44c 100644
--- a/app/views/accounts/index.html.erb
+++ b/app/views/accounts/index.html.erb
@@ -2,23 +2,23 @@
<%= t(".accounts") %>
- <%= button_to sync_all_accounts_path,
- disabled: Current.family.syncing?,
- class: "md:btn md:btn--outline flex items-center justify-center gap-2 w-9 h-9 md:w-auto md:h-auto rounded-full md:rounded-lg",
- title: t(".sync") do %>
- <%= lucide_icon "refresh-cw", class: "w-5 h-5" %>
-
<%= t(".sync") %>
- <% end %>
+ <%= render ButtonComponent.new(
+ text: "Sync all",
+ href: sync_all_accounts_path,
+ method: :post,
+ variant: "outline",
+ disabled: Current.family.syncing?,
+ icon: "refresh-cw",
+ class: ""
+ ) %>
- <%= link_to new_account_path(return_to: accounts_path),
- data: { turbo_frame: "modal" },
- class: "btn btn--primary flex items-center justify-center gap-1 w-9 h-9 md:w-auto md:h-auto rounded-full md:rounded-lg" do %>
-
-
- <%= lucide_icon("plus") %>
-
-
<%= t(".new_account") %>
- <% end %>
+ <%= render LinkComponent.new(
+ text: "New account",
+ href: new_account_path(return_to: accounts_path),
+ variant: "primary",
+ icon: "plus",
+ frame: :modal
+ ) %>
diff --git a/app/views/accounts/index/_manual_accounts.html.erb b/app/views/accounts/index/_manual_accounts.html.erb
index b20c400e..0dd8f66d 100644
--- a/app/views/accounts/index/_manual_accounts.html.erb
+++ b/app/views/accounts/index/_manual_accounts.html.erb
@@ -2,10 +2,10 @@
- <%= lucide_icon "chevron-right", class: "group-open:transform group-open:rotate-90 text-secondary w-5" %>
+ <%= icon("chevron-right", class: "group-open:transform group-open:rotate-90") %>
- <%= lucide_icon("folder-pen", class: "w-5 h-5 text-secondary") %>
+ <%= icon("folder-pen") %>
<%= t(".other_accounts") %>
diff --git a/app/views/accounts/new.html.erb b/app/views/accounts/new.html.erb
index 0bde1bd8..1ca5229e 100644
--- a/app/views/accounts/new.html.erb
+++ b/app/views/accounts/new.html.erb
@@ -24,10 +24,12 @@
<% unless params[:return_to].present? %>
<%= button_to imports_path(import: { type: "AccountImport" }),
data: { turbo_frame: :_top },
- class: "flex items-center gap-4 w-full text-center focus:outline-hidden hover:bg-surface-hover focus:bg-surface-hover fg-contrast hover:fg-primary focus:fg-primary border border-transparent block px-2 rounded-lg p-2" do %>
-
- <%= lucide_icon("download", style: "color: #F79009", class: "w-5 h-5") %>
-
+ class: "flex items-center gap-4 w-full text-center focus:outline-hidden hover:bg-surface-hover focus:bg-surface-hover fg-primary border border-transparent block px-2 rounded-lg p-2" do %>
+ <%= render FilledIconComponent.new(
+ icon: "download",
+ hex_color: "#F79009",
+ ) %>
+
<%= t("accounts.new.import_accounts") %>
<% end %>
<% end %>
diff --git a/app/views/accounts/new/_container.html.erb b/app/views/accounts/new/_container.html.erb
index d83d2e8c..2d3582f7 100644
--- a/app/views/accounts/new/_container.html.erb
+++ b/app/views/accounts/new/_container.html.erb
@@ -1,18 +1,22 @@
<%# locals: (title:, back_path: nil) %>
-<%= modal do %>
-
-
- <% if back_path %>
- <%= link_to back_path, class: "flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 focus:outline-gray-300 focus:outline" do %>
- <%= lucide_icon("arrow-left", class: "text-secondary w-5 h-5") %>
+<%= render DialogComponent.new do |dialog| %>
+
+
+
+ <% if back_path %>
+ <%= render LinkComponent.new(
+ variant: "icon",
+ icon: "arrow-left",
+ href: back_path,
+ size: "lg"
+ ) %>
<% end %>
- <% end %>
- <%= title %>
-
- <%= lucide_icon("x", class: "text-secondary w-6 h-6") %>
-
+ <%= title %>
+
+
+ <%= icon("x", as_button: true, size: "lg", data: { action: "dialog#close" }) %>
@@ -22,20 +26,26 @@
<%= yield %>
-
+
Select
- <%= lucide_icon("corner-down-left", class: "inline w-3 h-3") %>
+
+ <%= icon("corner-down-left", size: "xs") %>
+
Navigate
- <%= lucide_icon("arrow-up", class: "inline w-3 h-3") %>
- <%= lucide_icon("arrow-down", class: "inline w-3 h-3") %>
+
+ <%= icon("arrow-up", size: "xs") %>
+
+
+ <%= icon("arrow-down", size: "xs") %>
+
- Close
+ Close
ESC
diff --git a/app/views/accounts/new/_method_selector.html.erb b/app/views/accounts/new/_method_selector.html.erb
index 07a70074..25dc9f62 100644
--- a/app/views/accounts/new/_method_selector.html.erb
+++ b/app/views/accounts/new/_method_selector.html.erb
@@ -2,18 +2,18 @@
<%= render layout: "accounts/new/container", locals: { title: t(".title"), back_path: new_account_path } do %>
- <%= link_to path, class: "flex items-center gap-4 w-full text-center 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 %>
+ <%= 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 %>
- <%= lucide_icon("keyboard", class: "text-secondary w-5 h-5") %>
+ <%= icon("keyboard") %>
<%= t("accounts.new.method_selector.manual_entry") %>
<% end %>
<% if us_link_token %>
<%# Default US-only Link %>
-
+
- <%= lucide_icon("link-2", class: "text-secondary w-5 h-5") %>
+ <%= icon("link-2") %>
<%= t("accounts.new.method_selector.connected_entry") %>
@@ -21,9 +21,9 @@
<%# EU Link %>
<% if eu_link_token %>
-
+
- <%= lucide_icon("link-2", class: "text-secondary w-5 h-5") %>
+ <%= icon("link-2") %>
<%= t("accounts.new.method_selector.connected_entry_eu") %>
diff --git a/app/views/accounts/show/_activity.html.erb b/app/views/accounts/show/_activity.html.erb
index 3e9805bc..00bf610d 100644
--- a/app/views/accounts/show/_activity.html.erb
+++ b/app/views/accounts/show/_activity.html.erb
@@ -2,30 +2,29 @@
<%= turbo_frame_tag dom_id(account, "entries") do %>
-
+
<%= tag.h2 t(".title"), class: "font-medium text-lg" %>
<% unless @account.plaid_account_id.present? %>
-
-
- <%= lucide_icon("plus", class: "w-4 h-4") %>
- <%= tag.span t(".new") %>
-
-
- <%= link_to new_valuation_path(account_id: @account.id), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
- <%= lucide_icon("circle-dollar-sign", class: "text-secondary w-5 h-5") %>
- <%= tag.span t(".new_balance"), class: "text-sm" %>
- <% end %>
+ <%= render MenuComponent.new(variant: "button") do |menu| %>
+ <% menu.with_button(text: "New", variant: "secondary", icon: "plus") %>
- <% unless @account.crypto? %>
- <%= link_to @account.investment? ? new_trade_path(account_id: @account.id) : new_transaction_path(account_id: @account.id), data: { turbo_frame: :modal }, class: "btn btn--primary flex items-center justify-center gap-2 rounded-full md:rounded-lg w-9 h-9 md:w-auto md:h-auto" do %>
-
- <%= lucide_icon("credit-card", class: "text-secondary w-5 h-5") %>
-
- <%= tag.span t(".new_transaction"), class: "text-sm md:block" %>
- <% end %>
- <% end %>
-
-
+ <% menu.with_item(
+ variant: "link",
+ text: "New balance",
+ icon: "circle-dollar-sign",
+ href: new_valuation_path(account_id: @account.id),
+ data: { turbo_frame: :modal }) %>
+
+ <% unless @account.crypto? %>
+ <% href = @account.investment? ? new_trade_path(account_id: @account.id) : new_transaction_path(account_id: @account.id) %>
+ <% menu.with_item(
+ variant: "link",
+ text: "New transaction",
+ icon: "credit-card",
+ href: href,
+ data: { turbo_frame: :modal }) %>
+ <% end %>
+ <% end %>
<% end %>
@@ -38,7 +37,7 @@
- <%= lucide_icon("search", class: "w-5 h-5 text-secondary") %>
+ <%= icon("search") %>
<%= hidden_field_tag :account_id, @account.id %>
<%= form.search_field :search,
placeholder: "Search entries by name",
diff --git a/app/views/accounts/show/_chart.html.erb b/app/views/accounts/show/_chart.html.erb
index 9910fee0..c94adeb3 100644
--- a/app/views/accounts/show/_chart.html.erb
+++ b/app/views/accounts/show/_chart.html.erb
@@ -24,7 +24,11 @@
data: { "auto-submit-form-target": "auto" } %>
<% end %>
- <%= period_select form: form, selected: period %>
+ <%= form.select :period,
+ Period.as_options,
+ { selected: period.key },
+ data: { "auto-submit-form-target": "auto" },
+ class: "bg-container border border-secondary font-medium rounded-lg px-3 py-2 text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0" %>
<% end %>
diff --git a/app/views/accounts/show/_header.html.erb b/app/views/accounts/show/_header.html.erb
index 6d822711..4c60e832 100644
--- a/app/views/accounts/show/_header.html.erb
+++ b/app/views/accounts/show/_header.html.erb
@@ -19,17 +19,27 @@
<% end %>
-
+
<% if account.plaid_account_id.present? %>
<% if Rails.env.development? %>
- <%= button_to sync_plaid_item_path(account.plaid_account.plaid_item), disabled: account.syncing?, data: { turbo: false }, class: "flex items-center gap-2", title: "Sync Account" do %>
- <%= lucide_icon "refresh-cw", class: "w-4 h-4 text-secondary hover:text-subdued" %>
- <% end %>
+ <%= icon(
+ "refresh-cw",
+ as_button: true,
+ size: "sm",
+ href: sync_plaid_item_path(account.plaid_account.plaid_item),
+ disabled: account.syncing?,
+ frame: :_top
+ ) %>
<% end %>
<% else %>
- <%= button_to sync_account_path(account), disabled: account.syncing?, data: { turbo: false }, class: "flex items-center gap-2", title: "Sync Account" do %>
- <%= lucide_icon "refresh-cw", class: "w-4 h-4 text-secondary hover:text-subdued" %>
- <% end %>
+ <%= icon(
+ "refresh-cw",
+ as_button: true,
+ size: "sm",
+ href: sync_account_path(account),
+ disabled: account.syncing?,
+ frame: :_top
+ ) %>
<% end %>
<%= render "accounts/show/menu", account: account %>
diff --git a/app/views/accounts/show/_menu.html.erb b/app/views/accounts/show/_menu.html.erb
index c5c9549a..5e59c88a 100644
--- a/app/views/accounts/show/_menu.html.erb
+++ b/app/views/accounts/show/_menu.html.erb
@@ -1,47 +1,25 @@
<%# locals: (account:) %>
-<%= contextual_menu do %>
-
- <% if account.plaid_account_id.present? %>
- <%= link_to accounts_path,
- data: { turbo_frame: :_top },
- class: "block w-full py-2 px-3 space-x-2 text-primary hover:bg-gray-50 flex items-center rounded-lg" do %>
- <%= lucide_icon "pencil-line", class: "w-5 h-5 text-secondary" %>
+<%= render MenuComponent.new(testid: "account-menu") do |menu| %>
+ <% menu.with_item(variant: "link", text: "Edit", href: edit_account_path(account), icon: "pencil-line", data: { turbo_frame: :modal }) %>
- <%= t(".manage") %>
- <% end %>
- <% else %>
- <%= link_to edit_account_path(account),
- data: { turbo_frame: :modal },
- class: "block w-full py-2 px-3 space-x-2 text-primary hover:bg-gray-50 flex items-center rounded-lg" do %>
- <%= lucide_icon "pencil-line", class: "w-5 h-5 text-secondary" %>
+ <% unless account.crypto? %>
+ <% menu.with_item(
+ variant: "link",
+ text: "Import transactions",
+ href: imports_path({ import: { type: account.investment? ? "TradeImport" : "TransactionImport", account_id: account.id } }),
+ icon: "download",
+ data: { turbo_frame: :_top }
+ ) %>
+ <% end %>
- <%= t(".edit") %>
- <% end %>
-
- <% unless account.crypto? %>
- <%= button_to imports_path({ import: { type: account.investment? ? "TradeImport" : "TransactionImport", account_id: account.id } }),
- data: { turbo_frame: :_top },
- class: "block w-full py-2 px-3 space-x-2 text-primary hover:bg-gray-50 flex items-center rounded-lg" do %>
- <%= lucide_icon "download", class: "w-5 h-5 text-secondary" %>
-
- <%= t(".import") %>
- <% end %>
- <% end %>
-
- <%= button_to account_path(account),
- method: :delete,
- class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg",
- data: {
- turbo_frame: :_top,
- turbo_confirm: {
- title: t(".confirm_title"),
- body: t(".confirm_body_html"),
- accept: t(".confirm_accept", name: account.name)
- }
- } do %>
- <%= lucide_icon("trash-2", class: "w-5 h-5 mr-2") %> Delete account
- <% end %>
- <% end %>
-
+ <% menu.with_item(
+ variant: "button",
+ text: "Delete account",
+ href: account_path(account),
+ method: :delete,
+ icon: "trash-2",
+ confirm: CustomConfirm.for_resource_deletion("Account", high_severity: true),
+ data: { turbo_frame: :_top }
+ ) %>
<% end %>
diff --git a/app/views/accounts/show/_tabs.html.erb b/app/views/accounts/show/_tabs.html.erb
index 2a0e2b2f..169fe0d4 100644
--- a/app/views/accounts/show/_tabs.html.erb
+++ b/app/views/accounts/show/_tabs.html.erb
@@ -1,11 +1,17 @@
<%# locals: (account:, tabs:) %>
-<% selected_tab = tabs.find { |tab| tab[:key] == params[:tab] } || tabs.first %>
+<% active_tab = tabs.find { |tab| tab[:key] == params[:tab] } || tabs.first %>
-
- <% tabs.each do |tab| %>
- <%= render "accounts/show/tab", account: account, key: tab[:key], is_selected: selected_tab[:key] == tab[:key] %>
+<%= render TabsComponent.new(active_tab: active_tab[:key], url_param_key: "tab") do |tabs_container| %>
+ <% tabs_container.with_nav(classes: "max-w-fit") do |nav| %>
+ <% tabs.each do |tab| %>
+ <% nav.with_btn(id: tab[:key], label: tab[:key].humanize, classes: "px-6") %>
+ <% end %>
<% end %>
-
-<%= selected_tab[:contents] %>
+ <% tabs.each do |tab| %>
+ <% tabs_container.with_panel(tab_id: tab[:key]) do %>
+ <%= tab[:contents] %>
+ <% end %>
+ <% end %>
+<% end %>
diff --git a/app/views/accounts/show/_template.html.erb b/app/views/accounts/show/_template.html.erb
index 5bc44376..20d352b5 100644
--- a/app/views/accounts/show/_template.html.erb
+++ b/app/views/accounts/show/_template.html.erb
@@ -16,7 +16,7 @@
<%= render "accounts/show/chart", account: account %>
<% end %>
-
+
Assistant reasoning
- <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-secondary w-5" %>
+ <%= icon("chevron-down", class: "group-open:transform group-open:rotate-180") %>
<%= markdown(assistant_message.content) %>
diff --git a/app/views/assistant_messages/_tool_calls.html.erb b/app/views/assistant_messages/_tool_calls.html.erb
index fc0e8129..59c14922 100644
--- a/app/views/assistant_messages/_tool_calls.html.erb
+++ b/app/views/assistant_messages/_tool_calls.html.erb
@@ -2,7 +2,7 @@
- <%= lucide_icon "chevron-right", class: "group-open:transform group-open:rotate-90 text-secondary w-5" %>
+ <%= icon("chevron-right", class: "group-open:transform group-open:rotate-90") %>
Tool Calls
diff --git a/app/views/budget_categories/_budget_category.html.erb b/app/views/budget_categories/_budget_category.html.erb
index 0c850678..6ae6ff38 100644
--- a/app/views/budget_categories/_budget_category.html.erb
+++ b/app/views/budget_categories/_budget_category.html.erb
@@ -8,11 +8,17 @@
<%= render "budget_categories/budget_category_donut", budget_category: budget_category %>
<% else %>
-
+
<% if budget_category.category.lucide_icon %>
- <%= icon(budget_category.category.lucide_icon) %>
+ <%= icon(budget_category.category.lucide_icon, color: "current") %>
<% else %>
- <%= render "shared/circle_logo", name: budget_category.category.name, hex: budget_category.category.color %>
+ <%= render FilledIconComponent.new(
+ variant: :text,
+ hex_color: budget_category.category.color,
+ text: budget_category.category.name,
+ size: "sm",
+ rounded: true
+ ) %>
<% end %>
<% end %>
diff --git a/app/views/budget_categories/_budget_category_donut.html.erb b/app/views/budget_categories/_budget_category_donut.html.erb
index 517135fe..0305d316 100644
--- a/app/views/budget_categories/_budget_category_donut.html.erb
+++ b/app/views/budget_categories/_budget_category_donut.html.erb
@@ -11,7 +11,9 @@
<% if budget_category.category.lucide_icon %>
- <%= lucide_icon budget_category.category.lucide_icon, class: "w-4 h-4 shrink-0", style: "color: #{budget_category.category.color}" %>
+
+ <%= icon(budget_category.category.lucide_icon, size: "sm", color: "current") %>
+
<% else %>
<%= budget_category.category.name.first.upcase %>
diff --git a/app/views/budget_categories/_confirm_button.html.erb b/app/views/budget_categories/_confirm_button.html.erb
index 6c949eae..f7e00a22 100644
--- a/app/views/budget_categories/_confirm_button.html.erb
+++ b/app/views/budget_categories/_confirm_button.html.erb
@@ -1,11 +1,10 @@
- <% if budget.allocations_valid? %>
- <%= link_to "Confirm",
- budget_path(budget),
- class: "block btn btn--primary w-full text-center" %>
- <% else %>
-
- Confirm
-
- <% end %>
+ <%= render ButtonComponent.new(
+ text: "Confirm",
+ variant: "primary",
+ full_width: true,
+ href: budget_path(budget),
+ method: :get,
+ disabled: !budget.allocations_valid?
+ ) %>
diff --git a/app/views/budget_categories/_no_categories.html.erb b/app/views/budget_categories/_no_categories.html.erb
index 5f489f94..927a0b2b 100644
--- a/app/views/budget_categories/_no_categories.html.erb
+++ b/app/views/budget_categories/_no_categories.html.erb
@@ -6,12 +6,18 @@
- <%= button_to "Use defaults (recommended)", bootstrap_categories_path, class: "btn btn--primary" %>
+ <%= render ButtonComponent.new(
+ text: "Use defaults (recommended)",
+ href: bootstrap_categories_path,
+ ) %>
- <%= link_to new_category_path, class: "btn btn--outline flex items-center gap-1", data: { turbo_frame: "modal" } do %>
- <%= lucide_icon("plus", class: "w-5 h-5") %>
- New category
- <% end %>
+ <%= render LinkComponent.new(
+ text: "New category",
+ variant: "outline",
+ icon: "plus",
+ href: new_category_path,
+ frame: :modal,
+ ) %>
diff --git a/app/views/budget_categories/index.html.erb b/app/views/budget_categories/index.html.erb
index 4d38f594..d5ded43d 100644
--- a/app/views/budget_categories/index.html.erb
+++ b/app/views/budget_categories/index.html.erb
@@ -32,7 +32,7 @@
<% group.budget_subcategories.each do |budget_subcategory| %>
- <%= lucide_icon "corner-down-right", class: "w-5 h-5 shrink-0" %>
+ <%= icon("corner-down-right") %>
<%= render "budget_categories/budget_category_form", budget_category: budget_subcategory %>
diff --git a/app/views/budget_categories/show.html.erb b/app/views/budget_categories/show.html.erb
index cf6bb5a8..53b06240 100644
--- a/app/views/budget_categories/show.html.erb
+++ b/app/views/budget_categories/show.html.erb
@@ -1,39 +1,32 @@
-<%= drawer do %>
-
-
-
-
Category
-
- <%= @budget_category.name %>
-
-
- <% if @budget_category.budget.initialized? %>
-
-
- <%= format_money(@budget_category.actual_spending_money) %>
-
- /
- <%= format_money(@budget_category.budgeted_spending_money) %>
-
- <% end %>
-
+<%= render DialogComponent.new(variant: :drawer) do |dialog| %>
+ <% dialog.with_header do %>
+
+
Category
+
+ <%= @budget_category.name %>
+
<% if @budget_category.budget.initialized? %>
-
- <%= render "budget_categories/budget_category_donut",
- budget_category: @budget_category %>
-
+
+
+ <%= format_money(@budget_category.actual_spending_money) %>
+
+ /
+ <%= format_money(@budget_category.budgeted_spending_money) %>
+
<% end %>
-
+
-
-
- Overview
- <%= lucide_icon "chevron-down",
- class: "group-open:transform group-open:rotate-180 text-secondary w-5" %>
-
+ <% if @budget_category.budget.initialized? %>
+
+ <%= render "budget_categories/budget_category_donut",
+ budget_category: @budget_category %>
+
+ <% end %>
+ <% end %>
+ <% dialog.with_body do %>
+ <% dialog.with_section(title: "Overview", open: true) do %>
@@ -49,20 +42,20 @@
Status
<% if @budget_category.available_to_spend.negative? %>
-
- <%= lucide_icon "alert-circle", class: "shrink-0 w-4 h-4 text-red-500" %>
+
+ <%= icon "alert-circle", size: "sm", color: "destructive" %>
<%= format_money @budget_category.available_to_spend_money.abs %>
overspent
<% elsif @budget_category.available_to_spend.zero? %>
-
- <%= lucide_icon "x-circle", class: "shrink-0 w-4 h-4 text-orange-500" %>
+
+ <%= icon "x-circle", size: "sm", color: "warning" %>
<%= format_money @budget_category.available_to_spend_money %>
left
<% else %>
- <%= lucide_icon "check-circle-2", class: "shrink-0 w-4 h-4 text-green-500" %>
+ <%= icon "check-circle", size: "sm", color: "success" %>
<%= format_money @budget_category.available_to_spend_money %>
left
@@ -92,16 +85,9 @@
-
-
-
-
- Recent Transactions
- <%= lucide_icon "chevron-down",
- class: "group-open:transform group-open:rotate-180 text-secondary w-5" %>
-
+ <% end %>
+ <% dialog.with_section(title: "Recent Transactions", open: true) do %>
<% if @recent_transactions.any? %>
@@ -133,14 +119,17 @@
<% end %>
- <%= link_to "View all category transactions",
- transactions_path(q: {
- categories: [@budget_category.name],
- start_date: @budget.start_date,
- end_date: @budget.end_date
- }),
- data: { turbo_frame: :_top },
- class: "block text-center btn btn--outline w-full" %>
+ <%= render LinkComponent.new(
+ text: "View all category transactions",
+ variant: "outline",
+ full_width: true,
+ href: transactions_path(q: {
+ categories: [@budget_category.name],
+ start_date: @budget.start_date,
+ end_date: @budget.end_date
+ }),
+ frame: :_top
+ ) %>
<% else %>
No transactions found for this budget period.
@@ -148,6 +137,6 @@
<% end %>
-
-
+ <% end %>
+ <% end %>
<% end %>
diff --git a/app/views/budgets/_budget_categories.html.erb b/app/views/budgets/_budget_categories.html.erb
index e9ff5f11..5b9e1a1c 100644
--- a/app/views/budgets/_budget_categories.html.erb
+++ b/app/views/budgets/_budget_categories.html.erb
@@ -25,7 +25,7 @@
<% group.budget_subcategories.each do |budget_subcategory| %>
- <%= lucide_icon "corner-down-right", class: "w-5 h-5 shrink-0" %>
+ <%= icon "corner-down-right" %>
<%= render "budget_categories/budget_category", budget_category: budget_subcategory %>
diff --git a/app/views/budgets/_budget_donut.html.erb b/app/views/budgets/_budget_donut.html.erb
index 8ec57be3..384830e5 100644
--- a/app/views/budgets/_budget_donut.html.erb
+++ b/app/views/budgets/_budget_donut.html.erb
@@ -8,24 +8,29 @@
Spent
- ">
+
">
<%= format_money(budget.actual_spending_money) %>
- <%= link_to edit_budget_path(budget), class: "btn btn--secondary flex items-center gap-1 mt-2" do %>
-
- of <%= format_money(budget.budgeted_spending_money) %>
-
- <%= lucide_icon "pencil", class: "w-4 h-4 text-secondary hover:text-gray-600" %>
- <% end %>
+ <%= render LinkComponent.new(
+ text: "of #{budget.budgeted_spending_money.format}",
+ variant: "secondary",
+ icon: "pencil",
+ icon_position: "right",
+ size: "sm",
+ href: edit_budget_path(budget)
+ ) %>
<% else %>
<%= format_money Money.new(0, budget.currency || budget.family.currency) %>
- <%= link_to edit_budget_path(budget), class: "flex items-center gap-2 btn btn--primary" do %>
- <%= lucide_icon "plus", class: "w-4 h-4 text-white" %>
- New budget
- <% end %>
+
+ <%= render LinkComponent.new(
+ text: "New budget",
+ size: "sm",
+ icon: "plus",
+ href: edit_budget_path(budget)
+ ) %>
<% end %>
@@ -41,11 +46,14 @@
<%= format_money(bc.actual_spending_money) %>
- <%= link_to budget_budget_categories_path(budget), class: "btn btn--secondary flex items-center gap-1" do %>
- of <%= format_money(bc.budgeted_spending_money, precision: 0) %>
-
- <%= lucide_icon "pencil", class: "w-4 h-4 text-secondary shrink-0" %>
- <% end %>
+ <%= render LinkComponent.new(
+ text: "of #{bc.budgeted_spending_money.format(precision: 0)}",
+ variant: "secondary",
+ icon: "pencil",
+ icon_position: "right",
+ size: "sm",
+ href: budget_budget_categories_path(budget)
+ ) %>
<% end %>
diff --git a/app/views/budgets/_budget_header.html.erb b/app/views/budgets/_budget_header.html.erb
index 8dfdb679..c6c8ce21 100644
--- a/app/views/budgets/_budget_header.html.erb
+++ b/app/views/budgets/_budget_header.html.erb
@@ -3,38 +3,46 @@
<% if budget.previous_budget_param %>
- <%= link_to budget_path(budget.previous_budget_param) do %>
- <%= lucide_icon "chevron-left" %>
- <% end %>
+ <%= render LinkComponent.new(
+ variant: "icon",
+ icon: "chevron-left",
+ href: budget_path(budget.previous_budget_param),
+ ) %>
<% else %>
- <%= lucide_icon "chevron-left", class: "text-subdued" %>
+
+ <%= icon "chevron-left", color: "current" %>
+
<% end %>
<% if budget.next_budget_param %>
- <%= link_to budget_path(budget.next_budget_param) do %>
- <%= lucide_icon "chevron-right" %>
- <% end %>
+ <%= render LinkComponent.new(
+ variant: "icon",
+ icon: "chevron-right",
+ href: budget_path(budget.next_budget_param),
+ ) %>
<% else %>
- <%= lucide_icon "chevron-right", class: "text-subdued" %>
+
+ <%= icon "chevron-right", color: "current" %>
+
<% end %>
-
- <%= tag.button data: { menu_target: "button" }, class: "flex items-center gap-1 hover:bg-alpha-black-25 cursor-pointer rounded-md p-2" do %>
-
<%= @budget.name %>
- <%= lucide_icon "chevron-down", class: "w-5 h-5 shrink-0 text-secondary" %>
+ <%= render MenuComponent.new(variant: "button") do |menu| %>
+ <% menu.with_button class: "flex items-center gap-1 hover:bg-alpha-black-25 cursor-pointer rounded-md p-2" do %>
+
<%= @budget.name %>
+ <%= icon("chevron-down") %>
<% end %>
-
+ <% menu.with_custom_content do %>
<%= render "budgets/picker", family: Current.family, year: budget.start_date.year %>
-
-
+ <% end %>
+ <% end %>
- <% if @budget.current? %>
- Today
- <% else %>
- <%= link_to "Today", budget_path(Budget.date_to_param(Date.current)), class: "btn btn--outline" %>
- <% end %>
+ <%= render LinkComponent.new(
+ text: "Today",
+ variant: "outline",
+ href: budget_path(Budget.date_to_param(Date.current)),
+ ) %>
diff --git a/app/views/budgets/_budget_nav.html.erb b/app/views/budgets/_budget_nav.html.erb
index 5ad87caa..41d81a61 100644
--- a/app/views/budgets/_budget_nav.html.erb
+++ b/app/views/budgets/_budget_nav.html.erb
@@ -24,7 +24,7 @@
<%= link_to step[:path], class: "flex items-center gap-3" do %>
- <%= step[:is_complete] && !is_current ? lucide_icon("check", class: "w-4 h-4") : idx + 1 %>
+ <%= step[:is_complete] && !is_current ? icon("check", size: "sm") : idx + 1 %>
<%= step[:name] %>
diff --git a/app/views/budgets/_over_allocation_warning.html.erb b/app/views/budgets/_over_allocation_warning.html.erb
index 95381811..8d9f5913 100644
--- a/app/views/budgets/_over_allocation_warning.html.erb
+++ b/app/views/budgets/_over_allocation_warning.html.erb
@@ -1,13 +1,15 @@
<%# locals: (budget:) %>
- <%= lucide_icon "alert-triangle", class: "w-6 h-6 text-red-500" %>
+ <%= icon "alert-triangle", size: "lg", color: "destructive" %>
You have over-allocated your budget. Please fix your allocations.
- <%= link_to budget_budget_categories_path(budget), class: "btn btn--secondary flex items-center gap-1" do %>
-
- Fix allocations
-
- <%= lucide_icon "pencil", class: "w-4 h-4 text-secondary hover:text-gray-600" %>
- <% end %>
+ <%= render LinkComponent.new(
+ text: "Fix allocations",
+ variant: "secondary",
+ size: "sm",
+ icon: "pencil",
+ icon_position: "right",
+ href: budget_budget_categories_path(budget)
+ ) %>
diff --git a/app/views/budgets/_picker.html.erb b/app/views/budgets/_picker.html.erb
index aca22c0b..4b112f30 100644
--- a/app/views/budgets/_picker.html.erb
+++ b/app/views/budgets/_picker.html.erb
@@ -1,17 +1,17 @@
<%# locals: (family:, year:) %>
<%= turbo_frame_tag "budget_picker" do %>
-
+
<% last_month_of_previous_year = Date.new(year - 1, 12, 1) %>
<% if Budget.budget_date_valid?(last_month_of_previous_year, family: family) %>
<%= link_to picker_budgets_path(year: year - 1), data: { turbo_frame: "budget_picker" }, class: "p-2 flex items-center justify-center hover:bg-alpha-black-25 rounded-md" do %>
- <%= lucide_icon "chevron-left", class: "w-5 h-5 shrink-0 text-secondary" %>
+ <%= icon "chevron-left" %>
<% end %>
<% else %>
-
- <%= lucide_icon "chevron-left", class: "w-5 h-5 shrink-0 text-subdued" %>
+
+ <%= icon "chevron-left", color: "current" %>
<% end %>
@@ -23,11 +23,11 @@
<% if Budget.budget_date_valid?(first_month_of_next_year, family: family) %>
<%= link_to picker_budgets_path(year: year + 1), data: { turbo_frame: "budget_picker" }, class: "p-2 flex items-center justify-center hover:bg-alpha-black-25 rounded-md" do %>
- <%= lucide_icon "chevron-right", class: "w-5 h-5 shrink-0 text-secondary" %>
+ <%= icon "chevron-right" %>
<% end %>
<% else %>
-
- <%= lucide_icon "chevron-right", class: "w-5 h-5 shrink-0 text-subdued" %>
+
+ <%= icon "chevron-right", color: "current" %>
<% end %>
@@ -38,7 +38,13 @@
<% param_key = Budget.date_to_param(date) %>
<% if Budget.budget_date_valid?(date, family: family) %>
- <%= link_to month_name, budget_path(param_key), data: { turbo_frame: "_top" }, class: "btn btn--ghost" %>
+ <%= render LinkComponent.new(
+ variant: "ghost",
+ text: month_name,
+ href: budget_path(param_key),
+ full_width: true,
+ frame: :_top
+ ) %>
<% else %>
<%= month_name %>
<% end %>
diff --git a/app/views/budgets/edit.html.erb b/app/views/budgets/edit.html.erb
index 3728dc8c..546aa54b 100644
--- a/app/views/budgets/edit.html.erb
+++ b/app/views/budgets/edit.html.erb
@@ -21,7 +21,7 @@
<% if @budget.estimated_income && @budget.estimated_spending %>
- <%= lucide_icon "sparkles", class: "w-5 h-5 text-secondary shrink-0" %>
+ <%= icon "sparkles" %>
Autosuggest income & spending budget
@@ -29,18 +29,18 @@
-
- <%= check_box_tag :auto_fill, "1", params[:auto_fill].present?, class: "sr-only peer", data: {
- action: "change->budget-form#toggleAutoFill",
- budget_form_income_param: { key: "budget_expected_income", value: sprintf("%.2f", @budget.estimated_income) },
- budget_form_spending_param: { key: "budget_budgeted_spending", value: sprintf("%.2f", @budget.estimated_spending) }
- } %>
-
-
+ <%= render ToggleComponent.new(
+ id: "auto_fill",
+ data: {
+ action: "change->budget-form#toggleAutoFill",
+ budget_form_income_param: { key: "budget_expected_income", value: sprintf("%.2f", @budget.estimated_income) },
+ budget_form_spending_param: { key: "budget_budgeted_spending", value: sprintf("%.2f", @budget.estimated_spending) }
+ }
+ ) %>
<% end %>
- <%= f.submit "Continue", class: "btn btn--primary w-full" %>
+ <%= f.submit "Continue" %>
<% end %>
diff --git a/app/views/budgets/show.html.erb b/app/views/budgets/show.html.erb
index bd04d8ce..5d92a2f2 100644
--- a/app/views/budgets/show.html.erb
+++ b/app/views/budgets/show.html.erb
@@ -54,10 +54,12 @@
Categories
<% if @budget.initialized? %>
- <%= link_to budget_budget_categories_path(@budget), class: "btn btn--secondary flex items-center gap-2" do %>
- <%= icon "settings-2", color: "gray" %>
-
Edit
- <% end %>
+ <%= render LinkComponent.new(
+ text: "Edit",
+ variant: "secondary",
+ icon: "settings-2",
+ href: budget_budget_categories_path(@budget)
+ ) %>
<% end %>
diff --git a/app/views/categories/_badge.html.erb b/app/views/categories/_badge.html.erb
index 67a42251..744ce94f 100644
--- a/app/views/categories/_badge.html.erb
+++ b/app/views/categories/_badge.html.erb
@@ -8,9 +8,8 @@
border-color: color-mix(in srgb, <%= category.color %> 30%, white);
color: <%= category.color %>;">
<% if category.lucide_icon.present? %>
- <%= lucide_icon category.lucide_icon, class: "w-4 h-4 shrink-0" %>
+ <%= icon category.lucide_icon, size: "sm", color: "current" %>
<% end %>
<%= category.name %>
-
diff --git a/app/views/categories/_category.html.erb b/app/views/categories/_category.html.erb
index f04e1e96..1b49a045 100644
--- a/app/views/categories/_category.html.erb
+++ b/app/views/categories/_category.html.erb
@@ -3,24 +3,22 @@
<%= "pb-4" unless category.subcategories.any? %> bg-container">
<% if category.subcategory? %>
- <%= lucide_icon "corner-down-right", class: "shrink-0 w-5 h-5 text-subdued ml-2" %>
+
+ <%= icon "corner-down-right", size: "sm", color: "current", class: "ml-2" %>
+
<% end %>
<%= render partial: "categories/badge", locals: { category: category } %>
+
- <%= contextual_menu do %>
- <%= contextual_menu_modal_action_item t(".edit"), edit_category_path(category) %>
+ <%= render MenuComponent.new do |menu| %>
+ <% menu.with_item(variant: "link", text: t(".edit"), icon: "pencil", href: edit_category_path(category), data: { turbo_frame: :modal }) %>
<% if category.transactions.any? %>
- <%= link_to new_category_deletion_path(category),
- class: "flex items-center w-full rounded-lg text-red-600 hover:bg-red-50 py-2 px-3 gap-2",
- data: { turbo_frame: :modal } do %>
- <%= lucide_icon "trash-2", class: "shrink-0 w-5 h-5" %>
- <%= t(".delete") %>
- <% end %>
+ <% menu.with_item(variant: "link", text: t(".delete"), icon: "trash-2", href: new_category_deletion_path(category), destructive: true, data: { turbo_frame: :modal }) %>
<% else %>
- <%= contextual_menu_destructive_item t(".delete"), category_path(category), turbo_confirm: nil %>
+ <% menu.with_item(variant: "button", text: t(".delete"), icon: "trash-2", href: category_path(category), method: :delete) %>
<% end %>
<% end %>
diff --git a/app/views/categories/_color_avatar.html.erb b/app/views/categories/_color_avatar.html.erb
index 6d2aea94..b8cde18a 100644
--- a/app/views/categories/_color_avatar.html.erb
+++ b/app/views/categories/_color_avatar.html.erb
@@ -4,5 +4,5 @@
data-category-target="avatar"
class="w-14 h-14 flex items-center justify-center rounded-full"
style="background-color: color-mix(in oklab, <%= category.color %> 10%, transparent); color: <%= category.color %>">
- <%= lucide_icon(category.lucide_icon, class: "w-8 h-8") %>
+ <%= icon(category.lucide_icon, size: "2xl", color: "current") %>
diff --git a/app/views/categories/_form.html.erb b/app/views/categories/_form.html.erb
index 54605e1b..bb15e650 100644
--- a/app/views/categories/_form.html.erb
+++ b/app/views/categories/_form.html.erb
@@ -2,59 +2,60 @@
<%= styled_form_with model: category, class: "space-y-4" do |f| %>
-
-
+
+
<%= render partial: "color_avatar", locals: { category: category } %>
+
+
+
+ <%= icon("pen", size: "sm") %>
+
+
+
+
">
+
+
Color
+
+ <% Category::COLORS.each do |color| %>
+
+ <%= f.radio_button :color, color, class: "sr-only peer", data: { action: "change->category#handleColorChange" } %>
+
+
+ <% end %>
+
+ <%= f.radio_button :color, "custom-color", class: "sr-only peer", data: { category_target: "colorPickerRadioBtn"} %>
+
+
+
+
+
+
+ <%= f.text_field :color , data: { category_target: "colorInput"}, inline: true %>
+ <%= icon "palette", size: "2xl", data: { action: "click->category#toggleSections" } %>
+
+
+ Poor contrast, choose darker color or
+ auto-adjust.
+
+
+
+
+
+
Icon
+
+ <% Category.icon_codes.each do |icon| %>
+
+ <%= f.radio_button :lucide_icon, icon, class: "sr-only peer", data: { action: "change->category#handleIconChange change->category#handleIconColorChange", category_target:"icon" } %>
+
+ <%= icon(icon, size: "sm", color: "current") %>
+
+
+ <% end %>
+
+
+
+
-
-
- <%= icon("pen", size: "sm") %>
-
-
-
-
">
-
-
Color
-
- <% Category::COLORS.each do |color| %>
-
- <%= f.radio_button :color, color, class: "sr-only peer", data: { action: "change->category#handleColorChange" } %>
-
-
- <% end %>
-
- <%= f.radio_button :color, "custom-color", class: "sr-only peer", data: { category_target: "colorPickerRadioBtn"} %>
-
-
-
-
-
-
- <%= f.text_field :color , data: { category_target: "colorInput"}, inline: true %>
- <%= lucide_icon "palette", class: "w-8 h-8 cursor-pointer hover:bg-gray-100 p-1", data: { action: "click->category#toggleSections" } %>
-
-
- Poor contrast, choose darker color or
- auto-adjust.
-
-
-
-
-
-
Icon
-
- <% Category.icon_codes.each do |icon| %>
-
- <%= f.radio_button :lucide_icon, icon, class: "sr-only peer", data: { action: "change->category#handleIconChange change->category#handleIconColorChange", category_target:"icon" } %>
-
- <%= lucide_icon icon, class: "w-6 h-6 p-1" %>
-
-
- <% end %>
-
-
-
-
<% if category.errors.any? %>
<%= render "shared/form_errors", model: category %>
diff --git a/app/views/categories/_menu.html.erb b/app/views/categories/_menu.html.erb
index 83532011..d6ec5d0b 100644
--- a/app/views/categories/_menu.html.erb
+++ b/app/views/categories/_menu.html.erb
@@ -1,16 +1,15 @@
<%# locals: (transaction:) %>
-
+<%= render MenuComponent.new(variant: "button") do |menu| %>
+ <% menu.with_button do %>
+ <% render partial: "categories/badge", locals: { category: transaction.category } %>
+ <% end %>
+
+ <% menu.with_custom_content do %>
+ <%= turbo_frame_tag "category_dropdown", src: category_dropdown_path(category_id: transaction.category_id, transaction_id: transaction.id), loading: :lazy do %>
+
+ <% end %>
+ <% end %>
+<% end %>
diff --git a/app/views/categories/edit.html.erb b/app/views/categories/edit.html.erb
index 43daea2d..dd8eb41c 100644
--- a/app/views/categories/edit.html.erb
+++ b/app/views/categories/edit.html.erb
@@ -1,3 +1,6 @@
-<%= modal_form_wrapper title: t(".edit"), overflow_visible: true do %>
- <%= render "form", category: @category, categories: @categories %>
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".edit")) %>
+ <% dialog.with_body do %>
+ <%= render "form", category: @category, categories: @categories %>
+ <% end %>
<% end %>
diff --git a/app/views/categories/index.html.erb b/app/views/categories/index.html.erb
index fec33344..95344043 100644
--- a/app/views/categories/index.html.erb
+++ b/app/views/categories/index.html.erb
@@ -2,18 +2,23 @@
<%= t(".categories") %>
- <%= contextual_menu do %>
- <%= contextual_menu_destructive_item "Delete all", destroy_all_categories_path, turbo_confirm: {
- title: "Delete all categories?",
- body: "All of your transactions will become uncategorized and this cannot be undone.",
- accept: "Delete all categories",
- } %>
+ <%= render MenuComponent.new do |menu| %>
+ <% menu.with_item(
+ variant: "button",
+ text: "Delete all",
+ href: destroy_all_categories_path,
+ method: :delete,
+ icon: "trash-2",
+ confirm: CustomConfirm.for_resource_deletion("All categories", high_severity: true)) %>
<% end %>
- <%= link_to new_category_path, class: "btn btn--primary flex items-center gap-1 justify-center", data: { turbo_frame: :modal } do %>
- <%= lucide_icon "plus", class: "w-5 h-5" %>
-
<%= t(".new") %>
- <% end %>
+ <%= render LinkComponent.new(
+ text: t(".new"),
+ variant: "primary",
+ icon: "plus",
+ href: new_category_path,
+ frame: :modal
+ ) %>
@@ -33,12 +38,18 @@
<%= t(".empty") %>
- <%= button_to t(".bootstrap"), bootstrap_categories_path, class: "btn btn--primary" %>
+ <%= render ButtonComponent.new(
+ text: t(".bootstrap"),
+ href: bootstrap_categories_path,
+ ) %>
- <%= link_to new_category_path, class: "btn btn--outline flex items-center gap-1", data: { turbo_frame: "modal" } do %>
- <%= lucide_icon("plus", class: "w-5 h-5") %>
- <%= t(".new") %>
- <% end %>
+ <%= render LinkComponent.new(
+ text: t(".new"),
+ variant: "outline",
+ icon: "plus",
+ href: new_category_path,
+ frame: :modal
+ ) %>
diff --git a/app/views/categories/new.html.erb b/app/views/categories/new.html.erb
index 6478d94d..09ac5cd7 100644
--- a/app/views/categories/new.html.erb
+++ b/app/views/categories/new.html.erb
@@ -1,3 +1,6 @@
-<%= modal_form_wrapper title: t(".new_category"), overflow_visible: true do %>
- <%= render "form", category: @category, categories: @categories %>
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".new_category")) %>
+ <% dialog.with_body do %>
+ <%= render "form", category: @category, categories: @categories %>
+ <% end %>
<% end %>
diff --git a/app/views/category/deletions/new.html.erb b/app/views/category/deletions/new.html.erb
index 7c337ccf..b199b880 100644
--- a/app/views/category/deletions/new.html.erb
+++ b/app/views/category/deletions/new.html.erb
@@ -1,21 +1,32 @@
-<%= modal_form_wrapper title: t(".delete_category"), subtitle: t(".explanation", category_name: @category.name) do %>
- <%= styled_form_with url: category_deletions_path(@category),
- class: "space-y-4",
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".delete_category"), subtitle: t(".explanation", category_name: @category.name)) %>
+
+ <% dialog.with_body do %>
+ <%= styled_form_with url: category_deletions_path(@category),
data: {
turbo: false,
controller: "deletion",
- deletion_dangerous_action_class: "form-field__submit bg-container text-red-600 border hover:bg-red-50",
- deletion_safe_action_class: "form-field__submit border border-transparent",
deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", category_name: @category.name),
deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", category_name: @category.name) } do |f| %>
- <%= f.collection_select :replacement_category_id,
+ <%= f.collection_select :replacement_category_id,
Current.family.categories.alphabetically.without(@category),
:id, :name,
- { prompt: t(".replacement_category_prompt"), label: t(".category") },
- { data: { deletion_target: "replacementField", action: "deletion#updateSubmitButton" } } %>
+ { prompt: t(".replacement_category_prompt"), label: t(".category"), container_class: "mb-4" },
+ data: { deletion_target: "replacementField", action: "deletion#chooseSubmitButton" } %>
- <%= f.submit t(".delete_and_leave_uncategorized", category_name: @category.name),
- class: "form-field__submit bg-container text-red-600 border hover:bg-red-50",
- data: { deletion_target: "submitButton" } %>
+ <%= render ButtonComponent.new(
+ variant: "destructive",
+ text: t(".delete_and_leave_uncategorized", category_name: @category.name),
+ full_width: true,
+ data: { deletion_target: "destructiveSubmitButton" }
+ ) %>
+
+ <%= render ButtonComponent.new(
+ text: "Delete and reassign",
+ data: { deletion_target: "safeSubmitButton" },
+ hidden: true,
+ full_width: true
+ ) %>
+ <% end %>
<% end %>
<% end %>
diff --git a/app/views/category/dropdowns/_row.html.erb b/app/views/category/dropdowns/_row.html.erb
index c4e0affc..e9c5f993 100644
--- a/app/views/category/dropdowns/_row.html.erb
+++ b/app/views/category/dropdowns/_row.html.erb
@@ -15,32 +15,17 @@
method: :patch,
class: "flex w-full items-center gap-1.5 cursor-pointer focus:outline-none" do %>
-
- <%= lucide_icon("check", class: "w-5 h-5 text-secondary") if is_selected %>
-
+ <%= icon("check") if is_selected %>
+
<% if category.subcategory? %>
- <%= lucide_icon "corner-down-right", class: "shrink-0 w-5 h-5 text-subdued" %>
+ <%= icon("corner-down-right", size: "sm") %>
<% end %>
+
<%= render partial: "categories/badge", locals: { category: category } %>
<% end %>
- <%= contextual_menu do %>
-
- <%= link_to edit_category_path(category),
- class: "block w-full py-2 px-3 space-x-2 text-primary hover:bg-gray-50 flex items-center rounded-lg",
- data: { turbo_frame: :modal } do %>
- <%= lucide_icon "pencil-line", class: "w-5 h-5 text-secondary" %>
-
- <%= t(".edit") %>
- <% end %>
-
- <%= link_to new_category_deletion_path(category),
- class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg",
- data: { turbo_frame: :modal } do %>
- <%= lucide_icon "trash-2", class: "w-5 h-5" %>
-
- <%= t(".delete") %>
- <% end %>
-
+ <%= render MenuComponent.new do |menu| %>
+ <% menu.with_item(variant: "link", text: t(".edit"), icon: "pencil-line", href: edit_category_path(category), data: { turbo_frame: :modal }) %>
+ <% menu.with_item(variant: "link", text: t(".delete"), icon: "trash-2", href: new_category_deletion_path(category), data: { turbo_frame: :modal }, destructive: true) %>
<% end %>
<% end %>
diff --git a/app/views/category/dropdowns/show.html.erb b/app/views/category/dropdowns/show.html.erb
index d559de6e..6bda0159 100644
--- a/app/views/category/dropdowns/show.html.erb
+++ b/app/views/category/dropdowns/show.html.erb
@@ -3,7 +3,7 @@
diff --git a/app/views/chats/_ai_consent.html.erb b/app/views/chats/_ai_consent.html.erb
index 25907f3f..960aa6b3 100644
--- a/app/views/chats/_ai_consent.html.erb
+++ b/app/views/chats/_ai_consent.html.erb
@@ -1,33 +1,26 @@
-
-
-
- <%= icon("sparkles") %>
-
-
-
Enable Personal Finance AI
-
-
- <% if Current.user.ai_available? %>
- Our personal finance AI can help answer questions about your finances and provide insights based on your data.
- To use this feature, you'll need to explicitly enable it.
- <% else %>
- To use the AI assistant, you need to set the OPENAI_ACCESS_TOKEN
- environment variable in your self-hosted instance.
- <% end %>
-
-
- <% unless self_hosted? %>
-
- NOTE: During beta testing, we'll be monitoring usage and AI conversations to improve the assistant.
-
- <% end %>
-
- <% if Current.user.ai_available? %>
- <%= form_with url: user_path(Current.user), method: :patch, class: "w-full", data: { turbo: false } do |form| %>
- <%= form.hidden_field "user[ai_enabled]", value: true %>
- <%= form.hidden_field "user[redirect_to]", value: "home" %>
- <%= form.submit "Enable AI Assistant", class: "cursor-pointer hover:bg-black w-full py-2 px-4 bg-gray-800 text-white rounded-lg text-sm font-medium" %>
- <% end %>
- <% end %>
+
+
+ <%= render "chats/ai_avatar" %>
+
+
Enable Maybe AI
+
+
+ <% if Current.user.ai_available? %>
+ Maybe AI can answer financial questions and provide insights based on your data. To use this feature you'll need to explicitly enable it.
+ <% else %>
+ To use the AI assistant, you need to set the OPENAI_ACCESS_TOKEN
+ environment variable in your self-hosted instance.
+ <% end %>
+
+
+ <% if Current.user.ai_available? %>
+ <%= form_with url: user_path(Current.user), method: :patch, class: "w-full", data: { turbo: false } do |form| %>
+ <%= form.hidden_field "user[ai_enabled]", value: true %>
+ <%= form.hidden_field "user[redirect_to]", value: "home" %>
+ <%= form.submit "Enable Maybe AI", class: "cursor-pointer hover:bg-black w-full py-2 px-4 bg-gray-800 text-white rounded-lg text-sm font-medium" %>
+ <% end %>
+ <% end %>
+
+
Disable anytime. All data sent to our LLM providers is anonymized.
diff --git a/app/views/chats/_chat.html.erb b/app/views/chats/_chat.html.erb
index aa0a915b..79e02b73 100644
--- a/app/views/chats/_chat.html.erb
+++ b/app/views/chats/_chat.html.erb
@@ -9,8 +9,14 @@
- <%= contextual_menu icon: "more-vertical" do %>
- <%= contextual_menu_item("Edit chat", url: edit_chat_path(chat), icon: "pencil", turbo_frame: dom_id(chat, :title)) %>
- <%= contextual_menu_destructive_item("Delete chat", chat_path(chat)) %>
+ <%= render MenuComponent.new(icon_vertical: true) do |menu| %>
+ <% menu.with_item(variant: "link", text: "Edit chat", href: edit_chat_path(chat), icon: "pencil", frame: dom_id(chat, "title")) %>
+ <% menu.with_item(
+ variant: "button",
+ text: "Delete chat",
+ href: chat_path(chat),
+ icon: "trash-2",
+ method: :delete,
+ confirm: CustomConfirm.for_resource_deletion("Chat")) %>
<% end %>
<% end %>
diff --git a/app/views/chats/_chat_nav.html.erb b/app/views/chats/_chat_nav.html.erb
index 5ef56402..47e56c8e 100644
--- a/app/views/chats/_chat_nav.html.erb
+++ b/app/views/chats/_chat_nav.html.erb
@@ -13,12 +13,23 @@
- <%= contextual_menu icon: "more-vertical", id: "chat-menu" do %>
- <%= contextual_menu_item "Start new chat", url: new_chat_path, icon: "plus" %>
+ <%= render MenuComponent.new(icon_vertical: true) do |menu| %>
+ <% menu.with_item(variant: "link", text: "Start new chat", href: new_chat_path, icon: "plus") %>
<% unless chat.new_record? %>
- <%= contextual_menu_item "Edit chat title", url: edit_chat_path(chat, ctx: "chat"), icon: "pencil", turbo_frame: dom_id(chat, "title") %>
- <%= contextual_menu_destructive_item "Delete chat", chat_path(chat), turbo_confirm: "Are you sure you want to delete this chat?" %>
+ <% menu.with_item(
+ variant: "link",
+ text: "Edit chat title",
+ href: edit_chat_path(chat, ctx: "chat"),
+ icon: "pencil", data: { turbo_frame: dom_id(chat, "title") }) %>
+
+ <% menu.with_item(
+ variant: "button",
+ text: "Delete chat",
+ href: chat_path(chat),
+ icon: "trash-2",
+ method: :delete,
+ confirm: CustomConfirm.for_resource_deletion("Chat")) %>
<% end %>
<% end %>
diff --git a/app/views/chats/_error.html.erb b/app/views/chats/_error.html.erb
index 94fb2d2a..d72dd13d 100644
--- a/app/views/chats/_error.html.erb
+++ b/app/views/chats/_error.html.erb
@@ -10,8 +10,9 @@
Failed to generate response. Please try again.
- <%= button_to retry_chat_path(chat), method: :post, class: "btn btn--primary" do %>
-
Retry
- <% end %>
+ <%= render ButtonComponent.new(
+ text: "Retry",
+ href: retry_chat_path(chat),
+ ) %>
diff --git a/app/views/chats/index.html.erb b/app/views/chats/index.html.erb
index 277a9b84..9a95fbfb 100644
--- a/app/views/chats/index.html.erb
+++ b/app/views/chats/index.html.erb
@@ -1,31 +1,30 @@
-<%= turbo_frame_tag chat_frame do %>
-
-
- <% back_path = @last_viewed_chat ? chat_path(@last_viewed_chat) : new_chat_path %>
- <%= link_to back_path, class: "w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-hover" do %>
- <%= icon("arrow-left", color: "gray" ) %>
- <% end %>
-
-
-
-
Chats
-
+
+ <%= turbo_frame_tag chat_frame do %>
+
<% if @chats.any? %>
-
- <%= render @chats %>
-
- <% else %>
-
-
- <%= icon("message-square", size: "lg") %>
-
-
No chats yet
-
Start a new conversation with the AI assistant
-
-
- <%= render "messages/chat_form", chat: nil %>
-
+
+ <% back_path = @last_viewed_chat ? chat_path(@last_viewed_chat) : new_chat_path %>
+ <%= link_to back_path, class: "w-9 h-9 flex items-center justify-center rounded-lg hover:bg-surface-hover" do %>
+ <%= icon("arrow-left", color: "gray" ) %>
+ <% end %>
+
<% end %>
+
+
+ <% if @chats.any? %>
+
Chats
+
+ <%= render @chats %>
+
+ <% else %>
+
Chats
+
+ <%= render "chats/ai_greeting" %>
+
+
+ <%= render "messages/chat_form" %>
+ <% end %>
+
-
-<% end %>
+ <% end %>
+
diff --git a/app/views/chats/show.html.erb b/app/views/chats/show.html.erb
index 341eca01..cb52b632 100644
--- a/app/views/chats/show.html.erb
+++ b/app/views/chats/show.html.erb
@@ -1,35 +1,38 @@
-<%= turbo_frame_tag chat_frame do %>
- <%= turbo_stream_from @chat %>
+
+ <%= turbo_frame_tag chat_frame do %>
+ <%= turbo_stream_from @chat %>
-
<%= @chat.title %>
+
<%= @chat.title %>
-
-
- <%= render "chats/chat_nav", chat: @chat %>
-
+
+
+ <%= render "chats/chat_nav", chat: @chat %>
+
-
- <% if @chat.conversation_messages.any? %>
- <% @chat.conversation_messages.ordered.each do |message| %>
- <%= render message %>
+
+ <% if @chat.conversation_messages.any? %>
+ <% @chat.conversation_messages.ordered.each do |message| %>
+ <%= render message %>
+ <% end %>
+ <% else %>
+
+ <%= render "chats/ai_greeting", context: "chat" %>
+
<% end %>
- <% else %>
-
- <%= render "chats/ai_greeting", context: "chat" %>
-
- <% end %>
- <% if params[:thinking].present? %>
- <%= render "chats/thinking_indicator", chat: @chat %>
- <% end %>
+ <% if params[:thinking].present? %>
+ <%= render "chats/thinking_indicator", chat: @chat %>
+ <% end %>
- <% if @chat.error.present? && @chat.needs_assistant_response? %>
- <%= render "chats/error", chat: @chat %>
- <% end %>
+ <% if @chat.error.present? && @chat.needs_assistant_response? %>
+ <%= render "chats/error", chat: @chat %>
+ <% end %>
+
+
+ <%# DESKTOP - Chat form %>
+
+ <%= render "messages/chat_form", chat: @chat %>
+
-
-
- <%= render "messages/chat_form", chat: @chat %>
-
-
-<% end %>
+ <% end %>
+
diff --git a/app/views/credit_cards/_overview.html.erb b/app/views/credit_cards/_overview.html.erb
index 85ce6527..fec910e0 100644
--- a/app/views/credit_cards/_overview.html.erb
+++ b/app/views/credit_cards/_overview.html.erb
@@ -27,5 +27,10 @@
- <%= link_to "Edit account details", edit_credit_card_path(account), class: "btn btn--ghost", data: { turbo_frame: :modal } %>
+ <%= render LinkComponent.new(
+ text: "Edit account details",
+ variant: "ghost",
+ href: edit_credit_card_path(account),
+ frame: :modal
+ ) %>
diff --git a/app/views/credit_cards/edit.html.erb b/app/views/credit_cards/edit.html.erb
index fc97a7a8..f30f68a0 100644
--- a/app/views/credit_cards/edit.html.erb
+++ b/app/views/credit_cards/edit.html.erb
@@ -1,3 +1,7 @@
-<%= modal_form_wrapper title: t(".edit", account: @account.name) do %>
- <%= render "form", account: @account, url: credit_card_path(@account) %>
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".edit", account: @account.name)) %>
+
+ <% dialog.with_body do %>
+ <%= render "form", account: @account, url: credit_card_path(@account) %>
+ <% end %>
<% end %>
diff --git a/app/views/credit_cards/new.html.erb b/app/views/credit_cards/new.html.erb
index 5b35dce1..0f7d4fcd 100644
--- a/app/views/credit_cards/new.html.erb
+++ b/app/views/credit_cards/new.html.erb
@@ -1,7 +1,10 @@
<% if params[:step] == "method_select" %>
<%= render "accounts/new/method_selector", path: new_credit_card_path(return_to: params[:return_to]), us_link_token: @us_link_token, eu_link_token: @eu_link_token %>
<% else %>
- <%= modal_form_wrapper title: t(".title") do %>
- <%= render "credit_cards/form", account: @account, url: credit_cards_path %>
+ <%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".title")) %>
+ <% dialog.with_body do %>
+ <%= render "credit_cards/form", account: @account, url: credit_cards_path %>
+ <% end %>
<% end %>
<% end %>
diff --git a/app/views/cryptos/edit.html.erb b/app/views/cryptos/edit.html.erb
index 5f5b4981..6b7812c2 100644
--- a/app/views/cryptos/edit.html.erb
+++ b/app/views/cryptos/edit.html.erb
@@ -1,3 +1,6 @@
-<%= modal_form_wrapper title: t(".edit", account: @account.name) do %>
- <%= render "form", account: @account, url: crypto_path(@account) %>
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".edit", account: @account.name)) %>
+ <% dialog.with_body do %>
+ <%= render "form", account: @account, url: crypto_path(@account) %>
+ <% end %>
<% end %>
diff --git a/app/views/cryptos/new.html.erb b/app/views/cryptos/new.html.erb
index f4979c6e..5587cfae 100644
--- a/app/views/cryptos/new.html.erb
+++ b/app/views/cryptos/new.html.erb
@@ -1,7 +1,10 @@
<% if params[:step] == "method_select" %>
<%= render "accounts/new/method_selector", path: new_crypto_path(return_to: params[:return_to]), us_link_token: @us_link_token, eu_link_token: @eu_link_token %>
<% else %>
- <%= modal_form_wrapper title: t(".title") do %>
- <%= render "cryptos/form", account: @account, url: cryptos_path %>
+ <%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".title")) %>
+ <% dialog.with_body do %>
+ <%= render "form", account: @account, url: cryptos_path %>
+ <% end %>
<% end %>
<% end %>
diff --git a/app/views/depositories/edit.html.erb b/app/views/depositories/edit.html.erb
index 0c61040d..03d3e668 100644
--- a/app/views/depositories/edit.html.erb
+++ b/app/views/depositories/edit.html.erb
@@ -1,3 +1,6 @@
-<%= modal_form_wrapper title: t(".edit", account: @account.name) do %>
- <%= render "form", account: @account, url: depository_path(@account) %>
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".edit", account: @account.name)) %>
+ <% dialog.with_body do %>
+ <%= render "form", account: @account, url: depository_path(@account) %>
+ <% end %>
<% end %>
diff --git a/app/views/depositories/new.html.erb b/app/views/depositories/new.html.erb
index 3ed099b3..4f08e3c9 100644
--- a/app/views/depositories/new.html.erb
+++ b/app/views/depositories/new.html.erb
@@ -1,7 +1,10 @@
<% if params[:step] == "method_select" %>
<%= render "accounts/new/method_selector", path: new_depository_path(return_to: params[:return_to]), us_link_token: @us_link_token, eu_link_token: @eu_link_token %>
<% else %>
- <%= modal_form_wrapper title: t(".title") do %>
- <%= render "depositories/form", account: @account, url: depositories_path %>
+ <%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".title")) %>
+ <% dialog.with_body do %>
+ <%= render "depositories/form", account: @account, url: depositories_path %>
+ <% end %>
<% end %>
<% end %>
diff --git a/app/views/entries/_selection_bar.html.erb b/app/views/entries/_selection_bar.html.erb
index 8f0a98f3..1c633bde 100644
--- a/app/views/entries/_selection_bar.html.erb
+++ b/app/views/entries/_selection_bar.html.erb
@@ -7,8 +7,8 @@
<%= form_with url: transactions_bulk_deletion_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
-
- <%= lucide_icon "trash-2", class: "w-5 group-hover:text-white" %>
+
+ <%= icon "trash-2", class: "group-hover:text-inverse" %>
<% end %>
diff --git a/app/views/family_merchants/_family_merchant.html.erb b/app/views/family_merchants/_family_merchant.html.erb
index ec1cc1fd..f47d64c9 100644
--- a/app/views/family_merchants/_family_merchant.html.erb
+++ b/app/views/family_merchants/_family_merchant.html.erb
@@ -15,17 +15,15 @@
- <%= contextual_menu do %>
- <%= contextual_menu_modal_action_item t(".edit"), edit_family_merchant_path(family_merchant), icon: "pencil", turbo_frame: "modal" %>
-
- <%= contextual_menu_destructive_item "Delete",
- family_merchant_path(family_merchant),
- turbo_frame: "_top",
- turbo_confirm: family_merchant.transactions.any? ? {
- title: "Delete #{family_merchant.name}?",
- body: "This will remove this merchant from all transactions it has been assigned to.",
- accept: "Delete"
- } : nil %>
+ <%= render MenuComponent.new do |menu| %>
+ <% menu.with_item(variant: "link", text: "Edit", href: edit_family_merchant_path(family_merchant), icon: "pencil", data: { turbo_frame: "modal" }) %>
+ <% menu.with_item(
+ variant: "button",
+ text: "Delete",
+ href: family_merchant_path(family_merchant),
+ icon: "trash-2",
+ method: :delete,
+ confirm: CustomConfirm.for_resource_deletion(family_merchant.name)) %>
<% end %>
diff --git a/app/views/family_merchants/_form.html.erb b/app/views/family_merchants/_form.html.erb
index 34306679..db5bd085 100644
--- a/app/views/family_merchants/_form.html.erb
+++ b/app/views/family_merchants/_form.html.erb
@@ -1,11 +1,13 @@
+<%# locals: (family_merchant:) %>
+
- <%= styled_form_with model: @merchant, class: "space-y-4" do |f| %>
+ <%= styled_form_with model: family_merchant, class: "space-y-4" do |f| %>
- <% if @merchant.errors.any? %>
- <%= render "shared/form_errors", model: @merchant %>
+ <% if family_merchant.errors.any? %>
+ <%= render "shared/form_errors", model: family_merchant %>
<% end %>
- <%= render partial: "shared/color_avatar", locals: { name: @merchant.name, color: @merchant.color } %>
+ <%= render partial: "shared/color_avatar", locals: { name: family_merchant.name, color: family_merchant.color } %>
<% FamilyMerchant::COLORS.each do |color| %>
diff --git a/app/views/family_merchants/edit.html.erb b/app/views/family_merchants/edit.html.erb
index a8776d3a..044ae422 100644
--- a/app/views/family_merchants/edit.html.erb
+++ b/app/views/family_merchants/edit.html.erb
@@ -1,3 +1,6 @@
-<%= modal_form_wrapper title: t(".title") do %>
- <%= render "form", merchant: @merchant %>
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".title")) %>
+ <% dialog.with_body do %>
+ <%= render "form", family_merchant: @family_merchant %>
+ <% end %>
<% end %>
diff --git a/app/views/family_merchants/index.html.erb b/app/views/family_merchants/index.html.erb
index 4f72e455..944b4bb9 100644
--- a/app/views/family_merchants/index.html.erb
+++ b/app/views/family_merchants/index.html.erb
@@ -1,10 +1,12 @@
Merchants
- <%= link_to new_family_merchant_path, class: "btn btn--primary flex items-center gap-2", data: { turbo_frame: "modal" } do %>
- <%= lucide_icon("plus", class: "w-5 h-5") %>
- New merchant
- <% end %>
+ <%= render LinkComponent.new(
+ text: "New merchant",
+ variant: "primary",
+ href: new_family_merchant_path,
+ frame: :modal
+ ) %>
@@ -26,10 +28,13 @@
<%= t(".empty") %>
- <%= link_to new_family_merchant_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %>
- <%= lucide_icon("plus", class: "w-5 h-5") %>
-
<%= t(".new") %>
- <% end %>
+
+ <%= render LinkComponent.new(
+ text: t(".new"),
+ icon: "plus",
+ href: new_family_merchant_path,
+ frame: :modal
+ ) %>
<% end %>
diff --git a/app/views/family_merchants/new.html.erb b/app/views/family_merchants/new.html.erb
index a8776d3a..fa9da88f 100644
--- a/app/views/family_merchants/new.html.erb
+++ b/app/views/family_merchants/new.html.erb
@@ -1,3 +1,6 @@
-<%= modal_form_wrapper title: t(".title") do %>
- <%= render "form", merchant: @merchant %>
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".title")) %>
+ <% dialog.with_body do %>
+ <%= render "form", family_merchant: @merchant %>
+ <% end %>
<% end %>
diff --git a/app/views/holdings/_cash.html.erb b/app/views/holdings/_cash.html.erb
index 235b5128..a279d9ab 100644
--- a/app/views/holdings/_cash.html.erb
+++ b/app/views/holdings/_cash.html.erb
@@ -4,7 +4,12 @@
- <%= render "shared/circle_logo", name: currency.iso_code %>
+ <%= render FilledIconComponent.new(
+ variant: :text,
+ text: currency.symbol,
+ rounded: true,
+ size: "lg"
+ ) %>
<%= tag.p t(".brokerage_cash"), class: "text-primary" %>
diff --git a/app/views/holdings/_missing_price_tooltip.html.erb b/app/views/holdings/_missing_price_tooltip.html.erb
index 933a4355..e24fa310 100644
--- a/app/views/holdings/_missing_price_tooltip.html.erb
+++ b/app/views/holdings/_missing_price_tooltip.html.erb
@@ -1,6 +1,6 @@
- <%= lucide_icon "info", class: "w-4 h-4 shrink-0" %>
+ <%= icon "info", size: "sm", color: "current" %>
<%= tag.span t(".missing_data"), class: "font-normal text-xs" %>
diff --git a/app/views/holdings/index.html.erb b/app/views/holdings/index.html.erb
index 7da9cd4c..2fcd0714 100644
--- a/app/views/holdings/index.html.erb
+++ b/app/views/holdings/index.html.erb
@@ -6,7 +6,9 @@
id: dom_id(@account, "new_trade"),
data: { turbo_frame: :modal },
class: "flex gap-1 font-medium items-center bg-gray-50 text-primary p-2 rounded-lg" do %>
- <%= lucide_icon("plus", class: "w-5 h-5 text-primary") %>
+
+ <%= icon("plus", color: "current") %>
+
<%= tag.span t(".new_holding"), class: "text-sm" %>
<% end %>
diff --git a/app/views/holdings/show.html.erb b/app/views/holdings/show.html.erb
index f536da9c..19e4b504 100644
--- a/app/views/holdings/show.html.erb
+++ b/app/views/holdings/show.html.erb
@@ -1,20 +1,17 @@
-<%= drawer do %>
-
-
+<%= render DialogComponent.new(variant: "drawer") do |dialog| %>
+ <% dialog.with_header do %>
+
<%= tag.h3 @holding.name, class: "text-2xl font-medium text-primary" %>
<%= tag.p @holding.ticker, class: "text-sm text-secondary" %>
<%= image_tag "https://logo.synthfinance.com/ticker/#{@holding.ticker}", loading: "lazy", class: "w-9 h-9 rounded-full" %>
-
-
-
-
- <%= t(".overview") %>
- <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-secondary w-5" %>
-
+
+ <% end %>
+ <% dialog.with_body do %>
+ <% dialog.with_section(title: t(".overview"), open: true) do %>
-
-
-
-
- <%= t(".history") %>
- <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-secondary w-5" %>
-
+ <% end %>
+ <% dialog.with_section(title: t(".history"), open: true) do %>
<% if @holding.trades.any? %>
@@ -85,15 +77,10 @@
<% end %>
-
+ <% end %>
<% unless @holding.account.plaid_account_id.present? %>
-
-
- <%= t(".settings") %>
- <%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-secondary w-5" %>
-
-
+ <% dialog.with_section(title: t(".settings"), open: true) do %>
@@ -108,7 +95,7 @@
data: { turbo_confirm: true } %>
-
+ <% end %>
<% end %>
-
+ <% end %>
<% end %>
diff --git a/app/views/impersonation_sessions/_approval_bar.html.erb b/app/views/impersonation_sessions/_approval_bar.html.erb
index 7ab60104..b8690c74 100644
--- a/app/views/impersonation_sessions/_approval_bar.html.erb
+++ b/app/views/impersonation_sessions/_approval_bar.html.erb
@@ -2,9 +2,9 @@
<% in_progress_session = Current.true_user.impersonated_support_sessions.in_progress.first %>
-
- <%= lucide_icon "alert-triangle", class: "w-6 h-6 text-white mr-2" %>
-
Access <%= in_progress_session.present? ? "Session" : "Request" %>
+
+ <%= icon "alert-triangle", size: "lg", color: "current", class: "mr-2" %>
+ Access <%= in_progress_session.present? ? "Session" : "Request" %>
<% if pending_session.present? %>
diff --git a/app/views/impersonation_sessions/_super_admin_bar.html.erb b/app/views/impersonation_sessions/_super_admin_bar.html.erb
index 57bfa825..a09a8250 100644
--- a/app/views/impersonation_sessions/_super_admin_bar.html.erb
+++ b/app/views/impersonation_sessions/_super_admin_bar.html.erb
@@ -1,7 +1,7 @@
- <%= lucide_icon "alert-triangle", class: "w-6 h-6 text-white mr-2" %>
- Super Admin
+ <%= icon "alert-triangle", size: "lg", color: "current", class: "mr-2" %>
+ Super Admin
<%= link_to "Jobs", sidekiq_web_url, class: "text-white underline hover:text-gray-100" %>
diff --git a/app/views/import/cleans/show.html.erb b/app/views/import/cleans/show.html.erb
index efce906c..1412cb5e 100644
--- a/app/views/import/cleans/show.html.erb
+++ b/app/views/import/cleans/show.html.erb
@@ -13,18 +13,24 @@
<% if @import.cleaned? %>
- <%= lucide_icon "check-circle", class: "w-4 h-4 text-green-500" %>
-
Your data has been cleaned
+ <%= icon "check-circle", size: "sm", color: "success" %>
+
Your data has been cleaned
- <%= link_to "Next step", import_confirm_path(@import), class: "btn btn--primary w-full md:w-auto" %>
+ <%= render LinkComponent.new(
+ text: "Next step",
+ variant: "primary",
+ href: import_confirm_path(@import),
+ frame: :_top,
+ class: "w-full md:w-auto"
+ ) %>
<% else %>
- <%= lucide_icon "alert-triangle", class: "w-4 h-4 text-red-500 flex-shrink-0" %>
-
<%= t(".errors_notice") %>
-
<%= t(".errors_notice_mobile") %>
+ <%= icon "alert-triangle", size: "sm", color: "destructive" %>
+
<%= t(".errors_notice") %>
+
<%= t(".errors_notice_mobile") %>
diff --git a/app/views/import/configurations/_account_import.html.erb b/app/views/import/configurations/_account_import.html.erb
index 7f6ff6f2..28096ff9 100644
--- a/app/views/import/configurations/_account_import.html.erb
+++ b/app/views/import/configurations/_account_import.html.erb
@@ -6,5 +6,5 @@
<%= form.select :amount_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Balance" }, required: true %>
<%= form.select :currency_col_label, import.csv_headers, { include_blank: "Default", label: "Currency" } %>
- <%= form.submit "Apply configuration", class: "w-full btn btn--primary", disabled: import.complete? %>
+ <%= form.submit "Apply configuration", disabled: import.complete? %>
<% end %>
diff --git a/app/views/import/configurations/_mint_import.html.erb b/app/views/import/configurations/_mint_import.html.erb
index e1271c4a..4fdbe698 100644
--- a/app/views/import/configurations/_mint_import.html.erb
+++ b/app/views/import/configurations/_mint_import.html.erb
@@ -1,7 +1,9 @@
<%# locals: (import:) %>
- <%= lucide_icon("check-circle", class: "w-5 h-5 shrink-0 text-green-500") %>
+
+ <%= icon("check-circle", color: "current") %>
+
We have pre-configured your Mint import for you. Please proceed to the next step.
@@ -29,5 +31,5 @@
<%= form.select :category_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Category (optional)" }, disabled: import.complete? %>
<%= form.select :tags_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Tags (optional)" }, disabled: import.complete? %>
- <%= form.submit "Apply configuration", class: "w-full btn btn--primary", disabled: import.complete? %>
+ <%= form.submit "Apply configuration", disabled: import.complete? %>
<% end %>
diff --git a/app/views/import/configurations/_trade_import.html.erb b/app/views/import/configurations/_trade_import.html.erb
index 8231e9cf..007bf34b 100644
--- a/app/views/import/configurations/_trade_import.html.erb
+++ b/app/views/import/configurations/_trade_import.html.erb
@@ -36,5 +36,5 @@
<% end %>
- <%= form.submit "Apply configuration", class: "w-full btn btn--primary", disabled: import.complete? %>
+ <%= form.submit "Apply configuration", disabled: import.complete? %>
<% end %>
diff --git a/app/views/import/configurations/_transaction_import.html.erb b/app/views/import/configurations/_transaction_import.html.erb
index 9181f97f..84301d69 100644
--- a/app/views/import/configurations/_transaction_import.html.erb
+++ b/app/views/import/configurations/_transaction_import.html.erb
@@ -107,7 +107,5 @@
import.csv_headers,
{ include_blank: "Leave empty", label: "Notes" } %>
- <%= form.submit "Apply configuration",
- class: "w-full btn btn--primary",
- disabled: import.complete? %>
+ <%= form.submit "Apply configuration", disabled: import.complete? %>
<% end %>
diff --git a/app/views/import/configurations/show.html.erb b/app/views/import/configurations/show.html.erb
index d061b692..89e53e07 100644
--- a/app/views/import/configurations/show.html.erb
+++ b/app/views/import/configurations/show.html.erb
@@ -18,8 +18,8 @@
We found a configuration from a previous import for this account. Would you like to apply it to this import?
- <%= link_to "Manually configure", import_configuration_path(@import), class: "btn btn--outline" %>
- <%= button_to "Apply template", apply_template_import_path(@import), class: "btn btn--primary", method: :put, data: { turbo_frame: :_top } %>
+ <%= render LinkComponent.new(text: "Manually configure", href: import_configuration_path(@import), variant: "outline") %>
+ <%= render ButtonComponent.new(text: "Apply template", href: apply_template_import_path(@import), method: :put, data: { turbo_frame: :_top }) %>
diff --git a/app/views/import/confirms/_mappings.html.erb b/app/views/import/confirms/_mappings.html.erb
index 76e353be..2fcb14ac 100644
--- a/app/views/import/confirms/_mappings.html.erb
+++ b/app/views/import/confirms/_mappings.html.erb
@@ -8,18 +8,29 @@
<% if import.requires_account? %>
-
+
<%= tag.p t(".no_accounts"), class: "text-sm" %>
- <%= link_to t(".create_account"), new_account_path(return_to: import_confirm_path(import)), class: "btn btn--primary whitespace-nowrap", data: { turbo_frame: :modal } %>
+
+ <%= render LinkComponent.new(
+ text: "Create account",
+ variant: "primary",
+ href: new_account_path(return_to: import_confirm_path(import)),
+ frame: :modal
+ ) %>
<% elsif import.has_unassigned_account? %>
-
+
<%= tag.p t(".unassigned_account"), class: "text-sm" %>
- <%= link_to t(".create_account"), new_account_path(return_to: import_confirm_path(import)), class: "btn btn--primary whitespace-nowrap", data: { turbo_frame: :modal } %>
+ <%= render LinkComponent.new(
+ text: t(".create_account"),
+ variant: "primary",
+ href: new_account_path(return_to: import_confirm_path(import)),
+ frame: :modal
+ ) %>
@@ -29,7 +40,7 @@
-
+
<%= t(".csv_mapping_label", mapping: mapping_label(mapping_class)) %>
<%= t(".maybe_mapping_label", mapping: mapping_label(mapping_class)) %>
@@ -48,10 +59,14 @@
- <%= link_to is_last_step ? import_path(import) : url_for(step: step_idx + 2), class: "btn btn--primary w-full md:w-36 flex items-center justify-between gap-2" do %>
- Next
- <%= lucide_icon "arrow-right", class: "w-5 h-5" %>
- <% end %>
+ <%= render LinkComponent.new(
+ text: "Next",
+ variant: "primary",
+ href: is_last_step ? import_path(import) : url_for(step: step_idx + 2),
+ icon: "arrow-right",
+ icon_position: "right",
+ class: "w-full md:w-auto"
+ ) %>
diff --git a/app/views/import/rows/_form.html.erb b/app/views/import/rows/_form.html.erb
index 43521eb6..b5057d63 100644
--- a/app/views/import/rows/_form.html.erb
+++ b/app/views/import/rows/_form.html.erb
@@ -28,10 +28,10 @@
disabled: row.import.complete? %>
<% if !cell_is_valid?(row, key) %>
-
- <%= lucide_icon "alert-circle", class: "w-4 h-4" %>
+ data-mobile-cell-interaction-target="errorIcon">
+ <%= icon "alert-circle", size: "sm", color: "destructive" %>
<%= t(".description") %>
-
-
-
- Upload CSV
- Copy & Paste
-
-
+ <%= render TabsComponent.new(active_tab: params[:tab] || "csv-upload", url_param_key: "tab", testid: "import-tabs") do |tabs| %>
+ <% tabs.with_nav do |nav| %>
+ <% nav.with_btn(id: "csv-upload", label: "Upload CSV") %>
+ <% nav.with_btn(id: "csv-paste", label: "Copy & Paste") %>
+ <% end %>
- <% ["csv-paste-tab", "csv-upload-tab"].each do |tab| %>
- <%= tag.div id: tab, data: { tabs_target: "tab" }, class: tab == "csv-upload-tab" ? "hidden" : "" do %>
- <%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2" do |form| %>
- <%= form.select :col_sep, Import::SEPARATORS, label: true %>
+ <% tabs.with_panel(tab_id: "csv-upload") do %>
+ <%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2" do |form| %>
+ <%= form.select :col_sep, Import::SEPARATORS, label: true %>
- <% if @import.type == "TransactionImport" || @import.type == "TradeImport" %>
- <%= form.select :account_id, @import.family.accounts.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %>
- <% end %>
+ <% if @import.type == "TransactionImport" || @import.type == "TradeImport" %>
+ <%= form.select :account_id, @import.family.accounts.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %>
+ <% end %>
- <% if tab == "csv-paste-tab" %>
- <%= form.text_area :raw_file_str,
+
+
+
+ <%= icon("plus", size: "lg", class: "mb-4 mx-auto") %>
+
+ Browse to add your CSV file here
+
+
+
+
+
+ <%= icon("file-text", size: "lg", color: "current") %>
+
+
+
+
+ <%= form.file_field :csv_file, class: "hidden", "data-auto-submit-form-target": "auto", "data-file-upload-target": "input" %>
+
+
+
+ <%= form.submit "Upload CSV", disabled: @import.complete? %>
+ <% end %>
+ <% end %>
+
+ <% tabs.with_panel(tab_id: "csv-paste") do %>
+ <%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2" do |form| %>
+ <%= form.select :col_sep, Import::SEPARATORS, label: true %>
+
+ <% if @import.type == "TransactionImport" || @import.type == "TradeImport" %>
+ <%= form.select :account_id, @import.family.accounts.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %>
+ <% end %>
+
+ <%= form.text_area :raw_file_str,
rows: 10,
required: true,
placeholder: "Paste your CSV file contents here",
"data-auto-submit-form-target": "auto" %>
- <% else %>
-
-
-
- <%= lucide_icon("plus", class: "w-6 h-6 mb-4 text-secondary mx-auto") %>
-
- Browse to add your CSV file here
-
-
-
- <%= lucide_icon("file-text", class: "w-6 h-6 mb-4 text-primary") %>
-
-
-
- <%= form.file_field :csv_file, class: "hidden", "data-auto-submit-form-target": "auto", "data-file-upload-target": "input" %>
-
-
- <% end %>
-
- <%= form.submit "Upload CSV", disabled: @import.complete? %>
- <% end %>
+ <%= form.submit "Upload CSV", disabled: @import.complete? %>
<% end %>
<% end %>
-
+ <% end %>
-
-
-
- <%= link_to "Download a sample CSV", "/imports/#{@import.id}/upload/sample_csv", class: "text-primary underline", data: { turbo: false } %> to see the required CSV format
-
-
-
+
+
+ <%= link_to "Download a sample CSV", "/imports/#{@import.id}/upload/sample_csv", class: "text-primary underline", data: { turbo: false } %> to see the required CSV format
+
+
diff --git a/app/views/imports/_empty.html.erb b/app/views/imports/_empty.html.erb
index 334ab4b6..8f147797 100644
--- a/app/views/imports/_empty.html.erb
+++ b/app/views/imports/_empty.html.erb
@@ -1,9 +1,13 @@
<%= t(".message") %>
- <%= link_to new_import_path, class: "btn btn--primary flex items-center gap-2", data: { turbo_frame: "modal" } do %>
- <%= lucide_icon("plus", class: "w-5 h-5") %>
-
<%= t(".new") %>
- <% end %>
+
+ <%= render LinkComponent.new(
+ text: t(".new"),
+ variant: "primary",
+ href: new_import_path,
+ icon: "plus",
+ frame: :modal
+ ) %>
diff --git a/app/views/imports/_failure.html.erb b/app/views/imports/_failure.html.erb
index 0ae9f2a5..533d4e52 100644
--- a/app/views/imports/_failure.html.erb
+++ b/app/views/imports/_failure.html.erb
@@ -3,7 +3,7 @@
- <%= lucide_icon "alert-octagon", class: "w-5 h-5 text-red-500" %>
+ <%= icon "alert-octagon", color: "destructive" %>
@@ -11,8 +11,6 @@
Please check that your file format, for any errors and that all required fields are filled, then come back and try again.
-
- <%= button_to "Try again", publish_import_path(import), class: "btn btn--primary text-center w-full" %>
-
+ <%= render ButtonComponent.new(text: "Try again", href: publish_import_path(import), full_width: true) %>
diff --git a/app/views/imports/_import.html.erb b/app/views/imports/_import.html.erb
index 2b84bd53..fca4764b 100644
--- a/app/views/imports/_import.html.erb
+++ b/app/views/imports/_import.html.erb
@@ -36,34 +36,30 @@
<% end %>
- <%= contextual_menu do %>
-
- <%= link_to import_path(import),
- class: "block w-full py-2 px-3 space-x-2 text-primary hover:bg-gray-50 flex items-center rounded-lg" do %>
- <%= lucide_icon "eye", class: "w-5 h-5 text-secondary" %>
+ <%= render MenuComponent.new do |menu| %>
+ <% menu.with_item(variant: "link", text: t(".view"), href: import_path(import), icon: "eye") %>
- <%= t(".view") %>
- <% end %>
+ <% if import.complete? || import.revert_failed? %>
+ <% menu.with_item(
+ variant: "button",
+ text: t(".revert"),
+ href: revert_import_path(import),
+ icon: "rotate-ccw",
+ method: :put,
+ confirm: CustomConfirm.new(
+ title: "Revert import?",
+ body: "This will delete transactions that were imported, but you will still be able to review and re-import your data at any time.",
+ btn_text: "Revert"
+ )) %>
- <% if import.complete? || import.revert_failed? %>
- <%= button_to revert_import_path(import),
- method: :put,
- class: "block w-full py-2 px-3 space-x-2 text-orange-600 hover:bg-orange-50 flex items-center rounded-lg",
- data: { turbo_confirm: true } do %>
- <%= lucide_icon "rotate-ccw", class: "w-5 h-5" %>
-
- Revert
- <% end %>
- <% else %>
- <%= button_to import_path(import),
- method: :delete,
- class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg",
- data: { turbo_confirm: true } do %>
- <%= lucide_icon "trash-2", class: "w-5 h-5" %>
-
- <%= t(".delete") %>
- <% end %>
- <% end %>
-
+ <% else %>
+ <% menu.with_item(
+ variant: "button",
+ text: t(".delete"),
+ href: import_path(import),
+ icon: "trash-2",
+ method: :delete,
+ confirm: CustomConfirm.for_resource_deletion("Import")) %>
+ <% end %>
<% end %>
diff --git a/app/views/imports/_importing.html.erb b/app/views/imports/_importing.html.erb
index df1c70a0..3815cc07 100644
--- a/app/views/imports/_importing.html.erb
+++ b/app/views/imports/_importing.html.erb
@@ -3,7 +3,7 @@
- <%= lucide_icon "loader", class: "animate-pulse w-5 h-5 text-secondary" %>
+ <%= icon "loader", class: "animate-pulse" %>
@@ -12,8 +12,8 @@
- <%= link_to "Check status", import_path(import), class: "block btn btn--primary text-center w-full" %>
- <%= link_to "Back to dashboard", root_path, class: "block btn btn--secondary text-center w-full" %>
+ <%= render LinkComponent.new(text: "Check status", href: import_path(import), variant: "primary") %>
+ <%= render LinkComponent.new(text: "Back to dashboard", href: root_path, variant: "secondary") %>
diff --git a/app/views/imports/_nav.html.erb b/app/views/imports/_nav.html.erb
index 3353e15f..eb8f5c08 100644
--- a/app/views/imports/_nav.html.erb
+++ b/app/views/imports/_nav.html.erb
@@ -36,7 +36,7 @@
<%= link_to step[:path], class: "flex items-center gap-3" do %>
- <%= step[:is_complete] && !is_current ? lucide_icon("check", class: "w-4 h-4") : idx + 1 %>
+ <%= step[:is_complete] && !is_current ? icon("check", size: "sm") : idx + 1 %>
<%= step[:name] %>
diff --git a/app/views/imports/_ready.html.erb b/app/views/imports/_ready.html.erb
index fbbf4a1d..225c0dd1 100644
--- a/app/views/imports/_ready.html.erb
+++ b/app/views/imports/_ready.html.erb
@@ -18,9 +18,9 @@
-
- <%= lucide_icon resource.icon, class: "#{resource.text_class} w-5 h-5 shrink-0" %>
-
+ <%= tag.div class: class_names(resource.bg_class, resource.text_class, "w-8 h-8 rounded-full flex justify-center items-center") do %>
+ <%= icon resource.icon, color: "current" %>
+ <% end %>
<%= resource.label %>
@@ -35,5 +35,5 @@
- <%= button_to "Publish import", publish_import_path(import), class: "btn btn--primary w-full" %>
+ <%= render ButtonComponent.new(text: "Publish import", href: publish_import_path(import), full_width: true) %>
diff --git a/app/views/imports/_revert_failure.html.erb b/app/views/imports/_revert_failure.html.erb
index 6566648d..6eacd786 100644
--- a/app/views/imports/_revert_failure.html.erb
+++ b/app/views/imports/_revert_failure.html.erb
@@ -3,7 +3,7 @@
- <%= lucide_icon "alert-octagon", class: "w-5 h-5 text-red-500" %>
+ <%= icon "alert-octagon", color: "destructive" %>
@@ -11,8 +11,10 @@
Please try again or contact support.
-
- <%= button_to "Try again", revert_import_path(import), class: "btn btn--primary text-center w-full" %>
-
+ <%= render ButtonComponent.new(
+ text: "Try again",
+ full_width: true,
+ href: revert_import_path(import)
+ ) %>
diff --git a/app/views/imports/_success.html.erb b/app/views/imports/_success.html.erb
index 4c58e07b..a1130c0d 100644
--- a/app/views/imports/_success.html.erb
+++ b/app/views/imports/_success.html.erb
@@ -3,7 +3,7 @@
- <%= lucide_icon "check", class: "w-5 h-5 text-green-500" %>
+ <%= icon "check", color: "success" %>
@@ -11,8 +11,11 @@
Your imported data has been successfully added to the app and is now ready for use.
-
- <%= link_to "Back to dashboard", root_path, class: "block btn btn--primary text-center w-full" %>
-
+ <%= render LinkComponent.new(
+ text: "Back to dashboard",
+ variant: "primary",
+ full_width: true,
+ href: root_path
+ ) %>
diff --git a/app/views/imports/index.html.erb b/app/views/imports/index.html.erb
index 1a87e72c..108f3dc5 100644
--- a/app/views/imports/index.html.erb
+++ b/app/views/imports/index.html.erb
@@ -1,10 +1,13 @@
<%= t(".title") %>
- <%= link_to new_import_path, class: "btn btn--primary flex items-center gap-2", data: { turbo_frame: :modal } do %>
- <%= lucide_icon("plus", class: "w-5 h-5") %>
- <%= t(".new") %>
- <% end %>
+ <%= render LinkComponent.new(
+ text: "New import",
+ href: new_import_path,
+ icon: "plus",
+ variant: "primary",
+ frame: :modal
+ ) %>
diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb
index 92794acb..ac739056 100644
--- a/app/views/imports/new.html.erb
+++ b/app/views/imports/new.html.erb
@@ -1,16 +1,7 @@
-<%= modal do %>
-
-
-
-
<%= t(".title") %>
-
- <%= lucide_icon("x", class: "w-5 h-5 text-primary") %>
-
-
-
-
<%= t(".description") %>
-
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".title"), subtitle: t(".description")) %>
+ <% dialog.with_body do %>
<%= t(".sources") %>
@@ -19,13 +10,15 @@
<%= link_to import_path(@pending_import), class: "flex items-center justify-between p-4 group cursor-pointer", data: { turbo: false } do %>
- <%= lucide_icon("loader", class: "w-5 h-5 text-orange-500") %>
+
+ <%= icon("loader", color: "current") %>
+
<%= t(".resume", type: @pending_import.type.titleize) %>
- <%= lucide_icon("chevron-right", class: "w-5 h-5 text-secondary") %>
+ <%= icon("chevron-right") %>
<% end %>
@@ -39,13 +32,15 @@
<%= button_to imports_path(import: { type: "TransactionImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %>
- <%= lucide_icon("file-spreadsheet", class: "w-5 h-5 text-indigo-500") %>
+
+ <%= icon("file-spreadsheet", color: "current") %>
+
<%= t(".import_transactions") %>
- <%= lucide_icon("chevron-right", class: "w-5 h-5 text-secondary") %>
+ <%= icon("chevron-right") %>
<% end %>
@@ -59,13 +54,15 @@
<%= button_to imports_path(import: { type: "TradeImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %>
- <%= lucide_icon("square-percent", class: "w-5 h-5 text-yellow-500") %>
+
+ <%= icon("square-percent", color: "current") %>
+
<%= t(".import_portfolio") %>
- <%= lucide_icon("chevron-right", class: "w-5 h-5 text-secondary") %>
+ <%= icon("chevron-right") %>
<% end %>
@@ -79,13 +76,15 @@
<%= button_to imports_path(import: { type: "AccountImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %>
- <%= lucide_icon("building", class: "w-5 h-5 text-violet-500") %>
+
+ <%= icon("building", color: "current") %>
+
<%= t(".import_accounts") %>
- <%= lucide_icon("chevron-right", class: "w-5 h-5 text-secondary") %>
+ <%= icon("chevron-right") %>
<% end %>
@@ -103,7 +102,7 @@
<%= t(".import_mint") %>
- <%= lucide_icon("chevron-right", class: "w-5 h-5 text-secondary") %>
+ <%= icon("chevron-right") %>
<% end %>
@@ -113,5 +112,5 @@
<% end %>
-
+ <% end %>
<% end %>
diff --git a/app/views/investments/_value_tooltip.html.erb b/app/views/investments/_value_tooltip.html.erb
index c311474a..62615d76 100644
--- a/app/views/investments/_value_tooltip.html.erb
+++ b/app/views/investments/_value_tooltip.html.erb
@@ -1,7 +1,7 @@
<%# locals: (balance:, holdings:, cash:) %>
- <%= lucide_icon("info", class: "w-4 h-4 shrink-0 text-secondary") %>
+ <%= icon("info", size: "sm") %>
<%= t(".total_value_tooltip") %>
diff --git a/app/views/investments/edit.html.erb b/app/views/investments/edit.html.erb
index c91d9eb2..433f118f 100644
--- a/app/views/investments/edit.html.erb
+++ b/app/views/investments/edit.html.erb
@@ -1,3 +1,6 @@
-<%= modal_form_wrapper title: t(".edit", account: @account.name) do %>
- <%= render "investments/form", account: @account, url: investment_path(@account) %>
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".edit", account: @account.name)) %>
+ <% dialog.with_body do %>
+ <%= render "investments/form", account: @account, url: investment_path(@account) %>
+ <% end %>
<% end %>
diff --git a/app/views/investments/new.html.erb b/app/views/investments/new.html.erb
index 92dc1da7..737c5818 100644
--- a/app/views/investments/new.html.erb
+++ b/app/views/investments/new.html.erb
@@ -1,7 +1,10 @@
<% if params[:step] == "method_select" %>
<%= render "accounts/new/method_selector", path: new_investment_path(return_to: params[:return_to]), us_link_token: @us_link_token, eu_link_token: @eu_link_token %>
<% else %>
- <%= modal_form_wrapper title: t(".title") do %>
- <%= render "investments/form", account: @account, url: investments_path %>
+ <%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".title")) %>
+ <% dialog.with_body do %>
+ <%= render "investments/form", account: @account, url: investments_path %>
+ <% end %>
<% end %>
<% end %>
diff --git a/app/views/invitations/new.html.erb b/app/views/invitations/new.html.erb
index 5d50e516..6d1c2cbc 100644
--- a/app/views/invitations/new.html.erb
+++ b/app/views/invitations/new.html.erb
@@ -1,20 +1,24 @@
-<%= modal_form_wrapper title: t(".title"), subtitle: t(".subtitle") do %>
- <%= styled_form_with model: @invitation, class: "space-y-4", data: { turbo: false } do |form| %>
- <%= form.email_field :email,
- required: true,
- placeholder: t(".email_placeholder"),
- label: t(".email_label") %>
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".title"), subtitle: t(".subtitle")) %>
- <%= form.select :role,
- options_for_select([
- [t(".role_member"), "member"],
- [t(".role_admin"), "admin"]
- ]),
- {},
- { label: t(".role_label") } %>
+ <% dialog.with_body do %>
+ <%= styled_form_with model: @invitation, class: "space-y-4", data: { turbo: false } do |form| %>
+ <%= form.email_field :email,
+ required: true,
+ placeholder: t(".email_placeholder"),
+ label: t(".email_label") %>
-
- <%= form.submit t(".submit"), class: "bg-gray-900 text-white rounded-lg px-4 py-2 w-full" %>
-
+ <%= form.select :role,
+ options_for_select([
+ [t(".role_member"), "member"],
+ [t(".role_admin"), "admin"]
+ ]),
+ {},
+ { label: t(".role_label") } %>
+
+
+ <%= form.submit t(".submit"), class: "bg-gray-900 text-white rounded-lg px-4 py-2 w-full" %>
+
+ <% end %>
<% end %>
<% end %>
diff --git a/app/views/invite_codes/_invite_code.html.erb b/app/views/invite_codes/_invite_code.html.erb
index cabf65dc..b4f52d45 100644
--- a/app/views/invite_codes/_invite_code.html.erb
+++ b/app/views/invite_codes/_invite_code.html.erb
@@ -6,10 +6,10 @@
- <%= lucide_icon "copy", class: "w-5 h-5" %>
+ <%= icon "copy" %>
- <%= lucide_icon "check", class: "w-5 h-4" %>
+ <%= icon "check" %>
diff --git a/app/views/invite_codes/index.html.erb b/app/views/invite_codes/index.html.erb
index ad27bc18..467037c4 100644
--- a/app/views/invite_codes/index.html.erb
+++ b/app/views/invite_codes/index.html.erb
@@ -4,7 +4,7 @@
<%= render @invite_codes %>
<% else %>
- <%= lucide_icon "binary", class: "w-6 h-6 text-sm text-secondary" %>
+ <%= icon "binary", size: "lg" %>
<%= t(".no_invite_codes") %>
<%= t(".invite_code_description") %>
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 846a656c..42275c90 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -1,135 +1,140 @@
+<% mobile_nav_items = [
+ { name: "Home", path: root_path, icon: "pie-chart", icon_custom: false, active: page_active?(root_path) },
+ { name: "Transactions", path: transactions_path, icon: "credit-card", icon_custom: false, active: page_active?(transactions_path) },
+ { name: "Budgets", path: budgets_path, icon: "map", icon_custom: false, active: page_active?(budgets_path) },
+ { name: "Assistant", path: chats_path, icon: "icon-assistant", icon_custom: true, active: page_active?(chats_path), mobile_only: true }
+] %>
+
+<% desktop_nav_items = mobile_nav_items.reject { |item| item[:mobile_only] } %>
+<% expanded_sidebar_class = "w-full" %>
+<% collapsed_sidebar_class = "w-0" %>
+
<%= render "layouts/shared/htmldoc" do %>
- <% sidebar_config = app_sidebar_config(Current.user) %>
+
+
+
+ <%= icon("x", as_button: true, data: { action: "app-layout#closeMobileSidebar" }) %>
+
-
- Toggle accounts
- Toggle chat
+ <%= render(
+ "accounts/account_sidebar_tabs",
+ family: Current.family,
+ active_account_group_tab: params[:account_group_tab] || "assets"
+ ) %>
+
- <% unless controller_name == 'chats' %>
-
-
- <%= icon("panel-left", color: "gray") %>
-
+ <%# MOBILE - Top nav %>
+
+ <%= icon("panel-left", as_button: true, data: { action: "app-layout#openMobileSidebar"}) %>
- <%# Mobile only account sidebar groups %>
- <%= tag.div class: class_names("hidden bg-surface z-20 absolute inset-0 h-dvh w-full p-4 overflow-y-auto transition-all duration-300 pt-safe"),
- data: { sidebar_target: "leftPanelMobile" } do %>
-
- <% end %>
+ <%= link_to root_path, class: "block" do %>
+ <%= image_tag "logomark-color.svg", class: "w-9 h-9 mx-auto" %>
+ <% end %>
-
+ <%= render "users/user_menu", user: Current.user, placement: "bottom-end", offset: 12 %>
+
+
+ <%# DESKTOP - Left navbar %>
+
+
+
<%= link_to root_path, class: "block" do %>
<%= image_tag "logomark-color.svg", class: "w-9 h-9 mx-auto" %>
<% end %>
-
-
- <%= render "layouts/sidebar/nav_item", name: "Home", path: root_path, icon_key: "pie-chart" %>
-
-
-
- <%= render "layouts/sidebar/nav_item", name: "Transactions", path: transactions_path, icon_key: "credit-card" %>
-
-
-
- <%= render "layouts/sidebar/nav_item", name: "Budgets", path: budgets_path, icon_key: "map" %>
-
+
+ <% desktop_nav_items.reject { |item| item[:mobile_only] }.each do |nav_item| %>
+
+ <%= render "layouts/shared/nav_item", **nav_item %>
+
+ <% end %>
-
-
- <%= render "users/user_menu", user: Current.user, placement: "bottom-end", offset: 12 %>
-
+
+ <%= render ButtonComponent.new(
+ variant: "icon",
+ icon: "message-circle-question",
+ data: { action: "intercom#show" }
+ ) %>
-
- <%= render "users/user_menu", user: Current.user %>
-
+ <%= render "users/user_menu", user: Current.user %>
+
+
+ <%# DESKTOP - Left sidebar %>
+ <%= tag.div class: class_names(
+ "hidden lg:block py-4 overflow-y-auto shrink-0 max-w-[320px] transition-all duration-300",
+ Current.user.show_sidebar? ? expanded_sidebar_class : collapsed_sidebar_class,
+ ),
+ data: { app_layout_target: "leftSidebar" } do %>
+ <% if content_for?(:sidebar) %>
+ <%= yield :sidebar %>
+ <% else %>
+
+ <% end %>
<% end %>
-
- <%= tag.div class: class_names("py-4 shrink-0 h-full overflow-y-auto transition-all duration-300 hidden lg:block"),
- style: "width: #{sidebar_config.dig(:left_panel, :initial_width)}px",
- data: { sidebar_target: "leftPanel" } do %>
- <% if content_for?(:sidebar) %>
- <%= yield :sidebar %>
- <% else %>
-
- <% end %>
- <% end %>
+ <%# SHARED - Main content %>
+ <%= tag.main class: class_names("grow overflow-y-auto px-3 lg:px-10 py-4 h-full w-full mx-auto max-w-5xl"), data: { app_layout_target: "content" } do %>
+
+
+ <%= icon("panel-left", as_button: true, data: { action: "app-layout#toggleLeftSidebar" }) %>
- <%= tag.main class: class_names("px-3 lg:px-10 py-4 grow h-full", require_upgrade? ? "relative overflow-hidden" : "overflow-y-auto") do %>
- <% if require_upgrade? %>
-
- <%= render "shared/subscribe_modal" %>
-
- <% end %>
-
- <%= tag.div class: class_names("mx-auto max-w-5xl w-full h-full"), data: { sidebar_target: "content" } do %>
<% if content_for?(:breadcrumbs) %>
<%= yield :breadcrumbs %>
<% else %>
<%= render "layouts/shared/breadcrumbs", breadcrumbs: @breadcrumbs %>
<% end %>
+
+ <%= icon("panel-right", as_button: true, data: { action: "app-layout#toggleRightSidebar" }) %>
+
- <% if content_for?(:page_header) %>
- <%= yield :page_header %>
- <% end %>
-
- <%= yield %>
- <% end %>
+ <% if content_for?(:page_header) %>
+ <%= yield :page_header %>
<% end %>
- <%# AI chat sidebar %>
- <%= tag.div id: "chat-container",
- style: "width: #{sidebar_config.dig(:right_panel, :initial_width)}px; overflow: #{sidebar_config.dig(:right_panel, :overflow)}",
- class: class_names("flex flex-col justify-between shrink-0 transition-all duration-300 hidden lg:block"),
- data: { controller: "chat hotkey", sidebar_target: "rightPanel", turbo_permanent: true } do %>
+ <%= yield %>
+ <% end %>
- <% if Current.user.ai_enabled? %>
+ <%# DESKTOP - Right sidebar %>
+ <%= tag.div class: class_names(
+ "hidden lg:block h-full overflow-y-auto shrink-0 max-w-[400px] transition-all duration-300",
+ Current.user.show_ai_sidebar? ? expanded_sidebar_class : collapsed_sidebar_class,
+ ),
+ data: { app_layout_target: "rightSidebar" } do %>
+ <%= tag.div id: "chat-container", class: "relative h-full", data: { controller: "chat hotkey", turbo_permanent: true } do %>
+
<%= turbo_frame_tag chat_frame, src: chat_view_path(@chat), loading: "lazy", class: "h-full" do %>
- <%= lucide_icon("loader-circle", class: "w-5 h-5 text-secondary animate-spin") %>
+ <%= icon("loader-circle", class: "animate-spin") %>
<% end %>
- <% else %>
- <%= render "chats/ai_consent" %>
+
+
+ <% unless Current.user.ai_enabled? %>
+
+ <%= render "chats/ai_consent" %>
+
<% end %>
<% end %>
-
+ <% end %>
-
-
-
- <%= render "layouts/sidebar/nav_item", name: "Home", path: root_path, icon_key: "pie-chart" %>
-
-
-
- <%= render "layouts/sidebar/nav_item", name: "Transactions", path: transactions_path, icon_key: "credit-card" %>
-
-
-
- <%= render "layouts/sidebar/nav_item", name: "Budgets", path: budgets_path, icon_key: "map" %>
-
-
-
- <%= render "layouts/sidebar/nav_item", name: "Assistant", path: chats_path, icon_key: "icon-assistant", is_custom: true %>
-
-
-
+ <%# MOBILE - Bottom Nav %>
+ <%= tag.nav class: "lg:hidden bg-surface shrink-0 z-10 pb-2 border-t border-tertiary pb-safe flex justify-around" do %>
+ <% mobile_nav_items.each do |nav_item| %>
+ <%= render "layouts/shared/nav_item", **nav_item %>
+ <% end %>
+ <% end %>
<% end %>
diff --git a/app/views/layouts/imports.html.erb b/app/views/layouts/imports.html.erb
index 8acf9c6c..6b73ad67 100644
--- a/app/views/layouts/imports.html.erb
+++ b/app/views/layouts/imports.html.erb
@@ -1,17 +1,21 @@
<%= render "layouts/shared/htmldoc" do %>
- <%= link_to content_for(:previous_path) || imports_path do %>
- <%= lucide_icon "arrow-left", class: "w-5 h-5 text-secondary" %>
- <% end %>
+ <%= render LinkComponent.new(
+ variant: "icon",
+ icon: "arrow-left",
+ href: content_for(:previous_path) || imports_path
+ ) %>
<%= yield :header_nav %>
- <%= link_to imports_path do %>
- <%= lucide_icon "x", class: "text-secondary w-5 h-5" %>
- <% end %>
+ <%= render LinkComponent.new(
+ variant: "icon",
+ icon: "x",
+ href: imports_path
+ ) %>
diff --git a/app/views/layouts/lookbooks.html.erb b/app/views/layouts/lookbooks.html.erb
new file mode 100644
index 00000000..de1b9dc9
--- /dev/null
+++ b/app/views/layouts/lookbooks.html.erb
@@ -0,0 +1,14 @@
+
+
+
+ Component Preview
+ <%= csrf_meta_tags %>
+ <%= csp_meta_tag %>
+ <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
+ <%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>
+ <%= javascript_importmap_tags %>
+
+
+ <%= yield %>
+
+
diff --git a/app/views/layouts/settings.html.erb b/app/views/layouts/settings.html.erb
index 30e248c1..e4e68784 100644
--- a/app/views/layouts/settings.html.erb
+++ b/app/views/layouts/settings.html.erb
@@ -10,7 +10,7 @@
<% if content_for?(:breadcrumbs) %>
<%= yield :breadcrumbs %>
<% else %>
- <%= render "layouts/shared/breadcrumbs", breadcrumbs: @breadcrumbs, sidebar_toggle_enabled: false %>
+ <%= render "layouts/shared/breadcrumbs", breadcrumbs: @breadcrumbs %>
<% end %>
<% if content_for?(:page_title) %>
diff --git a/app/views/layouts/shared/_breadcrumbs.html.erb b/app/views/layouts/shared/_breadcrumbs.html.erb
index a9d41928..665fa4be 100644
--- a/app/views/layouts/shared/_breadcrumbs.html.erb
+++ b/app/views/layouts/shared/_breadcrumbs.html.erb
@@ -1,33 +1,17 @@
-<%# locals: (breadcrumbs:, sidebar_toggle_enabled: true) %>
+<%# locals: (breadcrumbs:) %>
-
- <% if sidebar_toggle_enabled %>
-
- <%= icon("panel-left", color: "gray") %>
-
- <% end %>
-
-
- <% breadcrumbs.each_with_index do |(name, path), index| %>
- <% if index > 0 %>
- <%= icon("chevron-right", color: "gray", size: "sm") %>
- <% end %>
-
- <% if path.present? && index < breadcrumbs.size - 1 %>
- <%= link_to name, path, class: "text-sm text-gray-500 font-medium" %>
- <% elsif index == breadcrumbs.size - 1 %>
-
<%= name %>
- <% else %>
-
<%= name %>
- <% end %>
+
+ <% breadcrumbs.each_with_index do |(name, path), index| %>
+ <% if index > 0 %>
+ <%= icon("chevron-right", color: "gray", size: "sm") %>
<% end %>
-
- <% if sidebar_toggle_enabled %>
-
-
- <%= icon("panel-right", color: "gray") %>
-
-
+ <% if path.present? && index < breadcrumbs.size - 1 %>
+ <%= link_to name, path, class: "text-sm text-gray-500 font-medium" %>
+ <% elsif index == breadcrumbs.size - 1 %>
+
<%= name %>
+ <% else %>
+
<%= name %>
+ <% end %>
<% end %>
-
+
diff --git a/app/views/layouts/shared/_confirm_dialog.html.erb b/app/views/layouts/shared/_confirm_dialog.html.erb
new file mode 100644
index 00000000..4709ffb4
--- /dev/null
+++ b/app/views/layouts/shared/_confirm_dialog.html.erb
@@ -0,0 +1,30 @@
+<%# This dialog is used as an override to the browser's confirm API when submitting forms with data-turbo-confirm %>
+<%# See confirm_dialog_controller.js and _htmldoc.html.erb %>
+<%= render DialogComponent.new(id: "confirm-dialog", auto_open: false, data: { controller: "confirm-dialog" }, width: "sm") do |dialog| %>
+ <% dialog.with_body do %>
+
+ <% end %>
+<% end %>
diff --git a/app/views/layouts/shared/_fixed_content.html.erb b/app/views/layouts/shared/_fixed_content.html.erb
deleted file mode 100644
index 88ace0d3..00000000
--- a/app/views/layouts/shared/_fixed_content.html.erb
+++ /dev/null
@@ -1,6 +0,0 @@
-<%= turbo_frame_tag "modal" %>
-<%= turbo_frame_tag "drawer" %>
-<%= render "shared/confirm_modal" %>
-
-<%= render "impersonation_sessions/super_admin_bar" if Current.true_user&.super_admin? && show_super_admin_bar? %>
-<%= render "impersonation_sessions/approval_bar" if Current.true_user&.impersonated_support_sessions&.initiated&.any? %>
diff --git a/app/views/layouts/shared/_htmldoc.html.erb b/app/views/layouts/shared/_htmldoc.html.erb
index 3643637d..efb89615 100644
--- a/app/views/layouts/shared/_htmldoc.html.erb
+++ b/app/views/layouts/shared/_htmldoc.html.erb
@@ -1,5 +1,5 @@
-">
+">
<%= render "layouts/shared/head" %>
<%= yield :head %>
@@ -20,9 +20,22 @@
<%= family_stream %>
+ <% if Rails.env.development? %>
+
+ <%= icon("eclipse", as_button: true, data: { action: "theme#toDark" }) %>
+ <%= icon("sun", as_button: true, data: { action: "theme#toLight" }) %>
+
+ <% end %>
+
+ <% if require_upgrade? %>
+ <%= render "shared/subscribe_modal" %>
+ <% end %>
+
<%= turbo_frame_tag "modal" %>
<%= turbo_frame_tag "drawer" %>
- <%= render "shared/confirm_modal" %>
+
+ <%# Custom overrides for browser's confirm API %>
+ <%= render "layouts/shared/confirm_dialog" %>
<%= render "impersonation_sessions/super_admin_bar" if Current.true_user&.super_admin? && show_super_admin_bar? %>
<%= render "impersonation_sessions/approval_bar" if Current.true_user&.impersonated_support_sessions&.initiated&.any? %>
diff --git a/app/views/layouts/shared/_nav_item.html.erb b/app/views/layouts/shared/_nav_item.html.erb
new file mode 100644
index 00000000..8c5fd683
--- /dev/null
+++ b/app/views/layouts/shared/_nav_item.html.erb
@@ -0,0 +1,20 @@
+<%# locals:(name:, path:, icon:, icon_custom:, active:, mobile_only: false) %>
+
+<%= link_to path, class: "space-y-1 group block relative pb-1" do %>
+
+ <%= tag.div class: class_names("w-4 h-1 lg:w-1 lg:h-4 rounded-bl-sm rounded-br-sm lg:rounded-tr-sm lg:rounded-br-sm lg:rounded-bl-none", "bg-nav-indicator" => active) %>
+
+ <%= tag.div class: class_names(
+ "w-8 h-8 flex items-center justify-center mx-auto rounded-lg",
+ active ? "bg-container shadow-xs text-primary" : "group-hover:bg-container-hover text-secondary"
+ ) do %>
+ <%= icon(icon, color: active ? "current" : "default", custom: icon_custom) %>
+ <% end %>
+
+
+
+ <%= tag.p class: class_names("font-medium text-[11px]", active ? "text-primary" : "text-secondary") do %>
+ <%= name %>
+ <% end %>
+
+<% end %>
diff --git a/app/views/layouts/sidebar/_nav_item.html.erb b/app/views/layouts/sidebar/_nav_item.html.erb
deleted file mode 100644
index dd8661af..00000000
--- a/app/views/layouts/sidebar/_nav_item.html.erb
+++ /dev/null
@@ -1,18 +0,0 @@
-<%# locals: (name:, path:, icon_key:, is_custom: false) %>
-<%= link_to path, class: "space-y-1 group block relative pb-1" do %>
-
- <%= tag.div class: class_names("w-4 h-1 lg:w-1 lg:h-4 rounded-bl-sm rounded-br-sm lg:rounded-tr-sm lg:rounded-br-sm lg:rounded-bl-none", "bg-nav-indicator" => page_active?(path)) %>
-
- <% icon_color = page_active?(path) ? "current" : "gray" %>
-
- <%= tag.div class: class_names("w-8 h-8 flex items-center justify-center mx-auto rounded-lg", page_active?(path) ? "bg-container shadow-xs text-primary" : "group-hover:bg-container-hover text-secondary") do %>
- <%= is_custom ? icon_custom(icon_key, color: icon_color) : icon(icon_key, color: icon_color) %>
- <% end %>
-
-
-
- <%= tag.p class: class_names("font-medium text-[11px]", page_active?(path) ? "text-primary" : "text-secondary") do %>
- <%= name %>
- <% end %>
-
-<% end %>
diff --git a/app/views/layouts/wizard.html.erb b/app/views/layouts/wizard.html.erb
index a188fe60..1be2229d 100644
--- a/app/views/layouts/wizard.html.erb
+++ b/app/views/layouts/wizard.html.erb
@@ -1,17 +1,21 @@
<%= render "layouts/shared/htmldoc" do %>
- <%= link_to content_for(:previous_path) || root_path do %>
- <%= lucide_icon "arrow-left", class: "w-5 h-5 text-secondary" %>
- <% end %>
+ <%= render LinkComponent.new(
+ variant: "icon",
+ icon: "arrow-left",
+ href: content_for(:previous_path) || root_path
+ ) %>
<%= yield :header_nav %>
- <%= link_to content_for(:cancel_path) || root_path do %>
- <%= lucide_icon "x", class: "text-secondary w-5 h-5" %>
- <% end %>
+ <%= render LinkComponent.new(
+ variant: "icon",
+ icon: "x",
+ href: content_for(:cancel_path) || root_path
+ ) %>
diff --git a/app/views/loans/_overview.html.erb b/app/views/loans/_overview.html.erb
index bbeccb6e..f04ccc49 100644
--- a/app/views/loans/_overview.html.erb
+++ b/app/views/loans/_overview.html.erb
@@ -45,5 +45,10 @@
- <%= link_to "Edit loan details", edit_loan_path(account), class: "btn btn--ghost", data: { turbo_frame: :modal } %>
+ <%= render LinkComponent.new(
+ text: "Edit loan details",
+ variant: "ghost",
+ href: edit_loan_path(account),
+ frame: :modal
+ ) %>
diff --git a/app/views/loans/edit.html.erb b/app/views/loans/edit.html.erb
index 5fb3b13e..f9a56f12 100644
--- a/app/views/loans/edit.html.erb
+++ b/app/views/loans/edit.html.erb
@@ -1,3 +1,6 @@
-<%= modal_form_wrapper title: t(".edit", account: @account.name) do %>
- <%= render "form", account: @account, url: loan_path(@account) %>
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".edit", account: @account.name)) %>
+ <% dialog.with_body do %>
+ <%= render "form", account: @account, url: loan_path(@account) %>
+ <% end %>
<% end %>
diff --git a/app/views/loans/new.html.erb b/app/views/loans/new.html.erb
index b09a6b32..de4c9575 100644
--- a/app/views/loans/new.html.erb
+++ b/app/views/loans/new.html.erb
@@ -1,7 +1,10 @@
<% if params[:step] == "method_select" %>
<%= render "accounts/new/method_selector", path: new_loan_path(return_to: params[:return_to]), us_link_token: @us_link_token, eu_link_token: @eu_link_token %>
<% else %>
- <%= modal_form_wrapper title: t(".title") do %>
- <%= render "loans/form", account: @account, url: loans_path %>
+ <%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".title")) %>
+ <% dialog.with_body do %>
+ <%= render "loans/form", account: @account, url: loans_path %>
+ <% end %>
<% end %>
<% end %>
diff --git a/app/views/merchants/_merchant.html.erb b/app/views/merchants/_merchant.html.erb
index 6c930ead..964a7fff 100644
--- a/app/views/merchants/_merchant.html.erb
+++ b/app/views/merchants/_merchant.html.erb
@@ -15,19 +15,15 @@
- <%= contextual_menu do %>
-
- <%= contextual_menu_modal_action_item t(".edit"), edit_merchant_path(merchant) %>
-
- <%= contextual_menu_destructive_item t(".delete"),
- merchant_path(merchant),
- turbo_frame: "_top",
- turbo_confirm: merchant.transactions.any? ? {
- title: t(".confirm_title"),
- body: t(".confirm_body"),
- accept: t(".confirm_accept")
- } : nil %>
-
+ <%= render MenuComponent.new do |menu| %>
+ <% menu.with_item(variant: "link", text: t(".edit"), href: edit_merchant_path(merchant), icon: "pencil", data: { turbo_frame: "modal" }) %>
+ <% menu.with_item(
+ variant: "button",
+ text: t(".delete"),
+ href: merchant_path(merchant),
+ icon: "trash-2",
+ method: :delete,
+ confirm: CustomConfirm.for_resource_deletion(merchant.name)) %>
<% end %>
diff --git a/app/views/messages/_chat_form.html.erb b/app/views/messages/_chat_form.html.erb
index 10c35e5a..0c536c94 100644
--- a/app/views/messages/_chat_form.html.erb
+++ b/app/views/messages/_chat_form.html.erb
@@ -19,15 +19,11 @@
<%# These are disabled for now, but in the future, will all open specific menus with their own context and search %>
<% ["plus", "command", "at-sign", "mouse-pointer-click"].each do |icon| %>
-
- <%= icon(icon, color: "gray") %>
-
+ <%= icon(icon, as_button: true, disabled: true, class: "cursor-not-allowed", title: "Coming soon") %>
<% end %>
-
- <%= icon("arrow-up") %>
-
+ <%= icon("arrow-up", as_button: true, type: "submit") %>
<% end %>
diff --git a/app/views/mfa/backup_codes.html.erb b/app/views/mfa/backup_codes.html.erb
index b27e78d4..e4684030 100644
--- a/app/views/mfa/backup_codes.html.erb
+++ b/app/views/mfa/backup_codes.html.erb
@@ -19,9 +19,12 @@
<% end %>
-
- <%= link_to t(".continue"), settings_security_path, class: "w-full btn btn--primary" %>
-
+ <%= render LinkComponent.new(
+ text: t(".continue"),
+ href: settings_security_path,
+ variant: "primary",
+ full_width: true
+ ) %>
<% end %>
diff --git a/app/views/mfa/new.html.erb b/app/views/mfa/new.html.erb
index 0726bf1b..c8ad0892 100644
--- a/app/views/mfa/new.html.erb
+++ b/app/views/mfa/new.html.erb
@@ -29,10 +29,10 @@
class="text-sm bg-container px-2 py-1 rounded border border-secondary w-96 font-mono">
- <%= lucide_icon "copy", class: "w-5 h-5" %>
+ <%= icon "copy" %>
- <%= lucide_icon "check", class: "w-5 h-5" %>
+ <%= icon "check" %>
@@ -57,7 +57,7 @@
placeholder: t(".code_placeholder") %>
- <%= f.submit t(".verify_button"), class: "btn btn--primary" %>
+ <%= f.submit t(".verify_button") %>
<% end %>
diff --git a/app/views/onboardings/_header.html.erb b/app/views/onboardings/_header.html.erb
index 6f671662..14dabc5e 100644
--- a/app/views/onboardings/_header.html.erb
+++ b/app/views/onboardings/_header.html.erb
@@ -1,7 +1,7 @@
<%= image_tag "logo.svg", class: "h-[22px]" %>
- <%= lucide_icon "log-in", class: "w-5 h-5 shrink-0 text-secondary gap-2" %>
+ <%= icon("log-in", color: "secondary") %>
<%= button_to t(".sign_out"), session_path(Current.session), method: :delete, class: "text-sm text-primary font-medium" %>
diff --git a/app/views/onboardings/preferences.html.erb b/app/views/onboardings/preferences.html.erb
index 0af8e27f..95e701dd 100644
--- a/app/views/onboardings/preferences.html.erb
+++ b/app/views/onboardings/preferences.html.erb
@@ -69,7 +69,7 @@
{ data: { action: "onboarding#setLocale" } } %>
<%= family_form.select :currency,
- currencies_for_select.map { |currency| [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] },
+ Money::Currency.as_options.map { |currency| [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] },
{ label: t(".currency"), required: true, selected: params[:currency] || @user.family.currency },
{ data: { action: "onboarding#setCurrency" } } %>
diff --git a/app/views/onboardings/show.html.erb b/app/views/onboardings/show.html.erb
index c4af9793..542691a3 100644
--- a/app/views/onboardings/show.html.erb
+++ b/app/views/onboardings/show.html.erb
@@ -5,7 +5,12 @@
<%= tag.h1 t(".title"), class: "text-3xl font-medium mb-2" %>
<%= tag.p t(".message"), class: "text-sm text-secondary mb-6" %>
- <%= link_to t(".setup"), profile_onboarding_path, class: "block flex justify-center items-center btn btn--primary w-full" %>
+ <%= render LinkComponent.new(
+ text: t(".setup"),
+ href: profile_onboarding_path,
+ variant: "primary",
+ full_width: true
+ ) %>
diff --git a/app/views/other_assets/edit.html.erb b/app/views/other_assets/edit.html.erb
index 4c38d223..271982fc 100644
--- a/app/views/other_assets/edit.html.erb
+++ b/app/views/other_assets/edit.html.erb
@@ -1,3 +1,6 @@
-<%= modal_form_wrapper title: t(".edit", account: @account.name) do %>
- <%= render "other_assets/form", account: @account, url: other_asset_path(@account) %>
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".edit", account: @account.name)) %>
+ <% dialog.with_body do %>
+ <%= render "form", account: @account, url: other_asset_path(@account) %>
+ <% end %>
<% end %>
diff --git a/app/views/other_assets/new.html.erb b/app/views/other_assets/new.html.erb
index bff7face..106b2994 100644
--- a/app/views/other_assets/new.html.erb
+++ b/app/views/other_assets/new.html.erb
@@ -1,3 +1,6 @@
-<%= modal_form_wrapper title: t(".title") do %>
- <%= render "other_assets/form", account: @account, url: other_assets_path(return_to: params[:return_to]) %>
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".title")) %>
+ <% dialog.with_body do %>
+ <%= render "form", account: @account, url: other_assets_path %>
+ <% end %>
<% end %>
diff --git a/app/views/other_liabilities/edit.html.erb b/app/views/other_liabilities/edit.html.erb
index 4473faff..8cc6c1be 100644
--- a/app/views/other_liabilities/edit.html.erb
+++ b/app/views/other_liabilities/edit.html.erb
@@ -1,3 +1,6 @@
-<%= modal_form_wrapper title: t(".edit", account: @account.name) do %>
- <%= render "form", account: @account, url: other_liability_path(@account) %>
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".edit", account: @account.name)) %>
+ <% dialog.with_body do %>
+ <%= render "form", account: @account, url: other_liability_path(@account) %>
+ <% end %>
<% end %>
diff --git a/app/views/other_liabilities/new.html.erb b/app/views/other_liabilities/new.html.erb
index 364a5ecf..f8a1ab46 100644
--- a/app/views/other_liabilities/new.html.erb
+++ b/app/views/other_liabilities/new.html.erb
@@ -1,3 +1,6 @@
-<%= modal_form_wrapper title: t(".title") do %>
- <%= render "other_liabilities/form", account: @account, url: other_liabilities_path(return_to: params[:return_to]) %>
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".title")) %>
+ <% dialog.with_body do %>
+ <%= render "form", account: @account, url: other_liabilities_path %>
+ <% end %>
<% end %>
diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb
index 9d0e21f7..298e48e3 100644
--- a/app/views/pages/dashboard.html.erb
+++ b/app/views/pages/dashboard.html.erb
@@ -1,24 +1,38 @@
<% content_for :page_header do %>
-
+
Welcome back, <%= Current.user.first_name %>
Here's what's happening with your finances
- <%= link_to new_account_path(step: "method_select", classification: "asset"),
- class: "btn btn--primary flex items-center justify-center gap-2 rounded-full w-9 h-9 md:hidden",
- data: { turbo_frame: "modal" } do %>
-
- <%= lucide_icon("plus", class: "size-5") %>
-
- <% end %>
+ <%= render LinkComponent.new(
+ icon: "plus",
+ text: "New",
+ href: new_account_path,
+ frame: :modal,
+ class: "hidden lg:inline-flex"
+ ) %>
+
+ <%= render LinkComponent.new(
+ variant: "icon-inverse",
+ icon: "plus",
+ href: new_account_path,
+ frame: :modal,
+ class: "rounded-full lg:hidden"
+ ) %>
<% end %>
-
- <%= render partial: "pages/dashboard/net_worth_chart", locals: { series: @balance_sheet.net_worth_series(period: @period), period: @period } %>
-
+ <% if Current.family.accounts.any? %>
+
+ <%= render partial: "pages/dashboard/net_worth_chart", locals: { series: @balance_sheet.net_worth_series(period: @period), period: @period } %>
+
+ <% else %>
+
+ <%= render "pages/dashboard/no_accounts_graph_placeholder" %>
+
+ <% end %>
<%= render "pages/dashboard/balance_sheet", balance_sheet: @balance_sheet %>
diff --git a/app/views/pages/dashboard/_balance_sheet.html.erb b/app/views/pages/dashboard/_balance_sheet.html.erb
index 0c76d046..e5383278 100644
--- a/app/views/pages/dashboard/_balance_sheet.html.erb
+++ b/app/views/pages/dashboard/_balance_sheet.html.erb
@@ -1,9 +1,19 @@
<%# locals: (balance_sheet:) %>
-
+
<% balance_sheet.classification_groups.each do |classification_group| %>
-
<%= classification_group.display_name %>
+
+
+ <%= classification_group.display_name %>
+
+
+ <% if classification_group.account_groups.any? %>
+ ·
+
+ <%= classification_group.total_money.format(precision: 0) %>
+ <% end %>
+
<% if classification_group.account_groups.any? %>
@@ -23,9 +33,9 @@
-
+
-
Name
+
Name
Weight
@@ -36,33 +46,22 @@
-
+
<% classification_group.account_groups.each do |account_group| %>
-
- <%= lucide_icon("chevron-right", class: "group-open:rotate-90 text-secondary w-5 h-5") %>
+
+ <%= icon("chevron-right", class: "group-open:rotate-90") %>
<%= account_group.name %>
-
-
- <%= render partial: "shared/progress_circle", locals: { progress: account_group.weight, color: account_group.color } %>
-
<%= number_to_percentage(account_group.weight, precision: 0) %>
-
-
-
- <% 10.times do |i| %>
-
" style="background-color: <%= account_group.color %>;">
- <% end %>
-
-
<%= number_to_percentage(account_group.weight, precision: 2) %>
-
+
+ <%= render "pages/dashboard/group_weight", weight: account_group.weight, color: account_group.color %>
-
+
<%= format_money(account_group.total_money) %>
@@ -70,39 +69,25 @@
<% account_group.accounts.each_with_index do |account, idx| %>
-
-
+
+
<%= render "accounts/logo", account: account, size: "sm", color: account_group.color %>
+
<%= link_to account.name, account_path(account) %>
-
-
- <%= render partial: "shared/progress_circle", locals: { progress: account.weight, color: account_group.color } %>
-
<%= number_to_percentage(account.weight, precision: 0) %>
-
-
-
- <% 10.times do |i| %>
-
" style="background-color: <%= account_group.color %>;">
- <% end %>
-
-
<%= number_to_percentage(account.weight, precision: 2) %>
-
+
+ <%= render "pages/dashboard/group_weight", weight: account.weight, color: account_group.color %>
-
+
<%= format_money(account.balance_money) %>
-
- <%= account.name %>
-
<% if idx < account_group.accounts.size - 1 %>
-
@@ -115,10 +100,14 @@
<% else %>
-
- <%= lucide_icon classification_group.icon, class: "w-6 h-6 shrink-0 text-secondary" %>
-
No <%= classification_group.display_name %>
-
<%= "You have no #{classification_group.display_name}" %>
+
+ <%= render FilledIconComponent.new(
+ variant: :container,
+ icon: classification_group.icon,
+ ) %>
+
+
No <%= classification_group.display_name %> yet
+
<%= "Add your #{classification_group.display_name} accounts to see a full breakdown" %>
<% end %>
diff --git a/app/views/pages/dashboard/_group_weight.html.erb b/app/views/pages/dashboard/_group_weight.html.erb
new file mode 100644
index 00000000..65bc4ea6
--- /dev/null
+++ b/app/views/pages/dashboard/_group_weight.html.erb
@@ -0,0 +1,10 @@
+<%# locals: (weight:, color:) %>
+
+
+
+ <% 10.times do |i| %>
+
" style="background-color: <%= color %>;">
+ <% end %>
+
+
<%= number_to_percentage(weight, precision: 2) %>
+
diff --git a/app/views/pages/dashboard/_net_worth_chart.html.erb b/app/views/pages/dashboard/_net_worth_chart.html.erb
index dfa22031..a8870cac 100644
--- a/app/views/pages/dashboard/_net_worth_chart.html.erb
+++ b/app/views/pages/dashboard/_net_worth_chart.html.erb
@@ -14,8 +14,13 @@
<% end %>
- <%= form_with url: root_path, method: :get, class: "flex items-center gap-4", data: { controller: "auto-submit-form" } do |form| %>
- <%= period_select form: form, selected: period %>
+
+ <%= form_with url: root_path, method: :get, data: { controller: "auto-submit-form" } do |form| %>
+ <%= form.select :period,
+ Period.as_options,
+ { selected: period.key },
+ data: { "auto-submit-form-target": "auto" },
+ class: "bg-container border border-secondary font-medium rounded-lg px-3 py-2 text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0" %>
<% end %>
diff --git a/app/views/pages/dashboard/_no_account_empty_state.html.erb b/app/views/pages/dashboard/_no_account_empty_state.html.erb
deleted file mode 100644
index 0fc0b00c..00000000
--- a/app/views/pages/dashboard/_no_account_empty_state.html.erb
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
- <%= lucide_icon "layers", class: "w-6 h-6 text-secondary" %>
-
-
-
<%= t(".no_account_title") %>
-
<%= t(".no_account_subtitle") %>
-
-
- <%= link_to new_account_path, class: "btn btn--primary flex items-center gap-1", data: { turbo_frame: "modal" } do %>
- <%= lucide_icon("plus", class: "w-5 h-5") %>
-
<%= t(".new_account") %>
- <% end %>
-
-
diff --git a/app/views/pages/dashboard/_no_accounts_graph_placeholder.html.erb b/app/views/pages/dashboard/_no_accounts_graph_placeholder.html.erb
new file mode 100644
index 00000000..836a5d69
--- /dev/null
+++ b/app/views/pages/dashboard/_no_accounts_graph_placeholder.html.erb
@@ -0,0 +1,17 @@
+
+
+ <%= render FilledIconComponent.new(
+ variant: :container,
+ icon: "layers",
+ ) %>
+
+
No accounts yet
+
Add accounts to display net worth data
+ <%= render LinkComponent.new(
+ text: "Add account",
+ icon: "plus",
+ href: new_account_path,
+ frame: :modal
+ ) %>
+
+
diff --git a/app/views/pages/early_access.html.erb b/app/views/pages/early_access.html.erb
index 1d2954b7..b710d85f 100644
--- a/app/views/pages/early_access.html.erb
+++ b/app/views/pages/early_access.html.erb
@@ -37,7 +37,7 @@
<%= link_to early_access_path, class: "w-full block text-center justify-center inline-flex items-center text-white hover:bg-gray-800 p-2 rounded-md text-base transition duration-150", data: { turbo_method: :get } do %>
- <%= lucide_icon "refresh-cw", class: "w-4 h-4 sm:w-5 sm:h-5 mr-2 text-subdued" %>
+ <%= icon "refresh-cw", class: "mr-2" %>
Refresh page
<% end %>
diff --git a/app/views/pages/feedback.html.erb b/app/views/pages/feedback.html.erb
index 91636a67..e2e1ab05 100644
--- a/app/views/pages/feedback.html.erb
+++ b/app/views/pages/feedback.html.erb
@@ -8,14 +8,15 @@
<%= image_tag "github-icon.svg", class: "w-8 h-8 mb-2" %>
Write a feature request
<% end %>
+
<% if self_hosted? %>
<%= link_to "https://github.com/maybe-finance/maybe/issues/new?assignees=&labels=bug&template=bug_report.md&title=", target: "_blank", rel: "noopener noreferrer", class: "w-full md:w-1/3 flex flex-col items-center p-4 border border-alpha-black-25 rounded-xl hover:bg-container-hover" do %>
<%= image_tag "github-icon.svg", class: "w-8 h-8 mb-2" %>
File a bug report
<% end %>
<% else %>
- <%= link_to "mailto:hello@maybefinance.com", class: "w-full md:w-1/3 flex flex-col items-center p-4 border border-alpha-black-25 rounded-xl hover:bg-container-hover", onclick: "Intercom('showNewMessage'); return false;" do %>
- <%= lucide_icon "bug", class: "w-8 h-8 mb-2" %>
+ <%= tag.button class: "w-full md:w-1/3 flex flex-col gap-2 items-center p-4 border border-alpha-black-25 rounded-xl hover:bg-container-hover", data: { action: "intercom#show" } do %>
+ <%= image_tag "github-icon.svg", class: "w-8 h-8 mb-2" %>
File a bug report
<% end %>
<% end %>
diff --git a/app/views/plaid_items/_plaid_item.html.erb b/app/views/plaid_items/_plaid_item.html.erb
index bd6b20f8..7c3dc8b2 100644
--- a/app/views/plaid_items/_plaid_item.html.erb
+++ b/app/views/plaid_items/_plaid_item.html.erb
@@ -4,9 +4,9 @@
- <%= lucide_icon "chevron-right", class: "group-open:transform group-open:rotate-90 text-secondary w-5" %>
+ <%= icon "chevron-right", class: "group-open:transform group-open:rotate-90" %>
-
+
<% if plaid_item.logo.attached? %>
<%= image_tag plaid_item.logo, class: "rounded-full h-full w-full", loading: "lazy" %>
<% else %>
@@ -20,23 +20,23 @@
<%= tag.p plaid_item.name, class: "font-medium text-primary" %>
<% if plaid_item.scheduled_for_deletion? %>
-
(deletion in progress...)
+
(deletion in progress...)
<% end %>
<% if plaid_item.syncing? %>
- <%= lucide_icon "loader", class: "w-4 h-4 animate-pulse" %>
+ <%= icon "loader", size: "sm", class: "animate-pulse" %>
<%= tag.span t(".syncing") %>
<% elsif plaid_item.requires_update? %>
-
- <%= lucide_icon "alert-triangle", class: "w-4 h-4" %>
+
+ <%= icon "alert-triangle", size: "sm" %>
<%= tag.span t(".requires_update") %>
<% elsif plaid_item.sync_error.present? %>
- <%= lucide_icon "alert-circle", class: "w-4 h-4 text-red-500" %>
- <%= tag.span t(".error"), class: "text-red-500" %>
+ <%= icon "alert-circle", size: "sm", color: "destructive" %>
+ <%= tag.span t(".error"), class: "text-destructive" %>
<% else %>
@@ -50,69 +50,66 @@
<% if plaid_item.requires_update? %>
<% begin %>
<% link_token = plaid_item.get_update_link_token(webhooks_url: plaid_webhooks_url(plaid_item.plaid_region), redirect_url: accounts_url) %>
-
- <%= lucide_icon "refresh-cw", class: "w-4 h-4" %>
- <%= tag.span t(".update") %>
-
+
+ <%= render ButtonComponent.new(
+ text: t(".update"),
+ icon: "refresh-cw",
+ variant: "secondary",
+ data: {
+ controller: "plaid",
+ action: "plaid#open",
+ plaid_region_value: plaid_item.plaid_region,
+ plaid_link_token_value: link_token,
+ plaid_is_update_value: true,
+ plaid_item_id_value: plaid_item.id
+ }
+ ) %>
<% rescue PlaidItem::PlaidConnectionLostError %>
-
- <%= lucide_icon "alert-triangle", class: "w-4 h-4" %>
+
+ <%= icon "alert-triangle", size: "sm", color: "warning" %>
<%= tag.span t(".connection_lost") %>
+
<%= t(".connection_lost_description") %>
+
- <%= button_to plaid_item_path(plaid_item),
- method: :delete,
- class: "btn btn--danger flex items-center gap-2",
- data: {
- turbo_confirm: {
- title: t(".confirm_title"),
- body: t(".confirm_body"),
- accept: t(".confirm_accept")
- }
- } do %>
- <%= lucide_icon "trash-2", class: "w-4 h-4" %>
- <%= tag.span t(".delete") %>
- <% end %>
- <%= link_to new_account_path, class: "btn btn--secondary flex items-center gap-2" do %>
- <%= lucide_icon "plus", class: "w-4 h-4" %>
- <%= tag.span t(".add_new") %>
- <% end %>
+ <%= render ButtonComponent.new(
+ text: t(".delete"),
+ icon: "trash-2",
+ variant: "destructive",
+ href: plaid_item_path(plaid_item),
+ method: :delete,
+ confirm: CustomConfirm.for_resource_deletion(plaid_item.name, high_severity: true)
+ ) %>
+
+ <%= render LinkComponent.new(
+ text: t(".add_new"),
+ icon: "plus",
+ variant: "secondary",
+ href: new_account_path
+ ) %>
<% end %>
<% else %>
- <%= button_to sync_plaid_item_path(plaid_item), disabled: plaid_item.syncing? || plaid_item.scheduled_for_deletion?, class: "disabled:text-subdued text-primary flex hover:text-gray-800 items-center text-sm font-medium hover:underline" do %>
- <%= lucide_icon "refresh-cw", class: "w-4 h-4" %>
- <% end %>
+ <%= icon(
+ "refresh-cw",
+ as_button: true,
+ href: sync_plaid_item_path(plaid_item),
+ disabled: plaid_item.syncing? || plaid_item.scheduled_for_deletion?
+ ) %>
<% end %>
- <%= contextual_menu do %>
-
- <%= button_to plaid_item_path(plaid_item),
- method: :delete,
- class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg",
- disabled: plaid_item.syncing? || plaid_item.scheduled_for_deletion?,
- data: {
- turbo_confirm: {
- title: t(".confirm_title"),
- body: t(".confirm_body"),
- accept: t(".confirm_accept")
- }
- } do %>
- <%= lucide_icon "trash-2", class: "w-5 h-5" %>
-
- <%= t(".delete") %>
- <% end %>
-
+ <%= render MenuComponent.new do |menu| %>
+ <% menu.with_item(
+ variant: "button",
+ text: t(".delete"),
+ icon: "trash-2",
+ href: plaid_item_path(plaid_item),
+ method: :delete,
+ confirm: CustomConfirm.for_resource_deletion(plaid_item.name, high_severity: true)
+ ) %>
<% end %>
diff --git a/app/views/properties/_overview.html.erb b/app/views/properties/_overview.html.erb
index 95d42c8e..3916fb05 100644
--- a/app/views/properties/_overview.html.erb
+++ b/app/views/properties/_overview.html.erb
@@ -29,5 +29,10 @@
- <%= link_to "Edit account details", edit_property_path(account), class: "btn btn--ghost", data: { turbo_frame: :modal } %>
+ <%= render LinkComponent.new(
+ text: "Edit account details",
+ href: edit_property_path(account),
+ variant: "ghost",
+ frame: :modal
+ ) %>
diff --git a/app/views/properties/edit.html.erb b/app/views/properties/edit.html.erb
index 187208b6..9681e031 100644
--- a/app/views/properties/edit.html.erb
+++ b/app/views/properties/edit.html.erb
@@ -1,3 +1,6 @@
-<%= modal_form_wrapper title: t(".edit", account: @account.name) do %>
- <%= render "form", account: @account, url: property_path(@account) %>
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".edit", account: @account.name)) %>
+ <% dialog.with_body do %>
+ <%= render "form", account: @account, url: property_path(@account) %>
+ <% end %>
<% end %>
diff --git a/app/views/properties/new.html.erb b/app/views/properties/new.html.erb
index 5cddb5eb..d888f198 100644
--- a/app/views/properties/new.html.erb
+++ b/app/views/properties/new.html.erb
@@ -1,3 +1,6 @@
-<%= modal_form_wrapper title: t(".title") do %>
- <%= render "properties/form", account: @account, url: properties_path(return_to: params[:return_to]) %>
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".title")) %>
+ <% dialog.with_body do %>
+ <%= render "properties/form", account: @account, url: properties_path(return_to: params[:return_to]) %>
+ <% end %>
<% end %>
diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb
index d0852937..7eb32066 100644
--- a/app/views/registrations/new.html.erb
+++ b/app/views/registrations/new.html.erb
@@ -19,7 +19,7 @@
<% if @user.errors.present? %>
- <%= lucide_icon "circle-alert", class: "w-5 h-5" %>
+ <%= icon("circle-alert") %>
<%= @user.errors.full_messages.to_sentence %>
<% end %>
@@ -56,10 +56,10 @@
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700 focus:outline-none"
data-action="click->password-visibility#toggle">
- <%= lucide_icon "eye", class: "w-5 h-5 text-gray" %>
+ <%= icon("eye") %>
- <%= lucide_icon "eye-off", class: "w-5 h-5 text-gray" %>
+ <%= icon("eye-off") %>
@@ -73,19 +73,19 @@
- <%= lucide_icon "check", class: "w-4 h-4" %>
+ <%= icon("check", size: "sm") %>
Minimum 8 characters
- <%= lucide_icon "check", class: "w-4 h-4" %>
+ <%= icon("check", size: "sm") %>
Upper and lowercase letters
- <%= lucide_icon "check", class: "w-4 h-4" %>
+ <%= icon("check", size: "sm") %>
A number (0-9)
- <%= lucide_icon "check", class: "w-4 h-4" %>
+ <%= icon("check", size: "sm") %>
A special character (!, @, #, $, %, etc)
diff --git a/app/views/rule/actions/_action.html.erb b/app/views/rule/actions/_action.html.erb
index 84bc8b1c..f41e4857 100644
--- a/app/views/rule/actions/_action.html.erb
+++ b/app/views/rule/actions/_action.html.erb
@@ -19,9 +19,9 @@
<% end %>
-
- <%= icon("trash-2", color: "gray", size: "sm") %>
-
+ <%= icon(
+ "trash-2",
+ size: "sm",
+ as_button: true,
+ data: { action: "rule--actions#remove", rule__actions_destroy_param: action.persisted? }) %>
diff --git a/app/views/rule/conditions/_condition.html.erb b/app/views/rule/conditions/_condition.html.erb
index 972c3d1d..b79978a1 100644
--- a/app/views/rule/conditions/_condition.html.erb
+++ b/app/views/rule/conditions/_condition.html.erb
@@ -32,9 +32,10 @@
-
- <%= icon("trash-2", color: "gray", size: "sm") %>
-
+ <%= icon(
+ "trash-2",
+ as_button: true,
+ size: "sm",
+ data: { action: "rule--conditions#remove", rule__conditions_destroy_param: condition.persisted? }
+ ) %>
diff --git a/app/views/rule/conditions/_condition_group.html.erb b/app/views/rule/conditions/_condition_group.html.erb
index e00df533..67b3eb0f 100644
--- a/app/views/rule/conditions/_condition_group.html.erb
+++ b/app/views/rule/conditions/_condition_group.html.erb
@@ -19,9 +19,12 @@
of the following conditions
-
- <%= icon("trash-2", color: "gray", size: "sm") %>
-
+ <%= icon(
+ "trash-2",
+ size: "sm",
+ as_button: true,
+ data: { action: "element-removal#remove" }
+ ) %>
<%# Sub-condition template, used by Stimulus controller to add new sub-conditions dynamically %>
@@ -37,8 +40,10 @@
<% end %>
-
- <%= icon("plus", color: "gray", size: "sm") %>
- Add condition
-
+ <%= render ButtonComponent.new(
+ text: "Add condition",
+ leading_icon: "plus",
+ variant: "ghost",
+ data: { action: "rule--conditions#addSubCondition" }
+ ) %>
diff --git a/app/views/rules/_category_rule_cta.html.erb b/app/views/rules/_category_rule_cta.html.erb
index 54f2b752..66996044 100644
--- a/app/views/rules/_category_rule_cta.html.erb
+++ b/app/views/rules/_category_rule_cta.html.erb
@@ -13,8 +13,9 @@
<%= f.hidden_field :rule_prompt_dismissed_at, value: Time.current %>
<%= tag.div class:"flex gap-2 justify-end" do %>
- <%= f.submit "Dismiss", class: "btn btn--secondary" %>
- <%= tag.a "Create rule", href: new_rule_path(resource_type: "transaction", action_type: "set_transaction_category", action_value: cta[:category_id]), class: "btn btn--primary", data: { turbo_frame: "modal" } %>
+ <%= render ButtonComponent.new(text: "Dismiss", variant: "secondary") %>
+ <% rule_href = new_rule_path(resource_type: "transaction", action_type: "set_transaction_category", action_value: cta[:category_id]) %>
+ <%= render LinkComponent.new(text: "Create rule", variant: "primary", href: rule_href, frame: :modal) %>
<% end %>
<% end %>
<% end %>
diff --git a/app/views/rules/_form.html.erb b/app/views/rules/_form.html.erb
index 5a03eff6..79c0b45e 100644
--- a/app/views/rules/_form.html.erb
+++ b/app/views/rules/_form.html.erb
@@ -1,6 +1,6 @@
<%# locals: (rule:) %>
-<%= styled_form_with model: rule, class: "space-y-4 w-[550px]",
+<%= styled_form_with model: rule, class: "space-y-4",
data: { controller: "rules", rule_registry_value: rule.registry.to_json } do |f| %>
<%= f.hidden_field :resource_type, value: rule.resource_type %>
@@ -37,15 +37,8 @@
-
- <%= icon("plus") %>
- Add condition
-
-
-
- <%= icon("boxes") %>
- Add condition group
-
+ <%= render ButtonComponent.new(text: "Add condition", icon: "plus", variant: "ghost", type: "button", data: { action: "rules#addCondition" }) %>
+ <%= render ButtonComponent.new(text: "Add condition group", icon: "boxes", variant: "ghost", type: "button", data: { action: "rules#addConditionGroup" }) %>
@@ -65,13 +58,7 @@
<% end %>
-
- <%= icon("plus") %>
- Add action
-
+ <%= render ButtonComponent.new(text: "Add action", icon: "plus", variant: "ghost", type: "button", data: { action: "rules#addAction" }) %>
diff --git a/app/views/rules/_rule.html.erb b/app/views/rules/_rule.html.erb
index 2e433c72..0a19d9b3 100644
--- a/app/views/rules/_rule.html.erb
+++ b/app/views/rules/_rule.html.erb
@@ -42,20 +42,20 @@
- <%= render "shared/toggle_form", model: rule, attribute: :active %>
+ <%= styled_form_with model: rule, data: { controller: "auto-submit-form" } do |f| %>
+ <%= f.toggle :active, { data: { auto_submit_form_target: "auto" } } %>
+ <% end %>
- <%= contextual_menu icon: "more-vertical", id: "chat-menu" do %>
- <%= contextual_menu_item "Edit", url: edit_rule_path(rule), icon: "pencil", turbo_frame: "modal" %>
-
- <%= contextual_menu_item "Re-apply rule", url: confirm_rule_path(rule), turbo_frame: "modal", icon: "refresh-cw" %>
-
- <% turbo_confirm = {
- title: "Delete rule",
- body: "Are you sure you want to delete this rule? Data affected by this rule will no longer be automatically updated. This action cannot be undone.",
- accept: "Delete rule",
- } %>
-
- <%= contextual_menu_destructive_item "Delete", rule_path(rule), turbo_confirm: turbo_confirm %>
+ <%= render MenuComponent.new do |menu| %>
+ <% menu.with_item(variant: "link", text: "Edit", href: edit_rule_path(rule), icon: "pencil", data: { turbo_frame: "modal" }) %>
+ <% menu.with_item(variant: "link", text: "Re-apply rule", href: confirm_rule_path(rule), icon: "refresh-cw", data: { turbo_frame: "modal" }) %>
+ <% menu.with_item(
+ variant: "button",
+ text: "Delete",
+ href: rule_path(rule),
+ icon: "trash-2",
+ method: :delete,
+ confirm: CustomConfirm.for_resource_deletion("Rule")) %>
<% end %>
diff --git a/app/views/rules/confirm.html.erb b/app/views/rules/confirm.html.erb
index 4749dea8..987a7fa9 100644
--- a/app/views/rules/confirm.html.erb
+++ b/app/views/rules/confirm.html.erb
@@ -1,20 +1,18 @@
-<%= modal(reload_on_close: true) do %>
-
-
-
-
Confirm changes
-
- <%= lucide_icon("x", class: "w-5 h-5 shrink-0 text-secondary") %>
-
-
-
-
- You are about to apply this rule to
- <%= @rule.affected_resource_count %> <%= @rule.resource_type.pluralize %>
- that meet the specified rule criteria. Please confirm if you wish to proceed with this change.
-
-
-
- <%= button_to "Confirm changes", apply_rule_path(@rule), class: "btn btn--primary w-full justify-center", data: { turbo_frame: "_top"} %>
-
+<%= render DialogComponent.new(reload_on_close: true) do |dialog| %>
+ <% dialog.with_header(title: "Confirm changes") %>
+
+ <% dialog.with_body do %>
+
+ You are about to apply this rule to
+ <%= @rule.affected_resource_count %> <%= @rule.resource_type.pluralize %>
+ that meet the specified rule criteria. Please confirm if you wish to proceed with this change.
+
+
+ <%= render ButtonComponent.new(
+ text: "Confirm changes",
+ href: apply_rule_path(@rule),
+ method: :post,
+ full_width: true,
+ data: { turbo_frame: "_top" }) %>
+ <% end %>
<% end %>
diff --git a/app/views/rules/edit.html.erb b/app/views/rules/edit.html.erb
index e5693fa2..6693ac5e 100644
--- a/app/views/rules/edit.html.erb
+++ b/app/views/rules/edit.html.erb
@@ -1,5 +1,8 @@
<%= link_to "Back to rules", rules_path %>
-<%= modal_form_wrapper title: "Edit #{@rule.resource_type} rule" do %>
- <%= render "rules/form", rule: @rule %>
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: "Edit #{@rule.resource_type} rule") %>
+ <% dialog.with_body do %>
+ <%= render "rules/form", rule: @rule %>
+ <% end %>
<% end %>
diff --git a/app/views/rules/index.html.erb b/app/views/rules/index.html.erb
index 39412b4e..13e538b4 100644
--- a/app/views/rules/index.html.erb
+++ b/app/views/rules/index.html.erb
@@ -1,29 +1,32 @@
Rules
- <% turbo_confirm = {
- title: "Delete all rules",
- body: "Are you sure you want to delete all rules? This action cannot be undone.",
- accept: "Delete all rules",
- } %>
-
<% if @rules.any? %>
- <%= contextual_menu do %>
- <%= contextual_menu_destructive_item "Delete all rules", destroy_all_rules_path, turbo_confirm: turbo_confirm %>
+ <%= render MenuComponent.new do |menu| %>
+ <% menu.with_item(
+ variant: "button",
+ text: "Delete all rules",
+ href: destroy_all_rules_path,
+ icon: "trash-2",
+ method: :delete,
+ confirm: CustomConfirm.for_resource_deletion("All rules", high_severity: true)) %>
<% end %>
<% end %>
- <%= link_to new_rule_path(resource_type: "transaction"), class: "btn btn--primary flex items-center gap-1 justify-center", data: { turbo_frame: :modal } do %>
- <%= lucide_icon "plus", class: "w-5 h-5" %>
-
New rule
- <% end %>
+ <%= render LinkComponent.new(
+ text: "New rule",
+ variant: "primary",
+ href: new_rule_path(resource_type: "transaction"),
+ icon: "plus",
+ frame: :modal
+ ) %>
<% if self_hosted? %>
- <%= lucide_icon("circle-alert", class: "w-4 h-4 text-secondary") %>
+ <%= icon("circle-alert", size: "sm") %>
AI-enabled rule actions will cost money. Be sure to filter as narrowly as possible to avoid unnecessary costs.
@@ -50,10 +53,13 @@
No rules yet
Set up rules to perform actions to your transactions and other data on every account sync.
- <%= link_to new_rule_path(resource_type: "transaction"), class: "btn btn--primary flex items-center gap-1", data: { turbo_frame: "modal" } do %>
- <%= lucide_icon("plus", class: "w-5 h-5") %>
- New rule
- <% end %>
+ <%= render LinkComponent.new(
+ text: "New rule",
+ variant: "primary",
+ href: new_rule_path(resource_type: "transaction"),
+ icon: "plus",
+ frame: :modal
+ ) %>
diff --git a/app/views/rules/new.html.erb b/app/views/rules/new.html.erb
index 37205dd9..a39a299b 100644
--- a/app/views/rules/new.html.erb
+++ b/app/views/rules/new.html.erb
@@ -1,5 +1,8 @@
<%= link_to "Back to rules", rules_path %>
-<%= modal_form_wrapper title: "New #{@rule.resource_type} rule" do %>
- <%= render "rules/form", rule: @rule %>
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: "New #{@rule.resource_type} rule") %>
+ <% dialog.with_body do %>
+ <%= render "rules/form", rule: @rule %>
+ <% end %>
<% end %>
diff --git a/app/views/settings/_settings_nav.html.erb b/app/views/settings/_settings_nav.html.erb
index be1ad416..0a035206 100644
--- a/app/views/settings/_settings_nav.html.erb
+++ b/app/views/settings/_settings_nav.html.erb
@@ -1,11 +1,15 @@
- <%= link_to previous_path, class: "flex items-center gap-1 text-primary font-medium text-sm" do %>
- <%= lucide_icon "chevron-left", class: "w-5 h-5 text-secondary" %>
- Back
- <% end %>
+ <%= render LinkComponent.new(
+ text: "Back",
+ icon: "chevron-left",
+ href: previous_path,
+ variant: "ghost",
+ class: "hidden md:inline-flex"
+ ) %>
+
<%= link_to previous_path, class: "hidden md:block uppercase bg-surface-inset-hover rounded-sm px-1 py-0.5 text-xs text-secondary shadow-sm ml-1 pointer-events-none", data: { controller: "hotkey", hotkey: "Escape" } do %>
- esc
+ esc
<% end %>
@@ -83,13 +87,13 @@
- <%= button_to session_path(Current.session), method: :delete, class: "flex items-center gap-2 btn btn--ghost text-destructive w-full" do %>
- <%= lucide_icon("log-out", class: "w-5 h-5 shrink-0") %>
+ <%= button_to session_path(Current.session), method: :delete, class: "flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-destructive hover:bg-surface-hover w-full" do %>
+ <%= icon("log-out", color: "current") %>
<%= t(".logout") %>
<% end %>
-
+
@@ -141,8 +145,8 @@
<%= render "settings/settings_nav_item", name: t(".feedback_label"), path: feedback_path, icon: "megaphone" %>
- <%= button_to session_path(Current.session), method: :delete, class: "flex items-center gap-2 btn btn--ghost text-destructive w-full" do %>
- <%= lucide_icon("log-out", class: "w-5 h-5 shrink-0") %>
+ <%= button_to session_path(Current.session), method: :delete, class: "flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-destructive hover:bg-surface-hover w-full" do %>
+ <%= icon("log-out") %>
<%= t(".logout") %>
<% end %>
diff --git a/app/views/settings/_settings_nav_item.html.erb b/app/views/settings/_settings_nav_item.html.erb
index 6515d117..cbf51311 100644
--- a/app/views/settings/_settings_nav_item.html.erb
+++ b/app/views/settings/_settings_nav_item.html.erb
@@ -1,9 +1,9 @@
<%# locals: (name:, path:, icon:) %>
<%= link_to path, class: class_names(
- "flex items-center gap-2 btn btn--ghost whitespace-nowrap",
+ "flex items-center gap-2 whitespace-nowrap px-3 py-2 rounded-lg text-sm",
page_active?(path) ? "text-primary bg-container shadow-border-xs" : "text-secondary hover:bg-surface-hover border-transparent"
), aria: { current: ("page" if page_active?(path)) } do %>
- <%= lucide_icon(icon, class: "w-5 h-5") if icon %>
+ <%= icon(icon) if icon %>
<%= name %>
<% end %>
diff --git a/app/views/settings/_settings_nav_link_large.html.erb b/app/views/settings/_settings_nav_link_large.html.erb
index beba1e89..73265aed 100644
--- a/app/views/settings/_settings_nav_link_large.html.erb
+++ b/app/views/settings/_settings_nav_link_large.html.erb
@@ -1,18 +1,14 @@
<%# locals: path, direction, title %>
<%= link_to path, class: "hidden md:flex w-full bg-container hover:bg-container-inset rounded-xl border border-alpha-black-25 shadow-xs p-4 items-center justify-between" do %>
<% if direction == 'previous' %>
-
- <%= lucide_icon("arrow-left") %>
-
+ <%= icon("arrow-left") %>
<% end %>
<%= "text-right" if direction == "previous" %>">
<%= t(".#{direction}") %>
<%= title %>
<% if direction == 'next' %>
-
- <%= lucide_icon("arrow-right") %>
-
+ <%= icon("arrow-right") %>
<% end %>
<% end %>
@@ -21,9 +17,7 @@
<% if direction == 'previous' %>
-
- <%= lucide_icon("arrow-left") %>
-
+ <%= icon("arrow-left") %>
Back
@@ -35,9 +29,7 @@
<%= title %>
-
- <%= lucide_icon("arrow-right") %>
-
+ <%= icon("arrow-right") %>
<% end %>
diff --git a/app/views/settings/_user_avatar.html.erb b/app/views/settings/_user_avatar.html.erb
index 46b322db..67da90cd 100644
--- a/app/views/settings/_user_avatar.html.erb
+++ b/app/views/settings/_user_avatar.html.erb
@@ -1,7 +1,7 @@
-<%# locals: (user:, variant: :thumbnail, lazy: false) %>
+<%# locals: (avatar_url: nil, initials: "U", lazy: false) %>
-<% if user.profile_image.attached? %>
- <%= image_tag user.profile_image.variant(variant), class: "rounded-full w-full h-full object-cover", loading: lazy ? "lazy" : "eager" %>
+<% if avatar_url.present? %>
+ <%= image_tag avatar_url, class: "rounded-full w-full h-full object-cover", loading: lazy ? "lazy" : "eager" %>
<% else %>
- <%= user.initial %>
+ <%= initials %>
<% end %>
diff --git a/app/views/settings/_user_avatar_field.html.erb b/app/views/settings/_user_avatar_field.html.erb
index e19bf1d1..c312b910 100644
--- a/app/views/settings/_user_avatar_field.html.erb
+++ b/app/views/settings/_user_avatar_field.html.erb
@@ -12,7 +12,7 @@
">
- <%= lucide_icon "image-plus", class: "w-6 h-6 text-secondary" %>
+ <%= icon "image-plus", size: "lg" %>
@@ -21,7 +21,7 @@
class="h-full w-full flex justify-center items-center <%= user.profile_image.attached? ? "" : "hidden" %>">
<% if user.profile_image.attached? %>
- <%= render "settings/user_avatar", user: user %>
+ <%= render "settings/user_avatar", avatar_url: user.profile_image.url %>
<% end %>
@@ -30,16 +30,15 @@
data-profile-image-preview-target="clearBtn"
data-action="click->profile-image-preview#clearFileInput"
class="<%= user.profile_image.attached? ? "" : "hidden" %> cursor-pointer absolute bottom-0 right-0 w-8 h-8 bg-gray-50 rounded-full flex justify-center items-center border border-white border-2">
- <%= lucide_icon "x", class: "w-4 h-4 text-secondary" %>
+ <%= icon "x", size: "sm" %>
<%= form.hidden_field :delete_profile_image, value: "0", data: { profile_image_preview_target: "deleteProfileImage" } %>
- <%= form.label :profile_image, class: "btn btn--outline inline-block", data: { profile_image_preview_target: "uploadButton" } do %>
-
- <%= lucide_icon "camera", class: "w-5 h-5 mr-2 inline-block", data: { profile_image_preview_target: "cameraIcon" } %>
+ <%= form.label :profile_image, class: "px-3 py-2 rounded-lg text-sm hover:bg-surface-hover border border-secondary inline-flex items-center gap-2 cursor-pointer", data: { profile_image_preview_target: "uploadButton" } do %>
+ <%= icon "camera", data: { profile_image_preview_target: "cameraIcon" } %>
<%= t(".choose") %> <%= t(".choose_label") %>
diff --git a/app/views/settings/billings/show.html.erb b/app/views/settings/billings/show.html.erb
index e683efa1..1b4cc5e2 100644
--- a/app/views/settings/billings/show.html.erb
+++ b/app/views/settings/billings/show.html.erb
@@ -5,7 +5,7 @@
- <%= lucide_icon "gem", class: "w-5 h-5 text-secondary" %>
+ <%= icon "gem" %>
@@ -19,15 +19,24 @@
<% if @user.family.subscribed? || subscription_pending? %>
- <%= link_to subscription_path, class: "btn btn--secondary flex items-center gap-1", target: "_blank", rel: "noopener" do %>
-
Manage
- <%= lucide_icon "external-link", class: "w-5 h-5 shrink-0 text-secondary" %>
- <% end %>
+ <%= render LinkComponent.new(
+ text: "Manage",
+ icon: "external-link",
+ variant: "primary",
+ icon_position: "right",
+ href: subscription_path,
+ target: "_blank",
+ rel: "noopener"
+ ) %>
<% else %>
- <%= link_to new_subscription_path, class: "btn btn--secondary flex items-center gap-1", target: "_blank", rel: "noopener" do %>
-
Subscribe
- <%= lucide_icon "external-link", class: "w-5 h-5 shrink-0 text-secondary" %>
- <% end %>
+ <%= render LinkComponent.new(
+ text: "Subscribe",
+ variant: "primary",
+ icon: "external-link",
+ icon_position: "right",
+ href: new_subscription_path,
+ target: "_blank",
+ rel: "noopener") %>
<% end %>
diff --git a/app/views/settings/hostings/_invite_code_settings.html.erb b/app/views/settings/hostings/_invite_code_settings.html.erb
index 138f5c95..63a5a335 100644
--- a/app/views/settings/hostings/_invite_code_settings.html.erb
+++ b/app/views/settings/hostings/_invite_code_settings.html.erb
@@ -5,11 +5,11 @@
<%= t(".description") %>
- <%= styled_form_with model: Setting.new, url: settings_hosting_path, method: :patch, data: { controller: "auto-submit-form", "auto-submit-form-trigger-event-value" => "blur" } do |form| %>
-
- <%= form.check_box :require_invite_for_signup, class: "sr-only peer", "data-auto-submit-form-target": "auto", "data-autosubmit-trigger-event": "input", disabled: !Current.user.admin? %>
- <%= form.label :require_invite_for_signup, " ".html_safe, class: "switch" %>
-
+ <%= styled_form_with model: Setting.new,
+ url: settings_hosting_path,
+ method: :patch,
+ data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %>
+ <%= form.toggle :require_invite_for_signup, { data: { auto_submit_form_target: "auto" } } %>
<% end %>
@@ -19,11 +19,11 @@
<%= t(".email_confirmation_description") %>
- <%= styled_form_with model: Setting.new, url: settings_hosting_path, method: :patch, data: { controller: "auto-submit-form", "auto-submit-form-trigger-event-value" => "blur" } do |form| %>
-
- <%= form.check_box :require_email_confirmation, class: "sr-only peer", "data-auto-submit-form-target": "auto", "data-autosubmit-trigger-event": "input", disabled: !Current.user.admin? %>
- <%= form.label :require_email_confirmation, " ".html_safe, class: "switch" %>
-
+ <%= styled_form_with model: Setting.new,
+ url: settings_hosting_path,
+ method: :patch,
+ data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %>
+ <%= form.toggle :require_email_confirmation, { data: { auto_submit_form_target: "auto" } } %>
<% end %>
diff --git a/app/views/settings/preferences/show.html.erb b/app/views/settings/preferences/show.html.erb
index 169df40a..fae15f30 100644
--- a/app/views/settings/preferences/show.html.erb
+++ b/app/views/settings/preferences/show.html.erb
@@ -7,7 +7,7 @@
<%= form.fields_for :family do |family_form| %>
<%= family_form.select :currency,
- currencies_for_select.map { |currency| [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] },
+ Money::Currency.as_options.map { |currency| [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] },
{ label: t(".currency") }, disabled: true %>
<%= family_form.select :locale,
@@ -46,9 +46,9 @@
<%= form_with model: @user, class: "flex flex-col md:flex-row justify-between items-center gap-4", id: "theme_form",
data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %>
<%= form.hidden_field :redirect_to, value: "preferences" %>
-
+
<% theme_option_class = "text-center transition-all duration-200 p-3 rounded-lg hover:bg-surface-hover cursor-pointer [&:has(input:checked)]:bg-surface-hover [&:has(input:checked)]:border [&:has(input:checked)]:border-primary [&:has(input:checked)]:shadow-xs" %>
-
+
<% [
{ value: "light", image: "light-mode-preview.png" },
{ value: "dark", image: "dark-mode-preview.png" },
@@ -57,8 +57,8 @@
<%= form.label :"theme_#{theme[:value]}", class: "group" do %>
<%= image_tag(theme[:image], alt: "#{theme[:value].titleize} Theme Preview", class: "h-44 mb-2") %>
-
- <%= form.radio_button :theme, theme[:value], checked: @user.theme == theme[:value], class: "sr-only",
+
">
+ <%= form.radio_button :theme, theme[:value], checked: @user.theme == theme[:value], class: "sr-only",
data: { auto_submit_form_target: "auto", autosubmit_trigger_event: "change", action: "theme#updateTheme" } %>
<%= t(".theme_#{theme[:value]}") %>
diff --git a/app/views/settings/profiles/show.html.erb b/app/views/settings/profiles/show.html.erb
index dae79cca..e375f2ad 100644
--- a/app/views/settings/profiles/show.html.erb
+++ b/app/views/settings/profiles/show.html.erb
@@ -6,17 +6,20 @@
<%= form.email_field :email, placeholder: t(".email"), label: t(".email") %>
+
<% if @user.unconfirmed_email.present? %>
You have requested to change your email to <%= @user.unconfirmed_email %>. Please go to your email and confirm for the change to take effect.
<% end %>
+
<%= form.text_field :first_name, placeholder: t(".first_name"), label: t(".first_name") %>
<%= form.text_field :last_name, placeholder: t(".last_name"), label: t(".last_name") %>
+
- <%= form.submit t(".save"), class: "btn btn--primary md:w-auto w-full" %>
+ <%= render ButtonComponent.new(text: t(".save"), class: "md:w-auto w-full justify-center") %>
<% end %>
@@ -40,7 +43,7 @@
<% @users.each do |user| %>
- <%= render "settings/user_avatar", user: user %>
+ <%= render "settings/user_avatar", avatar_url: user.profile_image.url %>
<%= user.display_name %>
@@ -48,17 +51,13 @@
<% if Current.user.admin? && user != Current.user %>
- <%= button_to settings_profile_path(user_id: user),
- method: :delete,
- class: "text-red-500 hover:text-red-700",
- data: { turbo_confirm: {
- title: t(".confirm_remove_member.title"),
- body: t(".confirm_remove_member.body", name: user.display_name),
- accept: t(".remove_member"),
- acceptClass: "w-full btn btn--destructive text-white rounded-xl text-center p-[10px] mb-2"
- }} do %>
- <%= lucide_icon "x", class: "w-5 h-5" %>
- <% end %>
+ <%= render ButtonComponent.new(
+ variant: "icon",
+ icon: "x",
+ href: settings_profile_path(user_id: user),
+ method: :delete,
+ confirm: CustomConfirm.for_resource_deletion(user.display_name, high_severity: true)
+ ) %>
<% end %>
@@ -89,26 +88,23 @@
class="text-sm bg-gray-50 px-2 py-1 rounded border border-secondary w-72">
- <%= lucide_icon "copy", class: "w-5 h-5" %>
+ <%= icon "copy" %>
- <%= lucide_icon "check", class: "w-5 h-5" %>
+ <%= icon "check" %>
<% end %>
+
<% if Current.user.admin? %>
- <%= button_to invitation_path(invitation),
- method: :delete,
- class: "text-red-500 hover:text-red-700",
- data: { turbo_confirm: {
- title: t(".confirm_remove_invitation.title"),
- body: t(".confirm_remove_invitation.body", email: invitation.email),
- accept: t(".remove_invitation"),
- acceptClass: "w-full btn btn--destructive text-white rounded-xl text-center p-[10px] mb-2"
- }} do %>
- <%= lucide_icon "x", class: "w-5 h-5" %>
- <% end %>
+ <%= render ButtonComponent.new(
+ variant: "icon",
+ icon: "x",
+ href: invitation_path(invitation),
+ method: :delete,
+ confirm: CustomConfirm.for_resource_deletion(invitation.email, high_severity: true)
+ ) %>
<% end %>
@@ -118,7 +114,7 @@
<%= link_to new_invitation_path,
class: "bg-container-inset flex items-center justify-center gap-2 text-secondary mt-1 hover:bg-container-inset-hover rounded-lg px-4 py-2 w-full text-center",
data: { turbo_frame: :modal } do %>
- <%= lucide_icon("plus", class: "w-5 h-5 text-secondary") %>
+ <%= icon("plus") %>
<%= t(".invite_member") %>
<% end %>
<% end %>
@@ -134,16 +130,14 @@
<%= t(".reset_account") %>
<%= t(".reset_account_warning") %>
- <%=
- button_to t(".reset_account"), reset_user_path(@user), method: :delete,
- class: "w-full md:w-auto btn btn--destructive",
- data: { turbo_confirm: {
- title: t(".confirm_reset.title"),
- body: t(".confirm_reset.body"),
- accept: t(".reset_account"),
- acceptClass: "w-full btn btn--destructive text-primary rounded-xl text-center p-[10px] mb-2"
- }}
- %>
+
+ <%= render ButtonComponent.new(
+ text: t(".reset_account"),
+ variant: "destructive",
+ href: reset_user_path(@user),
+ method: :delete,
+ confirm: CustomConfirm.for_resource_deletion("Account", high_severity: true)
+ ) %>
<% end %>
@@ -151,16 +145,14 @@
<%= t(".delete_account") %>
<%= t(".delete_account_warning") %>
- <%=
- button_to t(".delete_account"), user_path(@user), method: :delete,
- class: "w-full md:w-auto btn btn--destructive",
- data: { turbo_confirm: {
- title: t(".confirm_delete.title"),
- body: t(".confirm_delete.body"),
- accept: t(".delete_account"),
- acceptClass: "w-full btn btn--destructive text-white rounded-xl text-center p-[10px] mb-2"
- }}
- %>
+
+ <%= render ButtonComponent.new(
+ text: t(".delete_account"),
+ variant: "destructive",
+ href: user_path(@user),
+ method: :delete,
+ confirm: CustomConfirm.for_resource_deletion("your account", high_severity: true)
+ ) %>
<% end %>
diff --git a/app/views/settings/securities/show.html.erb b/app/views/settings/securities/show.html.erb
index e1bd3af3..1ac38d73 100644
--- a/app/views/settings/securities/show.html.erb
+++ b/app/views/settings/securities/show.html.erb
@@ -5,7 +5,7 @@
- <%= lucide_icon "shield-check", class: "w-5 h-5 text-secondary" %>
+ <%= icon "shield-check" %>
@@ -21,18 +21,24 @@
<% if Current.user.otp_required? %>
- <%= button_to t(".disable_mfa"), disable_mfa_path,
- method: :delete,
- class: "w-full md:w-auto btn btn--secondary flex items-center gap-1 justify-center",
- data: { turbo_confirm: {
- title: t(".disable_mfa_confirm"),
- body: t(".disable_mfa_confirm"),
- accept: t(".disable_mfa"),
- acceptClass: "w-full bg-red-500 text-white rounded-xl text-center p-[10px] border mb-2"
- } } %>
+ <%= render ButtonComponent.new(
+ text: t(".disable_mfa"),
+ variant: "secondary",
+ href: disable_mfa_path,
+ method: :delete,
+ confirm: CustomConfirm.new(
+ title: t(".disable_mfa_confirm"),
+ body: t(".disable_mfa_confirm"),
+ btn_text: t(".disable_mfa"),
+ destructive: true
+ )
+ ) %>
<% else %>
- <%= link_to t(".enable_mfa"), new_mfa_path,
- class: "w-full md:w-auto btn btn--primary flex items-center gap-1 justify-center" %>
+ <%= render LinkComponent.new(
+ text: t(".enable_mfa"),
+ variant: "primary",
+ href: new_mfa_path
+ ) %>
<% end %>
diff --git a/app/views/shared/_circle_logo.html.erb b/app/views/shared/_circle_logo.html.erb
deleted file mode 100644
index 0aba2373..00000000
--- a/app/views/shared/_circle_logo.html.erb
+++ /dev/null
@@ -1,13 +0,0 @@
-<%# locals: (name:, hex: nil, size: "md") %>
-
-<% size_classes = {
- "sm" => "w-6 h-6",
- "md" => "w-9 h-9",
- "lg" => "w-10 h-10",
- "full" => "w-full h-full"
-} %>
-
-<%= tag.div style: mixed_hex_styles(hex || "#1570EF"),
- class: [size_classes[size], "flex shrink-0 items-center justify-center rounded-full"] do %>
- <%= tag.span (name.presence&.first || "T").upcase, class: ["font-medium", size == "sm" ? "text-xs" : "text-sm"] %>
-<% end %>
diff --git a/app/views/shared/_confirm_modal.html.erb b/app/views/shared/_confirm_modal.html.erb
deleted file mode 100644
index ec24a1e0..00000000
--- a/app/views/shared/_confirm_modal.html.erb
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
diff --git a/app/views/shared/_disclosure.html.erb b/app/views/shared/_disclosure.html.erb
deleted file mode 100644
index c4e59d9c..00000000
--- a/app/views/shared/_disclosure.html.erb
+++ /dev/null
@@ -1,12 +0,0 @@
-<%# locals: (title:, content:, open: true) %>
-
-
>
-
- <%= title %>
- <%= lucide_icon "chevron-down",
- class: "group-open:transform group-open:rotate-180 text-secondary w-5 h-5" %>
-
-
- <%= content %>
-
diff --git a/app/views/shared/_drawer.html.erb b/app/views/shared/_drawer.html.erb
deleted file mode 100644
index e133fa0a..00000000
--- a/app/views/shared/_drawer.html.erb
+++ /dev/null
@@ -1,19 +0,0 @@
-<%# locals: (content:, reload_on_close: false) %>
-
-<%= turbo_frame_tag "drawer" do %>
-
-
-
-
- <%= lucide_icon("x", class: "w-5 h-5 shrink-0") %>
-
-
-
- <%= content %>
-
-
-
-<% end %>
diff --git a/app/views/shared/_form_errors.html.erb b/app/views/shared/_form_errors.html.erb
index 30e37a97..f2d703e2 100644
--- a/app/views/shared/_form_errors.html.erb
+++ b/app/views/shared/_form_errors.html.erb
@@ -1,6 +1,6 @@
<%# locals: (model:) %>
- <%= lucide_icon("alert-circle", class: "text-red-500 w-4 h-4 shrink-0") %>
-
<%= model.errors.full_messages.to_sentence %>
+ <%= icon("alert-circle", size: "sm", color: "destructive") %>
+
<%= model.errors.full_messages.to_sentence %>
diff --git a/app/views/shared/_icon.html.erb b/app/views/shared/_icon.html.erb
deleted file mode 100644
index 82f999de..00000000
--- a/app/views/shared/_icon.html.erb
+++ /dev/null
@@ -1,6 +0,0 @@
-<%# locals: (key:, size: "md", color: "current") %>
-
-<% size_class = case size when "sm" then "w-4 h-4" when "md" then "w-5 h-5" when "lg" then "w-6 h-6" end %>
-<% color_class = case color when "current" then "text-current" when "gray" then "text-secondary" end %>
-
-<%= lucide_icon key, class: class_names(size_class, color_class, "shrink-0") %>
diff --git a/app/views/shared/_icon_custom.html.erb b/app/views/shared/_icon_custom.html.erb
deleted file mode 100644
index ae3b6935..00000000
--- a/app/views/shared/_icon_custom.html.erb
+++ /dev/null
@@ -1,6 +0,0 @@
-<%# locals: (key:, size: "md", color: "current") %>
-
-<% size_class = case size when "sm" then "w-4 h-4" when "md" then "w-5 h-5" when "lg" then "w-6 h-6" end %>
-<% color_class = case color when "current" then "text-current" when "gray" then "text-secondary" end %>
-
-<%= inline_svg_tag "#{key}.svg", class: class_names(size_class, color_class, "shrink-0") %>
diff --git a/app/views/shared/_icon_image.html.erb b/app/views/shared/_icon_image.html.erb
deleted file mode 100644
index b782dc3f..00000000
--- a/app/views/shared/_icon_image.html.erb
+++ /dev/null
@@ -1,6 +0,0 @@
-<%# locals: (key:, size: "md", color: "current") %>
-
-<% size_class = case size when "sm" then "w-4 h-4" when "md" then "w-5 h-5" when "lg" then "w-6 h-6" end %>
-<% color_class = case color when "current" then "text-current" when "gray" then "text-secondary" end %>
-
-<%= image_tag("icon-#{key}.svg", class: class_names(size_class, color_class, "shrink-0"), alt: key ) %>
diff --git a/app/views/shared/_modal.html.erb b/app/views/shared/_modal.html.erb
deleted file mode 100644
index 3fdcb70a..00000000
--- a/app/views/shared/_modal.html.erb
+++ /dev/null
@@ -1,19 +0,0 @@
-<%# locals: (content:, reload_on_close:, overflow_visible: false) -%>
-
-<%= turbo_frame_tag "modal" do %>
- <%= tag.dialog(
- class: class_names(
- "focus:outline-none md:m-auto bg-container rounded-none md:rounded-2xl max-w-screen max-h-screen md:max-w-max w-full h-full md:h-fit md:w-auto shadow-border-xs",
- overflow_visible ? "overflow-visible" : "overflow-auto"
- ),
- data: {
- controller: "modal",
- action: "mousedown->modal#clickOutside",
- modal_reload_on_close_value: reload_on_close
- }
- ) do %>
-
- <%= content %>
-
- <% end %>
-<% end %>
diff --git a/app/views/shared/_modal_form.html.erb b/app/views/shared/_modal_form.html.erb
deleted file mode 100644
index fb6d21c8..00000000
--- a/app/views/shared/_modal_form.html.erb
+++ /dev/null
@@ -1,18 +0,0 @@
-<%# locals: (title:, content:, subtitle: nil, overflow_visible: false) %>
-
-<%= modal overflow_visible: overflow_visible do %>
-
-
-
- <%= title %>
- <%= lucide_icon("x", class: "cursor-pointer w-6 h-6 md:w-5 md:w-5 text-secondary", data: { action: "mousedown->modal#close" }) %>
-
-
- <% if subtitle.present? %>
- <%= tag.p subtitle, class: "text-secondary font-light" %>
- <% end %>
-
-
- <%= content %>
-
-<% end %>
diff --git a/app/views/shared/_money_field.html.erb b/app/views/shared/_money_field.html.erb
index 9b83820a..ac8e6e55 100644
--- a/app/views/shared/_money_field.html.erb
+++ b/app/views/shared/_money_field.html.erb
@@ -44,7 +44,7 @@
<% unless options[:hide_currency] %>
<%= form.select currency_method,
- currencies_for_select.map(&:iso_code),
+ Money::Currency.as_options.map(&:iso_code),
{ inline: true, selected: currency.iso_code },
{
class: "w-fit pr-5 disabled:text-subdued form-field__input",
diff --git a/app/views/shared/_pagination.html.erb b/app/views/shared/_pagination.html.erb
index 73a6ddea..afc047de 100644
--- a/app/views/shared/_pagination.html.erb
+++ b/app/views/shared/_pagination.html.erb
@@ -7,11 +7,11 @@
<%= link_to pagy_url_for(pagy, pagy.prev),
data: { turbo_frame: :_top },
class: "inline-flex items-center p-2 text-sm font-medium text-secondary bg-container-inset hover:border-secondary hover:text-secondary" do %>
- <%= lucide_icon("chevron-left", class: "w-5 h-5 text-secondary") %>
+ <%= icon("chevron-left") %>
<% end %>
<% else %>
- <%= lucide_icon("chevron-left", class: "w-5 h-5 text-secondary") %>
+ <%= icon("chevron-left") %>
<% end %>
@@ -39,11 +39,11 @@
<%= link_to pagy_url_for(pagy, pagy.next),
data: { turbo_frame: :_top },
class: "inline-flex items-center p-2 text-sm font-medium text-secondary hover:border-secondary hover:text-secondary" do %>
- <%= lucide_icon("chevron-right", class: "w-5 h-5 text-secondary") %>
+ <%= icon("chevron-right") %>
<% end %>
<% else %>
- <%= lucide_icon("chevron-right", class: "w-5 h-5 text-secondary") %>
+ <%= icon("chevron-right") %>
<% end %>
diff --git a/app/views/shared/_subscribe_modal.html.erb b/app/views/shared/_subscribe_modal.html.erb
index 3afedaab..62d7d72a 100644
--- a/app/views/shared/_subscribe_modal.html.erb
+++ b/app/views/shared/_subscribe_modal.html.erb
@@ -1,25 +1,26 @@
-
-
-
-
'); background-size: cover; background-position: center top;">
-
-
-
- <%= image_tag "maybe-plus-logo.png", class: "w-20" %>
-
-
-
Join Maybe+
-
-
-
Nobody likes paywalls, but we need feedback from users willing to pay for Maybe.
-
-
To continue using the app, please subscribe. In this early beta testing phase, we require that you upgrade within one hour to claim your spot.
-
-
- <%= link_to "Upgrade to Maybe+", new_subscription_path, class: "btn btn--primary text-center w-full block" %>
-
+<%= render DialogComponent.new do |dialog| %>
+
'); background-size: cover; background-position: center top;">
+
+
+
+ <%= image_tag "maybe-plus-logo.png", class: "w-20" %>
+
+
Join Maybe+
+
+
+
Nobody likes paywalls, but we need feedback from users willing to pay for Maybe.
+
+
To continue using the app, please subscribe. In this early beta testing phase, we require that you upgrade within one hour to claim your spot.
+
+
+ <%= render LinkComponent.new(
+ text: "Upgrade to Maybe+",
+ href: new_subscription_path,
+ variant: "primary",
+ full_width: true
+ ) %>
-
+<% end %>
diff --git a/app/views/shared/_toggle_form.html.erb b/app/views/shared/_toggle_form.html.erb
deleted file mode 100644
index a5d1ed25..00000000
--- a/app/views/shared/_toggle_form.html.erb
+++ /dev/null
@@ -1,11 +0,0 @@
-<%# locals: (model:, attribute:, turbo_frame: nil) %>
-
-<%= form_with model: model,
- namespace: model.id,
- class: "flex items-center",
- data: { controller: "auto-submit-form", turbo_frame: turbo_frame } do |form| %>
-
- <%= form.check_box attribute, { class: "sr-only peer", data: { "auto-submit-form-target": "auto" } } %>
- <%= form.label attribute, " ".html_safe, class: "switch" %>
-
-<% end %>
diff --git a/app/views/shared/_transaction_type_tabs.html.erb b/app/views/shared/_transaction_type_tabs.html.erb
index 0c55b6cc..c2b53b2f 100644
--- a/app/views/shared/_transaction_type_tabs.html.erb
+++ b/app/views/shared/_transaction_type_tabs.html.erb
@@ -1,24 +1,24 @@
- <% active_tab = local_assigns[:active_tab] || 'expense' %>
+ <% active_tab = local_assigns[:active_tab] || "expense" %>
- <%= link_to new_transaction_path(nature: 'outflow'),
+ <%= link_to new_transaction_path(nature: "outflow"),
data: { turbo_frame: :modal },
class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-sm md:text-normal text-subdued #{active_tab == 'expense' ? 'bg-container text-gray-800 shadow-sm' : 'hover:bg-container hover:text-gray-800 hover:shadow-sm'}" do %>
- <%= lucide_icon "minus-circle", class: "w-4 h-4 md:w-5 md:h-5" %>
+ <%= icon "minus-circle" %>
<%= tag.span t("shared.transaction_tabs.expense") %>
<% end %>
- <%= link_to new_transaction_path(nature: 'inflow'),
+ <%= link_to new_transaction_path(nature: "inflow"),
data: { turbo_frame: :modal },
class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-sm md:text-normal text-subdued #{active_tab == 'income' ? 'bg-container text-gray-800 shadow-sm' : 'hover:bg-container hover:text-gray-800 hover:shadow-sm'}" do %>
- <%= lucide_icon "plus-circle", class: "w-4 h-4 md:w-5 md:h-5" %>
+ <%= icon "plus-circle" %>
<%= tag.span t("shared.transaction_tabs.income") %>
<% end %>
<%= link_to new_transfer_path,
data: { turbo_frame: :modal },
class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-sm md:text-normal text-subdued #{active_tab == 'transfer' ? 'bg-container text-gray-800 shadow-sm' : 'hover:bg-container hover:text-gray-800 hover:shadow-sm'}" do %>
- <%= lucide_icon "arrow-right-left", class: "w-4 h-4 md:w-5 md:h-5" %>
+ <%= icon "arrow-right-left" %>
<%= tag.span t("shared.transaction_tabs.transfer") %>
<% end %>
diff --git a/app/views/shared/_trend_change.html.erb b/app/views/shared/_trend_change.html.erb
index b2503a7a..ad9c44cf 100644
--- a/app/views/shared/_trend_change.html.erb
+++ b/app/views/shared/_trend_change.html.erb
@@ -8,7 +8,7 @@
<%= trend.value.is_a?(Money) ? format_money(trend.value) : trend.value.round(2) %>
<% unless trend.percent.infinite? %>
-
(<%= lucide_icon(trend.icon, class: "w-4 h-4 align-text-bottom inline") %><%= trend.percent_formatted %>)
+
(<%= icon(trend.icon, size: "sm", color: "current", class: "mb-0.5 inline") %><%= trend.percent_formatted %>)
<% end %>
<%= " #{comparison_label}" if defined?(comparison_label) && comparison_label.present? %>
diff --git a/app/views/shared/notifications/_alert.html.erb b/app/views/shared/notifications/_alert.html.erb
index 63269f8c..28373aa8 100644
--- a/app/views/shared/notifications/_alert.html.erb
+++ b/app/views/shared/notifications/_alert.html.erb
@@ -4,13 +4,13 @@
data: { controller: "element-removal" } do %>
- <%= lucide_icon "x", class: "w-3 h-3" %>
+ <%= icon "x", size: "xs" %>
<%= tag.p message, class: "text-primary text-sm font-medium" %>
- <%= lucide_icon "x", data: { action: "click->element-removal#remove" }, class: "w-5 h-5 text-secondary hover:text-gray-600 cursor-pointer" %>
+ <%= icon "x", data: { action: "click->element-removal#remove" }, class: "cursor-pointer" %>
<% end %>
diff --git a/app/views/shared/notifications/_cta.html.erb b/app/views/shared/notifications/_cta.html.erb
index f1fa2496..6b38ff53 100644
--- a/app/views/shared/notifications/_cta.html.erb
+++ b/app/views/shared/notifications/_cta.html.erb
@@ -4,7 +4,7 @@
<%= tag.div class: "relative flex gap-3 rounded-lg bg-container-inset p-4 group w-full md:max-w-80 shadow-border-xs", data: { controller: "element-removal" } do %>
- <%= lucide_icon "check", class: "w-3 h-3" %>
+ <%= icon "check", size: "xs" %>
diff --git a/app/views/shared/notifications/_loading.html.erb b/app/views/shared/notifications/_loading.html.erb
index 2a444028..c16771c0 100644
--- a/app/views/shared/notifications/_loading.html.erb
+++ b/app/views/shared/notifications/_loading.html.erb
@@ -2,7 +2,7 @@
<%= tag.div id: id, class: "flex gap-3 rounded-lg bg-container-inset p-4 group w-full md:max-w-80 shadow-border-xs" do %>
- <%= lucide_icon "loader", class: "w-5 h-5 text-secondary animate-pulse" %>
+ <%= icon "loader", class: "animate-pulse" %>
<%= tag.p message, class: "text-primary text-sm font-medium" %>
diff --git a/app/views/shared/notifications/_notice.html.erb b/app/views/shared/notifications/_notice.html.erb
index 76c1ea60..d4931716 100644
--- a/app/views/shared/notifications/_notice.html.erb
+++ b/app/views/shared/notifications/_notice.html.erb
@@ -1,14 +1,13 @@
-<%# locals: (message:, description: nil, cta: nil) %>
+<%# locals: (message:, description: nil) %>
<%= tag.div class: "relative flex gap-3 rounded-lg bg-container-inset p-4 group w-full md:max-w-80 shadow-border-xs",
data: {
controller: "element-removal",
action: "animationend->element-removal#remove"
} do %>
-
- <%= lucide_icon "check", class: "w-3 h-3" %>
+ <%= icon "check", size: "xs" %>
@@ -20,29 +19,18 @@
<%= tag.p description, class: "text-secondary text-sm" %>
<% end %>
-
- <% if cta %>
- <%= tag.div class:"flex gap-2 justify-end" do %>
- <%= tag.button cta[:decline][:label], class: "btn btn--secondary", data: { action: "click->element-removal#remove" } %>
- <%= tag.a cta[:accept][:label], href: cta[:accept][:href], class: "btn btn--primary" %>
- <% end %>
- <% end %>
- <% unless cta %>
-
-
-
-
- <% end %>
+
+
+
+
- <% unless cta %>
-
- <%= lucide_icon "x", class: "w-5 h-5 p-0.5 hidden group-hover:inline-block border border-alpha-black-50 border-solid rounded-lg bg-white text-subdued cursor-pointer", data: { action: "click->element-removal#remove" } %>
-
- <% end %>
+
+ <%= icon "x", class: "p-0.5 hidden group-hover:inline-block border border-alpha-black-50 border-solid rounded-lg bg-white text-subdued cursor-pointer", data: { action: "click->element-removal#remove" } %>
+
<% end %>
diff --git a/app/views/tag/deletions/new.html.erb b/app/views/tag/deletions/new.html.erb
index afcd2d29..57f4d14b 100644
--- a/app/views/tag/deletions/new.html.erb
+++ b/app/views/tag/deletions/new.html.erb
@@ -1,21 +1,32 @@
-<%= modal_form_wrapper title: t(".delete_tag"), subtitle: t(".explanation", tag_name: @tag.name) do %>
- <%= styled_form_with url: tag_deletions_path(@tag),
- class: "space-y-4",
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".delete_tag"), subtitle: t(".explanation", tag_name: @tag.name)) %>
+
+ <% dialog.with_body do %>
+ <%= styled_form_with url: tag_deletions_path(@tag),
data: {
turbo: false,
controller: "deletion",
- deletion_dangerous_action_class: "form-field__submit bg-container text-red-600 border hover:bg-red-50",
- deletion_safe_action_class: "form-field__submit border border-transparent",
deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", tag_name: @tag.name),
deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", tag_name: @tag.name) } do |f| %>
- <%= f.collection_select :replacement_tag_id,
+ <%= f.collection_select :replacement_tag_id,
Current.family.tags.alphabetically.without(@tag),
:id, :name,
- { prompt: t(".replacement_tag_prompt"), label: t(".tag") },
- { data: { deletion_target: "replacementField", action: "deletion#updateSubmitButton" } } %>
+ { prompt: t(".replacement_tag_prompt"), label: t(".tag"), container_class: "mb-4" },
+ data: { deletion_target: "replacementField", action: "deletion#chooseSubmitButton" } %>
- <%= f.submit t(".delete_and_leave_uncategorized", tag_name: @tag.name),
- class: "form-field__submit bg-container text-red-600 border hover:bg-red-50",
- data: { deletion_target: "submitButton" } %>
+ <%= render ButtonComponent.new(
+ variant: "destructive",
+ text: t(".delete_and_leave_uncategorized", tag_name: @tag.name),
+ full_width: true,
+ data: { deletion_target: "destructiveSubmitButton" }
+ ) %>
+
+ <%= render ButtonComponent.new(
+ text: "Delete and reassign",
+ data: { deletion_target: "safeSubmitButton" },
+ hidden: true,
+ full_width: true
+ ) %>
+ <% end %>
<% end %>
<% end %>
diff --git a/app/views/tags/_tag.html.erb b/app/views/tags/_tag.html.erb
index 96c61dea..654e4fdb 100644
--- a/app/views/tags/_tag.html.erb
+++ b/app/views/tags/_tag.html.erb
@@ -8,21 +8,27 @@
- <%= contextual_menu do %>
-
- <%= contextual_menu_modal_action_item t(".edit"), edit_tag_path(tag) %>
+ <%= render MenuComponent.new do |menu| %>
+ <% menu.with_item(variant: "link", text: t(".edit"), href: edit_tag_path(tag), icon: "pencil", data: { turbo_frame: "modal" }) %>
- <% if tag.transactions.any? %>
- <%= link_to new_tag_deletion_path(tag),
- class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg",
- data: { turbo_frame: :modal } do %>
- <%= lucide_icon "trash-2", class: "w-5 h-5" %>
- <%= t(".delete") %>
- <% end %>
- <% else %>
- <%= contextual_menu_destructive_item t(".delete"), tag_path(tag), turbo_confirm: nil %>
- <% end %>
-
+ <% if tag.transactions.any? %>
+ <% menu.with_item(
+ variant: "link",
+ text: t(".delete"),
+ href: new_tag_deletion_path(tag),
+ icon: "trash-2",
+ frame: :modal
+ ) %>
+ <% else %>
+ <% menu.with_item(
+ variant: "button",
+ text: t(".delete"),
+ href: tag_path(tag),
+ icon: "trash-2",
+ method: :delete,
+ confirm: CustomConfirm.for_resource_deletion(tag.name)
+ ) %>
+ <% end %>
<% end %>
diff --git a/app/views/tags/edit.html.erb b/app/views/tags/edit.html.erb
index 75ad4a0f..3453189d 100644
--- a/app/views/tags/edit.html.erb
+++ b/app/views/tags/edit.html.erb
@@ -1,3 +1,6 @@
-<%= modal_form_wrapper title: t(".edit") do %>
- <%= render "form", tag: @tag %>
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".edit")) %>
+ <% dialog.with_body do %>
+ <%= render "form", tag: @tag %>
+ <% end %>
<% end %>
diff --git a/app/views/tags/index.html.erb b/app/views/tags/index.html.erb
index 1b05c051..d12bd356 100644
--- a/app/views/tags/index.html.erb
+++ b/app/views/tags/index.html.erb
@@ -1,10 +1,13 @@
<%= t(".tags") %>
- <%= link_to new_tag_path, class: "btn btn--primary flex items-center gap-1 justify-center", data: { turbo_frame: :modal } do %>
- <%= lucide_icon "plus", class: "w-5 h-5" %>
- <%= t(".new") %>
- <% end %>
+ <%= render LinkComponent.new(
+ text: t(".new"),
+ variant: "primary",
+ href: new_tag_path,
+ icon: "plus",
+ frame: :modal
+ ) %>
@@ -26,10 +29,13 @@
<%= t(".empty") %>
- <%= link_to new_tag_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %>
- <%= lucide_icon("plus", class: "w-5 h-5") %>
-
<%= t(".new") %>
- <% end %>
+
+ <%= render LinkComponent.new(
+ text: t(".new"),
+ icon: "plus",
+ href: new_tag_path,
+ frame: :modal
+ ) %>
<% end %>
diff --git a/app/views/tags/new.html.erb b/app/views/tags/new.html.erb
index ad97c79d..19508471 100644
--- a/app/views/tags/new.html.erb
+++ b/app/views/tags/new.html.erb
@@ -1,3 +1,6 @@
-<%= modal_form_wrapper title: t(".new") do %>
- <%= render "form", tag: @tag %>
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".new")) %>
+ <% dialog.with_body do %>
+ <%= render "form", tag: @tag %>
+ <% end %>
<% end %>
diff --git a/app/views/trades/_header.html.erb b/app/views/trades/_header.html.erb
index a649ebf5..7fcf1a47 100644
--- a/app/views/trades/_header.html.erb
+++ b/app/views/trades/_header.html.erb
@@ -26,7 +26,7 @@
<% trade = entry.trade %>
- <%= disclosure t(".overview") do %>
+ <%= render DisclosureComponent.new(title: t(".overview"), open: true) do %>
diff --git a/app/views/trades/_trade.html.erb b/app/views/trades/_trade.html.erb
index eb558dd3..2a4c32f0 100644
--- a/app/views/trades/_trade.html.erb
+++ b/app/views/trades/_trade.html.erb
@@ -12,9 +12,12 @@
<%= tag.div class: ["flex items-center gap-2"] do %>
-
- <%= entry.name.first.upcase %>
-
+ <%= render FilledIconComponent.new(
+ variant: :text,
+ text: entry.name,
+ size: "sm",
+ rounded: true
+ ) %>
<%= link_to entry.name,
diff --git a/app/views/trades/new.html.erb b/app/views/trades/new.html.erb
index d3df030f..381a809e 100644
--- a/app/views/trades/new.html.erb
+++ b/app/views/trades/new.html.erb
@@ -1,3 +1,6 @@
-<%= modal_form_wrapper title: t(".title") do %>
- <%= render "trades/form", entry: @entry %>
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".title")) %>
+ <% dialog.with_body do %>
+ <%= render "trades/form", entry: @entry %>
+ <% end %>
<% end %>
diff --git a/app/views/trades/show.html.erb b/app/views/trades/show.html.erb
index 565f8c8b..97c48e2d 100644
--- a/app/views/trades/show.html.erb
+++ b/app/views/trades/show.html.erb
@@ -1,11 +1,12 @@
-<%= drawer do %>
- <%= render "trades/header", entry: @entry %>
+<%= render DialogComponent.new(variant: "drawer") do |dialog| %>
+ <% dialog.with_header do %>
+ <%= render "trades/header", entry: @entry %>
+ <% end %>
<% trade = @entry.trade %>
-
-
- <%= disclosure t(".details") do %>
+ <% dialog.with_body do %>
+ <% dialog.with_section(title: t(".details"), open: true) do %>
<%= styled_form_with model: @entry,
url: trade_path(@entry),
@@ -42,8 +43,7 @@
<% end %>
-
- <%= disclosure t(".additional") do %>
+ <% dialog.with_section(title: t(".additional")) do %>
<%= styled_form_with model: @entry,
url: trade_path(@entry),
@@ -58,8 +58,7 @@
<% end %>
-
- <%= disclosure t(".settings") do %>
+ <% dialog.with_section(title: t(".settings")) do %>
<%= styled_form_with model: @entry,
@@ -72,13 +71,7 @@
<%= t(".exclude_subtitle") %>
-
- <%= f.check_box :excluded,
- class: "sr-only peer",
- "data-auto-submit-form-target": "auto" %>
-
-
+ <%= f.toggle :excluded, { data: { auto_submit_form_target: "auto" } } %>
<% end %>
@@ -98,5 +91,5 @@
<% end %>
-
+ <% end %>
<% end %>
diff --git a/app/views/transactions/_form.html.erb b/app/views/transactions/_form.html.erb
index 19c9acaa..733af700 100644
--- a/app/views/transactions/_form.html.erb
+++ b/app/views/transactions/_form.html.erb
@@ -1,4 +1,6 @@
-<%= styled_form_with model: @entry, url: transactions_path, class: "space-y-4 text-subdued", data: { controller: "transaction-form" } do |f| %>
+<%# locals: (entry:, income_categories:, expense_categories:) %>
+
+<%= styled_form_with model: entry, url: transactions_path, class: "space-y-4 text-subdued", data: { controller: "transaction-form" } do |f| %>
<% if entry.errors.any? %>
<%= render "shared/form_errors", model: entry %>
<% end %>
@@ -21,13 +23,13 @@
<%= f.money_field :amount, label: t(".amount"), required: true %>
<%= f.fields_for :entryable do |ef| %>
- <% categories = params[:nature] == "inflow" ? @income_categories : @expense_categories %>
+ <% categories = params[:nature] == "inflow" ? income_categories : expense_categories %>
<%= ef.collection_select :category_id, categories, :id, :name, { prompt: t(".category_prompt"), label: t(".category") } %>
<% end %>
<%= f.date_field :date, label: t(".date"), required: true, min: Entry.min_supported_date, max: Date.current, value: Date.current %>
- <%= disclosure t(".details"), default_open: false do %>
+ <%= render DisclosureComponent.new(title: t(".details")) do %>
<%= f.fields_for :entryable do |ef| %>
<%= ef.select :tag_ids,
Current.family.tags.alphabetically.pluck(:name, :id),
diff --git a/app/views/transactions/_header.html.erb b/app/views/transactions/_header.html.erb
index 5b3e75b8..565892d5 100644
--- a/app/views/transactions/_header.html.erb
+++ b/app/views/transactions/_header.html.erb
@@ -13,7 +13,7 @@
<% if entry.transaction.transfer? %>
- <%= lucide_icon "arrow-left-right", class: "text-secondary mt-1 w-5 h-5" %>
+ <%= icon "arrow-left-right", class: "mt-1" %>
<% end %>
diff --git a/app/views/transactions/_selection_bar.html.erb b/app/views/transactions/_selection_bar.html.erb
index fada06aa..65e2ce12 100644
--- a/app/views/transactions/_selection_bar.html.erb
+++ b/app/views/transactions/_selection_bar.html.erb
@@ -8,15 +8,15 @@
<%= turbo_frame_tag "bulk_transaction_edit_drawer" %>
<%= link_to new_transactions_bulk_update_path,
- class: "p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md",
+ class: "p-1.5 group hover:bg-inverse flex items-center justify-center rounded-md",
title: "Edit",
data: { turbo_frame: "bulk_transaction_edit_drawer" } do %>
- <%= lucide_icon "pencil-line", class: "w-5 group-hover:text-white" %>
+ <%= icon "pencil-line", class: "group-hover:text-inverse" %>
<% end %>
<%= form_with url: transactions_bulk_deletion_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
-
- <%= lucide_icon "trash-2", class: "w-5 group-hover:text-white" %>
+
+ <%= icon "trash-2", class: "group-hover:text-inverse" %>
<% end %>
diff --git a/app/views/transactions/_transaction.html.erb b/app/views/transactions/_transaction.html.erb
index 4aa8ddd1..1ad74980 100644
--- a/app/views/transactions/_transaction.html.erb
+++ b/app/views/transactions/_transaction.html.erb
@@ -26,9 +26,12 @@
class: "w-6 h-6 rounded-full",
loading: "lazy" %>
<% else %>
- <%= render "shared/circle_logo",
- name: entry.name,
- size: "sm" %>
+ <%= render FilledIconComponent.new(
+ variant: :text,
+ text: entry.name,
+ size: "sm",
+ rounded: true
+ ) %>
<% end %>
@@ -45,8 +48,8 @@
) %>
<% if entry.excluded %>
-
(excluded from averages)">
- <%= lucide_icon "asterisk", class: "w-4 h-4 shrink-0 text-orange-500" %>
+ (excluded from averages)">
+ <%= icon "asterisk", size: "sm", color: "current" %>
<% end %>
diff --git a/app/views/transactions/_transfer_match.html.erb b/app/views/transactions/_transfer_match.html.erb
index a406e7f4..a1ff6a76 100644
--- a/app/views/transactions/_transfer_match.html.erb
+++ b/app/views/transactions/_transfer_match.html.erb
@@ -3,7 +3,7 @@
" class="flex items-center gap-1">
<% if transaction.transfer.confirmed? %>
is confirmed">
- <%= lucide_icon "link-2", class: "w-4 h-4 text-indigo-600" %>
+ <%= icon "link-2", size: "sm", class: "text-indigo-600" %>
<% elsif transaction.transfer.pending? %>
@@ -14,7 +14,7 @@
method: :patch,
class: "text-secondary hover:text-gray-800 flex items-center justify-center cursor-pointer",
title: "Confirm match" do %>
- <%= lucide_icon "check", class: "w-4 h-4 text-indigo-400 hover:text-indigo-600" %>
+ <%= icon "check", size: "sm", class: "text-indigo-400 hover:text-indigo-600" %>
<% end %>
<%= button_to transfer_path(transaction.transfer, transfer: { status: "rejected" }),
@@ -22,7 +22,7 @@
data: { turbo: false },
class: "text-secondary hover:text-gray-800 flex items-center justify-center cursor-pointer",
title: "Reject match" do %>
- <%= lucide_icon "x", class: "w-4 h-4 text-subdued hover:text-gray-600" %>
+ <%= icon "x", size: "sm", class: "text-subdued hover:text-gray-600" %>
<% end %>
<% end %>
diff --git a/app/views/transactions/bulk_updates/new.html.erb b/app/views/transactions/bulk_updates/new.html.erb
index 80c04526..072605b6 100644
--- a/app/views/transactions/bulk_updates/new.html.erb
+++ b/app/views/transactions/bulk_updates/new.html.erb
@@ -1,63 +1,25 @@
-<%= turbo_frame_tag "bulk_transaction_edit_drawer" do %>
-
+<%= render DialogComponent.new(variant: "drawer", frame: "bulk_transaction_edit_drawer") do |dialog| %>
+ <% dialog.with_header(title: "Edit transactions", data: { bulk_select_target: "bulkEditDrawerHeader" }) %>
+
+ <% dialog.with_body do %>
<%= styled_form_with url: transactions_bulk_update_path, scope: "bulk_update", class: "h-full", data: { turbo_frame: "_top" } do |form| %>
-
-
-
-
- <%= lucide_icon("x", class: "w-5 h-5 shrink-0") %>
-
-
-
-
+ <% dialog.with_section(title: "Overview", open: true) do %>
+
+ <%= form.date_field :date, label: "Date", max: Date.current %>
+ <% end %>
-
- <%= link_to "Cancel", transactions_path, class: "btn btn--ghost" %>
-
- <%= tag.button "Save",
- type: "button",
- data: { "bulk-select-scope-param": "bulk_update", action: "bulk-select#submitBulkRequest" },
- class: "btn btn--primary" %>
+ <% dialog.with_section(title: "Transactions", open: true) do %>
+
+ <%= form.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: "Select a category", label: "Category", class: "text-subdued" } %>
+ <%= form.collection_select :merchant_id, Current.family.merchants.alphabetically, :id, :name, { prompt: "Select a merchant", label: "Merchant", class: "text-subdued" } %>
+ <%= form.select :tag_ids, Current.family.tags.alphabetically.pluck(:name, :id), { include_blank: "None", multiple: true, label: "Tags", container_class: "h-40" } %>
+ <%= form.text_area :notes, label: "Notes", placeholder: "Enter a note that will be applied to selected transactions", rows: 5 %>
-
+ <% end %>
<% end %>
-
+ <% end %>
+
+ <% dialog.with_action(cancel_action: true, text: "Cancel", variant: "ghost") %>
+ <% dialog.with_action(text: "Save", data: { bulk_select_scope_param: "bulk_update", action: "bulk-select#submitBulkRequest" }) %>
<% end %>
diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb
index 7ed329aa..6ab1cc44 100644
--- a/app/views/transactions/index.html.erb
+++ b/app/views/transactions/index.html.erb
@@ -3,30 +3,43 @@
Transactions
- <%= contextual_menu do %>
- <% if Rails.env.development? %>
- <%= button_to "Dev only: Sync all", sync_all_accounts_path, class: "btn btn--ghost w-full" %>
- <% end %>
- <%= contextual_menu_item "New rule", url: new_rule_path(resource_type: "transaction"), icon: "plus", turbo_frame: :modal %>
- <%= contextual_menu_item "Edit rules", url: rules_path, icon: "git-branch", turbo_frame: :_top %>
- <%= contextual_menu_modal_action_item t(".edit_categories"), categories_path, icon: "shapes", turbo_frame: :_top %>
- <%= contextual_menu_modal_action_item t(".edit_tags"), tags_path, icon: "tags", turbo_frame: :_top %>
- <%= contextual_menu_modal_action_item t(".edit_merchants"), family_merchants_path, icon: "store", turbo_frame: :_top %>
- <%= contextual_menu_modal_action_item t(".edit_imports"), imports_path, icon: "hard-drive-upload", turbo_frame: :_top %>
- <%= contextual_menu_modal_action_item t(".import"), new_import_path, icon: "download", turbo_frame: "modal", class_name: "md:!hidden" %>
+ <%= render MenuComponent.new do |menu| %>
+ <% menu.with_item(variant: "button", text: "Dev only: Sync all", href: sync_all_accounts_path, method: :post, icon: "refresh-cw") %>
+ <% menu.with_item(variant: "link", text: "New rule", href: new_rule_path(resource_type: "transaction"), icon: "plus", data: { turbo_frame: :modal }) %>
+ <% menu.with_item(variant: "link", text: "Edit rules", href: rules_path, icon: "git-branch", data: { turbo_frame: :_top }) %>
+ <% menu.with_item(variant: "link", text: "Edit categories", href: categories_path, icon: "shapes", data: { turbo_frame: :_top }) %>
+ <% menu.with_item(variant: "link", text: "Edit tags", href: tags_path, icon: "tags", data: { turbo_frame: :_top }) %>
+ <% menu.with_item(variant: "link", text: "Edit merchants", href: family_merchants_path, icon: "store", data: { turbo_frame: :_top }) %>
+ <% menu.with_item(variant: "link", text: "Edit imports", href: imports_path, icon: "hard-drive-upload", data: { turbo_frame: :_top }) %>
+ <% menu.with_item(variant: "link", text: "Import", href: new_import_path, icon: "download", data: { turbo_frame: "modal", class_name: "md:!hidden" }) %>
<% end %>
- <%= link_to new_import_path, class: "btn btn--outline flex items-center gap-2 hidden md:flex", data: { turbo_frame: "modal" } do %>
- <%= lucide_icon("download", class: "text-secondary w-4 h-4") %>
-
<%= t(".import") %>
- <% end %>
+
+ <%= render LinkComponent.new(
+ text: t(".import"),
+ icon: "download",
+ variant: "outline",
+ href: new_import_path,
+ frame: :modal,
+ ) %>
+
- <%= link_to new_transaction_path, class: "btn btn--primary flex items-center justify-center gap-2 rounded-full md:rounded-lg w-9 h-9 md:w-auto md:h-auto", data: { turbo_frame: :modal } do %>
-
- <%= lucide_icon("plus", class: "w-5 h-5") %>
-
-
New transaction
- <% end %>
+ <%= render LinkComponent.new(
+ text: "New transaction",
+ icon: "plus",
+ variant: "primary",
+ href: new_transaction_path,
+ frame: :modal,
+ class: "hidden md:inline-flex"
+ ) %>
+
+ <%= render LinkComponent.new(
+ icon: "plus",
+ variant: "icon-inverse",
+ href: new_transaction_path,
+ frame: :modal,
+ class: "rounded-full md:hidden"
+ ) %>
diff --git a/app/views/transactions/new.html.erb b/app/views/transactions/new.html.erb
index 3ad75f36..c9183822 100644
--- a/app/views/transactions/new.html.erb
+++ b/app/views/transactions/new.html.erb
@@ -1,3 +1,6 @@
-<%= modal_form_wrapper title: "New transaction" do %>
- <%= render "form", entry: @entry %>
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: "New transaction") %>
+ <% dialog.with_body do %>
+ <%= render "form", entry: @entry, income_categories: @income_categories, expense_categories: @expense_categories %>
+ <% end %>
<% end %>
diff --git a/app/views/transactions/searches/_form.html.erb b/app/views/transactions/searches/_form.html.erb
index dfffd7fd..eba44cb5 100644
--- a/app/views/transactions/searches/_form.html.erb
+++ b/app/views/transactions/searches/_form.html.erb
@@ -8,7 +8,7 @@
- <%= lucide_icon("search", class: "w-5 h-5 text-secondary") %>
+ <%= icon("search") %>
<%= form.text_field :search,
placeholder: "Search transactions ...",
value: @q[:search],
@@ -16,13 +16,20 @@
"data-auto-submit-form-target": "auto" %>
-
-
- <%= lucide_icon("list-filter", class: "w-5 h-5 text-secondary") %>
- Filter
-
- <%= render "transactions/searches/menu", form: form %>
-
+ <%= render MenuComponent.new(variant: "button", no_padding: true) do |menu| %>
+ <% menu.with_button(
+ id: "transaction-filters-button",
+ type: "button",
+ text: "Filter",
+ variant: "outline",
+ icon: "list-filter",
+ data: { menu_target: "button" }
+ ) %>
+
+ <% menu.with_custom_content do %>
+ <%= render "transactions/searches/menu", form: form %>
+ <% end %>
+ <% end %>
<% end %>
diff --git a/app/views/transactions/searches/_menu.html.erb b/app/views/transactions/searches/_menu.html.erb
index cdcd2536..3d11a37a 100644
--- a/app/views/transactions/searches/_menu.html.erb
+++ b/app/views/transactions/searches/_menu.html.erb
@@ -1,47 +1,46 @@
<%# locals: (form:) %>
-
-
- <% transaction_search_filters.each do |filter| %>
-
- <%= lucide_icon(filter[:icon], class: "w-5 h-5") %>
- <%= t(".#{filter[:key]}") %>
-
- <% end %>
-
-
-
+<%= render TabsComponent.new(
+ variant: :unstyled,
+ active_tab: get_default_transaction_search_filter[:key],
+ active_btn_classes: "bg-surface text-primary",
+ inactive_btn_classes: "text-secondary hover:bg-container-inset"
+) do |tabs| %>
+
+ <% end %>
-
-
- <% if @q.present? %>
- <%= link_to t(".clear_filters"), transactions_path(clear_filters: true), class: "btn btn--ghost" %>
+
+
+ <% transaction_search_filters.each do |filter| %>
+ <%= tabs.with_panel(tab_id: filter[:key]) do %>
+ <%= render partial: get_transaction_search_filter_partial_path(filter), locals: { form: form } %>
+ <% end %>
<% end %>
-
- <%= button_tag type: "reset", data: { action: "menu#close" }, class: "py-2 px-3 bg-container-inset rounded-lg text-sm text-primary font-medium" do %>
- <%= t(".cancel") %>
- <% end %>
- <%= form.submit t(".apply"), name: nil, class: "py-2 px-3 bg-primary hover:bg-primary-dark rounded-lg text-sm text-primary font-medium cursor-pointer" %>
+
+
+ <% if @q.present? %>
+ <%= render LinkComponent.new(
+ text: t(".clear_filters"),
+ variant: "ghost",
+ href: transactions_path(clear_filters: true),
+ ) %>
+ <% end %>
+
+
+
+ <%= render ButtonComponent.new(text: t(".cancel"), type: "button", variant: "ghost", data: { action: "menu#close" }) %>
+ <%= render ButtonComponent.new(text: t(".apply")) %>
+
-
+<% end %>
diff --git a/app/views/transactions/searches/filters/_account_filter.html.erb b/app/views/transactions/searches/filters/_account_filter.html.erb
index 6e516dc7..8182dedc 100644
--- a/app/views/transactions/searches/filters/_account_filter.html.erb
+++ b/app/views/transactions/searches/filters/_account_filter.html.erb
@@ -2,7 +2,7 @@
- <%= lucide_icon("search", class: "w-5 h-5 text-secondary absolute inset-y-0 left-2 top-1/2 transform -translate-y-1/2") %>
+ <%= icon("search", class: "absolute inset-y-0 left-2 top-1/2 transform -translate-y-1/2") %>
<% Current.family.accounts.alphabetically.each do |account| %>
diff --git a/app/views/transactions/searches/filters/_category_filter.html.erb b/app/views/transactions/searches/filters/_category_filter.html.erb
index 5d974b88..6b848ed9 100644
--- a/app/views/transactions/searches/filters/_category_filter.html.erb
+++ b/app/views/transactions/searches/filters/_category_filter.html.erb
@@ -2,7 +2,7 @@
- <%= lucide_icon("search", class: "w-5 h-5 text-secondary absolute inset-y-0 left-2 top-1/2 transform -translate-y-1/2") %>
+ <%= icon("search", class: "absolute inset-y-0 left-2 top-1/2 transform -translate-y-1/2") %>
<% family_categories.each do |category| %>
diff --git a/app/views/transactions/searches/filters/_merchant_filter.html.erb b/app/views/transactions/searches/filters/_merchant_filter.html.erb
index 986255f1..3166374c 100644
--- a/app/views/transactions/searches/filters/_merchant_filter.html.erb
+++ b/app/views/transactions/searches/filters/_merchant_filter.html.erb
@@ -2,7 +2,7 @@
- <%= lucide_icon("search", class: "w-5 h-5 text-secondary absolute inset-y-0 left-2 top-1/2 transform -translate-y-1/2") %>
+ <%= icon("search", class: "absolute inset-y-0 left-2 top-1/2 transform -translate-y-1/2") %>
<% Current.family.assigned_merchants.alphabetically.each do |merchant| %>
@@ -16,7 +16,14 @@
merchant.name,
nil %>
<%= form.label :merchants, value: merchant.name, class: "text-sm text-primary flex items-center gap-2" do %>
- <%= circle_logo(merchant.name, hex: merchant.color, size: "sm") %>
+ <%= render FilledIconComponent.new(
+ variant: :text,
+ hex_color: merchant.color,
+ text: merchant.name,
+ size: "sm",
+ rounded: true
+ ) %>
+
<%= merchant.name %>
<% end %>
diff --git a/app/views/transactions/searches/filters/_tag_filter.html.erb b/app/views/transactions/searches/filters/_tag_filter.html.erb
index dfde1053..8cf20adc 100644
--- a/app/views/transactions/searches/filters/_tag_filter.html.erb
+++ b/app/views/transactions/searches/filters/_tag_filter.html.erb
@@ -2,7 +2,7 @@
- <%= lucide_icon("search", class: "w-5 h-5 text-secondary absolute inset-y-0 left-2 top-1/2 transform -translate-y-1/2") %>
+ <%= icon("search", class: "absolute inset-y-0 left-2 top-1/2 transform -translate-y-1/2") %>
<% Current.family.tags.alphabetically.each do |tag| %>
@@ -16,7 +16,14 @@
tag.name,
nil %>
<%= form.label :tags, value: tag.name, class: "text-sm text-primary flex items-center gap-2" do %>
- <%= circle_logo(tag.name, hex: tag.color || Tag::UNCATEGORIZED_COLOR, size: "sm") %>
+ <%= render FilledIconComponent.new(
+ variant: :text,
+ hex_color: tag.color || Tag::UNCATEGORIZED_COLOR,
+ text: tag.name,
+ size: "sm",
+ rounded: true
+ ) %>
+
<%= tag.name %>
<% end %>
diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb
index 7306ffc0..c5e65334 100644
--- a/app/views/transactions/show.html.erb
+++ b/app/views/transactions/show.html.erb
@@ -1,9 +1,10 @@
-<%= drawer do %>
- <%= render "transactions/header", entry: @entry %>
+<%= render DialogComponent.new(variant: "drawer") do |dialog| %>
+ <% dialog.with_header do %>
+ <%= render "transactions/header", entry: @entry %>
+ <% end %>
-
-
- <%= disclosure t(".overview") do %>
+ <% dialog.with_body do %>
+ <% dialog.with_section(title: t(".overview"), open: true) do %>
<%= styled_form_with model: @entry,
url: transaction_path(@entry),
@@ -47,8 +48,7 @@
<% end %>
-
- <%= disclosure t(".details"), default_open: false do %>
+ <% dialog.with_section(title: t(".details")) do %>
<%= styled_form_with model: @entry,
url: transaction_path(@entry),
@@ -95,10 +95,8 @@
<% end %>
-
- <%= disclosure t(".settings") do %>
+ <% dialog.with_section(title: t(".settings")) do %>
-
<%= styled_form_with model: @entry,
url: transaction_path(@entry),
class: "p-3",
@@ -109,13 +107,7 @@
One-time transactions will be excluded from certain budgeting calculations and reports to help you see what's really important.
-
- <%= f.check_box :excluded,
- class: "sr-only peer",
- "data-auto-submit-form-target": "auto" %>
-
-
+ <%= f.toggle :excluded, { data: { auto_submit_form_target: "auto" } } %>
<% end %>
@@ -125,10 +117,13 @@
Transfers and payments are special types of transactions that indicate money movement between 2 accounts.
- <%= link_to new_transaction_transfer_match_path(@entry), class: "btn btn--outline flex items-center gap-2", data: { turbo_frame: :modal } do %>
- <%= lucide_icon "arrow-left-right", class: "w-4 h-4 shrink-0" %>
-
Open matcher
- <% end %>
+ <%= render LinkComponent.new(
+ text: "Open matcher",
+ icon: "arrow-left-right",
+ variant: "outline",
+ href: new_transaction_transfer_match_path(@entry),
+ frame: :modal
+ ) %>
@@ -138,14 +133,16 @@
<%= t(".delete_subtitle") %>
- <%= button_to t(".delete"),
- entry_path(@entry),
- method: :delete,
- class: "rounded-lg px-3 py-2 text-red-500 text-sm
- font-medium border border-secondary",
- data: { turbo_confirm: true, turbo_frame: "_top" } %>
+ <%= render ButtonComponent.new(
+ text: t(".delete"),
+ variant: "outline-destructive",
+ href: entry_path(@entry),
+ method: :delete,
+ confirm: CustomConfirm.for_resource_deletion("transaction"),
+ frame: "_top"
+ ) %>
<% end %>
-
+ <% end %>
<% end %>
diff --git a/app/views/transfer_matches/new.html.erb b/app/views/transfer_matches/new.html.erb
index d8a7e3f4..e4709e30 100644
--- a/app/views/transfer_matches/new.html.erb
+++ b/app/views/transfer_matches/new.html.erb
@@ -1,60 +1,63 @@
-<%= modal_form_wrapper title: "Match transfer or payment" do %>
- <%= styled_form_with(
- url: transaction_transfer_match_path(@entry),
- scope: :transfer_match,
- class: "space-y-8",
- data: { turbo_frame: :_top }
- ) do |f| %>
-
-
-
- <%= @entry.amount.positive? ? "From account: #{@entry.account.name}" : "From account" %>
-
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: "Match transfer or payment") %>
+ <% dialog.with_body do %>
+ <%= styled_form_with(
+ url: transaction_transfer_match_path(@entry),
+ scope: :transfer_match,
+ class: "space-y-8",
+ data: { turbo_frame: :_top }
+ ) do |f| %>
+
+
+
+ <%= @entry.amount.positive? ? "From account: #{@entry.account.name}" : "From account" %>
+
- <% if @entry.amount.positive? %>
- <%= f.select(
- :entry_id,
- [[entry_name_detailed(@entry), @entry.id]],
- {
- label: "Outflow transaction",
- selected: @entry.id,
- },
- disabled: true
- ) %>
- <% else %>
- <%= render "transfer_matches/matching_fields",
- form: f, entry: @entry, candidates: @transfer_match_candidates.map { |pm| pm.outflow_transaction.entry }, accounts: @accounts %>
- <% end %>
+ <% if @entry.amount.positive? %>
+ <%= f.select(
+ :entry_id,
+ [[entry_name_detailed(@entry), @entry.id]],
+ {
+ label: "Outflow transaction",
+ selected: @entry.id,
+ },
+ disabled: true
+ ) %>
+ <% else %>
+ <%= render "transfer_matches/matching_fields",
+ form: f, entry: @entry, candidates: @transfer_match_candidates.map { |pm| pm.outflow_transaction.entry }, accounts: @accounts %>
+ <% end %>
+
+
+
+
+ <%= icon "arrow-down" %>
-
-
- <%= lucide_icon "arrow-down", class: "w-5 h-5" %>
-
+
+
+
+ <%= @entry.amount.negative? ? "To account: #{@entry.account.name}" : "To account" %>
+
-
-
-
- <%= @entry.amount.negative? ? "To account: #{@entry.account.name}" : "To account" %>
-
+ <% if @entry.amount.negative? %>
+ <%= f.select(
+ :entry_id,
+ [[entry_name_detailed(@entry), @entry.id]],
+ {
+ label: "Inflow transaction",
+ selected: @entry.id,
+ },
+ disabled: true
+ ) %>
+ <% else %>
+ <%= render "transfer_matches/matching_fields",
+ form: f, entry: @entry, candidates: @transfer_match_candidates.map { |pm| pm.inflow_transaction.entry }, accounts: @accounts %>
+ <% end %>
+
+
- <% if @entry.amount.negative? %>
- <%= f.select(
- :entry_id,
- [[entry_name_detailed(@entry), @entry.id]],
- {
- label: "Inflow transaction",
- selected: @entry.id,
- },
- disabled: true
- ) %>
- <% else %>
- <%= render "transfer_matches/matching_fields",
- form: f, entry: @entry, candidates: @transfer_match_candidates.map { |pm| pm.inflow_transaction.entry }, accounts: @accounts %>
- <% end %>
-
-
-
- <%= f.submit "Create transfer match", data: { turbo_submits_with: "Saving..."} %>
+ <%= f.submit "Create transfer match" %>
+ <% end %>
<% end %>
<% end %>
diff --git a/app/views/transfers/_account_links.html.erb b/app/views/transfers/_account_links.html.erb
index f7ba4365..33390b31 100644
--- a/app/views/transfers/_account_links.html.erb
+++ b/app/views/transfers/_account_links.html.erb
@@ -1,24 +1,7 @@
<%# locals: (transfer:, is_inflow: false) %>
<% first_account, second_account = is_inflow ? [transfer.to_account, transfer.from_account] : [transfer.from_account, transfer.to_account] %>
-
- <%# Check if first_account exists before creating link %>
- <% if first_account %>
- <%= link_to first_account.name, account_path(first_account, tab: "activity"), class: "hover:underline", data: { turbo_frame: "_top" } %>
- <% else %>
-
- Data Error: Missing account
-
- <% end %>
-
- <%= lucide_icon is_inflow ? "arrow-left" : "arrow-right", class: "w-4 h-4 shrink-0" %>
-
- <%# Check if second_account exists before creating link %>
- <% if second_account %>
- <%= link_to second_account.name, account_path(second_account, tab: "activity"), class: "hover:underline", data: { turbo_frame: "_top" } %>
- <% else %>
-
- Data Error: Missing account
-
- <% end %>
+ <%= link_to first_account.name, account_path(first_account, tab: "activity"), class: "hover:underline", data: { turbo_frame: "_top" } %>
+ <%= icon(is_inflow ? "arrow-left" : "arrow-right", size: "sm") %>
+ <%= link_to second_account.name, account_path(second_account, tab: "activity"), class: "hover:underline", data: { turbo_frame: "_top" } %>
diff --git a/app/views/transfers/_form.html.erb b/app/views/transfers/_form.html.erb
index 26344579..d584cb7c 100644
--- a/app/views/transfers/_form.html.erb
+++ b/app/views/transfers/_form.html.erb
@@ -1,7 +1,7 @@
<%= styled_form_with model: transfer, class: "space-y-4", data: { turbo_frame: "_top", controller: "transfer-form" } do |f| %>
<% if transfer.errors.present? %>
-
- <%= lucide_icon "circle-alert", class: "w-5 h-5" %>
+
+ <%= icon "circle-alert", size: "sm" %>
<%= @transfer.errors.full_messages.to_sentence %>
<% end %>
diff --git a/app/views/transfers/new.html.erb b/app/views/transfers/new.html.erb
index f1a7ae0d..5456f1b3 100644
--- a/app/views/transfers/new.html.erb
+++ b/app/views/transfers/new.html.erb
@@ -1,3 +1,6 @@
-<%= modal_form_wrapper title: t(".title") do %>
- <%= render "form", transfer: @transfer %>
+<%= render DialogComponent.new do |dialog| %>
+ <% dialog.with_header(title: t(".title")) %>
+ <% dialog.with_body do %>
+ <%= render "form", transfer: @transfer %>
+ <% end %>
<% end %>
diff --git a/app/views/transfers/show.html.erb b/app/views/transfers/show.html.erb
index 911f382b..42b05219 100644
--- a/app/views/transfers/show.html.erb
+++ b/app/views/transfers/show.html.erb
@@ -1,27 +1,28 @@
-<%= drawer do %>
-
-
-
-
- <%= format_money @transfer.amount_abs %>
-
+<%= render DialogComponent.new(variant: "drawer") do |dialog| %>
+ <% dialog.with_header do %>
+
+
+
+
+ <%= format_money @transfer.amount_abs %>
+
-
- <%= @transfer.amount_abs.currency.iso_code %>
-
-
+
+ <%= @transfer.amount_abs.currency.iso_code %>
+
+
- <%= lucide_icon "arrow-left-right", class: "text-secondary mt-1 w-5 h-5" %>
+ <%= icon "arrow-left-right", size: "sm" %>
+
+
+
+ <%= @transfer.name %>
+
+ <% end %>
-
- <%= @transfer.name %>
-
-
-
-
-
- <%= disclosure t(".overview") do %>
+ <% dialog.with_body do %>
+ <% dialog.with_section(title: t(".overview"), open: true) do %>
<% end %>
-
- <%= disclosure t(".details") do %>
+ <% dialog.with_section(title: t(".details")) do %>
<%= styled_form_with model: @transfer,
data: { controller: "auto-submit-form" }, class: "space-y-2" do |f| %>
<% if @transfer.categorizable? %>
@@ -83,8 +83,7 @@
<% end %>
<% end %>
-
- <%= disclosure t(".settings") do %>
+ <% dialog.with_section(title: t(".settings")) do %>
+ <% end %>
<% end %>
diff --git a/app/views/users/_user_menu.html.erb b/app/views/users/_user_menu.html.erb
index 584cd4e7..92566c49 100644
--- a/app/views/users/_user_menu.html.erb
+++ b/app/views/users/_user_menu.html.erb
@@ -1,73 +1,47 @@
<%# locals: (user:, placement: "right-start", offset: 16) %>
-