Compare commits
183 Commits
v0.5.0
...
zachgoll/p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a8ac82823 | ||
|
|
6e202bd7ec | ||
|
|
e1b81ef879 | ||
|
|
151bf25d27 | ||
|
|
854a21993a | ||
|
|
d21e385962 | ||
|
|
c701755b02 | ||
|
|
43a403e431 | ||
|
|
03e0230e99 | ||
|
|
ffc5f844b2 | ||
|
|
5125411822 | ||
|
|
aecb5aafd8 | ||
|
|
6935ffa3d1 | ||
|
|
03a146222d | ||
|
|
5c82af0e8c | ||
|
|
5cfb4addbd | ||
|
|
fd65b5a747 | ||
|
|
6d4509fbe6 | ||
|
|
c7d9c94489 | ||
|
|
fcdc42760d | ||
|
|
19804d2b05 | ||
|
|
fe24117c50 | ||
|
|
e4ee06c9f6 | ||
|
|
857436d894 | ||
|
|
092350f1f8 | ||
|
|
b719a8b80d | ||
|
|
a71b62575c | ||
|
|
2fbd6cbc5d | ||
|
|
a7438e5c78 | ||
|
|
fd3b583737 | ||
|
|
34b3e4ae20 | ||
|
|
8070986763 | ||
|
|
3d2213b760 | ||
|
|
cc9a75ee28 | ||
|
|
443b834b46 | ||
|
|
868d4ede6e | ||
|
|
caf35701ef | ||
|
|
94a807c3c9 | ||
|
|
dd605a577e | ||
|
|
137219c121 | ||
|
|
ab5bce3462 | ||
|
|
a262a749fe | ||
|
|
7e7ae31216 | ||
|
|
efdd03cfe7 | ||
|
|
1b4577e21e | ||
|
|
e569ad0a8c | ||
|
|
6f68d66eda | ||
|
|
e26e5c5aec | ||
|
|
f82f77466a | ||
|
|
74c7b0941d | ||
|
|
29a8ac9d8a | ||
|
|
9f13b5bb83 | ||
|
|
10f255a9a9 | ||
|
|
b8903d0980 | ||
|
|
35d1447494 | ||
|
|
6dc1d22672 | ||
|
|
6917cecf33 | ||
|
|
5efa8268f6 | ||
|
|
9155e737b2 | ||
|
|
a565343102 | ||
|
|
10dd9e061a | ||
|
|
9793cc74f9 | ||
|
|
3f48992aea | ||
|
|
bcb47a9d29 | ||
|
|
bebe7b40d6 | ||
|
|
050d5ebaad | ||
|
|
30d3eef67f | ||
|
|
df8e22afe9 | ||
|
|
0fb689290a | ||
|
|
9e6e4b1ce6 | ||
|
|
908b3e2489 | ||
|
|
a268c5a563 | ||
|
|
0006b6f6ca | ||
|
|
48a07d6158 | ||
|
|
5d798fe0a0 | ||
|
|
f07c41821e | ||
|
|
7605b0221d | ||
|
|
ab2cec55e7 | ||
|
|
03e3899541 | ||
|
|
3371243a00 | ||
|
|
d8e058d7c6 | ||
|
|
867318cbc1 | ||
|
|
1e5edd9f2f | ||
|
|
42207e487e | ||
|
|
ea1b6f2bd8 | ||
|
|
2707a40a2a | ||
|
|
8b857e9c8a | ||
|
|
a07e9d40a3 | ||
|
|
71be2a04ad | ||
|
|
a67f36bf64 | ||
|
|
628d266980 | ||
|
|
64d5a73eb7 | ||
|
|
2b2dfd03e0 | ||
|
|
fb7107d614 | ||
|
|
c26a7dd2dd | ||
|
|
5da4bb6dc3 | ||
|
|
8c10e87387 | ||
|
|
60c3a04a48 | ||
|
|
c0267d5665 | ||
|
|
0fdeebceb1 | ||
|
|
2e0794b8e1 | ||
|
|
2000f05453 | ||
|
|
470b753833 | ||
|
|
fea1baeb1e | ||
|
|
c022e862aa | ||
|
|
dcc43cb253 | ||
|
|
6e4d35d6ae | ||
|
|
98644f1b87 | ||
|
|
fc9961d420 | ||
|
|
441f436187 | ||
|
|
bc7e32deab | ||
|
|
a7a29b4780 | ||
|
|
1e1ed5ca45 | ||
|
|
793a5d2502 | ||
|
|
84eb2c90d4 | ||
|
|
1210a8f3a3 | ||
|
|
752835f492 | ||
|
|
a1d64d6c2e | ||
|
|
c24ae1762f | ||
|
|
adc5bf58d7 | ||
|
|
0c79b335f1 | ||
|
|
be0d51057d | ||
|
|
cf72f1a387 | ||
|
|
0946a1497a | ||
|
|
aebbb9a3c1 | ||
|
|
194dad702d | ||
|
|
17fa5413f6 | ||
|
|
38b6e30bea | ||
|
|
a51c4d2cba | ||
|
|
79b4a3769b | ||
|
|
9a291edbc8 | ||
|
|
d266b6a35e | ||
|
|
d8cf35eca7 | ||
|
|
23adfb2ef0 | ||
|
|
ed8011f792 | ||
|
|
90a9546f32 | ||
|
|
1aafed5f8b | ||
|
|
47017a6432 | ||
|
|
ae41b3de46 | ||
|
|
d8e34cf791 | ||
|
|
e70295394a | ||
|
|
8019a0b33c | ||
|
|
fe578c8f08 | ||
|
|
9be0553b18 | ||
|
|
218040584d | ||
|
|
90d491906e | ||
|
|
3370ae260d | ||
|
|
341a800b65 | ||
|
|
e6b69c1f5c | ||
|
|
71bc51ca15 | ||
|
|
ce83418f0b | ||
|
|
210b89cd17 | ||
|
|
47aeaf8cea | ||
|
|
db34f6d7a2 | ||
|
|
b6cf6198f4 | ||
|
|
a7dfafc907 | ||
|
|
9b33e50b89 | ||
|
|
1b1add38f2 | ||
|
|
08091d24f9 | ||
|
|
21623eeb2d | ||
|
|
bcfbc4b324 | ||
|
|
04ee1e73be | ||
|
|
79243822bd | ||
|
|
297a695d0f | ||
|
|
8edd7ecef0 | ||
|
|
c88fe2e3b2 | ||
|
|
8cf077f28d | ||
|
|
8985592967 | ||
|
|
d22a16d8de | ||
|
|
77a2d6a048 | ||
|
|
65e1bc6edd | ||
|
|
6a21f26d2d | ||
|
|
298e150f43 | ||
|
|
e657c40d19 | ||
|
|
f181ba941f | ||
|
|
5cb2183bdf | ||
|
|
6f70a54d6f | ||
|
|
f235697178 | ||
|
|
e517127062 | ||
|
|
b06fd1edf0 | ||
|
|
1e01840fee | ||
|
|
48c8499b70 | ||
|
|
8648f11413 |
@@ -11,13 +11,11 @@ alwaysApply: true
|
||||
- Read [project-design.mdc](mdc:.cursor/rules/project-design.mdc) to understand the codebase
|
||||
- Read [project-conventions.mdc](mdc:.cursor/rules/project-conventions.mdc) to understand _how_ to write code for the codebase
|
||||
- Read [ui-ux-design-guidelines.mdc](mdc:.cursor/rules/ui-ux-design-guidelines.mdc) to understand how to implement frontend code specifically
|
||||
- Ignore i18n methods and files. Hardcode strings in English for now to optimize speed of development.
|
||||
|
||||
## Prohibited actions
|
||||
|
||||
Do not under any circumstance do the following:
|
||||
|
||||
- Do not run `rails server` in your responses.
|
||||
- Do not run `touch tmp/restart.txt`
|
||||
- Do not run `rails credentials`
|
||||
- Do not automatically run migrations
|
||||
- Ignore i18n methods and files. Hardcode strings in English for now to optimize speed of development.
|
||||
- Do not automatically run migrations
|
||||
@@ -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.
|
||||
|
||||
@@ -71,7 +72,7 @@ Due to the open-source nature of this project, we have chosen Minitest + Fixture
|
||||
|
||||
- Always use Minitest and fixtures for testing.
|
||||
- Keep fixtures to a minimum. Most models should have 2-3 fixtures maximum that represent the "base cases" for that model. "Edge cases" should be created on the fly, within the context of the test which it is needed.
|
||||
- For tests that require a large number of fixture records to be created, use Rails helpers such as [entries_test_helper.rb](mdc:test/support/account/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [forward_calculator_test.rb](mdc:test/models/account/balance/forward_calculator_test.rb)
|
||||
- For tests that require a large number of fixture records to be created, use Rails helpers such as [entries_test_helper.rb](mdc:test/support/entries_test_helper.rb) to act as a "factory" for creating these. For a great example of this, check out [forward_calculator_test.rb](mdc:test/models/account/balance/forward_calculator_test.rb)
|
||||
- Take a minimal approach to testing—only test the absolutely critical code paths that will significantly increase developer confidence
|
||||
|
||||
#### Convention 5a: Write minimal, effective tests
|
||||
@@ -87,26 +88,26 @@ Below are examples of necessary vs. unnecessary tests:
|
||||
# GOOD!!
|
||||
# Necessary test - in this case, we're testing critical domain business logic
|
||||
test "syncs balances" do
|
||||
Account::Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once
|
||||
Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once
|
||||
|
||||
@account.expects(:start_date).returns(2.days.ago.to_date)
|
||||
|
||||
Account::Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
|
||||
Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
|
||||
[
|
||||
Account::Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
|
||||
Account::Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
|
||||
Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
|
||||
Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
|
||||
]
|
||||
)
|
||||
|
||||
assert_difference "@account.balances.count", 2 do
|
||||
Account::Balance::Syncer.new(@account, strategy: :forward).sync_balances
|
||||
Balance::Syncer.new(@account, strategy: :forward).sync_balances
|
||||
end
|
||||
end
|
||||
|
||||
# BAD!!
|
||||
# Unnecessary test - in this case, this is simply testing ActiveRecord's functionality
|
||||
test "saves balance" do
|
||||
balance_record = Account::Balance.new(balance: 100, currency: "USD")
|
||||
balance_record = Balance.new(balance: 100, currency: "USD")
|
||||
|
||||
assert balance_record.save
|
||||
end
|
||||
@@ -117,4 +118,3 @@ end
|
||||
- Enforce `null` checks, unique indexes, and other simple validations in the DB
|
||||
- ActiveRecord validations _may_ mirror the DB level ones, but not 100% necessary. These are for convenience when error handling in forms. Always prefer client-side form validation when possible.
|
||||
- Complex validations and business logic should remain in ActiveRecord
|
||||
|
||||
|
||||
@@ -55,29 +55,29 @@ All balances are calculated daily by [balance_calculator.rb](mdc:app/models/acco
|
||||
|
||||
### Account Holdings
|
||||
|
||||
An account [holding.rb](mdc:app/models/account/holding.rb) applies to [investment.rb](mdc:app/models/investment.rb) type accounts and represents a `qty` of a certain [security.rb](mdc:app/models/security.rb) at a specific `price` on a specific `date`.
|
||||
An account [holding.rb](mdc:app/models/holding.rb) applies to [investment.rb](mdc:app/models/investment.rb) type accounts and represents a `qty` of a certain [security.rb](mdc:app/models/security.rb) at a specific `price` on a specific `date`.
|
||||
|
||||
For investment accounts with holdings, [holding_calculator.rb](mdc:app/models/account/holding_calculator.rb) is used to calculate the daily historical holding quantities and prices, which are then rolled up into a final "Balance" for the account in [balance_calculator.rb](mdc:app/models/account/balance_calculator.rb).
|
||||
For investment accounts with holdings, [base_calculator.rb](mdc:app/models/holding/base_calculator.rb) is used to calculate the daily historical holding quantities and prices, which are then rolled up into a final "Balance" for the account in [base_calculator.rb](mdc:app/models/account/balance/base_calculator.rb).
|
||||
|
||||
### Account Entries
|
||||
|
||||
An account [entry.rb](mdc:app/models/account/entry.rb) is also a Rails "delegated type". `Account::Entry` represents any record that _modifies_ an `Account` [balance.rb](mdc:app/models/account/balance.rb) and/or [holding.rb](mdc:app/models/account/holding.rb). Therefore, every entry must have a `date`, `amount`, and `currency`.
|
||||
An account [entry.rb](mdc:app/models/entry.rb) is also a Rails "delegated type". `Entry` represents any record that _modifies_ an `Account` [balance.rb](mdc:app/models/account/balance.rb) and/or [holding.rb](mdc:app/models/holding.rb). Therefore, every entry must have a `date`, `amount`, and `currency`.
|
||||
|
||||
The `amount` of an [entry.rb](mdc:app/models/account/entry.rb) is a signed value. A _negative_ amount is an "inflow" of money to that account. A _positive_ value is an "outflow" of money from that account. For example:
|
||||
The `amount` of an [entry.rb](mdc:app/models/entry.rb) is a signed value. A _negative_ amount is an "inflow" of money to that account. A _positive_ value is an "outflow" of money from that account. For example:
|
||||
|
||||
- A negative amount for a credit card account represents a "payment" to that account, which _reduces_ its balance (since it is a `liability`)
|
||||
- A negative amount for a checking account represents an "income" to that account, which _increases_ its balance (since it is an `asset`)
|
||||
- A negative amount for an investment/brokerage trade represents a "sell" transaction, which _increases_ the cash balance of the account
|
||||
|
||||
There are 3 entry types, defined as [entryable.rb](mdc:app/models/account/entryable.rb) records:
|
||||
There are 3 entry types, defined as [entryable.rb](mdc:app/models/entryable.rb) records:
|
||||
|
||||
- `Account::Valuation` - an account [valuation.rb](mdc:app/models/account/valuation.rb) is an entry that says, "here is the value of this account on this date". It is an absolute measure of an account value / debt. If there is an `Account::Valuation` of 5,000 for today's date, that means that the account balance will be 5,000 today.
|
||||
- `Account::Transaction` - an account [transaction.rb](mdc:app/models/account/transaction.rb) is an entry that alters the account balance by the `amount`. This is the most common type of entry and can be thought of as an "income" or "expense".
|
||||
- `Account::Trade` - an account [trade.rb](mdc:app/models/account/trade.rb) is an entry that only applies to an investment account. This represents a "buy" or "sell" of a holding and has a `qty` and `price`.
|
||||
- `Valuation` - an account [valuation.rb](mdc:app/models/valuation.rb) is an entry that says, "here is the value of this account on this date". It is an absolute measure of an account value / debt. If there is an `Valuation` of 5,000 for today's date, that means that the account balance will be 5,000 today.
|
||||
- `Transaction` - an account [transaction.rb](mdc:app/models/transaction.rb) is an entry that alters the account balance by the `amount`. This is the most common type of entry and can be thought of as an "income" or "expense".
|
||||
- `Trade` - an account [trade.rb](mdc:app/models/trade.rb) is an entry that only applies to an investment account. This represents a "buy" or "sell" of a holding and has a `qty` and `price`.
|
||||
|
||||
### Account Transfers
|
||||
|
||||
A [transfer.rb](mdc:app/models/transfer.rb) represents a movement of money between two accounts. A transfer has an inflow [transaction.rb](mdc:app/models/account/transaction.rb) and an outflow [transaction.rb](mdc:app/models/account/transaction.rb). The Maybe system auto-matches transfers based on the following criteria:
|
||||
A [transfer.rb](mdc:app/models/transfer.rb) represents a movement of money between two accounts. A transfer has an inflow [transaction.rb](mdc:app/models/transaction.rb) and an outflow [transaction.rb](mdc:app/models/transaction.rb). The Maybe system auto-matches transfers based on the following criteria:
|
||||
|
||||
- Must be from different accounts
|
||||
- Must be within 4 days of each other
|
||||
@@ -115,10 +115,10 @@ The most important type of sync is the account sync. It is orchestrated by the
|
||||
|
||||
- Auto-matches transfer records for the account
|
||||
- Calculates daily [balance.rb](mdc:app/models/account/balance.rb) records for the account from `account.start_date` to `Date.current` using [base_calculator.rb](mdc:app/models/account/balance/base_calculator.rb)
|
||||
- Balances are dependent on the calculation of [holding.rb](mdc:app/models/account/holding.rb), which uses [base_calculator.rb](mdc:app/models/account/holding/base_calculator.rb)
|
||||
- Balances are dependent on the calculation of [holding.rb](mdc:app/models/holding.rb), which uses [base_calculator.rb](mdc:app/models/account/holding/base_calculator.rb)
|
||||
- Enriches transaction data if enabled by user
|
||||
|
||||
An account sync happens every time an [entry.rb](mdc:app/models/account/entry.rb) is updated.
|
||||
An account sync happens every time an [entry.rb](mdc:app/models/entry.rb) is updated.
|
||||
|
||||
### Plaid Item Syncs
|
||||
|
||||
@@ -126,7 +126,7 @@ A Plaid Item sync is an ETL (extract, transform, load) operation:
|
||||
|
||||
1. [plaid_item.rb](mdc:app/models/plaid_item.rb) fetches data from the external Plaid API
|
||||
2. [plaid_item.rb](mdc:app/models/plaid_item.rb) creates and loads this data to [plaid_account.rb](mdc:app/models/plaid_account.rb) records
|
||||
3. [plaid_item.rb](mdc:app/models/plaid_item.rb) and [plaid_account.rb](mdc:app/models/plaid_account.rb) transform and load data to [account.rb](mdc:app/models/account.rb) and [entry.rb](mdc:app/models/account/entry.rb), the internal Maybe representations of the data.
|
||||
3. [plaid_item.rb](mdc:app/models/plaid_item.rb) and [plaid_account.rb](mdc:app/models/plaid_account.rb) transform and load data to [account.rb](mdc:app/models/account.rb) and [entry.rb](mdc:app/models/entry.rb), the internal Maybe representations of the data.
|
||||
|
||||
### Family Syncs
|
||||
|
||||
@@ -247,10 +247,3 @@ class ConcreteProvider < Provider
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ The codebase uses TailwindCSS v4.x (the newest version) with a custom design sys
|
||||
|
||||
- Always start by referencing [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) to see the base primitives, functional tokens, and component tokens we use in the codebase
|
||||
- Always prefer using the functional "tokens" defined in @maybe-design-system.css when possible.
|
||||
- Example 1: use `text-primary` rather than `text-primary`
|
||||
- Example 1: use `text-primary` rather than `text-white`
|
||||
- Example 2: use `bg-container` rather than `bg-white`
|
||||
- Example 3: use `border border-primary` rather than `border border-gray-200`
|
||||
- Never create new styles in [maybe-design-system.css](mdc:app/assets/tailwind/maybe-design-system.css) or [application.css](mdc:app/assets/tailwind/application.css) without explicitly receiving permission to do so
|
||||
- Always generate semantic HTML
|
||||
- Always generate semantic HTML
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
ARG RUBY_VERSION=3.4.1
|
||||
ARG RUBY_VERSION=3.4.4
|
||||
FROM ruby:${RUBY_VERSION}-slim-bullseye
|
||||
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
@@ -10,6 +10,8 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
imagemagick \
|
||||
iproute2 \
|
||||
libpq-dev \
|
||||
libyaml-dev \
|
||||
libyaml-0-2 \
|
||||
openssh-client \
|
||||
postgresql-client \
|
||||
vim
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -77,6 +77,8 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
|
||||
env:
|
||||
PLAID_CLIENT_ID: foo
|
||||
PLAID_SECRET: bar
|
||||
DATABASE_URL: postgres://postgres:postgres@localhost:5432
|
||||
RAILS_ENV: test
|
||||
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -68,4 +68,6 @@ coverage
|
||||
# Ignore node related files
|
||||
node_modules
|
||||
|
||||
compose.yml
|
||||
compose.yml
|
||||
|
||||
plaid_test_accounts/
|
||||
@@ -1 +1 @@
|
||||
3.4.1
|
||||
3.4.4
|
||||
|
||||
@@ -4,7 +4,7 @@ It means so much that you're interested in contributing to Maybe! Seriously. Tha
|
||||
|
||||
## House Rules
|
||||
|
||||
- Before contributing, familiarize yourself with our project conventions. You should read through our [Project Conventions Rule](https://github.com/maybe-finance/maybe/.cursor/rules/project-conventions.mdc), which is intended for LLMs, but is also an excellent primer on how we write code for Maybe.
|
||||
- Before contributing, familiarize yourself with our project conventions. You should read through our [Project Conventions Rule](https://github.com/maybe-finance/maybe/.cursor/rules/project-conventions.mdc), which is intended for LLMs, but is also an excellent primer on how we write code for Maybe.
|
||||
- While totally optional, consider using Cursor + VSCode as it will automatically apply our project conventions to your code via the `.cursor/rules` directory.
|
||||
- Before contributing, please check if it already exists in [issues](https://github.com/maybe-finance/maybe/issues) or [PRs](https://github.com/maybe-finance/maybe/pulls)
|
||||
- Given the speed at which we're moving on the codebase, we don't assign issues or "give" issues to anyone.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# syntax = docker/dockerfile:1
|
||||
|
||||
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile
|
||||
ARG RUBY_VERSION=3.4.1
|
||||
ARG RUBY_VERSION=3.4.4
|
||||
FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim AS base
|
||||
|
||||
# Rails app lives here
|
||||
@@ -9,7 +9,7 @@ WORKDIR /rails
|
||||
|
||||
# Install base packages
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install --no-install-recommends -y curl libvips postgresql-client
|
||||
apt-get install --no-install-recommends -y curl libvips postgresql-client libyaml-0-2
|
||||
|
||||
# Set production environment
|
||||
ARG BUILD_COMMIT_SHA
|
||||
@@ -23,7 +23,7 @@ ENV RAILS_ENV="production" \
|
||||
FROM base AS build
|
||||
|
||||
# Install packages needed to build gems
|
||||
RUN apt-get install --no-install-recommends -y build-essential libpq-dev git pkg-config
|
||||
RUN apt-get install --no-install-recommends -y build-essential libpq-dev git pkg-config libyaml-dev
|
||||
|
||||
# Install application gems
|
||||
COPY .ruby-version Gemfile Gemfile.lock ./
|
||||
|
||||
17
Gemfile
17
Gemfile
@@ -19,28 +19,32 @@ 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"
|
||||
|
||||
# Background Jobs
|
||||
gem "sidekiq"
|
||||
gem "sidekiq-cron"
|
||||
|
||||
# Error logging
|
||||
# Monitoring
|
||||
gem "vernier"
|
||||
gem "rack-mini-profiler"
|
||||
gem "sentry-ruby"
|
||||
gem "sentry-rails"
|
||||
gem "sentry-sidekiq"
|
||||
gem "logtail-rails"
|
||||
gem "skylight"
|
||||
|
||||
# Active Storage
|
||||
gem "aws-sdk-s3", "~> 1.177.0", require: false
|
||||
gem "image_processing", ">= 1.2"
|
||||
|
||||
# Other
|
||||
gem "ostruct"
|
||||
gem "bcrypt", "~> 3.1"
|
||||
gem "jwt"
|
||||
gem "faraday"
|
||||
@@ -57,9 +61,13 @@ gem "stripe"
|
||||
gem "intercom-rails"
|
||||
gem "plaid"
|
||||
gem "rotp", "~> 6.3"
|
||||
gem "rqrcode", "~> 2.2"
|
||||
gem "rqrcode", "~> 3.0"
|
||||
gem "activerecord-import"
|
||||
|
||||
# State machines
|
||||
gem "aasm"
|
||||
gem "after_commit_everywhere", "~> 1.0"
|
||||
|
||||
# AI
|
||||
gem "ruby-openai"
|
||||
|
||||
@@ -79,6 +87,7 @@ group :development do
|
||||
gem "web-console"
|
||||
gem "faker"
|
||||
gem "benchmark-ips"
|
||||
gem "foreman"
|
||||
end
|
||||
|
||||
group :test do
|
||||
|
||||
218
Gemfile.lock
218
Gemfile.lock
@@ -8,6 +8,8 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
aasm (5.5.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
actioncable (7.2.2.1)
|
||||
actionpack (= 7.2.2.1)
|
||||
activesupport (= 7.2.2.1)
|
||||
@@ -83,17 +85,20 @@ GEM
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
after_commit_everywhere (1.6.0)
|
||||
activerecord (>= 4.2)
|
||||
activesupport
|
||||
ast (2.4.3)
|
||||
aws-eventstream (1.3.2)
|
||||
aws-partitions (1.1073.0)
|
||||
aws-sdk-core (3.221.0)
|
||||
aws-partitions (1.1105.0)
|
||||
aws-sdk-core (3.224.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.99.0)
|
||||
aws-sdk-kms (1.101.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.177.0)
|
||||
@@ -115,7 +120,7 @@ GEM
|
||||
smart_properties
|
||||
bigdecimal (3.1.9)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.18.4)
|
||||
bootsnap (1.18.6)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.0.2)
|
||||
racc
|
||||
@@ -133,23 +138,29 @@ GEM
|
||||
logger (~> 1.5)
|
||||
chunky_png (1.4.0)
|
||||
climate_control (1.2.0)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.0)
|
||||
concurrent-ruby (1.3.4)
|
||||
connection_pool (2.5.3)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
csv (3.3.3)
|
||||
cronex (0.15.0)
|
||||
tzinfo
|
||||
unicode (>= 0.4.4.5)
|
||||
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 (5.0.1)
|
||||
erb_lint (0.9.0)
|
||||
activesupport
|
||||
better_html (>= 2.0.1)
|
||||
@@ -158,10 +169,12 @@ GEM
|
||||
rubocop (>= 1)
|
||||
smart_properties
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
event_stream_parser (1.0.0)
|
||||
faker (3.5.1)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.12.2)
|
||||
faraday (2.13.1)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
@@ -171,14 +184,18 @@ 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)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
hashdiff (1.1.2)
|
||||
@@ -193,6 +210,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)
|
||||
@@ -220,15 +239,15 @@ 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)
|
||||
jmespath (1.6.2)
|
||||
json (2.10.2)
|
||||
json (2.12.0)
|
||||
jwt (2.10.1)
|
||||
base64
|
||||
language_server-protocol (3.17.0.4)
|
||||
language_server-protocol (3.17.0.5)
|
||||
launchy (3.1.1)
|
||||
addressable (~> 2.8)
|
||||
childprocess (~> 5.0)
|
||||
@@ -251,9 +270,21 @@ GEM
|
||||
logtail (~> 0.1, >= 0.1.14)
|
||||
logtail-rack (~> 0.1)
|
||||
railties (>= 5.0.0)
|
||||
loofah (2.24.0)
|
||||
loofah (2.24.1)
|
||||
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
|
||||
@@ -261,6 +292,7 @@ GEM
|
||||
net-smtp
|
||||
marcel (1.0.4)
|
||||
matrix (0.4.2)
|
||||
method_source (1.1.0)
|
||||
mini_magick (5.2.0)
|
||||
benchmark
|
||||
logger
|
||||
@@ -272,7 +304,7 @@ GEM
|
||||
multipart-post (2.4.1)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-imap (0.5.6)
|
||||
net-imap (0.5.8)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -282,32 +314,33 @@ 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 (9.2.0)
|
||||
octokit (10.0.0)
|
||||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
ostruct (0.6.1)
|
||||
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)
|
||||
plaid (36.1.0)
|
||||
plaid (39.0.0)
|
||||
faraday (>= 1.0.1, < 3.0)
|
||||
faraday-multipart (>= 1.0.1, < 2.0)
|
||||
platform_agent (1.0.1)
|
||||
@@ -322,17 +355,18 @@ GEM
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
railties (>= 7.0.0)
|
||||
psych (5.2.3)
|
||||
psych (5.2.6)
|
||||
date
|
||||
stringio
|
||||
public_suffix (6.0.1)
|
||||
public_suffix (6.0.2)
|
||||
puma (6.6.0)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.1.12)
|
||||
rack (3.1.15)
|
||||
rack-mini-profiler (3.3.1)
|
||||
rack (>= 1.2.0)
|
||||
rack-session (2.1.0)
|
||||
rack-session (2.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.2.0)
|
||||
@@ -379,9 +413,10 @@ GEM
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.11.1)
|
||||
ffi (~> 1.0)
|
||||
rbs (3.9.1)
|
||||
rbs (3.9.4)
|
||||
logger
|
||||
rdoc (6.13.0)
|
||||
rdoc (6.14.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
redcarpet (3.6.1)
|
||||
redis (5.4.0)
|
||||
@@ -389,15 +424,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 (2.2.0)
|
||||
rouge (4.5.2)
|
||||
rqrcode (3.1.0)
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 1.0)
|
||||
rqrcode_core (1.2.0)
|
||||
rubocop (1.74.0)
|
||||
rqrcode_core (~> 2.0)
|
||||
rqrcode_core (2.0.0)
|
||||
rubocop (1.75.6)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
@@ -405,32 +441,33 @@ 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.32.0)
|
||||
activesupport (>= 4.2.0)
|
||||
lint_roller (~> 1.1)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 1.72.1, < 2.0)
|
||||
rubocop-ast (>= 1.38.0, < 2.0)
|
||||
rubocop (>= 1.75.0, < 2.0)
|
||||
rubocop-ast (>= 1.44.0, < 2.0)
|
||||
rubocop-rails-omakase (1.1.0)
|
||||
rubocop (>= 1.72)
|
||||
rubocop-performance (>= 1.24)
|
||||
rubocop-rails (>= 2.30)
|
||||
ruby-lsp (0.23.12)
|
||||
ruby-lsp (0.23.20)
|
||||
language_server-protocol (~> 3.17.0)
|
||||
prism (>= 1.2, < 2.0)
|
||||
rbs (>= 3, < 4)
|
||||
sorbet-runtime (>= 0.5.10782)
|
||||
ruby-lsp-rails (0.4.0)
|
||||
ruby-lsp (>= 0.23.0, < 0.24.0)
|
||||
ruby-lsp-rails (0.4.3)
|
||||
ruby-lsp (>= 0.23.18, < 0.24.0)
|
||||
ruby-openai (8.1.0)
|
||||
event_stream_parser (>= 0.3.0, < 2.0.0)
|
||||
faraday (>= 1)
|
||||
@@ -445,49 +482,56 @@ GEM
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.31.0)
|
||||
selenium-webdriver (4.32.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 3.0)
|
||||
websocket (~> 1.0)
|
||||
sentry-rails (5.23.0)
|
||||
sentry-rails (5.24.0)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.23.0)
|
||||
sentry-ruby (5.23.0)
|
||||
sentry-ruby (~> 5.24.0)
|
||||
sentry-ruby (5.24.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
sentry-sidekiq (5.23.0)
|
||||
sentry-ruby (~> 5.23.0)
|
||||
sentry-sidekiq (5.24.0)
|
||||
sentry-ruby (~> 5.24.0)
|
||||
sidekiq (>= 3.0)
|
||||
sidekiq (8.0.2)
|
||||
sidekiq (8.0.3)
|
||||
connection_pool (>= 2.5.0)
|
||||
json (>= 2.9.0)
|
||||
logger (>= 1.6.2)
|
||||
rack (>= 3.1.0)
|
||||
redis-client (>= 0.23.2)
|
||||
sidekiq-cron (2.3.0)
|
||||
cronex (>= 0.13.0)
|
||||
fugit (~> 1.8, >= 1.11.1)
|
||||
globalid (>= 1.0.1)
|
||||
sidekiq (>= 6.5.0)
|
||||
simplecov (0.22.0)
|
||||
docile (~> 1.1)
|
||||
simplecov-html (~> 0.11)
|
||||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.13.1)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
skylight (6.0.4)
|
||||
activesupport (>= 5.2.0)
|
||||
smart_properties (1.17.0)
|
||||
sorbet-runtime (0.5.11953)
|
||||
sorbet-runtime (0.5.12117)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.5)
|
||||
stripe (14.0.0)
|
||||
tailwindcss-rails (4.2.1)
|
||||
stringio (3.1.7)
|
||||
stripe (15.1.0)
|
||||
tailwindcss-rails (4.2.3)
|
||||
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.7)
|
||||
tailwindcss-ruby (4.1.7-aarch64-linux-gnu)
|
||||
tailwindcss-ruby (4.1.7-aarch64-linux-musl)
|
||||
tailwindcss-ruby (4.1.7-arm64-darwin)
|
||||
tailwindcss-ruby (4.1.7-x86_64-darwin)
|
||||
tailwindcss-ruby (4.1.7-x86_64-linux-gnu)
|
||||
tailwindcss-ruby (4.1.7-x86_64-linux-musl)
|
||||
terminal-table (4.0.0)
|
||||
unicode-display_width (>= 1.1.1, < 4)
|
||||
thor (1.3.2)
|
||||
@@ -497,6 +541,7 @@ GEM
|
||||
railties (>= 7.1.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unicode (0.4.4.5)
|
||||
unicode-display_width (3.1.4)
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
@@ -504,7 +549,11 @@ GEM
|
||||
useragent (0.16.11)
|
||||
vcr (6.3.1)
|
||||
base64
|
||||
vernier (1.7.0)
|
||||
vernier (1.7.1)
|
||||
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)
|
||||
@@ -521,7 +570,8 @@ GEM
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.7.2)
|
||||
yard (0.9.37)
|
||||
zeitwerk (2.7.3)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux-gnu
|
||||
@@ -534,7 +584,9 @@ PLATFORMS
|
||||
x86_64-linux-musl
|
||||
|
||||
DEPENDENCIES
|
||||
aasm
|
||||
activerecord-import
|
||||
after_commit_everywhere (~> 1.0)
|
||||
aws-sdk-s3 (~> 1.177.0)
|
||||
bcrypt (~> 3.1)
|
||||
benchmark-ips
|
||||
@@ -550,6 +602,7 @@ DEPENDENCIES
|
||||
faraday
|
||||
faraday-multipart
|
||||
faraday-retry
|
||||
foreman
|
||||
hotwire-livereload
|
||||
hotwire_combobox
|
||||
i18n-tasks
|
||||
@@ -560,9 +613,11 @@ DEPENDENCIES
|
||||
jwt
|
||||
letter_opener
|
||||
logtail-rails
|
||||
lookbook (>= 2.3.7)
|
||||
lucide-rails!
|
||||
mocha
|
||||
octokit
|
||||
ostruct
|
||||
pagy
|
||||
pg (~> 1.5)
|
||||
plaid
|
||||
@@ -574,7 +629,7 @@ DEPENDENCIES
|
||||
redcarpet
|
||||
redis (~> 5.4)
|
||||
rotp (~> 6.3)
|
||||
rqrcode (~> 2.2)
|
||||
rqrcode (~> 3.0)
|
||||
rubocop-rails-omakase
|
||||
ruby-lsp-rails
|
||||
ruby-openai
|
||||
@@ -583,7 +638,9 @@ DEPENDENCIES
|
||||
sentry-ruby
|
||||
sentry-sidekiq
|
||||
sidekiq
|
||||
sidekiq-cron
|
||||
simplecov
|
||||
skylight
|
||||
stimulus-rails
|
||||
stripe
|
||||
tailwindcss-rails
|
||||
@@ -591,11 +648,12 @@ DEPENDENCIES
|
||||
tzinfo-data
|
||||
vcr
|
||||
vernier
|
||||
view_component
|
||||
web-console
|
||||
webmock
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.4.1p0
|
||||
ruby 3.4.4p34
|
||||
|
||||
BUNDLED WITH
|
||||
2.6.3
|
||||
2.6.9
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<img width="1440" alt="dashboard_mockup" src="https://github.com/maybe-finance/maybe/assets/35243/a7763d0e-a942-42db-bde7-eb8d28106917">
|
||||
<sup><i>(Note: The image above is a mockup of what we're working towards. We're rapidly approaching the functionality shown, but not all of the parts are ready just yet.)</i></sup>
|
||||
|
||||
# Maybe: The OS for your personal finances
|
||||
<img width="1190" alt="maybe_hero" src="https://github.com/user-attachments/assets/13fc5ef4-ce0f-4073-a163-9dbc3eb4c8e5" />
|
||||
|
||||
# Maybe: The personal finance app for everyone
|
||||
|
||||
<b>Get
|
||||
involved: [Discord](https://link.maybe.co/discord) • [Website](https://maybefinance.com) • [Issues](https://github.com/maybe-finance/maybe/issues)</b>
|
||||
@@ -24,7 +24,7 @@ and eventually offer a hosted version of the app for a small monthly fee.
|
||||
|
||||
## Maybe Hosting
|
||||
|
||||
There are 3 primary ways to use the Maybe app:
|
||||
There are 2 primary ways to use the Maybe app:
|
||||
|
||||
1. Managed (easiest) - we're in alpha and release invites in our Discord
|
||||
2. [Self-host with Docker](docs/hosting/docker.md)
|
||||
|
||||
71
app/assets/images/ai-dark.svg
Normal file
71
app/assets/images/ai-dark.svg
Normal file
@@ -0,0 +1,71 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||
<g filter="url(#filter0_i_2942_1229)">
|
||||
<rect width="32" height="32" rx="10" fill="url(#paint0_linear_2942_1229)"/>
|
||||
<rect width="32" height="32" rx="10" fill="white" fill-opacity="0.07" style="mix-blend-mode:plus-lighter"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_ii_2942_1229)">
|
||||
<rect x="1.75" y="1.75" width="28.5" height="28.5" rx="8" fill="url(#paint1_linear_2942_1229)"/>
|
||||
</g>
|
||||
<path d="M21.524 8.8489C21.9357 8.84115 22.2757 9.16862 22.2835 9.58033C22.3104 11.0111 22.3592 12.4355 22.4277 13.8639C22.4474 14.2752 22.1299 14.6246 21.7186 14.6443C21.3073 14.664 20.9579 14.3466 20.9382 13.9353C20.8691 12.4933 20.8198 11.0544 20.7926 9.60841C20.7848 9.1967 21.1123 8.85665 21.524 8.8489Z" fill="#141414"/>
|
||||
<path d="M21.524 8.8489C21.9357 8.84115 22.2757 9.16862 22.2835 9.58033C22.3104 11.0111 22.3592 12.4355 22.4277 13.8639C22.4474 14.2752 22.1299 14.6246 21.7186 14.6443C21.3073 14.664 20.9579 14.3466 20.9382 13.9353C20.8691 12.4933 20.8198 11.0544 20.7926 9.60841C20.7848 9.1967 21.1123 8.85665 21.524 8.8489Z" fill="url(#paint2_linear_2942_1229)"/>
|
||||
<path d="M15.4203 9.51724C15.6565 9.17999 15.5747 8.71506 15.2375 8.47877C14.9002 8.24249 14.4353 8.32434 14.199 8.66158C13.5874 9.53452 12.935 10.3942 12.2667 11.2747C12.0113 11.6113 11.7536 11.951 11.4949 12.2955C11.4948 12.2256 11.4945 12.1549 11.494 12.0833C11.4899 11.4759 11.4682 10.827 11.3511 10.1757C11.2781 9.77047 10.8905 9.50105 10.4852 9.57398C10.0799 9.64691 9.8105 10.0346 9.88343 10.4398C9.97721 10.9609 9.99884 11.5056 10.0029 12.0935C10.0039 12.2387 10.0037 12.3886 10.0036 12.5417C10.0032 12.9877 10.0028 13.4604 10.0303 13.919C10.0365 14.0218 10.0632 14.1185 10.1062 14.2053C9.39275 15.2317 8.72212 16.2913 8.16155 17.3909C8.15394 17.4058 8.14271 17.427 8.12867 17.4534C8.04301 17.6148 7.85275 17.9733 7.74077 18.3186C7.67701 18.5151 7.60594 18.7988 7.63944 19.0917C7.65733 19.2481 7.70803 19.4336 7.8297 19.6082C7.95607 19.7894 8.13073 19.9179 8.32815 19.9925C9.5403 20.4508 10.8812 20.4975 12.1498 20.4009C13.1799 20.3225 14.2197 20.1434 15.1461 19.9837C15.358 19.9472 15.5639 19.9117 15.7625 19.8787C16.1687 19.8111 16.4432 19.4271 16.3756 19.0209C16.3081 18.6147 15.924 18.3402 15.5178 18.4077C15.3021 18.4436 15.0842 18.4811 14.8647 18.5189C13.9419 18.6776 12.9891 18.8415 12.0367 18.914C10.998 18.9931 10.0276 18.956 9.18383 18.7076C9.25117 18.5246 9.34702 18.3419 9.42851 18.1866C9.45032 18.145 9.47115 18.1053 9.49008 18.0682C10.2409 16.5954 11.2184 15.1729 12.268 13.7535C12.6447 13.2441 13.035 12.7296 13.4266 12.2134C14.1092 11.3136 14.7956 10.4088 15.4203 9.51724Z" fill="#141414"/>
|
||||
<path d="M15.4203 9.51724C15.6565 9.17999 15.5747 8.71506 15.2375 8.47877C14.9002 8.24249 14.4353 8.32434 14.199 8.66158C13.5874 9.53452 12.935 10.3942 12.2667 11.2747C12.0113 11.6113 11.7536 11.951 11.4949 12.2955C11.4948 12.2256 11.4945 12.1549 11.494 12.0833C11.4899 11.4759 11.4682 10.827 11.3511 10.1757C11.2781 9.77047 10.8905 9.50105 10.4852 9.57398C10.0799 9.64691 9.8105 10.0346 9.88343 10.4398C9.97721 10.9609 9.99884 11.5056 10.0029 12.0935C10.0039 12.2387 10.0037 12.3886 10.0036 12.5417C10.0032 12.9877 10.0028 13.4604 10.0303 13.919C10.0365 14.0218 10.0632 14.1185 10.1062 14.2053C9.39275 15.2317 8.72212 16.2913 8.16155 17.3909C8.15394 17.4058 8.14271 17.427 8.12867 17.4534C8.04301 17.6148 7.85275 17.9733 7.74077 18.3186C7.67701 18.5151 7.60594 18.7988 7.63944 19.0917C7.65733 19.2481 7.70803 19.4336 7.8297 19.6082C7.95607 19.7894 8.13073 19.9179 8.32815 19.9925C9.5403 20.4508 10.8812 20.4975 12.1498 20.4009C13.1799 20.3225 14.2197 20.1434 15.1461 19.9837C15.358 19.9472 15.5639 19.9117 15.7625 19.8787C16.1687 19.8111 16.4432 19.4271 16.3756 19.0209C16.3081 18.6147 15.924 18.3402 15.5178 18.4077C15.3021 18.4436 15.0842 18.4811 14.8647 18.5189C13.9419 18.6776 12.9891 18.8415 12.0367 18.914C10.998 18.9931 10.0276 18.956 9.18383 18.7076C9.25117 18.5246 9.34702 18.3419 9.42851 18.1866C9.45032 18.145 9.47115 18.1053 9.49008 18.0682C10.2409 16.5954 11.2184 15.1729 12.268 13.7535C12.6447 13.2441 13.035 12.7296 13.4266 12.2134C14.1092 11.3136 14.7956 10.4088 15.4203 9.51724Z" fill="url(#paint3_linear_2942_1229)"/>
|
||||
<path d="M21.5709 21.534C21.6983 21.1424 21.484 20.7217 21.0924 20.5944C20.7008 20.467 20.2801 20.6813 20.1528 21.0729C19.9756 21.6179 19.7424 22.0319 19.4518 22.3169C19.1738 22.5896 18.8105 22.7774 18.2938 22.8258C17.5241 22.898 16.7434 22.5737 16.4029 22.0103C16.1898 21.6579 15.7315 21.545 15.3791 21.758C15.0267 21.971 14.9137 22.4294 15.1267 22.7818C15.8398 23.9614 17.2577 24.4207 18.4329 24.3105C19.2796 24.2311 19.9656 23.9017 20.496 23.3815C21.0138 22.8737 21.3481 22.2193 21.5709 21.534Z" fill="#141414"/>
|
||||
<path d="M21.5709 21.534C21.6983 21.1424 21.484 20.7217 21.0924 20.5944C20.7008 20.467 20.2801 20.6813 20.1528 21.0729C19.9756 21.6179 19.7424 22.0319 19.4518 22.3169C19.1738 22.5896 18.8105 22.7774 18.2938 22.8258C17.5241 22.898 16.7434 22.5737 16.4029 22.0103C16.1898 21.6579 15.7315 21.545 15.3791 21.758C15.0267 21.971 14.9137 22.4294 15.1267 22.7818C15.8398 23.9614 17.2577 24.4207 18.4329 24.3105C19.2796 24.2311 19.9656 23.9017 20.496 23.3815C21.0138 22.8737 21.3481 22.2193 21.5709 21.534Z" fill="url(#paint4_linear_2942_1229)"/>
|
||||
<defs>
|
||||
<filter id="filter0_i_2942_1229" x="0" y="0" width="32" height="32" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.49869"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_2942_1229"/>
|
||||
</filter>
|
||||
<filter id="filter1_ii_2942_1229" x="1.75" y="0.75" width="28.5" height="30.5" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-1"/>
|
||||
<feGaussianBlur stdDeviation="1"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.980392 0 0 0 0 0.309804 0 0 0 0 0.67451 0 0 0 0.2 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_2942_1229"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="1"/>
|
||||
<feGaussianBlur stdDeviation="1"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.2 0 0 0 0 0.835294 0 0 0 0 1 0 0 0 0.15 0"/>
|
||||
<feBlend mode="normal" in2="effect1_innerShadow_2942_1229" result="effect2_innerShadow_2942_1229"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_2942_1229" x1="16" y1="0" x2="16" y2="32" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_2942_1229" x1="16" y1="10.6562" x2="16" y2="30.25" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#171717"/>
|
||||
<stop offset="0.3" stop-color="#0B0B0B"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_2942_1229" x1="17.8739" y1="7.84186" x2="13.7328" y2="23.2967" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_2942_1229" x1="17.8739" y1="7.84186" x2="13.7328" y2="23.2967" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint4_linear_2942_1229" x1="17.8739" y1="7.84186" x2="13.7328" y2="23.2967" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.6 KiB |
@@ -1,85 +1,71 @@
|
||||
<svg width="62" height="68" viewBox="0 0 62 68" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_f_7620_90382)">
|
||||
<path d="M15.0109 27.3668C14.8138 11.2848 17.2087 15.4884 28.5797 15.5133L32.8675 15.5228C44.2383 15.5478 46.7179 11.3549 46.9149 27.4368L46.9891 33.5015C47.1861 49.5834 44.7913 53.0249 33.4205 52.9999L29.1325 52.9906C17.7617 52.9656 15.2823 49.5134 15.0852 33.4315L15.0109 27.3668Z" fill="url(#paint0_linear_7620_90382)" fill-opacity="0.15"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_i_7620_90382)">
|
||||
<rect x="15" y="13" width="32" height="32" rx="10.6667" fill="url(#paint1_linear_7620_90382)"/>
|
||||
<rect x="15" y="13" width="32" height="32" rx="10.6667" fill="white" fill-opacity="0.07" style="mix-blend-mode:plus-lighter"/>
|
||||
</g>
|
||||
<g filter="url(#filter2_ii_7620_90382)">
|
||||
<rect x="16.7773" y="14.7778" width="28.4444" height="28.4444" rx="8.88889" fill="url(#paint2_linear_7620_90382)"/>
|
||||
<path d="M36.1921 22.073C36.6039 22.0652 36.9439 22.3927 36.9517 22.8044C36.9786 24.2352 37.0273 25.6596 37.0958 27.088C37.1155 27.4993 36.7981 27.8487 36.3868 27.8684C35.9755 27.8881 35.6261 27.5707 35.6063 27.1594C35.5372 25.7174 35.488 24.2785 35.4607 22.8325C35.453 22.4208 35.7804 22.0807 36.1921 22.073Z" fill="#141414"/>
|
||||
<path d="M36.1921 22.073C36.6039 22.0652 36.9439 22.3927 36.9517 22.8044C36.9786 24.2352 37.0273 25.6596 37.0958 27.088C37.1155 27.4993 36.7981 27.8487 36.3868 27.8684C35.9755 27.8881 35.6261 27.5707 35.6063 27.1594C35.5372 25.7174 35.488 24.2785 35.4607 22.8325C35.453 22.4208 35.7804 22.0807 36.1921 22.073Z" fill="url(#paint3_linear_7620_90382)"/>
|
||||
<path d="M30.0884 22.7413C30.3247 22.4041 30.2428 21.9392 29.9056 21.7029C29.5684 21.4666 29.1034 21.5484 28.8671 21.8857C28.2555 22.7586 27.6031 23.6183 26.9349 24.4988C26.6795 24.8354 26.4217 25.1751 26.1631 25.5196C26.1629 25.4497 26.1627 25.379 26.1622 25.3074C26.158 24.7 26.1364 24.0511 26.0192 23.3998C25.9463 22.9946 25.5586 22.7251 25.1533 22.7981C24.7481 22.871 24.4787 23.2587 24.5516 23.6639C24.6454 24.185 24.667 24.7297 24.671 25.3176C24.672 25.4628 24.6719 25.6127 24.6718 25.7658C24.6714 26.2118 24.6709 26.6845 24.6985 27.1431C24.7046 27.2459 24.7313 27.3426 24.7744 27.4294C24.0609 28.4557 23.3903 29.5154 22.8297 30.615C22.8221 30.6299 22.8109 30.6511 22.7968 30.6775C22.7112 30.8389 22.5209 31.1974 22.4089 31.5427C22.3452 31.7392 22.2741 32.0229 22.3076 32.3158C22.3255 32.4722 22.3762 32.6577 22.4979 32.8323C22.6242 33.0135 22.7989 33.142 22.9963 33.2166C24.2085 33.6749 25.5494 33.7216 26.818 33.625C27.848 33.5466 28.8878 33.3675 29.8142 33.2078C30.0261 33.1713 30.2321 33.1358 30.4306 33.1028C30.8368 33.0352 31.1113 32.6512 31.0438 32.245C30.9762 31.8388 30.5921 31.5643 30.1859 31.6318C29.9702 31.6677 29.7524 31.7052 29.5328 31.743C28.6101 31.9017 27.6572 32.0656 26.7048 32.1381C25.6662 32.2172 24.6958 32.1801 23.852 31.9317C23.9193 31.7487 24.0152 31.566 24.0967 31.4107C24.1185 31.3691 24.1393 31.3294 24.1582 31.2923C24.909 29.8195 25.8865 28.397 26.9361 26.9776C27.3129 26.4681 27.7032 25.9536 28.0947 25.4375C28.7774 24.5377 29.4637 23.6329 30.0884 22.7413Z" fill="#141414"/>
|
||||
<path d="M30.0884 22.7413C30.3247 22.4041 30.2428 21.9392 29.9056 21.7029C29.5684 21.4666 29.1034 21.5484 28.8671 21.8857C28.2555 22.7586 27.6031 23.6183 26.9349 24.4988C26.6795 24.8354 26.4217 25.1751 26.1631 25.5196C26.1629 25.4497 26.1627 25.379 26.1622 25.3074C26.158 24.7 26.1364 24.0511 26.0192 23.3998C25.9463 22.9946 25.5586 22.7251 25.1533 22.7981C24.7481 22.871 24.4787 23.2587 24.5516 23.6639C24.6454 24.185 24.667 24.7297 24.671 25.3176C24.672 25.4628 24.6719 25.6127 24.6718 25.7658C24.6714 26.2118 24.6709 26.6845 24.6985 27.1431C24.7046 27.2459 24.7313 27.3426 24.7744 27.4294C24.0609 28.4557 23.3903 29.5154 22.8297 30.615C22.8221 30.6299 22.8109 30.6511 22.7968 30.6775C22.7112 30.8389 22.5209 31.1974 22.4089 31.5427C22.3452 31.7392 22.2741 32.0229 22.3076 32.3158C22.3255 32.4722 22.3762 32.6577 22.4979 32.8323C22.6242 33.0135 22.7989 33.142 22.9963 33.2166C24.2085 33.6749 25.5494 33.7216 26.818 33.625C27.848 33.5466 28.8878 33.3675 29.8142 33.2078C30.0261 33.1713 30.2321 33.1358 30.4306 33.1028C30.8368 33.0352 31.1113 32.6512 31.0438 32.245C30.9762 31.8388 30.5921 31.5643 30.1859 31.6318C29.9702 31.6677 29.7524 31.7052 29.5328 31.743C28.6101 31.9017 27.6572 32.0656 26.7048 32.1381C25.6662 32.2172 24.6958 32.1801 23.852 31.9317C23.9193 31.7487 24.0152 31.566 24.0967 31.4107C24.1185 31.3691 24.1393 31.3294 24.1582 31.2923C24.909 29.8195 25.8865 28.397 26.9361 26.9776C27.3129 26.4681 27.7032 25.9536 28.0947 25.4375C28.7774 24.5377 29.4637 23.6329 30.0884 22.7413Z" fill="url(#paint4_linear_7620_90382)"/>
|
||||
<path d="M36.2391 34.7581C36.3664 34.3664 36.1522 33.9458 35.7606 33.8185C35.369 33.6911 34.9483 33.9054 34.821 34.297C34.6438 34.842 34.4106 35.256 34.12 35.541C33.8419 35.8137 33.4787 36.0015 32.9619 36.0499C32.1922 36.1221 31.4116 35.7978 31.071 35.2344C30.858 34.882 30.3996 34.7691 30.0472 34.9821C29.6948 35.1951 29.5818 35.6535 29.7949 36.0059C30.5079 37.1855 31.9259 37.6448 33.1011 37.5346C33.9477 37.4552 34.6338 37.1258 35.1641 36.6056C35.6819 36.0978 36.0163 35.4433 36.2391 34.7581Z" fill="#141414"/>
|
||||
<path d="M36.2391 34.7581C36.3664 34.3664 36.1522 33.9458 35.7606 33.8185C35.369 33.6911 34.9483 33.9054 34.821 34.297C34.6438 34.842 34.4106 35.256 34.12 35.541C33.8419 35.8137 33.4787 36.0015 32.9619 36.0499C32.1922 36.1221 31.4116 35.7978 31.071 35.2344C30.858 34.882 30.3996 34.7691 30.0472 34.9821C29.6948 35.1951 29.5818 35.6535 29.7949 36.0059C30.5079 37.1855 31.9259 37.6448 33.1011 37.5346C33.9477 37.4552 34.6338 37.1258 35.1641 36.6056C35.6819 36.0978 36.0163 35.4433 36.2391 34.7581Z" fill="url(#paint5_linear_7620_90382)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_7620_90382" x="0.937778" y="0.937778" width="60.1244" height="66.1244" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="7.03111" result="effect1_foregroundBlur_7620_90382"/>
|
||||
</filter>
|
||||
<filter id="filter1_i_7620_90382" x="15" y="13" width="32" height="32" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.49869"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_7620_90382"/>
|
||||
</filter>
|
||||
<filter id="filter2_ii_7620_90382" x="16.7773" y="13.8889" width="28.4453" height="30.2222" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-0.888889"/>
|
||||
<feGaussianBlur stdDeviation="0.888889"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.980392 0 0 0 0 0.309804 0 0 0 0 0.67451 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_7620_90382"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.888889"/>
|
||||
<feGaussianBlur stdDeviation="0.888889"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.2 0 0 0 0 0.835294 0 0 0 0 1 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="effect1_innerShadow_7620_90382" result="effect2_innerShadow_7620_90382"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_7620_90382" x1="30.1185" y1="16.1417" x2="33.7041" y2="53.1754" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_7620_90382" x1="31" y1="13" x2="31" y2="45" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_7620_90382" x1="30.9996" y1="23.6667" x2="30.9996" y2="43.2222" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="0.3" stop-color="#F7F7F7"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_7620_90382" x1="32.5419" y1="21.0645" x2="28.4008" y2="36.5193" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint4_linear_7620_90382" x1="32.5419" y1="21.0645" x2="28.4008" y2="36.5193" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint5_linear_7620_90382" x1="32.5419" y1="21.0645" x2="28.4008" y2="36.5193" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||
<g filter="url(#filter0_i_2942_1218)">
|
||||
<rect width="32" height="32" rx="10" fill="url(#paint0_linear_2942_1218)"/>
|
||||
<rect width="32" height="32" rx="10" fill="white" fill-opacity="0.07" style="mix-blend-mode:plus-lighter"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_ii_2942_1218)">
|
||||
<rect x="1.75" y="1.75" width="28.5" height="28.5" rx="8" fill="url(#paint1_linear_2942_1218)"/>
|
||||
</g>
|
||||
<path d="M21.524 8.8489C21.9357 8.84115 22.2757 9.16862 22.2835 9.58033C22.3104 11.0111 22.3592 12.4355 22.4277 13.8639C22.4474 14.2752 22.1299 14.6246 21.7186 14.6443C21.3073 14.664 20.9579 14.3466 20.9382 13.9353C20.8691 12.4933 20.8198 11.0544 20.7926 9.60841C20.7848 9.1967 21.1123 8.85665 21.524 8.8489Z" fill="#141414"/>
|
||||
<path d="M21.524 8.8489C21.9357 8.84115 22.2757 9.16862 22.2835 9.58033C22.3104 11.0111 22.3592 12.4355 22.4277 13.8639C22.4474 14.2752 22.1299 14.6246 21.7186 14.6443C21.3073 14.664 20.9579 14.3466 20.9382 13.9353C20.8691 12.4933 20.8198 11.0544 20.7926 9.60841C20.7848 9.1967 21.1123 8.85665 21.524 8.8489Z" fill="url(#paint2_linear_2942_1218)"/>
|
||||
<path d="M15.4203 9.51724C15.6565 9.17999 15.5747 8.71506 15.2375 8.47877C14.9002 8.24249 14.4353 8.32434 14.199 8.66158C13.5874 9.53452 12.935 10.3942 12.2667 11.2747C12.0113 11.6113 11.7536 11.951 11.4949 12.2955C11.4948 12.2256 11.4945 12.1549 11.494 12.0833C11.4899 11.4759 11.4682 10.827 11.3511 10.1757C11.2781 9.77047 10.8905 9.50105 10.4852 9.57398C10.0799 9.64691 9.8105 10.0346 9.88343 10.4398C9.97721 10.9609 9.99884 11.5056 10.0029 12.0935C10.0039 12.2387 10.0037 12.3886 10.0036 12.5417C10.0032 12.9877 10.0028 13.4604 10.0303 13.919C10.0365 14.0218 10.0632 14.1185 10.1062 14.2053C9.39275 15.2317 8.72212 16.2913 8.16155 17.3909C8.15394 17.4058 8.14271 17.427 8.12867 17.4534C8.04301 17.6148 7.85275 17.9733 7.74077 18.3186C7.67701 18.5151 7.60594 18.7988 7.63944 19.0917C7.65733 19.2481 7.70803 19.4336 7.8297 19.6082C7.95607 19.7894 8.13073 19.9179 8.32815 19.9925C9.5403 20.4508 10.8812 20.4975 12.1498 20.4009C13.1799 20.3225 14.2197 20.1434 15.1461 19.9837C15.358 19.9472 15.5639 19.9117 15.7625 19.8787C16.1687 19.8111 16.4432 19.4271 16.3756 19.0209C16.3081 18.6147 15.924 18.3402 15.5178 18.4077C15.3021 18.4436 15.0842 18.4811 14.8647 18.5189C13.9419 18.6776 12.9891 18.8415 12.0367 18.914C10.998 18.9931 10.0276 18.956 9.18383 18.7076C9.25117 18.5246 9.34702 18.3419 9.42851 18.1866C9.45032 18.145 9.47115 18.1053 9.49008 18.0682C10.2409 16.5954 11.2184 15.1729 12.268 13.7535C12.6447 13.2441 13.035 12.7296 13.4266 12.2134C14.1092 11.3136 14.7956 10.4088 15.4203 9.51724Z" fill="#141414"/>
|
||||
<path d="M15.4203 9.51724C15.6565 9.17999 15.5747 8.71506 15.2375 8.47877C14.9002 8.24249 14.4353 8.32434 14.199 8.66158C13.5874 9.53452 12.935 10.3942 12.2667 11.2747C12.0113 11.6113 11.7536 11.951 11.4949 12.2955C11.4948 12.2256 11.4945 12.1549 11.494 12.0833C11.4899 11.4759 11.4682 10.827 11.3511 10.1757C11.2781 9.77047 10.8905 9.50105 10.4852 9.57398C10.0799 9.64691 9.8105 10.0346 9.88343 10.4398C9.97721 10.9609 9.99884 11.5056 10.0029 12.0935C10.0039 12.2387 10.0037 12.3886 10.0036 12.5417C10.0032 12.9877 10.0028 13.4604 10.0303 13.919C10.0365 14.0218 10.0632 14.1185 10.1062 14.2053C9.39275 15.2317 8.72212 16.2913 8.16155 17.3909C8.15394 17.4058 8.14271 17.427 8.12867 17.4534C8.04301 17.6148 7.85275 17.9733 7.74077 18.3186C7.67701 18.5151 7.60594 18.7988 7.63944 19.0917C7.65733 19.2481 7.70803 19.4336 7.8297 19.6082C7.95607 19.7894 8.13073 19.9179 8.32815 19.9925C9.5403 20.4508 10.8812 20.4975 12.1498 20.4009C13.1799 20.3225 14.2197 20.1434 15.1461 19.9837C15.358 19.9472 15.5639 19.9117 15.7625 19.8787C16.1687 19.8111 16.4432 19.4271 16.3756 19.0209C16.3081 18.6147 15.924 18.3402 15.5178 18.4077C15.3021 18.4436 15.0842 18.4811 14.8647 18.5189C13.9419 18.6776 12.9891 18.8415 12.0367 18.914C10.998 18.9931 10.0276 18.956 9.18383 18.7076C9.25117 18.5246 9.34702 18.3419 9.42851 18.1866C9.45032 18.145 9.47115 18.1053 9.49008 18.0682C10.2409 16.5954 11.2184 15.1729 12.268 13.7535C12.6447 13.2441 13.035 12.7296 13.4266 12.2134C14.1092 11.3136 14.7956 10.4088 15.4203 9.51724Z" fill="url(#paint3_linear_2942_1218)"/>
|
||||
<path d="M21.5709 21.534C21.6983 21.1424 21.484 20.7217 21.0924 20.5944C20.7008 20.467 20.2801 20.6813 20.1528 21.0729C19.9756 21.6179 19.7424 22.0319 19.4518 22.3169C19.1738 22.5896 18.8105 22.7774 18.2938 22.8258C17.5241 22.898 16.7434 22.5737 16.4029 22.0103C16.1898 21.6579 15.7315 21.545 15.3791 21.758C15.0267 21.971 14.9137 22.4294 15.1267 22.7818C15.8398 23.9614 17.2577 24.4207 18.4329 24.3105C19.2796 24.2311 19.9656 23.9017 20.496 23.3815C21.0138 22.8737 21.3481 22.2193 21.5709 21.534Z" fill="#141414"/>
|
||||
<path d="M21.5709 21.534C21.6983 21.1424 21.484 20.7217 21.0924 20.5944C20.7008 20.467 20.2801 20.6813 20.1528 21.0729C19.9756 21.6179 19.7424 22.0319 19.4518 22.3169C19.1738 22.5896 18.8105 22.7774 18.2938 22.8258C17.5241 22.898 16.7434 22.5737 16.4029 22.0103C16.1898 21.6579 15.7315 21.545 15.3791 21.758C15.0267 21.971 14.9137 22.4294 15.1267 22.7818C15.8398 23.9614 17.2577 24.4207 18.4329 24.3105C19.2796 24.2311 19.9656 23.9017 20.496 23.3815C21.0138 22.8737 21.3481 22.2193 21.5709 21.534Z" fill="url(#paint4_linear_2942_1218)"/>
|
||||
<defs>
|
||||
<filter id="filter0_i_2942_1218" x="0" y="0" width="32" height="32" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.49869"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_2942_1218"/>
|
||||
</filter>
|
||||
<filter id="filter1_ii_2942_1218" x="1.75" y="0.861111" width="28.5" height="30.2778" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="-0.888889"/>
|
||||
<feGaussianBlur stdDeviation="0.888889"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.980392 0 0 0 0 0.309804 0 0 0 0 0.67451 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_2942_1218"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.888889"/>
|
||||
<feGaussianBlur stdDeviation="0.888889"/>
|
||||
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.2 0 0 0 0 0.835294 0 0 0 0 1 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="effect1_innerShadow_2942_1218" result="effect2_innerShadow_2942_1218"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_2942_1218" x1="16" y1="0" x2="16" y2="32" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_2942_1218" x1="16" y1="10.6562" x2="16" y2="30.25" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="0.3" stop-color="#F7F7F7"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_2942_1218" x1="17.8739" y1="7.84186" x2="13.7328" y2="23.2967" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_2942_1218" x1="17.8739" y1="7.84186" x2="13.7328" y2="23.2967" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint4_linear_2942_1218" x1="17.8739" y1="7.84186" x2="13.7328" y2="23.2967" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#22CCEE"/>
|
||||
<stop offset="0.274483" stop-color="#1570EF"/>
|
||||
<stop offset="0.629793" stop-color="#6927DA"/>
|
||||
<stop offset="1" stop-color="#F23E94"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 8.7 KiB |
37
app/assets/images/icon-assistant.svg
Normal file
37
app/assets/images/icon-assistant.svg
Normal file
@@ -0,0 +1,37 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.75" y="0.75" width="18.5" height="18.5" rx="5.25"
|
||||
class="gradient-fill"
|
||||
fill="url(#paint0_linear_2046_1939)" />
|
||||
<rect x="0.75" y="0.75" width="18.5" height="18.5" rx="5.25" stroke="currentColor" stroke-width="1.5" />
|
||||
<path
|
||||
d="M13.166 5.78146C13.4233 5.77662 13.6358 5.98129 13.6407 6.2386C13.6575 7.13281 13.688 8.02308 13.7308 8.91583C13.7431 9.1729 13.5447 9.39128 13.2876 9.40361C13.0306 9.41593 12.8122 9.21753 12.7999 8.96046C12.7567 8.05922 12.7259 7.1599 12.7089 6.25615C12.704 5.99883 12.9087 5.78631 13.166 5.78146Z"
|
||||
fill="currentColor" />
|
||||
<path
|
||||
d="M9.35116 6.19917C9.49883 5.98839 9.44768 5.69781 9.2369 5.55013C9.02612 5.40246 8.73554 5.45361 8.58786 5.66439C8.20561 6.20997 7.79785 6.74728 7.3802 7.29762C7.22057 7.50796 7.05946 7.72025 6.89782 7.93558C6.89774 7.8919 6.89758 7.84771 6.89727 7.80294C6.89466 7.42332 6.88115 7.01776 6.8079 6.61074C6.76232 6.35744 6.52004 6.18906 6.26674 6.23464C6.01345 6.28022 5.84506 6.5225 5.89064 6.7758C5.94925 7.10149 5.96277 7.44189 5.9653 7.80935C5.96592 7.90008 5.96583 7.9938 5.96575 8.08947C5.96549 8.36824 5.96523 8.66365 5.98243 8.95029C5.98629 9.01454 6.00299 9.07497 6.02989 9.12923C5.58396 9.77068 5.16482 10.433 4.81446 11.1202C4.8097 11.1296 4.80269 11.1428 4.79392 11.1593C4.74038 11.2602 4.62146 11.4842 4.55147 11.7C4.51162 11.8229 4.46721 12.0002 4.48814 12.1832C4.49932 12.281 4.53101 12.3969 4.60706 12.506C4.68604 12.6193 4.7952 12.6996 4.91859 12.7462C5.67618 13.0326 6.51425 13.0618 7.30714 13.0015C7.95092 12.9525 8.60078 12.8405 9.17979 12.7407C9.31222 12.7179 9.44094 12.6957 9.56504 12.6751C9.81891 12.6329 9.99049 12.3928 9.94826 12.1389C9.90603 11.8851 9.66599 11.7135 9.41211 11.7557C9.27728 11.7782 9.14113 11.8016 9.00391 11.8252C8.42721 11.9244 7.83168 12.0269 7.2364 12.0722C6.58727 12.1216 5.98075 12.0984 5.45339 11.9432C5.49547 11.8288 5.55538 11.7146 5.60631 11.6175C5.61995 11.5915 5.63296 11.5667 5.64479 11.5435C6.11404 10.623 6.72498 9.73396 7.38096 8.84686C7.61644 8.52843 7.86038 8.20687 8.10509 7.8843C8.53175 7.3219 8.96074 6.75642 9.35116 6.19917Z"
|
||||
fill="currentColor" />
|
||||
<path
|
||||
d="M13.1953 13.7096C13.2749 13.4649 13.141 13.2019 12.8962 13.1224C12.6515 13.0428 12.3886 13.1767 12.309 13.4215C12.1983 13.7621 12.0525 14.0208 11.8709 14.199C11.6971 14.3694 11.4701 14.4868 11.1471 14.517C10.6661 14.5621 10.1781 14.3594 9.96528 14.0074C9.83214 13.7871 9.54566 13.7165 9.32541 13.8496C9.10516 13.9828 9.03455 14.2693 9.16769 14.4895C9.61336 15.2268 10.4996 15.5138 11.2341 15.445C11.7632 15.3954 12.192 15.1895 12.5235 14.8644C12.8471 14.547 13.0561 14.1379 13.1953 13.7096Z"
|
||||
fill="currentColor" />
|
||||
<path
|
||||
d="M13.166 5.78146C13.4233 5.77662 13.6358 5.98129 13.6407 6.2386C13.6575 7.13281 13.688 8.02308 13.7308 8.91583C13.7431 9.1729 13.5447 9.39128 13.2876 9.40361C13.0306 9.41593 12.8122 9.21753 12.7999 8.96046C12.7567 8.05922 12.7259 7.1599 12.7089 6.25615C12.704 5.99883 12.9087 5.78631 13.166 5.78146Z"
|
||||
stroke="currentColor" stroke-width="0.3" stroke-linecap="round" />
|
||||
<path
|
||||
d="M9.35116 6.19917C9.49883 5.98839 9.44768 5.69781 9.2369 5.55013C9.02612 5.40246 8.73554 5.45361 8.58786 5.66439C8.20561 6.20997 7.79785 6.74728 7.3802 7.29762C7.22057 7.50796 7.05946 7.72025 6.89782 7.93558C6.89774 7.8919 6.89758 7.84771 6.89727 7.80294C6.89466 7.42332 6.88115 7.01776 6.8079 6.61074C6.76232 6.35744 6.52004 6.18906 6.26674 6.23464C6.01345 6.28022 5.84506 6.5225 5.89064 6.7758C5.94925 7.10149 5.96277 7.44189 5.9653 7.80935C5.96592 7.90008 5.96583 7.9938 5.96575 8.08947C5.96549 8.36824 5.96523 8.66365 5.98243 8.95029C5.98629 9.01454 6.00299 9.07497 6.02989 9.12923C5.58396 9.77068 5.16482 10.433 4.81446 11.1202C4.8097 11.1296 4.80269 11.1428 4.79392 11.1593C4.74038 11.2602 4.62146 11.4842 4.55147 11.7C4.51162 11.8229 4.46721 12.0002 4.48814 12.1832C4.49932 12.281 4.53101 12.3969 4.60706 12.506C4.68604 12.6193 4.7952 12.6996 4.91859 12.7462C5.67618 13.0326 6.51425 13.0618 7.30714 13.0015C7.95092 12.9525 8.60078 12.8405 9.17979 12.7407C9.31222 12.7179 9.44094 12.6957 9.56504 12.6751C9.81891 12.6329 9.99049 12.3928 9.94826 12.1389C9.90603 11.8851 9.66599 11.7135 9.41211 11.7557C9.27728 11.7782 9.14113 11.8016 9.00391 11.8252C8.42721 11.9244 7.83168 12.0269 7.2364 12.0722C6.58727 12.1216 5.98075 12.0984 5.45339 11.9432C5.49547 11.8288 5.55538 11.7146 5.60631 11.6175C5.61995 11.5915 5.63296 11.5667 5.64479 11.5435C6.11404 10.623 6.72498 9.73396 7.38096 8.84686C7.61644 8.52843 7.86038 8.20687 8.10509 7.8843C8.53175 7.3219 8.96074 6.75642 9.35116 6.19917Z"
|
||||
stroke="currentColor" stroke-width="0.3" stroke-linecap="round" />
|
||||
<path
|
||||
d="M13.1953 13.7096C13.2749 13.4649 13.141 13.2019 12.8962 13.1224C12.6515 13.0428 12.3886 13.1767 12.309 13.4215C12.1983 13.7621 12.0525 14.0208 11.8709 14.199C11.6971 14.3694 11.4701 14.4868 11.1471 14.517C10.6661 14.5621 10.1781 14.3594 9.96528 14.0074C9.83214 13.7871 9.54566 13.7165 9.32541 13.8496C9.10516 13.9828 9.03455 14.2693 9.16769 14.4895C9.61336 15.2268 10.4996 15.5138 11.2341 15.445C11.7632 15.3954 12.192 15.1895 12.5235 14.8644C12.8471 14.547 13.0561 14.1379 13.1953 13.7096Z"
|
||||
stroke="currentColor" stroke-width="0.3" stroke-linecap="round" />
|
||||
<style>
|
||||
[data-theme=dark] .gradient-fill {
|
||||
fill: transparent;
|
||||
}
|
||||
</style>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_2046_1939" x1="10" y1="6.25" x2="10" y2="20"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white" />
|
||||
<stop offset="0.3" stop-color="#F7F7F7" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 5.6 KiB |
5
app/assets/images/icon-csv.svg
Normal file
5
app/assets/images/icon-csv.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M2.5 7.5H17.5M2.5 12.5H17.5M7.5 7.5V17.5M12.5 7.5V17.5M4.16667 2.5H15.8333C16.7538 2.5 17.5 3.24619 17.5 4.16667V15.8333C17.5 16.7538 16.7538 17.5 15.8333 17.5H4.16667C3.24619 17.5 2.5 16.7538 2.5 15.8333V4.16667C2.5 3.24619 3.24619 2.5 4.16667 2.5Z"
|
||||
stroke="#737373" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 468 B |
@@ -20,7 +20,6 @@
|
||||
}
|
||||
.pcr-color-palette{
|
||||
height: 12em !important;
|
||||
width: 21.5rem !important;
|
||||
}
|
||||
.pcr-palette{
|
||||
border-radius: 10px !important;
|
||||
@@ -71,18 +70,22 @@
|
||||
|
||||
/* Typography */
|
||||
.prose {
|
||||
@apply max-w-none;
|
||||
@apply max-w-none text-primary;
|
||||
|
||||
a {
|
||||
@apply text-link;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-xl font-medium;
|
||||
@apply text-xl font-medium text-primary;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-lg font-medium;
|
||||
@apply text-lg font-medium text-primary;
|
||||
}
|
||||
|
||||
li {
|
||||
@apply m-0;
|
||||
@apply m-0 text-primary;
|
||||
}
|
||||
|
||||
details {
|
||||
@@ -165,5 +168,4 @@
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: #a6a6a6;
|
||||
}
|
||||
}
|
||||
/* The following Markdown CSS has been removed as requested */
|
||||
}
|
||||
@@ -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,12 @@
|
||||
--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%);
|
||||
|
||||
/* Colors used in Stimulus controllers with SVGs (easier to define light/dark mode here than toggle within the controllers) */
|
||||
/* See @layer base block below for dark mode overrides */
|
||||
--budget-unused-fill: var(--color-gray-200);
|
||||
--budget-unallocated-fill: var(--color-gray-50);
|
||||
|
||||
/* Gray scale */
|
||||
--color-gray-25: #FAFAFA;
|
||||
@@ -217,264 +229,48 @@
|
||||
--shadow-md: 0px 4px 8px -2px --alpha(var(--color-black) / 6%);
|
||||
--shadow-lg: 0px 12px 16px -4px --alpha(var(--color-black) / 6%);
|
||||
--shadow-xl: 0px 20px 24px -4px --alpha(var(--color-black) / 6%);
|
||||
|
||||
--animate-stroke-fill: stroke-fill 3s 300ms forwards;
|
||||
|
||||
@keyframes stroke-fill {
|
||||
0% {
|
||||
stroke-dashoffset: 43.9822971503;
|
||||
}
|
||||
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 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%);
|
||||
|
||||
/* Dark mode overrides for colors used in Stimulus controllers with SVGs */
|
||||
--budget-unused-fill: var(--color-gray-500);
|
||||
--budget-unallocated-fill: var(--color-gray-700);
|
||||
|
||||
--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;
|
||||
}
|
||||
@@ -483,17 +279,24 @@
|
||||
@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;
|
||||
}
|
||||
|
||||
|
||||
details>summary {
|
||||
@apply list-none;
|
||||
}
|
||||
|
||||
input[type='radio'] {
|
||||
@apply border-gray-300 text-indigo-600 focus:ring-indigo-600; /* Default light mode */
|
||||
|
||||
@apply border-gray-300 text-indigo-600 focus:ring-indigo-600;
|
||||
/* Default light mode */
|
||||
|
||||
@variant theme-dark {
|
||||
/* Dark mode radio button base and checked styles */
|
||||
@apply border-gray-600 bg-gray-700 checked:bg-blue-500 focus:ring-blue-500 focus:ring-offset-gray-800;
|
||||
@@ -502,66 +305,12 @@
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Buttons */
|
||||
.btn {
|
||||
@apply 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 button-bg-secondary text-white;
|
||||
@apply hover:button-bg-secondary-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.btn--outline {
|
||||
@apply border border-alpha-black-200 text-primary disabled:button-bg-disabled disabled:hover:button-bg-disabled disabled:text-gray-400;
|
||||
@apply hover:button-bg-outline-hover;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply border-alpha-white-300 text-white disabled:button-bg-disabled disabled:hover:button-bg-disabled disabled:text-gray-600;
|
||||
@apply hover:button-bg-outline-hover;
|
||||
}
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
@apply border border-transparent text-primary hover:button-bg-ghost-hover;
|
||||
|
||||
@variant theme-dark {
|
||||
@apply fg-primary hover:button-bg-ghost-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;
|
||||
@apply focus-within:border-secondary focus-within:shadow-none focus-within:ring-4 focus-within:ring-alpha-black-200;
|
||||
@apply transition-all duration-300;
|
||||
|
||||
|
||||
@variant theme-dark {
|
||||
@apply focus-within:ring-alpha-white-300;
|
||||
}
|
||||
@@ -569,43 +318,51 @@
|
||||
/* Add styles for multiple select within form fields */
|
||||
select[multiple] {
|
||||
@apply py-2 pr-2 space-y-0.5 overflow-y-auto;
|
||||
|
||||
|
||||
option {
|
||||
@apply py-2 rounded-md;
|
||||
}
|
||||
|
||||
|
||||
option:checked {
|
||||
@apply after:content-['\2713'] bg-container-inset after:text-gray-500 after:ml-2;
|
||||
}
|
||||
|
||||
|
||||
option:active,
|
||||
option:focus {
|
||||
@apply bg-container-inset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.form-field__label {
|
||||
@apply block text-xs text-secondary peer-disabled:text-subdued;
|
||||
}
|
||||
|
||||
.form-field__input {
|
||||
@apply border-none bg-transparent text-sm opacity-100 w-full p-0;
|
||||
@apply text-primary border-none bg-container text-sm opacity-100 w-full p-0;
|
||||
@apply focus:opacity-100 focus:outline-hidden focus:ring-0;
|
||||
@apply placeholder-shown:opacity-50;
|
||||
@apply disabled:text-subdued;
|
||||
@apply text-ellipsis overflow-hidden whitespace-nowrap;
|
||||
@apply transition-opacity duration-300;
|
||||
|
||||
@apply placeholder:text-subdued;
|
||||
|
||||
&select {
|
||||
@apply pr-8;
|
||||
}
|
||||
|
||||
@variant theme-dark {
|
||||
&::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.form-field__radio {
|
||||
@apply text-primary;
|
||||
}
|
||||
|
||||
|
||||
.form-field__submit {
|
||||
@apply cursor-pointer rounded-lg bg-surface p-3 text-center text-white hover:bg-surface-hover;
|
||||
}
|
||||
@@ -622,142 +379,53 @@
|
||||
&[type='checkbox'] {
|
||||
@apply border-alpha-black-200 checked:bg-gray-900 checked:ring-gray-900 focus:ring-gray-900 focus-visible:ring-gray-900 checked:hover:bg-gray-500;
|
||||
}
|
||||
|
||||
|
||||
&[type='checkbox']:disabled {
|
||||
@apply cursor-not-allowed opacity-80 bg-gray-50 border-gray-200 checked:bg-gray-400 checked:ring-gray-400;
|
||||
}
|
||||
}
|
||||
|
||||
@variant theme-dark {
|
||||
&[type='checkbox'] {
|
||||
@apply ring-gray-900 checked:text-white;
|
||||
background-color: var(--color-gray-600);
|
||||
}
|
||||
|
||||
&[type='checkbox']:disabled {
|
||||
@apply cursor-not-allowed opacity-80 ring-gray-600;
|
||||
}
|
||||
|
||||
&[type='checkbox']:checked {
|
||||
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
|
||||
background-color: var(--color-gray-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox--dark {
|
||||
&[type='checkbox'] {
|
||||
@apply ring-gray-900 checked:text-white;
|
||||
}
|
||||
|
||||
|
||||
&[type='checkbox']:disabled {
|
||||
@apply cursor-not-allowed opacity-80 ring-gray-600;
|
||||
}
|
||||
|
||||
|
||||
&[type='checkbox']:checked {
|
||||
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='111827' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Tooltips */
|
||||
.tooltip {
|
||||
@apply hidden absolute;
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
.qrcode svg path {
|
||||
fill: var(--color-black);
|
||||
@variant theme-dark {
|
||||
fill: var(--color-white);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
91
app/assets/tailwind/maybe-design-system/background-utils.css
Normal file
91
app/assets/tailwind/maybe-design-system/background-utils.css
Normal file
@@ -0,0 +1,91 @@
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
@utility bg-loader {
|
||||
@apply bg-surface-inset animate-pulse;
|
||||
}
|
||||
92
app/assets/tailwind/maybe-design-system/border-utils.css
Normal file
92
app/assets/tailwind/maybe-design-system/border-utils.css
Normal file
@@ -0,0 +1,92 @@
|
||||
/* 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-divider {
|
||||
@apply border-tertiary;
|
||||
}
|
||||
|
||||
@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) %>
|
||||
<%= helpers.icon(icon, size: size, color: icon_color) %>
|
||||
<% end %>
|
||||
|
||||
<% unless icon_only? %>
|
||||
<%= text %>
|
||||
<% end %>
|
||||
|
||||
<% if icon && icon_position == :right %>
|
||||
<%= helpers.icon(icon, size: size, color: icon_color) %>
|
||||
<% 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
|
||||
156
app/components/buttonish_component.rb
Normal file
156
app/components/buttonish_component.rb
Normal file
@@ -0,0 +1,156 @@
|
||||
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"
|
||||
},
|
||||
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"
|
||||
},
|
||||
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"
|
||||
}
|
||||
}.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_color
|
||||
# Map variant to icon color for the icon helper
|
||||
case variant
|
||||
when :primary, :icon_inverse
|
||||
:white
|
||||
when :destructive, :outline_destructive
|
||||
:destructive
|
||||
else
|
||||
:default
|
||||
end
|
||||
end
|
||||
|
||||
def icon_classes
|
||||
class_names(
|
||||
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 flex flex-col">
|
||||
<% if header? %>
|
||||
<%= header %>
|
||||
<% end %>
|
||||
|
||||
<% if body? %>
|
||||
<div class="px-4 grow">
|
||||
<%= 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 %>
|
||||
115
app/components/dialog_component.rb
Normal file
115
app/components/dialog_component.rb
Normal file
@@ -0,0 +1,115 @@
|
||||
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", frame: nil, disable_frame: false, **opts)
|
||||
@variant = variant.to_sym
|
||||
@auto_open = auto_open
|
||||
@reload_on_close = reload_on_close
|
||||
@width = width.to_sym
|
||||
@frame = frame
|
||||
@disable_frame = disable_frame
|
||||
@opts = opts
|
||||
end
|
||||
|
||||
def frame
|
||||
@frame || variant
|
||||
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: frame, &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
|
||||
33
app/components/dialog_controller.js
Normal file
33
app/components/dialog_controller.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// 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;
|
||||
if (this.autoOpenValue) {
|
||||
this.element.showModal();
|
||||
}
|
||||
}
|
||||
|
||||
// If the user clicks anywhere outside of the visible content, close the dialog
|
||||
clickOutside(e) {
|
||||
if (!this.contentTarget.contains(e.target)) {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.element.close();
|
||||
|
||||
if (this.reloadOnCloseValue) {
|
||||
Turbo.visit(window.location.href);
|
||||
}
|
||||
}
|
||||
}
|
||||
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 %>
|
||||
<%= helpers.icon "chevron-right", class: "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 %>
|
||||
<%= helpers.icon "chevron-down", class: "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 shrink-0",
|
||||
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
|
||||
15
app/components/link_component.html.erb
Normal file
15
app/components/link_component.html.erb
Normal file
@@ -0,0 +1,15 @@
|
||||
<%= link_to href, **merged_opts do %>
|
||||
<% if icon && (icon_position != "right") %>
|
||||
<%= helpers.icon(icon, size: size, color: icon_color) %>
|
||||
|
||||
<% end %>
|
||||
|
||||
<% unless icon_only? %>
|
||||
<%= text %>
|
||||
<% end %>
|
||||
|
||||
<% if icon && icon_position == "right" %>
|
||||
<%= helpers.icon(icon, size: size, color: icon_color) %>
|
||||
|
||||
<% 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, initials: initials %>
|
||||
</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 %>
|
||||
38
app/components/menu_component.rb
Normal file
38
app/components/menu_component.rb
Normal file
@@ -0,0 +1,38 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class MenuComponent < ViewComponent::Base
|
||||
attr_reader :variant, :avatar_url, :initials, :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, initials: nil, placement: "bottom-end", offset: 12, icon_vertical: false, no_padding: false, testid: nil)
|
||||
@variant = variant.to_sym
|
||||
@avatar_url = avatar_url
|
||||
@initials = initials
|
||||
@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 %>
|
||||
<%= render "shared/ruler", classes: "my-1" %>
|
||||
<% else %>
|
||||
<div class="px-1">
|
||||
<%= wrapper do %>
|
||||
<% if icon %>
|
||||
<%= helpers.icon(icon, color: destructive? ? :destructive : :default) %>
|
||||
<% end %>
|
||||
<%= tag.span(text, class: text_classes) %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
62
app/components/menu_item_component.rb
Normal file
62
app/components/menu_item_component.rb
Normal file
@@ -0,0 +1,62 @@
|
||||
class MenuItemComponent < ViewComponent::Base
|
||||
VARIANTS = %i[link button divider].freeze
|
||||
|
||||
attr_reader :variant, :text, :icon, :href, :method, :destructive, :confirm, :frame, :opts
|
||||
|
||||
def initialize(variant:, text: nil, icon: nil, href: nil, method: :post, destructive: false, confirm: nil, frame: nil, **opts)
|
||||
@variant = variant.to_sym
|
||||
@text = text
|
||||
@icon = icon
|
||||
@href = href
|
||||
@method = method.to_sym
|
||||
@destructive = destructive
|
||||
@confirm = confirm
|
||||
@frame = frame
|
||||
@opts = opts
|
||||
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_opts, &block
|
||||
elsif variant == :link
|
||||
link_to href, class: container_classes, **merged_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_opts
|
||||
merged_opts = opts.dup || {}
|
||||
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(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
|
||||
18
app/components/tabs_component.html.erb
Normal file
18
app/components/tabs_component.html.erb
Normal file
@@ -0,0 +1,18 @@
|
||||
<%= tag.div data: {
|
||||
controller: "tabs",
|
||||
testid: testid,
|
||||
tabs_session_key_value: session_key,
|
||||
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 %>
|
||||
66
app/components/tabs_component.rb
Normal file
66
app/components/tabs_component.rb
Normal file
@@ -0,0 +1,66 @@
|
||||
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, :session_key, :variant, :testid
|
||||
|
||||
def initialize(active_tab:, url_param_key: nil, session_key: nil, variant: :default, active_btn_classes: "", inactive_btn_classes: "", testid: nil)
|
||||
@active_tab = active_tab
|
||||
@url_param_key = url_param_key
|
||||
@session_key = session_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
|
||||
57
app/components/tabs_controller.js
Normal file
57
app/components/tabs_controller.js
Normal file
@@ -0,0 +1,57 @@
|
||||
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 = { sessionKey: String, 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");
|
||||
}
|
||||
});
|
||||
|
||||
if (this.urlParamKeyValue) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(this.urlParamKeyValue, selectedTabId);
|
||||
window.history.replaceState({}, "", url);
|
||||
}
|
||||
|
||||
// Update URL with the selected tab
|
||||
if (this.sessionKeyValue) {
|
||||
this.#updateSessionPreference(selectedTabId);
|
||||
}
|
||||
}
|
||||
|
||||
#updateSessionPreference(selectedTabId) {
|
||||
fetch("/current_session", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"X-CSRF-Token": document.querySelector('[name="csrf-token"]').content,
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
"current_session[tab_key]": this.sessionKeyValue,
|
||||
"current_session[tab_value]": selectedTabId,
|
||||
}).toString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -1,37 +0,0 @@
|
||||
class Account::TradesController < ApplicationController
|
||||
include EntryableResource
|
||||
|
||||
permitted_entryable_attributes :id, :qty, :price
|
||||
|
||||
private
|
||||
def build_entry
|
||||
Account::TradeBuilder.new(create_entry_params)
|
||||
end
|
||||
|
||||
def create_entry_params
|
||||
params.require(:account_entry).permit(
|
||||
:account_id, :date, :amount, :currency, :qty, :price, :ticker, :manual_ticker, :type, :transfer_account_id
|
||||
).tap do |params|
|
||||
account_id = params.delete(:account_id)
|
||||
params[:account] = Current.family.accounts.find(account_id)
|
||||
end
|
||||
end
|
||||
|
||||
def update_entry_params
|
||||
return entry_params unless entry_params[:entryable_attributes].present?
|
||||
|
||||
update_params = entry_params
|
||||
update_params = update_params.merge(entryable_type: "Account::Trade")
|
||||
|
||||
qty = update_params[:entryable_attributes][:qty]
|
||||
price = update_params[:entryable_attributes][:price]
|
||||
|
||||
if qty.present? && price.present?
|
||||
qty = update_params[:nature] == "inflow" ? -qty.to_d : qty.to_d
|
||||
update_params[:entryable_attributes][:qty] = qty
|
||||
update_params[:amount] = qty * price.to_d
|
||||
end
|
||||
|
||||
update_params.except(:nature)
|
||||
end
|
||||
end
|
||||
@@ -1,22 +0,0 @@
|
||||
class Account::TransactionCategoriesController < ApplicationController
|
||||
def update
|
||||
@entry = Current.family.entries.account_transactions.find(params[:transaction_id])
|
||||
@entry.update!(entry_params)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_transaction_path(@entry) }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
"category_menu_account_transaction_#{@entry.account_transaction_id}",
|
||||
partial: "categories/menu",
|
||||
locals: { transaction: @entry.account_transaction }
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def entry_params
|
||||
params.require(:account_entry).permit(:entryable_type, entryable_attributes: [ :id, :category_id ])
|
||||
end
|
||||
end
|
||||
@@ -1,37 +0,0 @@
|
||||
class Account::TransactionsController < ApplicationController
|
||||
include EntryableResource
|
||||
|
||||
permitted_entryable_attributes :id, :category_id, :merchant_id, { tag_ids: [] }
|
||||
|
||||
def bulk_delete
|
||||
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
|
||||
destroyed.map(&:account).uniq.each(&:sync_later)
|
||||
redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count)
|
||||
end
|
||||
|
||||
def bulk_edit
|
||||
end
|
||||
|
||||
def bulk_update
|
||||
updated = Current.family
|
||||
.entries
|
||||
.where(id: bulk_update_params[:entry_ids])
|
||||
.bulk_update!(bulk_update_params)
|
||||
|
||||
redirect_back_or_to transactions_url, notice: t(".success", count: updated)
|
||||
end
|
||||
|
||||
private
|
||||
def bulk_delete_params
|
||||
params.require(:bulk_delete).permit(entry_ids: [])
|
||||
end
|
||||
|
||||
def bulk_update_params
|
||||
params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: [])
|
||||
end
|
||||
|
||||
def search_params
|
||||
params.fetch(:q, {})
|
||||
.permit(:start_date, :end_date, :search, :amount, :amount_operator, accounts: [], account_ids: [], categories: [], merchants: [], types: [], tags: [])
|
||||
end
|
||||
end
|
||||
@@ -1,3 +0,0 @@
|
||||
class Account::ValuationsController < ApplicationController
|
||||
include EntryableResource
|
||||
end
|
||||
@@ -3,12 +3,17 @@ class AccountableSparklinesController < ApplicationController
|
||||
@accountable = Accountable.from_type(params[:accountable_type]&.classify)
|
||||
|
||||
@series = Rails.cache.fetch(cache_key) do
|
||||
family.accounts.active
|
||||
.where(accountable_type: @accountable.name)
|
||||
.balance_series(
|
||||
currency: family.currency,
|
||||
favorable_direction: @accountable.favorable_direction
|
||||
)
|
||||
account_ids = family.accounts.active.where(accountable_type: @accountable.name).pluck(:id)
|
||||
|
||||
builder = Balance::ChartSeriesBuilder.new(
|
||||
account_ids: account_ids,
|
||||
currency: family.currency,
|
||||
period: Period.last_30_days,
|
||||
favorable_direction: @accountable.favorable_direction,
|
||||
interval: "1 day"
|
||||
)
|
||||
|
||||
builder.balance_series
|
||||
end
|
||||
|
||||
render layout: false
|
||||
|
||||
@@ -26,14 +26,6 @@ class AccountsController < ApplicationController
|
||||
render layout: false
|
||||
end
|
||||
|
||||
def sync_all
|
||||
unless family.syncing?
|
||||
family.sync_later
|
||||
end
|
||||
|
||||
redirect_to accounts_path
|
||||
end
|
||||
|
||||
private
|
||||
def family
|
||||
Current.family
|
||||
|
||||
@@ -1,28 +1,15 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include Onboardable, Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation, Impersonatable, Breadcrumbable, FeatureGuardable
|
||||
include Pagy::Backend
|
||||
include RestoreLayoutPreferences, Onboardable, Localize, AutoSync, Authentication, Invitable,
|
||||
SelfHostable, StoreLocation, Impersonatable, Breadcrumbable,
|
||||
FeatureGuardable, Notifiable
|
||||
|
||||
helper_method :require_upgrade?, :subscription_pending?
|
||||
include Pagy::Backend
|
||||
|
||||
before_action :detect_os
|
||||
before_action :set_default_chat
|
||||
before_action :set_active_storage_url_options
|
||||
|
||||
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
|
||||
@@ -40,4 +27,12 @@ class ApplicationController < ActionController::Base
|
||||
@last_viewed_chat = Current.user&.last_viewed_chat
|
||||
@chat = @last_viewed_chat
|
||||
end
|
||||
|
||||
def set_active_storage_url_options
|
||||
ActiveStorage::Current.url_options = {
|
||||
protocol: request.protocol,
|
||||
host: request.host,
|
||||
port: request.optional_port
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,14 +11,14 @@ class BudgetCategoriesController < ApplicationController
|
||||
|
||||
if params[:id] == BudgetCategory.uncategorized.id
|
||||
@budget_category = @budget.uncategorized_budget_category
|
||||
@recent_transactions = @recent_transactions.where(account_transactions: { category_id: nil })
|
||||
@recent_transactions = @recent_transactions.where(transactions: { category_id: nil })
|
||||
else
|
||||
@budget_category = Current.family.budget_categories.find(params[:id])
|
||||
@recent_transactions = @recent_transactions.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id")
|
||||
@recent_transactions = @recent_transactions.joins("LEFT JOIN categories ON categories.id = transactions.category_id")
|
||||
.where("categories.id = ? OR categories.parent_id = ?", @budget_category.category.id, @budget_category.category.id)
|
||||
end
|
||||
|
||||
@recent_transactions = @recent_transactions.order("account_entries.date DESC, ABS(account_entries.amount) DESC").take(3)
|
||||
@recent_transactions = @recent_transactions.order("entries.date DESC, ABS(entries.amount) DESC").take(3)
|
||||
end
|
||||
|
||||
def update
|
||||
|
||||
@@ -56,8 +56,13 @@ class CategoriesController < ApplicationController
|
||||
redirect_back_or_to categories_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy_all
|
||||
Current.family.categories.destroy_all
|
||||
redirect_back_or_to categories_path, notice: "All categories deleted"
|
||||
end
|
||||
|
||||
def bootstrap
|
||||
Current.family.categories.bootstrap_defaults
|
||||
Current.family.categories.bootstrap!
|
||||
|
||||
redirect_back_or_to categories_path, notice: t(".success")
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,7 +5,7 @@ module AccountableResource
|
||||
include ScrollFocusable, Periodable
|
||||
|
||||
before_action :set_account, only: [ :show, :edit, :update, :destroy ]
|
||||
before_action :set_link_token, only: :new
|
||||
before_action :set_link_options, only: :new
|
||||
end
|
||||
|
||||
class_methods do
|
||||
@@ -37,48 +37,31 @@ module AccountableResource
|
||||
|
||||
def create
|
||||
@account = Current.family.accounts.create_and_sync(account_params.except(:return_to))
|
||||
@account.lock_saved_attributes!
|
||||
|
||||
redirect_to account_params[:return_to].presence || @account, notice: t("accounts.create.success", type: accountable_type.name.underscore.humanize)
|
||||
end
|
||||
|
||||
def update
|
||||
@account.update_with_sync!(account_params.except(:return_to))
|
||||
@account.lock_saved_attributes!
|
||||
|
||||
redirect_back_or_to @account, notice: t("accounts.update.success", type: accountable_type.name.underscore.humanize)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@account.destroy_later
|
||||
redirect_to accounts_path, notice: t("accounts.destroy.success", type: accountable_type.name.underscore.humanize)
|
||||
if @account.linked?
|
||||
redirect_to account_path(@account), alert: "Cannot delete a linked account"
|
||||
else
|
||||
@account.destroy_later
|
||||
redirect_to accounts_path, notice: t("accounts.destroy.success", type: accountable_type.name.underscore.humanize)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def set_link_token
|
||||
@us_link_token = Current.family.get_link_token(
|
||||
webhooks_url: plaid_us_webhooks_url,
|
||||
redirect_url: accounts_url,
|
||||
accountable_type: accountable_type.name,
|
||||
region: :us
|
||||
)
|
||||
|
||||
if Current.family.eu?
|
||||
@eu_link_token = Current.family.get_link_token(
|
||||
webhooks_url: plaid_eu_webhooks_url,
|
||||
redirect_url: accounts_url,
|
||||
accountable_type: accountable_type.name,
|
||||
region: :eu
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def plaid_us_webhooks_url
|
||||
return webhooks_plaid_url if Rails.env.production?
|
||||
|
||||
ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/webhooks/plaid"
|
||||
end
|
||||
|
||||
def plaid_eu_webhooks_url
|
||||
return webhooks_plaid_eu_url if Rails.env.production?
|
||||
|
||||
ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/webhooks/plaid_eu"
|
||||
def set_link_options
|
||||
@show_us_link = Current.family.can_connect_plaid_us?
|
||||
@show_eu_link = Current.family.can_connect_plaid_eu?
|
||||
end
|
||||
|
||||
def accountable_type
|
||||
|
||||
@@ -7,14 +7,16 @@ module AutoSync
|
||||
|
||||
private
|
||||
def sync_family
|
||||
Current.family.update!(last_synced_at: Time.current)
|
||||
Current.family.sync_later
|
||||
end
|
||||
|
||||
def family_needs_auto_sync?
|
||||
return false unless Current.family.present?
|
||||
return false unless Current.family.accounts.active.any?
|
||||
return false unless Current.family&.accounts&.active&.any?
|
||||
return false if (Current.family.last_sync_created_at&.to_date || 1.day.ago) >= Date.current
|
||||
return false unless Current.family.auto_sync_on_login
|
||||
|
||||
(Current.family.last_synced_at&.to_date || 1.day.ago) < Date.current
|
||||
Rails.logger.info "Auto-syncing family #{Current.family.id}, last sync was #{Current.family.last_sync_created_at}"
|
||||
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,14 +2,9 @@ module EntryableResource
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :set_entry, only: %i[show update destroy]
|
||||
end
|
||||
include StreamExtensions, ActionView::RecordIdentifier
|
||||
|
||||
class_methods do
|
||||
def permitted_entryable_attributes(*attrs)
|
||||
@permitted_entryable_attributes = attrs if attrs.any?
|
||||
@permitted_entryable_attributes ||= [ :id ]
|
||||
end
|
||||
before_action :set_entry, only: %i[show update destroy]
|
||||
end
|
||||
|
||||
def show
|
||||
@@ -21,49 +16,16 @@ module EntryableResource
|
||||
@entry = Current.family.entries.new(
|
||||
account: account,
|
||||
currency: account ? account.currency : Current.family.currency,
|
||||
entryable: entryable_type.new
|
||||
entryable: entryable
|
||||
)
|
||||
end
|
||||
|
||||
def create
|
||||
@entry = build_entry
|
||||
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
|
||||
flash[:notice] = t("account.entries.create.success")
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account) }
|
||||
|
||||
redirect_target_url = request.referer || account_path(@entry.account)
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
|
||||
end
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
raise NotImplementedError, "Entryable resources must implement #create"
|
||||
end
|
||||
|
||||
def update
|
||||
if @entry.update(update_entry_params)
|
||||
@entry.sync_account_later
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account), notice: t("account.entries.update.success") }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
"header_account_entry_#{@entry.id}",
|
||||
partial: "#{entryable_type.name.underscore.pluralize}/header",
|
||||
locals: { entry: @entry }
|
||||
),
|
||||
turbo_stream.replace("account_entry_#{@entry.id}", partial: "account/entries/entry", locals: { entry: @entry })
|
||||
]
|
||||
end
|
||||
end
|
||||
else
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
raise NotImplementedError, "Entryable resources must implement #update"
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -71,58 +33,15 @@ module EntryableResource
|
||||
@entry.destroy!
|
||||
@entry.sync_account_later
|
||||
|
||||
flash[:notice] = t("account.entries.destroy.success")
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(account) }
|
||||
|
||||
redirect_target_url = request.referer || account_path(@entry.account)
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
|
||||
end
|
||||
redirect_back_or_to account_path(account), notice: t("account.entries.destroy.success")
|
||||
end
|
||||
|
||||
private
|
||||
def entryable_type
|
||||
permitted_entryable_types = %w[Account::Transaction Account::Valuation Account::Trade]
|
||||
klass = params[:entryable_type] || "Account::#{controller_name.classify}"
|
||||
klass.constantize if permitted_entryable_types.include?(klass)
|
||||
def entryable
|
||||
controller_name.classify.constantize.new
|
||||
end
|
||||
|
||||
def set_entry
|
||||
@entry = Current.family.entries.find(params[:id])
|
||||
end
|
||||
|
||||
def build_entry
|
||||
Current.family.entries.new(create_entry_params)
|
||||
end
|
||||
|
||||
def update_entry_params
|
||||
prepared_entry_params
|
||||
end
|
||||
|
||||
def create_entry_params
|
||||
prepared_entry_params.merge({
|
||||
entryable_type: entryable_type.name,
|
||||
entryable_attributes: entry_params[:entryable_attributes] || {}
|
||||
})
|
||||
end
|
||||
|
||||
def prepared_entry_params
|
||||
default_params = entry_params.except(:nature)
|
||||
default_params = default_params.merge(entryable_type: entryable_type.name) if entry_params[:entryable_attributes].present?
|
||||
|
||||
if entry_params[:nature].present? && entry_params[:amount].present?
|
||||
signed_amount = entry_params[:nature] == "inflow" ? -entry_params[:amount].to_d : entry_params[:amount].to_d
|
||||
default_params = default_params.merge(amount: signed_amount)
|
||||
end
|
||||
|
||||
default_params
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:account_entry).permit(
|
||||
:account_id, :name, :enriched_name, :date, :amount, :currency, :excluded, :notes, :nature,
|
||||
entryable_attributes: self.class.permitted_entryable_attributes
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
56
app/controllers/concerns/notifiable.rb
Normal file
56
app/controllers/concerns/notifiable.rb
Normal file
@@ -0,0 +1,56 @@
|
||||
module Notifiable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
helper_method :render_flash_notifications
|
||||
helper_method :flash_notification_stream_items
|
||||
end
|
||||
|
||||
private
|
||||
def render_flash_notifications
|
||||
notifications = flash.flat_map { |type, data| resolve_notifications(type, data) }.compact
|
||||
|
||||
view_context.safe_join(
|
||||
notifications.map { |notification| view_context.render(**notification) }
|
||||
)
|
||||
end
|
||||
|
||||
def flash_notification_stream_items
|
||||
items = flash.flat_map do |type, data|
|
||||
notifications = resolve_notifications(type, data)
|
||||
|
||||
if type == "cta"
|
||||
notifications.map { |notification| turbo_stream.replace("cta", **notification) }
|
||||
else
|
||||
notifications.map { |notification| turbo_stream.append("notification-tray", **notification) }
|
||||
end
|
||||
end.compact
|
||||
|
||||
# If rendering flash notifications via stream, we mark them as used to avoid
|
||||
# them being rendered again on the next page load
|
||||
flash.clear
|
||||
|
||||
items
|
||||
end
|
||||
|
||||
def resolve_cta(cta)
|
||||
case cta[:type]
|
||||
when "category_rule"
|
||||
{ partial: "rules/category_rule_cta", locals: { cta: } }
|
||||
end
|
||||
end
|
||||
|
||||
def resolve_notifications(type, data)
|
||||
case type
|
||||
when "alert"
|
||||
[ { partial: "shared/notifications/alert", locals: { message: data } } ]
|
||||
when "cta"
|
||||
[ resolve_cta(data) ]
|
||||
when "notice"
|
||||
messages = Array(data)
|
||||
messages.map { |message| { partial: "shared/notifications/notice", locals: { message: message } } }
|
||||
else
|
||||
[]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,16 +2,35 @@ module Onboardable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :redirect_to_onboarding, if: :needs_onboarding?
|
||||
before_action :require_onboarding_and_upgrade
|
||||
end
|
||||
|
||||
private
|
||||
def redirect_to_onboarding
|
||||
redirect_to onboarding_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.needs_onboarding?
|
||||
redirect_to onboarding_path
|
||||
elsif Current.family.needs_subscription?
|
||||
redirect_to trial_onboarding_path
|
||||
elsif Current.family.upgrade_required?
|
||||
redirect_to upgrade_subscription_path
|
||||
end
|
||||
end
|
||||
|
||||
def needs_onboarding?
|
||||
Current.user && Current.user.onboarded_at.blank? &&
|
||||
!%w[/users /onboarding /sessions].any? { |path| request.path.start_with?(path) }
|
||||
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
|
||||
|
||||
24
app/controllers/concerns/restore_layout_preferences.rb
Normal file
24
app/controllers/concerns/restore_layout_preferences.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
module RestoreLayoutPreferences
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :restore_active_tabs
|
||||
end
|
||||
|
||||
private
|
||||
def restore_active_tabs
|
||||
last_selected_tab = Current.session&.get_preferred_tab("account_sidebar_tab") || "asset"
|
||||
|
||||
@account_group_tab = account_group_tab_param || last_selected_tab
|
||||
end
|
||||
|
||||
def valid_account_group_tabs
|
||||
%w[asset liability all]
|
||||
end
|
||||
|
||||
def account_group_tab_param
|
||||
param_value = params[:account_sidebar_tab]
|
||||
return nil unless param_value.in?(valid_account_group_tabs)
|
||||
param_value
|
||||
end
|
||||
end
|
||||
20
app/controllers/concerns/stream_extensions.rb
Normal file
20
app/controllers/concerns/stream_extensions.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
module StreamExtensions
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def stream_redirect_to(path, notice: nil, alert: nil)
|
||||
custom_stream_redirect(path, notice: notice, alert: alert)
|
||||
end
|
||||
|
||||
def stream_redirect_back_or_to(path, notice: nil, alert: nil)
|
||||
custom_stream_redirect(path, redirect_back: true, notice: notice, alert: alert)
|
||||
end
|
||||
|
||||
private
|
||||
def custom_stream_redirect(path, redirect_back: false, notice: nil, alert: nil)
|
||||
flash[:notice] = notice if notice.present?
|
||||
flash[:alert] = alert if alert.present?
|
||||
|
||||
redirect_target_url = redirect_back ? request.referer : path
|
||||
render turbo_stream: turbo_stream.action(:redirect, redirect_target_url)
|
||||
end
|
||||
end
|
||||
22
app/controllers/cookie_sessions_controller.rb
Normal file
22
app/controllers/cookie_sessions_controller.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class CookieSessionsController < ApplicationController
|
||||
def update
|
||||
save_kv_to_session(
|
||||
cookie_session_params[:tab_key],
|
||||
cookie_session_params[:tab_value]
|
||||
)
|
||||
|
||||
redirect_back_or_to root_path
|
||||
end
|
||||
|
||||
private
|
||||
def cookie_session_params
|
||||
params.require(:cookie_session).permit(:tab_key, :tab_value)
|
||||
end
|
||||
|
||||
def save_kv_to_session(key, value)
|
||||
raise "Key must be a string" unless key.is_a?(String)
|
||||
raise "Value must be a string" unless value.is_a?(String)
|
||||
|
||||
session["custom_#{key}"] = value
|
||||
end
|
||||
end
|
||||
14
app/controllers/current_sessions_controller.rb
Normal file
14
app/controllers/current_sessions_controller.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
class CurrentSessionsController < ApplicationController
|
||||
def update
|
||||
if session_params[:tab_key].present? && session_params[:tab_value].present?
|
||||
Current.session.set_preferred_tab(session_params[:tab_key], session_params[:tab_value])
|
||||
end
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
def session_params
|
||||
params.require(:current_session).permit(:tab_key, :tab_value)
|
||||
end
|
||||
end
|
||||
54
app/controllers/family_merchants_controller.rb
Normal file
54
app/controllers/family_merchants_controller.rb
Normal file
@@ -0,0 +1,54 @@
|
||||
class FamilyMerchantsController < ApplicationController
|
||||
before_action :set_merchant, only: %i[edit update destroy]
|
||||
|
||||
def index
|
||||
@breadcrumbs = [ [ "Home", root_path ], [ "Merchants", nil ] ]
|
||||
|
||||
@merchants = Current.family.merchants.alphabetically
|
||||
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def new
|
||||
@merchant = FamilyMerchant.new(family: Current.family)
|
||||
end
|
||||
|
||||
def create
|
||||
@merchant = FamilyMerchant.new(merchant_params.merge(family: Current.family))
|
||||
|
||||
if @merchant.save
|
||||
respond_to do |format|
|
||||
format.html { redirect_to family_merchants_path, notice: t(".success") }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) }
|
||||
end
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@merchant.update!(merchant_params)
|
||||
respond_to do |format|
|
||||
format.html { redirect_to family_merchants_path, notice: t(".success") }
|
||||
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, family_merchants_path) }
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@merchant.destroy!
|
||||
redirect_to family_merchants_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_merchant
|
||||
@merchant = Current.family.merchants.find(params[:id])
|
||||
end
|
||||
|
||||
def merchant_params
|
||||
params.require(:family_merchant).permit(:name, :color)
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class Account::HoldingsController < ApplicationController
|
||||
class HoldingsController < ApplicationController
|
||||
before_action :set_holding, only: %i[show destroy]
|
||||
|
||||
def index
|
||||
@@ -36,7 +36,9 @@ class Import::ConfigurationsController < ApplicationController
|
||||
:currency_col_label,
|
||||
:date_format,
|
||||
:number_format,
|
||||
:signage_convention
|
||||
:signage_convention,
|
||||
:amount_type_strategy,
|
||||
:amount_type_inflow_value,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,6 +6,13 @@ class Import::UploadsController < ApplicationController
|
||||
def show
|
||||
end
|
||||
|
||||
def sample_csv
|
||||
send_data @import.csv_template.to_csv,
|
||||
filename: "#{@import.type.underscore.split('_').first}_sample.csv",
|
||||
type: "text/csv",
|
||||
disposition: "attachment"
|
||||
end
|
||||
|
||||
def update
|
||||
if csv_valid?(csv_str)
|
||||
@import.account = Current.family.accounts.find_by(id: params.dig(:import, :account_id))
|
||||
|
||||
@@ -5,6 +5,8 @@ class ImportsController < ApplicationController
|
||||
@import.publish_later
|
||||
|
||||
redirect_to import_path(@import), notice: "Your import has started in the background."
|
||||
rescue Import::MaxRowCountExceededError
|
||||
redirect_back_or_to import_path(@import), alert: "Your import exceeds the maximum row count of #{@import.max_row_count}."
|
||||
end
|
||||
|
||||
def index
|
||||
|
||||
@@ -2,6 +2,6 @@ class LoansController < ApplicationController
|
||||
include AccountableResource
|
||||
|
||||
permitted_accountable_attributes(
|
||||
:id, :rate_type, :interest_rate, :term_months
|
||||
:id, :rate_type, :interest_rate, :term_months, :initial_balance
|
||||
)
|
||||
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,46 +0,0 @@
|
||||
class MerchantsController < ApplicationController
|
||||
before_action :set_merchant, only: %i[edit update destroy]
|
||||
|
||||
def index
|
||||
@merchants = Current.family.merchants.alphabetically
|
||||
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def new
|
||||
@merchant = Merchant.new
|
||||
end
|
||||
|
||||
def create
|
||||
@merchant = Current.family.merchants.new(merchant_params)
|
||||
|
||||
if @merchant.save
|
||||
redirect_to merchants_path, notice: t(".success")
|
||||
else
|
||||
redirect_to merchants_path, alert: t(".error", error: @merchant.errors.full_messages.to_sentence)
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@merchant.update!(merchant_params)
|
||||
redirect_to merchants_path, notice: t(".success")
|
||||
end
|
||||
|
||||
def destroy
|
||||
@merchant.destroy!
|
||||
redirect_to merchants_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_merchant
|
||||
@merchant = Current.family.merchants.find(params[:id])
|
||||
end
|
||||
|
||||
def merchant_params
|
||||
params.require(:merchant).permit(:name, :color)
|
||||
end
|
||||
end
|
||||
@@ -1,18 +1,19 @@
|
||||
class OnboardingsController < ApplicationController
|
||||
layout "wizard"
|
||||
|
||||
before_action :set_user
|
||||
before_action :load_invitation
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def profile
|
||||
end
|
||||
|
||||
def preferences
|
||||
end
|
||||
|
||||
private
|
||||
def trial
|
||||
end
|
||||
|
||||
private
|
||||
def set_user
|
||||
@user = Current.user
|
||||
end
|
||||
|
||||
@@ -6,6 +6,23 @@ class PagesController < ApplicationController
|
||||
@balance_sheet = Current.family.balance_sheet
|
||||
@accounts = Current.family.accounts.active.with_attached_logo
|
||||
|
||||
period_param = params[:cashflow_period]
|
||||
@cashflow_period = if period_param.present?
|
||||
begin
|
||||
Period.from_key(period_param)
|
||||
rescue Period::InvalidKeyError
|
||||
Period.last_30_days
|
||||
end
|
||||
else
|
||||
Period.last_30_days
|
||||
end
|
||||
|
||||
family_currency = Current.family.currency
|
||||
income_totals = Current.family.income_statement.income_totals(period: @cashflow_period)
|
||||
expense_totals = Current.family.income_statement.expense_totals(period: @cashflow_period)
|
||||
|
||||
@cashflow_sankey_data = build_cashflow_sankey_data(income_totals, expense_totals, family_currency)
|
||||
|
||||
@breadcrumbs = [ [ "Home", root_path ], [ "Dashboard", nil ] ]
|
||||
end
|
||||
|
||||
@@ -31,4 +48,100 @@ class PagesController < ApplicationController
|
||||
def github_provider
|
||||
Provider::Registry.get_provider(:github)
|
||||
end
|
||||
|
||||
def build_cashflow_sankey_data(income_totals, expense_totals, currency_symbol)
|
||||
nodes = []
|
||||
links = []
|
||||
node_indices = {} # Memoize node indices by a unique key: "type_categoryid"
|
||||
|
||||
# Helper to add/find node and return its index
|
||||
add_node = ->(unique_key, display_name, value, percentage, color) {
|
||||
node_indices[unique_key] ||= begin
|
||||
nodes << { name: display_name, value: value.to_f.round(2), percentage: percentage.to_f.round(1), color: color }
|
||||
nodes.size - 1
|
||||
end
|
||||
}
|
||||
|
||||
total_income_val = income_totals.total.to_f.round(2)
|
||||
total_expense_val = expense_totals.total.to_f.round(2)
|
||||
|
||||
# --- Create Central Cash Flow Node ---
|
||||
cash_flow_idx = add_node.call("cash_flow_node", "Cash Flow", total_income_val, 0, "var(--color-success)")
|
||||
|
||||
# --- Process Income Side (Top-level categories only) ---
|
||||
income_totals.category_totals.each do |ct|
|
||||
# Skip subcategories – only include root income categories
|
||||
next if ct.category.parent_id.present?
|
||||
|
||||
val = ct.total.to_f.round(2)
|
||||
next if val.zero?
|
||||
|
||||
percentage_of_total_income = total_income_val.zero? ? 0 : (val / total_income_val * 100).round(1)
|
||||
|
||||
node_display_name = ct.category.name
|
||||
node_color = ct.category.color.presence || Category::COLORS.sample
|
||||
|
||||
current_cat_idx = add_node.call(
|
||||
"income_#{ct.category.id}",
|
||||
node_display_name,
|
||||
val,
|
||||
percentage_of_total_income,
|
||||
node_color
|
||||
)
|
||||
|
||||
links << {
|
||||
source: current_cat_idx,
|
||||
target: cash_flow_idx,
|
||||
value: val,
|
||||
color: node_color,
|
||||
percentage: percentage_of_total_income
|
||||
}
|
||||
end
|
||||
|
||||
# --- Process Expense Side (Top-level categories only) ---
|
||||
expense_totals.category_totals.each do |ct|
|
||||
# Skip subcategories – only include root expense categories to keep Sankey shallow
|
||||
next if ct.category.parent_id.present?
|
||||
|
||||
val = ct.total.to_f.round(2)
|
||||
next if val.zero?
|
||||
|
||||
percentage_of_total_expense = total_expense_val.zero? ? 0 : (val / total_expense_val * 100).round(1)
|
||||
|
||||
node_display_name = ct.category.name
|
||||
node_color = ct.category.color.presence || Category::UNCATEGORIZED_COLOR
|
||||
|
||||
current_cat_idx = add_node.call(
|
||||
"expense_#{ct.category.id}",
|
||||
node_display_name,
|
||||
val,
|
||||
percentage_of_total_expense,
|
||||
node_color
|
||||
)
|
||||
|
||||
links << {
|
||||
source: cash_flow_idx,
|
||||
target: current_cat_idx,
|
||||
value: val,
|
||||
color: node_color,
|
||||
percentage: percentage_of_total_expense
|
||||
}
|
||||
end
|
||||
|
||||
# --- Process Surplus ---
|
||||
leftover = (total_income_val - total_expense_val).round(2)
|
||||
if leftover.positive?
|
||||
percentage_of_total_income_for_surplus = total_income_val.zero? ? 0 : (leftover / total_income_val * 100).round(1)
|
||||
surplus_idx = add_node.call("surplus_node", "Surplus", leftover, percentage_of_total_income_for_surplus, "var(--color-success)")
|
||||
links << { source: cash_flow_idx, target: surplus_idx, value: leftover, color: "var(--color-success)", percentage: percentage_of_total_income_for_surplus }
|
||||
end
|
||||
|
||||
# Update Cash Flow and Income node percentages (relative to total income)
|
||||
if node_indices["cash_flow_node"]
|
||||
nodes[node_indices["cash_flow_node"]][:percentage] = 100.0
|
||||
end
|
||||
# No primary income node anymore, percentages are on individual income cats relative to total_income_val
|
||||
|
||||
{ nodes: nodes, links: links, currency_symbol: Money::Currency.new(currency_symbol).symbol }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
class PlaidItemsController < ApplicationController
|
||||
before_action :set_plaid_item, only: %i[destroy sync]
|
||||
before_action :set_plaid_item, only: %i[edit destroy sync]
|
||||
|
||||
def new
|
||||
region = params[:region] == "eu" ? :eu : :us
|
||||
webhooks_url = region == :eu ? plaid_eu_webhooks_url : plaid_us_webhooks_url
|
||||
|
||||
@link_token = Current.family.get_link_token(
|
||||
webhooks_url: webhooks_url,
|
||||
redirect_url: accounts_url,
|
||||
accountable_type: params[:accountable_type] || "Depository",
|
||||
region: region
|
||||
)
|
||||
end
|
||||
|
||||
def edit
|
||||
webhooks_url = @plaid_item.plaid_region == "eu" ? plaid_eu_webhooks_url : plaid_us_webhooks_url
|
||||
|
||||
@link_token = @plaid_item.get_update_link_token(
|
||||
webhooks_url: webhooks_url,
|
||||
redirect_url: accounts_url,
|
||||
)
|
||||
end
|
||||
|
||||
def create
|
||||
Current.family.plaid_items.create_from_public_token(
|
||||
plaid_item_params[:public_token],
|
||||
Current.family.create_plaid_item!(
|
||||
public_token: plaid_item_params[:public_token],
|
||||
item_name: item_name,
|
||||
region: plaid_item_params[:region]
|
||||
)
|
||||
@@ -39,4 +60,16 @@ class PlaidItemsController < ApplicationController
|
||||
def item_name
|
||||
plaid_item_params.dig(:metadata, :institution, :name)
|
||||
end
|
||||
|
||||
def plaid_us_webhooks_url
|
||||
return webhooks_plaid_url if Rails.env.production?
|
||||
|
||||
ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/webhooks/plaid"
|
||||
end
|
||||
|
||||
def plaid_eu_webhooks_url
|
||||
return webhooks_plaid_eu_url if Rails.env.production?
|
||||
|
||||
ENV.fetch("DEV_WEBHOOKS_URL", root_url.chomp("/")) + "/webhooks/plaid_eu"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,6 +6,7 @@ class RegistrationsController < ApplicationController
|
||||
before_action :set_user, only: :create
|
||||
before_action :set_invitation
|
||||
before_action :claim_invite_code, only: :create, if: :invite_code_required?
|
||||
before_action :validate_password_requirements, only: :create
|
||||
|
||||
def new
|
||||
@user = User.new(email: @invitation&.email)
|
||||
@@ -53,4 +54,29 @@ class RegistrationsController < ApplicationController
|
||||
redirect_to new_registration_path, alert: t("registrations.create.invalid_invite_code")
|
||||
end
|
||||
end
|
||||
|
||||
def validate_password_requirements
|
||||
password = user_params[:password]
|
||||
return if password.blank? # Let Rails built-in validations handle blank passwords
|
||||
|
||||
if password.length < 8
|
||||
@user.errors.add(:password, "must be at least 8 characters")
|
||||
end
|
||||
|
||||
unless password.match?(/[A-Z]/) && password.match?(/[a-z]/)
|
||||
@user.errors.add(:password, "must include both uppercase and lowercase letters")
|
||||
end
|
||||
|
||||
unless password.match?(/\d/)
|
||||
@user.errors.add(:password, "must include at least one number")
|
||||
end
|
||||
|
||||
unless password.match?(/[!@#$%^&*(),.?":{}|<>]/)
|
||||
@user.errors.add(:password, "must include at least one special character")
|
||||
end
|
||||
|
||||
if @user.errors.present?
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
84
app/controllers/rules_controller.rb
Normal file
84
app/controllers/rules_controller.rb
Normal file
@@ -0,0 +1,84 @@
|
||||
class RulesController < ApplicationController
|
||||
include StreamExtensions
|
||||
|
||||
before_action :set_rule, only: [ :edit, :update, :destroy, :apply, :confirm ]
|
||||
|
||||
def index
|
||||
@sort_by = params[:sort_by] || "name"
|
||||
@direction = params[:direction] || "asc"
|
||||
|
||||
allowed_columns = [ "name", "updated_at" ]
|
||||
@sort_by = "name" unless allowed_columns.include?(@sort_by)
|
||||
@direction = "asc" unless [ "asc", "desc" ].include?(@direction)
|
||||
|
||||
@rules = Current.family.rules.order(@sort_by => @direction)
|
||||
render layout: "settings"
|
||||
end
|
||||
|
||||
def new
|
||||
@rule = Current.family.rules.build(
|
||||
resource_type: params[:resource_type] || "transaction",
|
||||
)
|
||||
end
|
||||
|
||||
def create
|
||||
@rule = Current.family.rules.build(rule_params)
|
||||
|
||||
if @rule.save
|
||||
redirect_to confirm_rule_path(@rule)
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def apply
|
||||
@rule.update!(active: true)
|
||||
@rule.apply_later(ignore_attribute_locks: true)
|
||||
redirect_back_or_to rules_path, notice: "#{@rule.resource_type.humanize} rule activated"
|
||||
end
|
||||
|
||||
def confirm
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
if @rule.update(rule_params)
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to rules_path, notice: "Rule updated" }
|
||||
format.turbo_stream { stream_redirect_back_or_to rules_path, notice: "Rule updated" }
|
||||
end
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@rule.destroy
|
||||
redirect_to rules_path, notice: "Rule deleted"
|
||||
end
|
||||
|
||||
def destroy_all
|
||||
Current.family.rules.destroy_all
|
||||
redirect_to rules_path, notice: "All rules deleted"
|
||||
end
|
||||
|
||||
private
|
||||
def set_rule
|
||||
@rule = Current.family.rules.find(params[:id])
|
||||
end
|
||||
|
||||
def rule_params
|
||||
params.require(:rule).permit(
|
||||
:resource_type, :effective_date, :active, :name,
|
||||
conditions_attributes: [
|
||||
:id, :condition_type, :operator, :value, :_destroy,
|
||||
sub_conditions_attributes: [ :id, :condition_type, :operator, :value, :_destroy ]
|
||||
],
|
||||
actions_attributes: [
|
||||
:id, :action_type, :value, :_destroy
|
||||
]
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -2,6 +2,6 @@ class Settings::BillingsController < ApplicationController
|
||||
layout "settings"
|
||||
|
||||
def show
|
||||
@user = Current.user
|
||||
@family = Current.family
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,9 +23,11 @@ class Settings::ProfilesController < ApplicationController
|
||||
end
|
||||
|
||||
if @user.destroy
|
||||
flash[:notice] = t("settings.profiles.destroy.member_removed")
|
||||
# Also destroy the invitation associated with this user for this family
|
||||
Current.family.invitations.find_by(email: @user.email)&.destroy
|
||||
flash[:notice] = "Member removed successfully."
|
||||
else
|
||||
flash[:alert] = t("settings.profiles.destroy.member_removal_failed")
|
||||
flash[:alert] = "Failed to remove member."
|
||||
end
|
||||
|
||||
redirect_to settings_profile_path
|
||||
|
||||
@@ -1,53 +1,64 @@
|
||||
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
|
||||
if Current.family.subscription&.active?
|
||||
redirect_to root_path, notice: "You are already subscribed."
|
||||
else
|
||||
@plan = params[:plan] || "annual"
|
||||
render layout: "onboardings"
|
||||
end
|
||||
end
|
||||
|
||||
def new
|
||||
if Current.family.stripe_customer_id.blank?
|
||||
customer = stripe_client.v1.customers.create(
|
||||
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 = stripe.create_checkout_session(
|
||||
plan: params[:plan],
|
||||
family_id: Current.family.id,
|
||||
family_email: Current.family.billing_email,
|
||||
success_url: success_subscription_url + "?session_id={CHECKOUT_SESSION_ID}",
|
||||
cancel_url: settings_billing_url
|
||||
})
|
||||
cancel_url: upgrade_subscription_url
|
||||
)
|
||||
|
||||
redirect_to session.url, allow_other_host: true, status: :see_other
|
||||
Current.family.update!(stripe_customer_id: checkout_session.customer_id)
|
||||
|
||||
redirect_to checkout_session.url, allow_other_host: true, status: :see_other
|
||||
end
|
||||
|
||||
# Only used for managing our "offline" trials. Paid subscriptions are handled in success callback of checkout session
|
||||
def create
|
||||
if Current.family.can_start_trial?
|
||||
Current.family.start_trial_subscription!
|
||||
redirect_to root_path, notice: "Welcome to Maybe!"
|
||||
else
|
||||
redirect_to root_path, alert: "You have already started or completed a trial. Please upgrade to continue."
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
portal_session = stripe_client.v1.billing_portal.sessions.create(
|
||||
customer: Current.family.stripe_customer_id,
|
||||
portal_session_url = stripe.create_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
|
||||
|
||||
# Stripe redirects here after a successful checkout session and passes the session ID in the URL
|
||||
def success
|
||||
checkout_session = stripe_client.v1.checkout.sessions.retrieve(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
|
||||
redirect_to settings_billing_path, alert: "Something went wrong processing your subscription. Please contact us to get this fixed."
|
||||
checkout_result = stripe.get_checkout_result(params[:session_id])
|
||||
|
||||
if checkout_result.success?
|
||||
Current.family.start_subscription!(checkout_result.subscription_id)
|
||||
redirect_to root_path, notice: "Welcome to Maybe! Your subscription has been created."
|
||||
else
|
||||
redirect_to root_path, alert: "Something went wrong processing your subscription. Please contact us to get this fixed."
|
||||
end
|
||||
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
|
||||
|
||||
@@ -34,6 +34,11 @@ class TagsController < ApplicationController
|
||||
redirect_to tags_path, notice: t(".deleted")
|
||||
end
|
||||
|
||||
def destroy_all
|
||||
Current.family.tags.destroy_all
|
||||
redirect_back_or_to tags_path, notice: "All tags deleted"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_tag
|
||||
|
||||
79
app/controllers/trades_controller.rb
Normal file
79
app/controllers/trades_controller.rb
Normal file
@@ -0,0 +1,79 @@
|
||||
class TradesController < ApplicationController
|
||||
include EntryableResource
|
||||
|
||||
def create
|
||||
@entry = build_entry
|
||||
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
|
||||
flash[:notice] = t("entries.create.success")
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account) }
|
||||
format.turbo_stream { stream_redirect_back_or_to account_path(@entry.account) }
|
||||
end
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @entry.update(update_entry_params)
|
||||
@entry.sync_account_later
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account), notice: t("entries.update.success") }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
"header_entry_#{@entry.id}",
|
||||
partial: "trades/header",
|
||||
locals: { entry: @entry }
|
||||
),
|
||||
turbo_stream.replace("entry_#{@entry.id}", partial: "entries/entry", locals: { entry: @entry })
|
||||
]
|
||||
end
|
||||
end
|
||||
else
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def build_entry
|
||||
account = Current.family.accounts.find(params.dig(:entry, :account_id))
|
||||
TradeBuilder.new(create_entry_params.merge(account: account))
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:entry).permit(
|
||||
:name, :date, :amount, :currency, :excluded, :notes, :nature,
|
||||
entryable_attributes: [ :id, :qty, :price ]
|
||||
)
|
||||
end
|
||||
|
||||
def create_entry_params
|
||||
params.require(:entry).permit(
|
||||
:date, :amount, :currency, :qty, :price, :ticker, :manual_ticker, :type, :transfer_account_id
|
||||
)
|
||||
end
|
||||
|
||||
def update_entry_params
|
||||
return entry_params unless entry_params[:entryable_attributes].present?
|
||||
|
||||
update_params = entry_params
|
||||
update_params = update_params.merge(entryable_type: "Trade")
|
||||
|
||||
qty = update_params[:entryable_attributes][:qty]
|
||||
price = update_params[:entryable_attributes][:price]
|
||||
|
||||
if qty.present? && price.present?
|
||||
qty = update_params[:nature] == "inflow" ? -qty.to_d : qty.to_d
|
||||
update_params[:entryable_attributes][:qty] = qty
|
||||
update_params[:amount] = qty * price.to_d
|
||||
end
|
||||
|
||||
update_params.except(:nature)
|
||||
end
|
||||
end
|
||||
52
app/controllers/transaction_categories_controller.rb
Normal file
52
app/controllers/transaction_categories_controller.rb
Normal file
@@ -0,0 +1,52 @@
|
||||
class TransactionCategoriesController < ApplicationController
|
||||
include ActionView::RecordIdentifier
|
||||
|
||||
def update
|
||||
@entry = Current.family.entries.transactions.find(params[:transaction_id])
|
||||
@entry.update!(entry_params)
|
||||
|
||||
transaction = @entry.transaction
|
||||
|
||||
if needs_rule_notification?(transaction)
|
||||
flash[:cta] = {
|
||||
type: "category_rule",
|
||||
category_id: transaction.category_id,
|
||||
category_name: transaction.category.name
|
||||
}
|
||||
end
|
||||
|
||||
transaction.lock_saved_attributes!
|
||||
@entry.lock_saved_attributes!
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to transaction_path(@entry) }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
dom_id(transaction, :category_menu),
|
||||
partial: "categories/menu",
|
||||
locals: { transaction: transaction }
|
||||
),
|
||||
*flash_notification_stream_items
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def entry_params
|
||||
params.require(:entry).permit(:entryable_type, entryable_attributes: [ :id, :category_id ])
|
||||
end
|
||||
|
||||
def needs_rule_notification?(transaction)
|
||||
return false if Current.user.rule_prompts_disabled
|
||||
|
||||
if Current.user.rule_prompt_dismissed_at.present?
|
||||
time_since_last_rule_prompt = Time.current - Current.user.rule_prompt_dismissed_at
|
||||
return false if time_since_last_rule_prompt < 1.day
|
||||
end
|
||||
|
||||
transaction.saved_change_to_category_id? &&
|
||||
transaction.eligible_for_category_rule?
|
||||
end
|
||||
end
|
||||
12
app/controllers/transactions/bulk_deletions_controller.rb
Normal file
12
app/controllers/transactions/bulk_deletions_controller.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
class Transactions::BulkDeletionsController < ApplicationController
|
||||
def create
|
||||
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
|
||||
destroyed.map(&:account).uniq.each(&:sync_later)
|
||||
redirect_back_or_to transactions_url, notice: "#{destroyed.count} transaction#{destroyed.count == 1 ? "" : "s"} deleted"
|
||||
end
|
||||
|
||||
private
|
||||
def bulk_delete_params
|
||||
params.require(:bulk_delete).permit(entry_ids: [])
|
||||
end
|
||||
end
|
||||
19
app/controllers/transactions/bulk_updates_controller.rb
Normal file
19
app/controllers/transactions/bulk_updates_controller.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
class Transactions::BulkUpdatesController < ApplicationController
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
updated = Current.family
|
||||
.entries
|
||||
.where(id: bulk_update_params[:entry_ids])
|
||||
.bulk_update!(bulk_update_params)
|
||||
|
||||
redirect_back_or_to transactions_path, notice: "#{updated} transactions updated"
|
||||
end
|
||||
|
||||
private
|
||||
def bulk_update_params
|
||||
params.require(:bulk_update)
|
||||
.permit(:date, :notes, :category_id, :merchant_id, entry_ids: [], tag_ids: [])
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,14 @@
|
||||
class TransactionsController < ApplicationController
|
||||
include ScrollFocusable
|
||||
include ScrollFocusable, EntryableResource
|
||||
|
||||
before_action :store_params!, only: :index
|
||||
|
||||
def new
|
||||
super
|
||||
@income_categories = Current.family.categories.incomes.alphabetically
|
||||
@expense_categories = Current.family.categories.expenses.alphabetically
|
||||
end
|
||||
|
||||
def index
|
||||
@q = search_params
|
||||
transactions_query = Current.family.transactions.active.search(@q)
|
||||
@@ -20,7 +26,24 @@ class TransactionsController < ApplicationController
|
||||
params: ->(params) { params.except(:focused_record_id) }
|
||||
)
|
||||
|
||||
@totals = Current.family.income_statement.totals(transactions_scope: transactions_query)
|
||||
# -------------------------------------------------------------------
|
||||
# Cache totals
|
||||
# -------------------------------------------------------------------
|
||||
# Totals calculation is expensive (heavy SQL with grouping). We cache the
|
||||
# result keyed by:
|
||||
# • Family id
|
||||
# • The family-level cache key that already embeds entries.maximum(:updated_at)
|
||||
# • A digest of the current search params so each distinct filter set gets
|
||||
# its own cache entry.
|
||||
# When any entry is created/updated/deleted, the family cache key changes,
|
||||
# automatically invalidating all related totals.
|
||||
|
||||
params_digest = Digest::MD5.hexdigest(@q.to_json)
|
||||
cache_key = Current.family.build_cache_key("transactions_totals_#{params_digest}")
|
||||
|
||||
@totals = Rails.cache.fetch(cache_key) do
|
||||
Current.family.income_statement.totals(transactions_scope: transactions_query)
|
||||
end
|
||||
end
|
||||
|
||||
def clear_filter
|
||||
@@ -48,20 +71,133 @@ class TransactionsController < ApplicationController
|
||||
redirect_to transactions_path(updated_params)
|
||||
end
|
||||
|
||||
def create
|
||||
account = Current.family.accounts.find(params.dig(:entry, :account_id))
|
||||
@entry = account.entries.new(entry_params)
|
||||
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
@entry.lock_saved_attributes!
|
||||
@entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?
|
||||
|
||||
flash[:notice] = "Transaction created"
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account) }
|
||||
format.turbo_stream { stream_redirect_back_or_to(account_path(@entry.account)) }
|
||||
end
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @entry.update(entry_params)
|
||||
transaction = @entry.transaction
|
||||
|
||||
if needs_rule_notification?(transaction)
|
||||
flash[:cta] = {
|
||||
type: "category_rule",
|
||||
category_id: transaction.category_id,
|
||||
category_name: transaction.category.name
|
||||
}
|
||||
end
|
||||
|
||||
@entry.sync_account_later
|
||||
@entry.lock_saved_attributes!
|
||||
@entry.transaction.lock_attr!(:tag_ids) if @entry.transaction.tags.any?
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account), notice: "Transaction updated" }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
dom_id(@entry, :header),
|
||||
partial: "transactions/header",
|
||||
locals: { entry: @entry }
|
||||
),
|
||||
turbo_stream.replace(@entry),
|
||||
*flash_notification_stream_items
|
||||
]
|
||||
end
|
||||
end
|
||||
else
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def needs_rule_notification?(transaction)
|
||||
return false if Current.user.rule_prompts_disabled
|
||||
|
||||
if Current.user.rule_prompt_dismissed_at.present?
|
||||
time_since_last_rule_prompt = Time.current - Current.user.rule_prompt_dismissed_at
|
||||
return false if time_since_last_rule_prompt < 1.day
|
||||
end
|
||||
|
||||
transaction.saved_change_to_category_id? && transaction.category_id.present? &&
|
||||
transaction.eligible_for_category_rule?
|
||||
end
|
||||
|
||||
def entry_params
|
||||
entry_params = params.require(:entry).permit(
|
||||
:name, :date, :amount, :currency, :excluded, :notes, :nature, :entryable_type,
|
||||
entryable_attributes: [ :id, :category_id, :merchant_id, { tag_ids: [] } ]
|
||||
)
|
||||
|
||||
nature = entry_params.delete(:nature)
|
||||
|
||||
if nature.present? && entry_params[:amount].present?
|
||||
signed_amount = nature == "inflow" ? -entry_params[:amount].to_d : entry_params[:amount].to_d
|
||||
entry_params = entry_params.merge(amount: signed_amount)
|
||||
end
|
||||
|
||||
entry_params
|
||||
end
|
||||
|
||||
def search_params
|
||||
cleaned_params = params.fetch(:q, {})
|
||||
.permit(
|
||||
:start_date, :end_date, :search, :amount,
|
||||
:amount_operator, accounts: [], account_ids: [],
|
||||
categories: [], merchants: [], types: [], tags: []
|
||||
)
|
||||
.to_h
|
||||
.compact_blank
|
||||
.permit(
|
||||
:start_date, :end_date, :search, :amount,
|
||||
:amount_operator, accounts: [], account_ids: [],
|
||||
categories: [], merchants: [], types: [], tags: []
|
||||
)
|
||||
.to_h
|
||||
.compact_blank
|
||||
|
||||
cleaned_params.delete(:amount_operator) unless cleaned_params[:amount].present?
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Performance optimisation
|
||||
# -------------------------------------------------------------------
|
||||
# When a user lands on the Transactions page without an explicit date
|
||||
# filter, the previous behaviour queried *all* historical transactions
|
||||
# for the family. For large datasets this results in very expensive
|
||||
# SQL (as shown in Skylight) – particularly the aggregation queries
|
||||
# used for @totals. To keep the UI responsive while still showing a
|
||||
# sensible period of activity, we fall back to the user's preferred
|
||||
# default period (stored on User#default_period, defaulting to
|
||||
# "last_30_days") when **no** date filters have been supplied.
|
||||
#
|
||||
# This effectively changes the default view from "all-time" to a
|
||||
# rolling window, dramatically reducing the rows scanned / grouped in
|
||||
# Postgres without impacting the UX (the user can always clear the
|
||||
# filter).
|
||||
# -------------------------------------------------------------------
|
||||
if cleaned_params[:start_date].blank? && cleaned_params[:end_date].blank?
|
||||
period_key = Current.user&.default_period.presence || "last_30_days"
|
||||
|
||||
begin
|
||||
period = Period.from_key(period_key)
|
||||
cleaned_params[:start_date] = period.start_date
|
||||
cleaned_params[:end_date] = period.end_date
|
||||
rescue Period::InvalidKeyError
|
||||
# Fallback – should never happen but keeps things safe.
|
||||
cleaned_params[:start_date] = 30.days.ago.to_date
|
||||
cleaned_params[:end_date] = Date.current
|
||||
end
|
||||
end
|
||||
|
||||
cleaned_params
|
||||
end
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
class Account::TransferMatchesController < ApplicationController
|
||||
class TransferMatchesController < ApplicationController
|
||||
before_action :set_entry
|
||||
|
||||
def new
|
||||
@accounts = Current.family.accounts.alphabetically.where.not(id: @entry.account_id)
|
||||
@transfer_match_candidates = @entry.account_transaction.transfer_match_candidates
|
||||
@transfer_match_candidates = @entry.transaction.transfer_match_candidates
|
||||
end
|
||||
|
||||
def create
|
||||
@@ -11,7 +11,7 @@ class Account::TransferMatchesController < ApplicationController
|
||||
@transfer.save!
|
||||
@transfer.sync_account_later
|
||||
|
||||
redirect_back_or_to transactions_path, notice: t(".success")
|
||||
redirect_back_or_to transactions_path, notice: "Transfer created"
|
||||
end
|
||||
|
||||
private
|
||||
@@ -27,7 +27,7 @@ class Account::TransferMatchesController < ApplicationController
|
||||
if transfer_match_params[:method] == "new"
|
||||
target_account = Current.family.accounts.find(transfer_match_params[:target_account_id])
|
||||
|
||||
missing_transaction = Account::Transaction.new(
|
||||
missing_transaction = Transaction.new(
|
||||
entry: target_account.entries.build(
|
||||
amount: @entry.amount * -1,
|
||||
currency: @entry.currency,
|
||||
@@ -37,8 +37,8 @@ class Account::TransferMatchesController < ApplicationController
|
||||
)
|
||||
|
||||
transfer = Transfer.find_or_initialize_by(
|
||||
inflow_transaction: @entry.amount.positive? ? missing_transaction : @entry.account_transaction,
|
||||
outflow_transaction: @entry.amount.positive? ? @entry.account_transaction : missing_transaction
|
||||
inflow_transaction: @entry.amount.positive? ? missing_transaction : @entry.transaction,
|
||||
outflow_transaction: @entry.amount.positive? ? @entry.transaction : missing_transaction
|
||||
)
|
||||
transfer.status = "confirmed"
|
||||
transfer
|
||||
@@ -46,8 +46,8 @@ class Account::TransferMatchesController < ApplicationController
|
||||
target_transaction = Current.family.entries.find(transfer_match_params[:matched_entry_id])
|
||||
|
||||
transfer = Transfer.find_or_initialize_by(
|
||||
inflow_transaction: @entry.amount.negative? ? @entry.account_transaction : target_transaction.account_transaction,
|
||||
outflow_transaction: @entry.amount.negative? ? target_transaction.account_transaction : @entry.account_transaction
|
||||
inflow_transaction: @entry.amount.negative? ? @entry.transaction : target_transaction.transaction,
|
||||
outflow_transaction: @entry.amount.negative? ? target_transaction.transaction : @entry.transaction
|
||||
)
|
||||
transfer.status = "confirmed"
|
||||
transfer
|
||||
@@ -38,7 +38,7 @@ class TransfersController < ApplicationController
|
||||
def update
|
||||
Transfer.transaction do
|
||||
update_transfer_status
|
||||
update_transfer_details
|
||||
update_transfer_details unless transfer_update_params[:status] == "rejected"
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
|
||||
@@ -49,6 +49,11 @@ class UsersController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def rule_prompt_settings
|
||||
@user.update!(rule_prompt_settings_params)
|
||||
redirect_back_or_to settings_profile_path
|
||||
end
|
||||
|
||||
private
|
||||
def handle_redirect(notice)
|
||||
case user_params[:redirect_to]
|
||||
@@ -58,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
|
||||
@@ -72,10 +81,16 @@ class UsersController < ApplicationController
|
||||
user_params[:email].present? && user_params[:email] != @user.email
|
||||
end
|
||||
|
||||
def rule_prompt_settings_params
|
||||
params.require(:user).permit(:rule_prompt_dismissed_at, :rule_prompts_disabled)
|
||||
end
|
||||
|
||||
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, :data_enrichment_enabled ]
|
||||
: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
|
||||
|
||||
|
||||
49
app/controllers/valuations_controller.rb
Normal file
49
app/controllers/valuations_controller.rb
Normal file
@@ -0,0 +1,49 @@
|
||||
class ValuationsController < ApplicationController
|
||||
include EntryableResource
|
||||
|
||||
def create
|
||||
account = Current.family.accounts.find(params.dig(:entry, :account_id))
|
||||
@entry = account.entries.new(entry_params.merge(entryable: Valuation.new))
|
||||
|
||||
if @entry.save
|
||||
@entry.sync_account_later
|
||||
|
||||
flash[:notice] = "Balance created"
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account) }
|
||||
format.turbo_stream { stream_redirect_back_or_to(account_path(@entry.account)) }
|
||||
end
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @entry.update(entry_params)
|
||||
@entry.sync_account_later
|
||||
|
||||
respond_to do |format|
|
||||
format.html { redirect_back_or_to account_path(@entry.account), notice: "Balance updated" }
|
||||
format.turbo_stream do
|
||||
render turbo_stream: [
|
||||
turbo_stream.replace(
|
||||
dom_id(@entry, :header),
|
||||
partial: "valuations/header",
|
||||
locals: { entry: @entry }
|
||||
),
|
||||
turbo_stream.replace(@entry)
|
||||
]
|
||||
end
|
||||
end
|
||||
else
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def entry_params
|
||||
params.require(:entry)
|
||||
.permit(:name, :date, :amount, :currency, :notes)
|
||||
end
|
||||
end
|
||||
@@ -6,10 +6,11 @@ class WebhooksController < ApplicationController
|
||||
webhook_body = request.body.read
|
||||
plaid_verification_header = request.headers["Plaid-Verification"]
|
||||
|
||||
client = Provider::Plaid.new(Rails.application.config.plaid, region: :us)
|
||||
client = Provider::Registry.plaid_provider_for_region(:us)
|
||||
|
||||
client.validate_webhook!(plaid_verification_header, webhook_body)
|
||||
client.process_webhook(webhook_body)
|
||||
|
||||
PlaidItem::WebhookProcessor.new(webhook_body).process
|
||||
|
||||
render json: { received: true }, status: :ok
|
||||
rescue => error
|
||||
@@ -21,10 +22,11 @@ class WebhooksController < ApplicationController
|
||||
webhook_body = request.body.read
|
||||
plaid_verification_header = request.headers["Plaid-Verification"]
|
||||
|
||||
client = Provider::Plaid.new(Rails.application.config.plaid_eu, region: :eu)
|
||||
client = Provider::Registry.plaid_provider_for_region(:eu)
|
||||
|
||||
client.validate_webhook!(plaid_verification_header, webhook_body)
|
||||
client.process_webhook(webhook_body)
|
||||
|
||||
PlaidItem::WebhookProcessor.new(webhook_body).process
|
||||
|
||||
render json: { received: true }, status: :ok
|
||||
rescue => error
|
||||
@@ -33,61 +35,23 @@ 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
|
||||
Rails.logger.error "JSON parser error: #{error.message}"
|
||||
head :bad_request
|
||||
rescue Stripe::SignatureVerificationError => error
|
||||
Sentry.capture_exception(error)
|
||||
render json: { error: "Invalid signature" }, status: :bad_request
|
||||
return
|
||||
Rails.logger.error "Stripe signature verification error: #{error.message}"
|
||||
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,8 +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(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)
|
||||
@@ -23,78 +45,10 @@ module ApplicationHelper
|
||||
content_for(:header_description) { page_description }
|
||||
end
|
||||
|
||||
def family_notifications_stream
|
||||
turbo_stream_from [ Current.family, :notifications ] if Current.family
|
||||
end
|
||||
|
||||
def family_stream
|
||||
turbo_stream_from Current.family if Current.family
|
||||
end
|
||||
|
||||
def render_flash_notifications
|
||||
notifications = flash.flat_map do |type, message_or_messages|
|
||||
Array(message_or_messages).map do |message|
|
||||
render partial: "shared/notification", locals: { type: type, message: message }
|
||||
end
|
||||
end
|
||||
|
||||
safe_join(notifications)
|
||||
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(options = {}, &block)
|
||||
content = capture &block
|
||||
render partial: "shared/modal", locals: { content:, classes: options[:classes] }
|
||||
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
|
||||
@@ -154,49 +108,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.titleize}?",
|
||||
body: "Are you sure you want to delete #{resource_name.downcase}? This is not reversible.",
|
||||
btn_text: "Delete #{resource_name.titleize}"
|
||||
)
|
||||
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,8 +1,8 @@
|
||||
module Account::EntriesHelper
|
||||
module EntriesHelper
|
||||
def entries_by_date(entries, totals: false)
|
||||
transfer_groups = entries.group_by do |entry|
|
||||
# Only check for transfer if it's a transaction
|
||||
next nil unless entry.entryable_type == "Account::Transaction"
|
||||
next nil unless entry.entryable_type == "Transaction"
|
||||
entry.entryable.transfer&.id
|
||||
end
|
||||
|
||||
@@ -12,7 +12,7 @@ module Account::EntriesHelper
|
||||
grouped_entries
|
||||
else
|
||||
grouped_entries.reject do |e|
|
||||
e.entryable_type == "Account::Transaction" &&
|
||||
e.entryable_type == "Transaction" &&
|
||||
e.entryable.transfer_as_inflow.present?
|
||||
end
|
||||
end
|
||||
@@ -25,7 +25,7 @@ module Account::EntriesHelper
|
||||
|
||||
next if content.blank?
|
||||
|
||||
render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, totals: }
|
||||
render partial: "entries/entry_group", locals: { date:, entries: grouped_entries, content:, totals: }
|
||||
end.compact.join.html_safe
|
||||
end
|
||||
|
||||
@@ -34,7 +34,7 @@ module Account::EntriesHelper
|
||||
entry.date,
|
||||
format_money(entry.amount_money),
|
||||
entry.account.name,
|
||||
entry.display_name
|
||||
entry.name
|
||||
].join(" • ")
|
||||
end
|
||||
end
|
||||
@@ -1,38 +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, &block)
|
||||
content = capture &block
|
||||
|
||||
render partial: "shared/modal_form", locals: { title:, subtitle:, content: }
|
||||
end
|
||||
|
||||
def radio_tab_tag(form:, name:, value:, label:, icon:, checked: false, disabled: false)
|
||||
form.label name, for: form.field_id(name, value), class: "group has-disabled:cursor-not-allowed" do
|
||||
concat radio_tab_contents(label:, icon:)
|
||||
concat form.radio_button(name, value, checked:, disabled:, class: "hidden")
|
||||
end
|
||||
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
|
||||
|
||||
private
|
||||
def radio_tab_contents(label:, icon:)
|
||||
tag.div(class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-subdued group-has-checked:bg-container group-has-checked:text-gray-800 group-has-checked:shadow-sm") do
|
||||
concat lucide_icon(icon, class: "w-5 h-5")
|
||||
concat tag.span(label, class: "group-has-checked:font-semibold")
|
||||
end
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user