mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-09 07:25:19 +02:00
Merge branch 'main' of github.com:maybe-finance/maybe into rule-name
# Conflicts: # app/views/rules/_form.html.erb # app/views/rules/confirm.html.erb # app/views/rules/edit.html.erb # db/schema.rb
This commit is contained in:
commit
94a04042b7
326 changed files with 4988 additions and 3440 deletions
|
@ -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.
|
||||
|
||||
|
|
4
Gemfile
4
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"
|
||||
|
||||
|
|
120
Gemfile.lock
120
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)
|
||||
stripe (15.0.0)
|
||||
tailwindcss-rails (4.2.1)
|
||||
stringio (3.1.7)
|
||||
stripe (15.1.0)
|
||||
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
|
||||
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
web: bundle exec ${DEBUG:+rdbg -O -n -c --} bin/rails server -b 0.0.0.0
|
||||
css: bundle exec bin/rails tailwindcss:watch
|
||||
css: bundle exec bin/rails tailwindcss:watch 2>/dev/null
|
||||
worker: bundle exec sidekiq
|
||||
|
|
|
@ -165,16 +165,4 @@
|
|||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: #a6a6a6;
|
||||
}
|
||||
}
|
||||
|
||||
.mt-safe {
|
||||
margin-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.pt-safe {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.pb-safe {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
|
@ -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,30 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* 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(var(--color-white) / 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%);
|
||||
}
|
||||
|
||||
html {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
button {
|
||||
@apply cursor-pointer focus-visible:outline-gray-900;
|
||||
}
|
||||
|
@ -495,6 +270,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 +296,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 +408,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 +422,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;
|
||||
}
|
||||
}
|
||||
|
|
87
app/assets/tailwind/maybe-design-system/background-utils.css
Normal file
87
app/assets/tailwind/maybe-design-system/background-utils.css
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
88
app/assets/tailwind/maybe-design-system/border-utils.css
Normal file
88
app/assets/tailwind/maybe-design-system/border-utils.css
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
109
app/assets/tailwind/maybe-design-system/component-utils.css
Normal file
109
app/assets/tailwind/maybe-design-system/component-utils.css
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
63
app/assets/tailwind/maybe-design-system/foreground-utils.css
Normal file
63
app/assets/tailwind/maybe-design-system/foreground-utils.css
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
39
app/assets/tailwind/maybe-design-system/text-utils.css
Normal file
39
app/assets/tailwind/maybe-design-system/text-utils.css
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
13
app/components/button_component.html.erb
Normal file
13
app/components/button_component.html.erb
Normal file
|
@ -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 %>
|
41
app/components/button_component.rb
Normal file
41
app/components/button_component.rb
Normal file
|
@ -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
|
148
app/components/buttonish_component.rb
Normal file
148
app/components/buttonish_component.rb
Normal file
|
@ -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
|
38
app/components/dialog_component.html.erb
Normal file
38
app/components/dialog_component.html.erb
Normal file
|
@ -0,0 +1,38 @@
|
|||
<%= wrapper_element do %>
|
||||
<%= 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 %>
|
||||
<div class="grow overflow-y-auto py-4 space-y-4">
|
||||
<% if header? %>
|
||||
<%= header %>
|
||||
<% end %>
|
||||
|
||||
<% if body? %>
|
||||
<div class="px-4">
|
||||
<%= body %>
|
||||
|
||||
<% if sections.any? %>
|
||||
<div class="space-y-4">
|
||||
<% sections.each do |section| %>
|
||||
<%= section %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%# Optional, for customizing dialogs %>
|
||||
<%= content %>
|
||||
</div>
|
||||
|
||||
<% if actions? %>
|
||||
<div class="flex items-center gap-2 justify-end p-4">
|
||||
<% actions.each do |action| %>
|
||||
<%= action %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
110
app/components/dialog_component.rb
Normal file
110
app/components/dialog_component.rb
Normal file
|
@ -0,0 +1,110 @@
|
|||
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, :width, :disable_frame, :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, width: "md", disable_frame: false, **opts)
|
||||
@variant = variant.to_sym
|
||||
@auto_open = auto_open
|
||||
@reload_on_close = reload_on_close
|
||||
@width = width.to_sym
|
||||
@disable_frame = disable_frame
|
||||
@opts = opts
|
||||
end
|
||||
|
||||
# Caller must "opt-out" of using the default turbo-frame based on the variant
|
||||
def wrapper_element(&block)
|
||||
if disable_frame
|
||||
content_tag(:div, &block)
|
||||
else
|
||||
content_tag("turbo-frame", id: variant, &block)
|
||||
end
|
||||
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 rounded-xl shadow-border-xs mx-3 lg:mx-0 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
|
|
@ -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();
|
||||
}
|
||||
}
|
25
app/components/disclosure_component.html.erb
Normal file
25
app/components/disclosure_component.html.erb
Normal file
|
@ -0,0 +1,25 @@
|
|||
<details class="group" <%= "open" if open %>>
|
||||
<%= tag.summary class: class_names(
|
||||
"px-3 py-2 rounded-xl cursor-pointer flex items-center justify-between bg-surface"
|
||||
) do %>
|
||||
<div class="flex items-center gap-3">
|
||||
<% 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 %>
|
||||
</div>
|
||||
|
||||
<% 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 %>
|
||||
|
||||
<div class="mt-2">
|
||||
<%= content %>
|
||||
</div>
|
||||
</details>
|
12
app/components/disclosure_component.rb
Normal file
12
app/components/disclosure_component.rb
Normal file
|
@ -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
|
8
app/components/filled_icon_component.html.erb
Normal file
8
app/components/filled_icon_component.html.erb
Normal file
|
@ -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 %>
|
99
app/components/filled_icon_component.rb
Normal file
99
app/components/filled_icon_component.rb
Normal file
|
@ -0,0 +1,99 @@
|
|||
class FilledIconComponent < ViewComponent::Base
|
||||
attr_reader :icon, :text, :hex_color, :size, :rounded, :variant
|
||||
|
||||
VARIANTS = %i[default text surface container inverse].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"
|
||||
when :inverse
|
||||
"bg-container"
|
||||
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
|
13
app/components/link_component.html.erb
Normal file
13
app/components/link_component.html.erb
Normal file
|
@ -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 %>
|
31
app/components/link_component.rb
Normal file
31
app/components/link_component.rb
Normal file
|
@ -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
|
27
app/components/menu_component.html.erb
Normal file
27
app/components/menu_component.html.erb
Normal file
|
@ -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 %>
|
||||
<button data-menu-target="button">
|
||||
<div class="w-9 h-9 cursor-pointer">
|
||||
<%= render "settings/user_avatar", avatar_url: avatar_url %>
|
||||
</div>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<div data-menu-target="content" class="px-2 lg:px-0 max-w-full hidden z-50">
|
||||
<div class="mx-auto min-w-[200px] shadow-border-xs bg-container rounded-lg">
|
||||
<%= header %>
|
||||
|
||||
<%= tag.div class: class_names("py-1" => !no_padding) do %>
|
||||
<% items.each do |item| %>
|
||||
<%= item %>
|
||||
<% end %>
|
||||
|
||||
<%= custom_content %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
37
app/components/menu_component.rb
Normal file
37
app/components/menu_component.rb
Normal file
|
@ -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
|
12
app/components/menu_item_component.html.erb
Normal file
12
app/components/menu_item_component.html.erb
Normal file
|
@ -0,0 +1,12 @@
|
|||
<% if variant == :divider %>
|
||||
<hr class="border-tertiary my-1">
|
||||
<% else %>
|
||||
<div class="px-1">
|
||||
<%= wrapper do %>
|
||||
<% if icon %>
|
||||
<%= lucide_icon(icon, class: destructive? ? "text-destructive" : "fg-gray") %>
|
||||
<% end %>
|
||||
<%= tag.span(text, class: text_classes) %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
57
app/components/menu_item_component.rb
Normal file
57
app/components/menu_item_component.rb
Normal file
|
@ -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
|
12
app/components/tab_component.rb
Normal file
12
app/components/tab_component.rb
Normal file
|
@ -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
|
29
app/components/tabs/nav_component.rb
Normal file
29
app/components/tabs/nav_component.rb
Normal file
|
@ -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
|
11
app/components/tabs/panel_component.rb
Normal file
11
app/components/tabs/panel_component.rb
Normal file
|
@ -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
|
17
app/components/tabs_component.html.erb
Normal file
17
app/components/tabs_component.html.erb
Normal file
|
@ -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 %>
|
65
app/components/tabs_component.rb
Normal file
65
app/components/tabs_component.rb
Normal file
|
@ -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
|
38
app/components/tabs_controller.js
Normal file
38
app/components/tabs_controller.js
Normal file
|
@ -0,0 +1,38 @@
|
|||
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 };
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
5
app/components/toggle_component.html.erb
Normal file
5
app/components/toggle_component.html.erb
Normal file
|
@ -0,0 +1,5 @@
|
|||
<div class="relative inline-block select-none">
|
||||
<%= 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 %>
|
||||
</div>
|
26
app/components/toggle_component.rb
Normal file
26
app/components/toggle_component.rb
Normal file
|
@ -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
|
|
@ -2,27 +2,10 @@ class ApplicationController < ActionController::Base
|
|||
include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable, FeatureGuardable, Notifiable
|
||||
include Pagy::Backend
|
||||
|
||||
helper_method :require_upgrade?, :subscription_pending?
|
||||
|
||||
before_action :detect_os
|
||||
before_action :set_default_chat
|
||||
|
||||
private
|
||||
def require_upgrade?
|
||||
return false if self_hosted?
|
||||
return false unless Current.session
|
||||
return false if Current.family.subscribed?
|
||||
return false if subscription_pending? || request.path == settings_billing_path
|
||||
return false if Current.family.active_accounts_count <= 3
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def subscription_pending?
|
||||
subscribed_at = Current.session.subscribed_at
|
||||
subscribed_at.present? && subscribed_at <= Time.current && subscribed_at > 1.hour.ago
|
||||
end
|
||||
|
||||
def detect_os
|
||||
user_agent = request.user_agent
|
||||
@os = case user_agent
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -2,16 +2,40 @@ module Onboardable
|
|||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :redirect_to_onboarding, if: :needs_onboarding?
|
||||
before_action :require_onboarding_and_upgrade
|
||||
helper_method :subscription_pending?
|
||||
end
|
||||
|
||||
private
|
||||
def redirect_to_onboarding
|
||||
redirect_to onboarding_path
|
||||
# A subscription goes into "pending" mode immediately after checkout, but before webhooks are processed.
|
||||
def subscription_pending?
|
||||
subscribed_at = Current.session.subscribed_at
|
||||
subscribed_at.present? && subscribed_at <= Time.current && subscribed_at > 1.hour.ago
|
||||
end
|
||||
|
||||
def needs_onboarding?
|
||||
Current.user && Current.user.onboarded_at.blank? &&
|
||||
!%w[/users /onboarding /sessions].any? { |path| request.path.start_with?(path) }
|
||||
# First, we require onboarding, then once that's complete, we require an upgrade for non-subscribed users.
|
||||
def require_onboarding_and_upgrade
|
||||
return unless Current.user
|
||||
return unless redirectable_path?(request.path)
|
||||
|
||||
if Current.user.onboarded_at.blank?
|
||||
redirect_to onboarding_path
|
||||
elsif !Current.family.subscribed? && !Current.family.trialing?
|
||||
redirect_to upgrade_subscription_path
|
||||
end
|
||||
end
|
||||
|
||||
def redirectable_path?(path)
|
||||
return false if path.starts_with?("/settings")
|
||||
return false if path.starts_with?("/subscription")
|
||||
return false if path.starts_with?("/onboarding")
|
||||
return false if path.starts_with?("/users")
|
||||
|
||||
[
|
||||
new_registration_path,
|
||||
new_session_path,
|
||||
new_password_reset_path,
|
||||
new_email_confirmation_path
|
||||
].exclude?(path)
|
||||
end
|
||||
end
|
||||
|
|
3
app/controllers/lookbooks_controller.rb
Normal file
3
app/controllers/lookbooks_controller.rb
Normal file
|
@ -0,0 +1,3 @@
|
|||
class LookbooksController < Lookbook::PreviewController
|
||||
layout "lookbooks"
|
||||
end
|
|
@ -1,14 +1,16 @@
|
|||
class OnboardingsController < ApplicationController
|
||||
layout "wizard"
|
||||
|
||||
before_action :set_user
|
||||
before_action :load_invitation
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def profile
|
||||
def preferences
|
||||
end
|
||||
|
||||
def preferences
|
||||
def trial
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -1,41 +1,63 @@
|
|||
class SubscriptionsController < ApplicationController
|
||||
before_action :redirect_to_root_if_self_hosted
|
||||
# Disables subscriptions for self hosted instances
|
||||
guard_feature if: -> { self_hosted? }
|
||||
|
||||
# Upgrade page for unsubscribed users
|
||||
def upgrade
|
||||
render layout: "onboardings"
|
||||
end
|
||||
|
||||
def start_trial
|
||||
if Current.family.trial_started_at.present?
|
||||
redirect_to root_path, alert: "You've already started or completed your trial"
|
||||
else
|
||||
Family.transaction do
|
||||
Current.family.update(trial_started_at: Time.current)
|
||||
Current.user.update(onboarded_at: Time.current)
|
||||
end
|
||||
|
||||
redirect_to root_path, notice: "Your trial has started"
|
||||
end
|
||||
end
|
||||
|
||||
def new
|
||||
if Current.family.stripe_customer_id.blank?
|
||||
customer = stripe_client.v1.customers.create(
|
||||
price_map = {
|
||||
monthly: ENV["STRIPE_MONTHLY_PRICE_ID"],
|
||||
annual: ENV["STRIPE_ANNUAL_PRICE_ID"]
|
||||
}
|
||||
|
||||
price_id = price_map[(params[:plan] || :monthly).to_sym]
|
||||
|
||||
unless Current.family.existing_customer?
|
||||
customer = stripe.create_customer(
|
||||
email: Current.family.primary_user.email,
|
||||
metadata: { family_id: Current.family.id }
|
||||
)
|
||||
|
||||
Current.family.update(stripe_customer_id: customer.id)
|
||||
end
|
||||
|
||||
session = stripe_client.v1.checkout.sessions.create({
|
||||
customer: Current.family.stripe_customer_id,
|
||||
line_items: [ {
|
||||
price: ENV["STRIPE_PLAN_ID"],
|
||||
quantity: 1
|
||||
} ],
|
||||
mode: "subscription",
|
||||
allow_promotion_codes: true,
|
||||
checkout_session_url = stripe.get_checkout_session_url(
|
||||
price_id: price_id,
|
||||
customer_id: Current.family.stripe_customer_id,
|
||||
success_url: success_subscription_url + "?session_id={CHECKOUT_SESSION_ID}",
|
||||
cancel_url: settings_billing_url
|
||||
})
|
||||
cancel_url: upgrade_subscription_url(plan: params[:plan])
|
||||
)
|
||||
|
||||
redirect_to session.url, allow_other_host: true, status: :see_other
|
||||
redirect_to checkout_session_url, allow_other_host: true, status: :see_other
|
||||
end
|
||||
|
||||
def show
|
||||
portal_session = stripe_client.v1.billing_portal.sessions.create(
|
||||
customer: Current.family.stripe_customer_id,
|
||||
portal_session_url = stripe.get_billing_portal_session_url(
|
||||
customer_id: Current.family.stripe_customer_id,
|
||||
return_url: settings_billing_url
|
||||
)
|
||||
|
||||
redirect_to portal_session.url, allow_other_host: true, status: :see_other
|
||||
redirect_to portal_session_url, allow_other_host: true, status: :see_other
|
||||
end
|
||||
|
||||
def success
|
||||
checkout_session = stripe_client.v1.checkout.sessions.retrieve(params[:session_id])
|
||||
checkout_session = stripe.retrieve_checkout_session(params[:session_id])
|
||||
Current.session.update(subscribed_at: Time.at(checkout_session.created))
|
||||
redirect_to root_path, notice: "You have successfully subscribed to Maybe+."
|
||||
rescue Stripe::InvalidRequestError
|
||||
|
@ -43,11 +65,7 @@ class SubscriptionsController < ApplicationController
|
|||
end
|
||||
|
||||
private
|
||||
def stripe_client
|
||||
@stripe_client ||= Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])
|
||||
end
|
||||
|
||||
def redirect_to_root_if_self_hosted
|
||||
redirect_to root_path, alert: I18n.t("subscriptions.self_hosted_alert") if self_hosted?
|
||||
def stripe
|
||||
@stripe ||= Provider::Registry.get_provider(:stripe)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -63,6 +63,10 @@ class UsersController < ApplicationController
|
|||
redirect_to root_path
|
||||
when "preferences"
|
||||
redirect_to settings_preferences_path, notice: notice
|
||||
when "goals"
|
||||
redirect_to goals_onboarding_path
|
||||
when "trial"
|
||||
redirect_to trial_onboarding_path
|
||||
else
|
||||
redirect_to settings_profile_path, notice: notice
|
||||
end
|
||||
|
@ -83,8 +87,10 @@ class UsersController < ApplicationController
|
|||
|
||||
def user_params
|
||||
params.require(:user).permit(
|
||||
:first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at, :show_sidebar, :default_period, :show_ai_sidebar, :ai_enabled, :theme,
|
||||
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id ]
|
||||
:first_name, :last_name, :email, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at,
|
||||
:show_sidebar, :default_period, :show_ai_sidebar, :ai_enabled, :theme, :set_onboarding_preferences_at, :set_onboarding_goals_at,
|
||||
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id ],
|
||||
goals: []
|
||||
)
|
||||
end
|
||||
|
||||
|
|
|
@ -33,61 +33,21 @@ class WebhooksController < ApplicationController
|
|||
end
|
||||
|
||||
def stripe
|
||||
webhook_body = request.body.read
|
||||
sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
|
||||
client = Stripe::StripeClient.new(ENV["STRIPE_SECRET_KEY"])
|
||||
stripe_provider = Provider::Registry.get_provider(:stripe)
|
||||
|
||||
begin
|
||||
thin_event = client.parse_thin_event(webhook_body, sig_header, ENV["STRIPE_WEBHOOK_SECRET"])
|
||||
webhook_body = request.body.read
|
||||
sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
|
||||
|
||||
event = client.v1.events.retrieve(thin_event.id)
|
||||
|
||||
case event.type
|
||||
when /^customer\.subscription\./
|
||||
handle_subscription_event(event)
|
||||
when "customer.created", "customer.updated", "customer.deleted"
|
||||
handle_customer_event(event)
|
||||
else
|
||||
Rails.logger.info "Unhandled event type: #{event.type}"
|
||||
end
|
||||
stripe_provider.process_webhook_later(webhook_body, sig_header)
|
||||
|
||||
head :ok
|
||||
rescue JSON::ParserError => error
|
||||
Sentry.capture_exception(error)
|
||||
render json: { error: "Invalid payload" }, status: :bad_request
|
||||
return
|
||||
head :bad_request
|
||||
rescue Stripe::SignatureVerificationError => error
|
||||
Sentry.capture_exception(error)
|
||||
render json: { error: "Invalid signature" }, status: :bad_request
|
||||
return
|
||||
head :bad_request
|
||||
end
|
||||
|
||||
render json: { received: true }, status: :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_subscription_event(event)
|
||||
subscription = event.data.object
|
||||
family = Family.find_by(stripe_customer_id: subscription.customer)
|
||||
|
||||
if family
|
||||
family.update(
|
||||
stripe_plan_id: subscription.plan.id,
|
||||
stripe_subscription_status: subscription.status
|
||||
)
|
||||
else
|
||||
Rails.logger.error "Family not found for Stripe customer ID: #{subscription.customer}"
|
||||
end
|
||||
end
|
||||
|
||||
def handle_customer_event(event)
|
||||
customer = event.data.object
|
||||
family = Family.find_by(stripe_customer_id: customer.id)
|
||||
|
||||
if family
|
||||
family.update(stripe_customer_id: customer.id)
|
||||
else
|
||||
Rails.logger.error "Family not found for Stripe customer ID: #{customer.id}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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", white: "fg-inverse", 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 %>
|
||||
# <div>Content here</div>
|
||||
# <% 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 %>
|
||||
# <div>Content here</div>
|
||||
# <% 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? }
|
||||
|
|
51
app/helpers/custom_confirm.rb
Normal file
51
app/helpers/custom_confirm.rb
Normal file
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
56
app/javascript/controllers/app_layout_controller.js
Normal file
56
app/javascript/controllers/app_layout_controller.js
Normal file
|
@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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 };
|
||||
|
|
|
@ -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()}`;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
59
app/javascript/controllers/confirm_dialog_controller.js
Normal file
59
app/javascript/controllers/confirm_dialog_controller.js
Normal file
|
@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
8
app/javascript/controllers/intercom_controller.js
Normal file
8
app/javascript/controllers/intercom_controller.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Connects to data-controller="intercom"
|
||||
export default class extends Controller {
|
||||
show() {
|
||||
Intercom("show");
|
||||
}
|
||||
}
|
|
@ -14,6 +14,10 @@ export default class extends Controller {
|
|||
this.refreshWithParam("currency", event.target.value);
|
||||
}
|
||||
|
||||
setTheme(event) {
|
||||
document.documentElement.setAttribute("data-theme", event.target.value);
|
||||
}
|
||||
|
||||
refreshWithParam(key, value) {
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set(key, value);
|
||||
|
|
|
@ -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);
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
|
@ -1,73 +1,96 @@
|
|||
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);
|
||||
}
|
||||
|
||||
toggle() {
|
||||
const currentTheme = document.documentElement.getAttribute("data-theme");
|
||||
if (currentTheme === "dark") {
|
||||
this.toLight();
|
||||
} else {
|
||||
this.toDark();
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
|||
<div style="margin-bottom: 4px; color: var(--color-gray-500);">
|
||||
${datum.date_formatted}
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 16px; margin-bottom: 0px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px; color: var(--color-black);">
|
||||
<div style="display: flex; align-items: center; justify-content: center; height: 16px; width: 16px;">
|
||||
${datum.trend.previous.amount === datum.trend.current.amount ? `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${datum.trend.color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-minus-icon lucide-minus"><path d="M5 12h14"/></svg>
|
||||
` : Number(datum.trend.previous.amount) < Number(datum.trend.current.amount) ? `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${datum.trend.color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-right-icon lucide-arrow-up-right"><path d="M7 7h10v10"/><path d="M7 17 17 7"/></svg>
|
||||
` : `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${datum.trend.color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down-right-icon lucide-arrow-down-right"><path d="m7 7 10 10"/><path d="M17 7v10H7"/></svg>
|
||||
`}
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2 text-primary">
|
||||
<div class="flex items-center justify-center h-4 w-4">
|
||||
${this._getTrendIcon(datum)}
|
||||
</div>
|
||||
${this._extractFormattedValue(datum.trend.current)}
|
||||
</div>
|
||||
|
||||
${
|
||||
datum.trend.value === 0
|
||||
? `<span style="width: 80px;"></span>`
|
||||
? `<span class="w-20"></span>`
|
||||
: `
|
||||
<span style="color: ${datum.trend.color};">
|
||||
${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 `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${datum.trend.color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-right-icon lucide-arrow-up-right"><path d="M7 7h10v10"/><path d="M7 17 17 7"/></svg>`;
|
||||
}
|
||||
|
||||
if (isDecrease) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${datum.trend.color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down-right-icon lucide-arrow-down-right"><path d="m7 7 10 10"/><path d="M17 7v10H7"/></svg>`;
|
||||
}
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="${datum.trend.color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-minus-icon lucide-minus"><path d="M5 12h14"/></svg>`;
|
||||
}
|
||||
|
||||
_getDatumValue = (datum) => {
|
||||
return this._extractNumericValue(datum.trend.current);
|
||||
};
|
||||
|
|
9
app/jobs/stripe_event_handler_job.rb
Normal file
9
app/jobs/stripe_event_handler_job.rb
Normal file
|
@ -0,0 +1,9 @@
|
|||
class StripeEventHandlerJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(event_id)
|
||||
stripe_provider = Provider::Registry.get_provider(:stripe)
|
||||
Rails.logger.info "Processing Stripe event: #{event_id}"
|
||||
stripe_provider.process_event(event_id)
|
||||
end
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -2,7 +2,7 @@ class Demo::Generator
|
|||
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
|
||||
|
||||
# Builds a semi-realistic mirror of what production data might look like
|
||||
def reset_and_clear_data!(family_names)
|
||||
def reset_and_clear_data!(family_names, require_onboarding: false)
|
||||
puts "Clearing existing data..."
|
||||
|
||||
destroy_everything!
|
||||
|
@ -10,7 +10,7 @@ class Demo::Generator
|
|||
puts "Data cleared"
|
||||
|
||||
family_names.each_with_index do |family_name, index|
|
||||
create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local")
|
||||
create_family_and_user!(family_name, "user#{index == 0 ? "" : index + 1}@maybe.local", require_onboarding: require_onboarding)
|
||||
end
|
||||
|
||||
puts "Users reset"
|
||||
|
@ -152,7 +152,7 @@ class Demo::Generator
|
|||
Security::Price.destroy_all
|
||||
end
|
||||
|
||||
def create_family_and_user!(family_name, user_email, currency: "USD")
|
||||
def create_family_and_user!(family_name, user_email, currency: "USD", require_onboarding: false)
|
||||
base_uuid = "d99e3c6e-d513-4452-8f24-dc263f8528c0"
|
||||
id = Digest::UUID.uuid_v5(base_uuid, family_name)
|
||||
|
||||
|
@ -160,7 +160,7 @@ class Demo::Generator
|
|||
id: id,
|
||||
name: family_name,
|
||||
currency: currency,
|
||||
stripe_subscription_status: "active",
|
||||
stripe_subscription_status: require_onboarding ? nil : "active",
|
||||
locale: "en",
|
||||
country: "US",
|
||||
timezone: "America/New_York",
|
||||
|
@ -173,7 +173,7 @@ class Demo::Generator
|
|||
last_name: "User",
|
||||
role: "admin",
|
||||
password: "password",
|
||||
onboarded_at: Time.current
|
||||
onboarded_at: require_onboarding ? nil : Time.current
|
||||
|
||||
family.users.create! \
|
||||
email: "member_#{user_email}",
|
||||
|
@ -181,7 +181,7 @@ class Demo::Generator
|
|||
last_name: "User",
|
||||
role: "member",
|
||||
password: "password",
|
||||
onboarded_at: Time.current
|
||||
onboarded_at: require_onboarding ? nil : Time.current
|
||||
end
|
||||
|
||||
def create_rules!(family)
|
||||
|
|
|
@ -131,6 +131,18 @@ class Family < ApplicationRecord
|
|||
stripe_subscription_status == "active"
|
||||
end
|
||||
|
||||
def trialing?
|
||||
!subscribed? && trial_started_at.present? && trial_started_at <= 14.days.from_now
|
||||
end
|
||||
|
||||
def trial_remaining_days
|
||||
(14 - (Time.current - trial_started_at).to_i / 86400).to_i
|
||||
end
|
||||
|
||||
def existing_customer?
|
||||
stripe_customer_id.present?
|
||||
end
|
||||
|
||||
def requires_data_provider?
|
||||
# If family has any trades, they need a provider for historical prices
|
||||
return true if trades.any?
|
||||
|
@ -146,6 +158,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
|
||||
|
|
|
@ -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|
|
||||
|
|
|
@ -19,6 +19,15 @@ class Provider::Registry
|
|||
end
|
||||
|
||||
private
|
||||
def stripe
|
||||
secret_key = ENV["STRIPE_SECRET_KEY"]
|
||||
webhook_secret = ENV["STRIPE_WEBHOOK_SECRET"]
|
||||
|
||||
return nil unless secret_key.present? && webhook_secret.present?
|
||||
|
||||
Provider::Stripe.new(secret_key:, webhook_secret:)
|
||||
end
|
||||
|
||||
def synth
|
||||
api_key = ENV.fetch("SYNTH_API_KEY", Setting.synth_api_key)
|
||||
|
||||
|
|
68
app/models/provider/stripe.rb
Normal file
68
app/models/provider/stripe.rb
Normal file
|
@ -0,0 +1,68 @@
|
|||
class Provider::Stripe
|
||||
def initialize(secret_key:, webhook_secret:)
|
||||
@client = Stripe::StripeClient.new(
|
||||
secret_key,
|
||||
stripe_version: "2025-04-30.basil"
|
||||
)
|
||||
@webhook_secret = webhook_secret
|
||||
end
|
||||
|
||||
def process_event(event_id)
|
||||
event = retrieve_event(event_id)
|
||||
|
||||
case event.type
|
||||
when /^customer\.subscription\./
|
||||
SubscriptionEventProcessor.new(client).process(event)
|
||||
when /^customer\./
|
||||
CustomerEventProcessor.new(client).process(event)
|
||||
else
|
||||
Rails.logger.info "Unhandled event type: #{event.type}"
|
||||
end
|
||||
end
|
||||
|
||||
def process_webhook_later(webhook_body, sig_header)
|
||||
thin_event = client.parse_thin_event(webhook_body, sig_header, webhook_secret)
|
||||
|
||||
if thin_event.type.start_with?("customer.")
|
||||
StripeEventHandlerJob.perform_later(thin_event.id)
|
||||
else
|
||||
Rails.logger.info "Unhandled event type: #{thin_event.type}"
|
||||
end
|
||||
end
|
||||
|
||||
def create_customer(email:, metadata: {})
|
||||
client.v1.customers.create(
|
||||
email: email,
|
||||
metadata: metadata
|
||||
)
|
||||
end
|
||||
|
||||
def get_checkout_session_url(price_id:, customer_id: nil, success_url: nil, cancel_url: nil)
|
||||
client.v1.checkout.sessions.create(
|
||||
customer: customer_id,
|
||||
line_items: [ { price: price_id, quantity: 1 } ],
|
||||
mode: "subscription",
|
||||
allow_promotion_codes: true,
|
||||
success_url: success_url,
|
||||
cancel_url: cancel_url
|
||||
).url
|
||||
end
|
||||
|
||||
def get_billing_portal_session_url(customer_id:, return_url: nil)
|
||||
client.v1.billing_portal.sessions.create(
|
||||
customer: customer_id,
|
||||
return_url: return_url
|
||||
).url
|
||||
end
|
||||
|
||||
def retrieve_checkout_session(session_id)
|
||||
client.v1.checkout.sessions.retrieve(session_id)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :client, :webhook_secret
|
||||
|
||||
def retrieve_event(event_id)
|
||||
client.v2.core.events.retrieve(event_id)
|
||||
end
|
||||
end
|
20
app/models/provider/stripe/customer_event_processor.rb
Normal file
20
app/models/provider/stripe/customer_event_processor.rb
Normal file
|
@ -0,0 +1,20 @@
|
|||
class Provider::Stripe::CustomerEventProcessor < Provider::Stripe::EventProcessor
|
||||
Error = Class.new(StandardError)
|
||||
|
||||
def process
|
||||
raise Error, "Family not found for Stripe customer ID: #{customer_id}" unless family
|
||||
|
||||
family.update(
|
||||
stripe_customer_id: customer_id
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
def family
|
||||
Family.find_by(stripe_customer_id: customer_id)
|
||||
end
|
||||
|
||||
def customer_id
|
||||
event_data.id
|
||||
end
|
||||
end
|
17
app/models/provider/stripe/event_processor.rb
Normal file
17
app/models/provider/stripe/event_processor.rb
Normal file
|
@ -0,0 +1,17 @@
|
|||
class Provider::Stripe::EventProcessor
|
||||
def initialize(event:, client:)
|
||||
@event = event
|
||||
@client = client
|
||||
end
|
||||
|
||||
def process
|
||||
raise NotImplementedError, "Subclasses must implement the process method"
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :event, :client
|
||||
|
||||
def event_data
|
||||
event.data.object
|
||||
end
|
||||
end
|
29
app/models/provider/stripe/subscription_event_processor.rb
Normal file
29
app/models/provider/stripe/subscription_event_processor.rb
Normal file
|
@ -0,0 +1,29 @@
|
|||
class Provider::Stripe::SubscriptionEventProcessor < Provider::Stripe::EventProcessor
|
||||
Error = Class.new(StandardError)
|
||||
|
||||
def process
|
||||
raise Error, "Family not found for Stripe customer ID: #{customer_id}" unless family
|
||||
|
||||
family.update(
|
||||
stripe_plan_id: plan_id,
|
||||
stripe_subscription_status: subscription_status
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
def family
|
||||
Family.find_by(stripe_customer_id: customer_id)
|
||||
end
|
||||
|
||||
def customer_id
|
||||
event_data.customer
|
||||
end
|
||||
|
||||
def plan_id
|
||||
event_data.plan.id
|
||||
end
|
||||
|
||||
def subscription_status
|
||||
event_data.status
|
||||
end
|
||||
end
|
|
@ -56,8 +56,8 @@ class Transfer < ApplicationRecord
|
|||
end
|
||||
|
||||
def sync_account_later
|
||||
inflow_transaction.entry.sync_account_later
|
||||
outflow_transaction.entry.sync_account_later
|
||||
inflow_transaction&.entry&.sync_account_later
|
||||
outflow_transaction&.entry&.sync_account_later
|
||||
end
|
||||
|
||||
def belongs_to_family?(family)
|
||||
|
@ -65,63 +65,67 @@ class Transfer < ApplicationRecord
|
|||
end
|
||||
|
||||
def to_account
|
||||
inflow_transaction.entry.account
|
||||
inflow_transaction&.entry&.account
|
||||
end
|
||||
|
||||
def from_account
|
||||
outflow_transaction.entry.account
|
||||
outflow_transaction&.entry&.account
|
||||
end
|
||||
|
||||
def amount_abs
|
||||
inflow_transaction.entry.amount_money.abs
|
||||
inflow_transaction&.entry&.amount_money&.abs
|
||||
end
|
||||
|
||||
def name
|
||||
acc = to_account
|
||||
if payment?
|
||||
I18n.t("transfer.payment_name", to_account: to_account.name)
|
||||
acc ? "Payment to #{acc.name}" : "Payment"
|
||||
else
|
||||
I18n.t("transfer.name", to_account: to_account.name)
|
||||
acc ? "Transfer to #{acc.name}" : "Transfer"
|
||||
end
|
||||
end
|
||||
|
||||
def payment?
|
||||
to_account.liability?
|
||||
to_account&.liability?
|
||||
end
|
||||
|
||||
def categorizable?
|
||||
to_account.accountable_type == "Loan"
|
||||
to_account&.accountable_type == "Loan"
|
||||
end
|
||||
|
||||
private
|
||||
def transfer_has_different_accounts
|
||||
return unless inflow_transaction.present? && outflow_transaction.present?
|
||||
errors.add(:base, :must_be_from_different_accounts) if inflow_transaction.entry.account == outflow_transaction.entry.account
|
||||
return unless inflow_transaction&.entry && outflow_transaction&.entry
|
||||
errors.add(:base, "Must be from different accounts") if to_account == from_account
|
||||
end
|
||||
|
||||
def transfer_has_same_family
|
||||
return unless inflow_transaction.present? && outflow_transaction.present?
|
||||
errors.add(:base, :must_be_from_same_family) unless inflow_transaction.entry.account.family == outflow_transaction.entry.account.family
|
||||
return unless inflow_transaction&.entry && outflow_transaction&.entry
|
||||
errors.add(:base, "Must be from same family") unless to_account&.family == from_account&.family
|
||||
end
|
||||
|
||||
def transfer_has_opposite_amounts
|
||||
return unless inflow_transaction.present? && outflow_transaction.present?
|
||||
return unless inflow_transaction&.entry && outflow_transaction&.entry
|
||||
|
||||
inflow_amount = inflow_transaction.entry.amount
|
||||
outflow_amount = outflow_transaction.entry.amount
|
||||
inflow_entry = inflow_transaction.entry
|
||||
outflow_entry = outflow_transaction.entry
|
||||
|
||||
if inflow_transaction.entry.currency == outflow_transaction.entry.currency
|
||||
inflow_amount = inflow_entry.amount
|
||||
outflow_amount = outflow_entry.amount
|
||||
|
||||
if inflow_entry.currency == outflow_entry.currency
|
||||
# For same currency, amounts must be exactly opposite
|
||||
errors.add(:base, :must_have_opposite_amounts) if inflow_amount + outflow_amount != 0
|
||||
errors.add(:base, "Must have opposite amounts") if inflow_amount + outflow_amount != 0
|
||||
else
|
||||
# For different currencies, just check the signs are opposite
|
||||
errors.add(:base, :must_have_opposite_amounts) unless inflow_amount.negative? && outflow_amount.positive?
|
||||
errors.add(:base, "Must have opposite amounts") unless inflow_amount.negative? && outflow_amount.positive?
|
||||
end
|
||||
end
|
||||
|
||||
def transfer_within_date_range
|
||||
return unless inflow_transaction.present? && outflow_transaction.present?
|
||||
return unless inflow_transaction&.entry && outflow_transaction&.entry
|
||||
|
||||
date_diff = (inflow_transaction.entry.date - outflow_transaction.entry.date).abs
|
||||
errors.add(:base, :must_be_within_date_range) if date_diff > 4
|
||||
errors.add(:base, "Must be within 4 days") if date_diff > 4
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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 %>
|
||||
</div>
|
||||
|
@ -35,7 +35,9 @@
|
|||
</p>
|
||||
|
||||
<% 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 %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,88 +1,91 @@
|
|||
<%# locals: (family:) %>
|
||||
<%# locals: (family:, active_account_group_tab:) %>
|
||||
|
||||
<% if family.requires_data_provider? && Provider::Registry.get_provider(:synth).nil? %>
|
||||
<details class="group bg-yellow-tint-10 rounded-lg p-2 text-yellow-600 mb-3 text-xs">
|
||||
<summary class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= icon "triangle-alert", size: "sm" %>
|
||||
<p class="font-medium">Missing historical data</p>
|
||||
<div>
|
||||
<% if family.missing_data_provider? %>
|
||||
<details class="group bg-yellow-tint-10 rounded-lg p-2 text-yellow-600 mb-3 text-xs">
|
||||
<summary class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= icon "triangle-alert", size: "sm", color: "warning" %>
|
||||
<p class="font-medium">Missing historical data</p>
|
||||
</div>
|
||||
|
||||
<%= icon("chevron-down", color: "warning", class: "group-open:transform group-open:rotate-180") %>
|
||||
</summary>
|
||||
<div class="text-xs py-2 space-y-2">
|
||||
<p>Maybe uses Synth API to fetch historical exchange rates, security prices, and more. This data is required to calculate accurate historical account balances.</p>
|
||||
|
||||
<p>
|
||||
<%= link_to "Add your Synth API key here.", settings_hosting_path, class: "text-yellow-600 underline" %>
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
<% end %>
|
||||
|
||||
<%= lucide_icon "chevron-down", class: "text-yellow-600 group-open:transform group-open:rotate-180 w-5" %>
|
||||
</summary>
|
||||
<div class="text-xs py-2 space-y-2">
|
||||
<p>Maybe uses Synth API to fetch historical exchange rates, security prices, and more. This data is required to calculate accurate historical account balances.</p>
|
||||
|
||||
<p>
|
||||
<%= link_to "Add your Synth API key here.", settings_hosting_path, class: "text-yellow-600 underline" %>
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
<% end %>
|
||||
|
||||
<div
|
||||
class="space-y-3"
|
||||
data-controller="tabs"
|
||||
data-tabs-local-storage-key-value="account-sidebar-tabs"
|
||||
data-tabs-active-class="bg-surface shadow-sm text-primary"
|
||||
data-tabs-inactive-class="text-secondary"
|
||||
data-tabs-default-tab-value="assets-tab">
|
||||
<div class="bg-surface-inset rounded-lg p-1 flex">
|
||||
<button type="button" data-id="assets-tab" class="w-1/3 px-2 py-1 rounded-md text-sm text-secondary font-medium" data-tabs-target="btn" data-action="click->tabs#select">
|
||||
Assets
|
||||
</button>
|
||||
|
||||
<button type="button" data-id="debts-tab" class="w-1/3 px-2 py-1 rounded-md text-secondary text-sm font-medium" data-tabs-target="btn" data-action="click->tabs#select">
|
||||
Debts
|
||||
</button>
|
||||
|
||||
<button type="button" data-id="all-tab" class="w-1/3 px-2 py-1 rounded-md text-secondary text-sm font-medium" data-tabs-target="btn" data-action="click->tabs#select">
|
||||
All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div data-tabs-target="tab" id="assets-tab">
|
||||
<%= 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") %>
|
||||
<span>New asset</span>
|
||||
<%= 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 %>
|
||||
|
||||
<div class="space-y-2">
|
||||
<% family.balance_sheet.account_groups("asset").each do |group| %>
|
||||
<%= render "accounts/accountable_group", account_group: group %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% tabs.with_panel(tab_id: "assets") do %>
|
||||
<div class="space-y-2">
|
||||
<%= 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"
|
||||
) %>
|
||||
|
||||
<div data-tabs-target="tab" id="debts-tab" class="hidden">
|
||||
<%= 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") %>
|
||||
<span>New debt</span>
|
||||
<div>
|
||||
<% family.balance_sheet.account_groups("asset").each do |group| %>
|
||||
<%= render "accounts/accountable_group", account_group: group %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="space-y-2">
|
||||
<% family.balance_sheet.account_groups("liability").each do |group| %>
|
||||
<%= render "accounts/accountable_group", account_group: group %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% tabs.with_panel(tab_id: "debts") do %>
|
||||
<div class="space-y-2">
|
||||
<%= 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"
|
||||
) %>
|
||||
|
||||
<div data-tabs-target="tab" id="all-tab" class="hidden">
|
||||
<%= 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") %>
|
||||
<span>New account</span>
|
||||
<div>
|
||||
<% family.balance_sheet.account_groups("liability").each do |group| %>
|
||||
<%= render "accounts/accountable_group", account_group: group %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="space-y-2">
|
||||
<% family.balance_sheet.account_groups.each do |group| %>
|
||||
<%= render "accounts/accountable_group", account_group: group %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% tabs.with_panel(tab_id: "all") do %>
|
||||
<div class="space-y-2">
|
||||
<%= 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"
|
||||
) %>
|
||||
|
||||
<div>
|
||||
<% family.balance_sheet.account_groups.each do |group| %>
|
||||
<%= render "accounts/accountable_group", account_group: group %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
|
@ -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 %>
|
||||
<span style="background-color: color-mix(in srgb, <%= accountable.color %> 10%, white);" class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg border border-alpha-black-25">
|
||||
<%= lucide_icon(accountable.icon, style: "color: #{accountable.color}", class: "w-5 h-5") %>
|
||||
</span>
|
||||
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 %>
|
||||
|
|
|
@ -1,11 +1,7 @@
|
|||
<%# locals: (account_group:) %>
|
||||
|
||||
<details class="group" data-controller="account-collapse" data-account-collapse-type-value="<%= account_group.key %>">
|
||||
<summary class="px-3 py-2 flex items-center gap-3 cursor-pointer h-10 mb-1">
|
||||
<%= 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 %>
|
||||
<div class="ml-auto text-right grow">
|
||||
<%= tag.p format_money(account_group.total_money), class: "text-sm font-medium text-primary" %>
|
||||
|
||||
|
@ -15,11 +11,11 @@
|
|||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</summary>
|
||||
<% end %>
|
||||
|
||||
<div class="space-y-1">
|
||||
<% 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 %>
|
||||
|
||||
<div class="min-w-0 grow">
|
||||
|
@ -40,10 +36,13 @@
|
|||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= 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") %>
|
||||
<span>New <%= account_group.name.downcase.singularize %></span>
|
||||
<% end %>
|
||||
</details>
|
||||
<%= 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 %>
|
||||
|
|
|
@ -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") %>
|
||||
<span><%= t(".new_account") %></span>
|
||||
<% end %>
|
||||
<%= render LinkComponent.new(
|
||||
text: t(".new_account"),
|
||||
href: new_account_path,
|
||||
frame: :modal
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -2,23 +2,23 @@
|
|||
<h1 class="text-xl"><%= t(".accounts") %></h1>
|
||||
<div class="flex items-center gap-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= 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" %>
|
||||
<span class="hidden md:inline"><%= t(".sync") %></span>
|
||||
<% 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 %>
|
||||
<div class="flex items-center justify-center w-5 h-5">
|
||||
|
||||
<%= lucide_icon("plus") %>
|
||||
</div>
|
||||
<p class="hidden md:block text-sm font-medium"><%= t(".new_account") %></p>
|
||||
<% end %>
|
||||
<%= render LinkComponent.new(
|
||||
text: "New account",
|
||||
href: new_account_path(return_to: accounts_path),
|
||||
variant: "primary",
|
||||
icon: "plus",
|
||||
frame: :modal
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
<details open class="group bg-container p-4 shadow-border-xs rounded-xl">
|
||||
<summary class="flex items-center gap-2 focus-visible:outline-hidden">
|
||||
<%= 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") %>
|
||||
|
||||
<div class="flex items-center justify-center h-8 w-8 rounded-full bg-black/5">
|
||||
<%= lucide_icon("folder-pen", class: "w-5 h-5 text-secondary") %>
|
||||
<%= icon("folder-pen") %>
|
||||
</div>
|
||||
|
||||
<span class="mr-auto text-sm font-medium text-primary"><%= t(".other_accounts") %></span>
|
||||
|
|
|
@ -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 %>
|
||||
<span style="background-color: color-mix(in srgb, #F79009 10%, white);" class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg border border-alpha-black-25">
|
||||
<%= lucide_icon("download", style: "color: #F79009", class: "w-5 h-5") %>
|
||||
</span>
|
||||
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 %>
|
||||
|
|
|
@ -1,18 +1,22 @@
|
|||
<%# locals: (title:, back_path: nil) %>
|
||||
|
||||
<%= modal do %>
|
||||
<div class="flex flex-col w-screen max-w-xl relative" data-controller="list-keyboard-navigation">
|
||||
<div class="border-b border-tertiary md:border-alpha-black-25 p-4 text-gray-800 flex items-center space-x-3">
|
||||
<% 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| %>
|
||||
<div class="flex flex-col relative" data-controller="list-keyboard-navigation">
|
||||
<div class="border-b border-tertiary md:border-alpha-black-25 px-4 pb-4 text-gray-800 flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<% if back_path %>
|
||||
<%= render LinkComponent.new(
|
||||
variant: "icon",
|
||||
icon: "arrow-left",
|
||||
href: back_path,
|
||||
size: "lg"
|
||||
) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<span class="text-primary"><%= title %></span>
|
||||
<button class="absolute top-1/2 -translate-y-1/2 right-4 flex w-8 h-8 items-center justify-center rounded-lg md:hidden outline-0" data-action="click->modal#close">
|
||||
<%= lucide_icon("x", class: "text-secondary w-6 h-6") %>
|
||||
</button>
|
||||
<span class="text-primary"><%= title %></span>
|
||||
</div>
|
||||
|
||||
<%= icon("x", as_button: true, size: "lg", data: { action: "dialog#close" }) %>
|
||||
</div>
|
||||
|
||||
<div class="p-2 text-subdued">
|
||||
|
@ -22,20 +26,26 @@
|
|||
<%= yield %>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-alpha-black-25 p-4 text-secondary text-sm justify-between hidden md:flex">
|
||||
<div class="border-t border-alpha-black-25 px-4 pt-4 text-secondary text-sm justify-between hidden md:flex">
|
||||
<div class="flex space-x-5">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span>Select</span>
|
||||
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center"><%= lucide_icon("corner-down-left", class: "inline w-3 h-3") %></kbd>
|
||||
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center">
|
||||
<%= icon("corner-down-left", size: "xs") %>
|
||||
</kbd>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span>Navigate</span>
|
||||
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center"><%= lucide_icon("arrow-up", class: "inline w-3 h-3") %></kbd>
|
||||
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center"><%= lucide_icon("arrow-down", class: "inline w-3 h-3") %></kbd>
|
||||
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center">
|
||||
<%= icon("arrow-up", size: "xs") %>
|
||||
</kbd>
|
||||
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-5 h-5 shrink-0 grow-0 items-center justify-center">
|
||||
<%= icon("arrow-down", size: "xs") %>
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button data-action="modal#close">Close</button>
|
||||
<button data-action="dialog#close">Close</button>
|
||||
<kbd class="bg-alpha-black-50 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.1)] p-1 rounded-md flex w-8 h-5 shrink-0 grow-0 items-center justify-center text-xs">ESC</kbd>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -2,18 +2,18 @@
|
|||
|
||||
<%= render layout: "accounts/new/container", locals: { title: t(".title"), back_path: new_account_path } do %>
|
||||
<div class="text-sm">
|
||||
<%= 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 %>
|
||||
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
|
||||
<%= lucide_icon("keyboard", class: "text-secondary w-5 h-5") %>
|
||||
<%= icon("keyboard") %>
|
||||
</span>
|
||||
<%= t("accounts.new.method_selector.manual_entry") %>
|
||||
<% end %>
|
||||
|
||||
<% if us_link_token %>
|
||||
<%# Default US-only Link %>
|
||||
<button data-controller="plaid" data-action="plaid#open modal#close" data-plaid-region-value="us" data-plaid-link-token-value="<%= us_link_token %>" class="flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2">
|
||||
<button data-controller="plaid" data-action="plaid#open dialog#close" data-plaid-region-value="us" data-plaid-link-token-value="<%= us_link_token %>" class="text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2">
|
||||
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
|
||||
<%= lucide_icon("link-2", class: "text-secondary w-5 h-5") %>
|
||||
<%= icon("link-2") %>
|
||||
</span>
|
||||
<%= t("accounts.new.method_selector.connected_entry") %>
|
||||
</button>
|
||||
|
@ -21,9 +21,9 @@
|
|||
|
||||
<%# EU Link %>
|
||||
<% if eu_link_token %>
|
||||
<button data-controller="plaid" data-action="plaid#open modal#close" data-plaid-region-value="eu" data-plaid-link-token-value="<%= eu_link_token %>" class="flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2">
|
||||
<button data-controller="plaid" data-action="plaid#open dialog#close" data-plaid-region-value="eu" data-plaid-link-token-value="<%= eu_link_token %>" class="text-primary flex items-center gap-4 w-full text-center focus:outline-hidden focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2">
|
||||
<span class="flex w-8 h-8 shrink-0 grow-0 items-center justify-center rounded-lg bg-alpha-black-50 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.02)]">
|
||||
<%= lucide_icon("link-2", class: "text-secondary w-5 h-5") %>
|
||||
<%= icon("link-2") %>
|
||||
</span>
|
||||
<%= t("accounts.new.method_selector.connected_entry_eu") %>
|
||||
</button>
|
||||
|
|
|
@ -2,30 +2,29 @@
|
|||
|
||||
<%= turbo_frame_tag dom_id(account, "entries") do %>
|
||||
<div class="bg-container p-5 shadow-border-xs rounded-xl" data-controller="focus-record" data-focus-record-id-value="<%= @focused_record ? dom_id(@focused_record) : nil %>">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center justify-between mb-4" data-testid="activity-menu">
|
||||
<%= tag.h2 t(".title"), class: "font-medium text-lg" %>
|
||||
<% unless @account.plaid_account_id.present? %>
|
||||
<div data-controller="menu" data-testid="activity-menu">
|
||||
<button class="btn btn--secondary flex items-center gap-2" data-menu-target="button">
|
||||
<%= lucide_icon("plus", class: "w-4 h-4") %>
|
||||
<%= tag.span t(".new") %>
|
||||
</button>
|
||||
<div data-menu-target="content" class="z-10 hidden bg-container rounded-lg border border-alpha-black-25 shadow-xs p-1">
|
||||
<%= 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 %>
|
||||
<span class="flex items-center justify-center">
|
||||
<%= lucide_icon("credit-card", class: "text-secondary w-5 h-5") %>
|
||||
</span>
|
||||
<%= tag.span t(".new_transaction"), class: "text-sm md:block" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% 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 %>
|
||||
</div>
|
||||
|
||||
|
@ -38,7 +37,7 @@
|
|||
<div class="flex gap-2 mb-4">
|
||||
<div class="grow">
|
||||
<div class="flex items-center px-3 py-2 gap-2 border border-secondary rounded-lg focus-within:ring-gray-100 focus-within:border-gray-900">
|
||||
<%= 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",
|
||||
|
|
|
@ -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" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
|
@ -19,17 +19,27 @@
|
|||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex items-center gap-3 ml-auto">
|
||||
<div class="flex items-center gap-1 ml-auto">
|
||||
<% 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 %>
|
||||
|
|
|
@ -1,47 +1,25 @@
|
|||
<%# locals: (account:) %>
|
||||
|
||||
<%= contextual_menu do %>
|
||||
<div class="w-48 p-1 text-sm leading-6 text-primary bg-container shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
||||
<% 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 }) %>
|
||||
|
||||
<span><%= t(".manage") %></span>
|
||||
<% 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 %>
|
||||
|
||||
<span><%= t(".edit") %></span>
|
||||
<% 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" %>
|
||||
|
||||
<span><%= t(".import") %></span>
|
||||
<% 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 %>
|
||||
</div>
|
||||
<% 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 %>
|
||||
|
|
|
@ -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 %>
|
||||
|
||||
<div class="flex gap-2 text-sm text-primary font-medium mb-4">
|
||||
<% 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 %>
|
||||
</div>
|
||||
|
||||
<%= selected_tab[:contents] %>
|
||||
<% tabs.each do |tab| %>
|
||||
<% tabs_container.with_panel(tab_id: tab[:key]) do %>
|
||||
<%= tab[:contents] %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
<%= render "accounts/show/chart", account: account %>
|
||||
<% end %>
|
||||
|
||||
<div class="min-h-[800px]">
|
||||
<div class="min-h-[800px]" data-testid="account-details>
|
||||
<% if tabs.present? %>
|
||||
<%= tabs %>
|
||||
<% else %>
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<details class="group mb-1">
|
||||
<summary class="flex items-center gap-2">
|
||||
<p class="text-secondary text-sm">Assistant reasoning</p>
|
||||
<%= 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") %>
|
||||
</summary>
|
||||
|
||||
<div class="prose prose--ai-chat"><%= markdown(assistant_message.content) %></div>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
<details class="my-2 group mb-4">
|
||||
<summary class="text-secondary text-xs cursor-pointer flex items-center gap-2">
|
||||
<%= 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") %>
|
||||
<p>Tool Calls</p>
|
||||
</summary>
|
||||
|
||||
|
|
|
@ -8,11 +8,17 @@
|
|||
<%= render "budget_categories/budget_category_donut", budget_category: budget_category %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="w-8 h-8 group-hover:scale-105 transition-all duration-300 rounded-full flex justify-center items-center" style="<%= mixed_hex_styles(budget_category.category.color) %>">
|
||||
<div class="w-8 h-8 group-hover:scale-105 transition-all duration-300 rounded-full flex justify-center items-center" style="color: <%= budget_category.category.color %>">
|
||||
<% 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 %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
|
@ -11,7 +11,9 @@
|
|||
<div data-donut-chart-target="contentContainer" class="flex justify-center items-center h-full p-1">
|
||||
<div data-donut-chart-target="defaultContent" class="h-full w-full rounded-full flex flex-col items-center justify-center" style="background-color: <%= hex_with_alpha(budget_category.category.color, 0.05) %>">
|
||||
<% 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}" %>
|
||||
<span style="color: <%= budget_category.category.color %>">
|
||||
<%= icon(budget_category.category.lucide_icon, size: "sm", color: "current") %>
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="text-sm uppercase" style="color: <%= budget_category.category.color %>">
|
||||
<%= budget_category.category.name.first.upcase %>
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
<div id="<%= dom_id(budget, :confirm_button) %>">
|
||||
<% if budget.allocations_valid? %>
|
||||
<%= link_to "Confirm",
|
||||
budget_path(budget),
|
||||
class: "block btn btn--primary w-full text-center" %>
|
||||
<% else %>
|
||||
<span class="block btn btn--secondary w-full text-center text-subdued cursor-not-allowed">
|
||||
Confirm
|
||||
</span>
|
||||
<% end %>
|
||||
<%= render ButtonComponent.new(
|
||||
text: "Confirm",
|
||||
variant: "primary",
|
||||
full_width: true,
|
||||
href: budget_path(budget),
|
||||
method: :get,
|
||||
disabled: !budget.allocations_valid?
|
||||
) %>
|
||||
</div>
|
||||
|
|
|
@ -6,12 +6,18 @@
|
|||
</p>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<%= 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") %>
|
||||
<span>New category</span>
|
||||
<% end %>
|
||||
<%= render LinkComponent.new(
|
||||
text: "New category",
|
||||
variant: "outline",
|
||||
icon: "plus",
|
||||
href: new_category_path,
|
||||
frame: :modal,
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -32,7 +32,7 @@
|
|||
<% group.budget_subcategories.each do |budget_subcategory| %>
|
||||
<div class="w-full flex items-center gap-4">
|
||||
<div class="ml-4 flex items-center justify-center text-subdued">
|
||||
<%= lucide_icon "corner-down-right", class: "w-5 h-5 shrink-0" %>
|
||||
<%= icon("corner-down-right") %>
|
||||
</div>
|
||||
|
||||
<%= render "budget_categories/budget_category_form", budget_category: budget_subcategory %>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue