Compare commits
75 Commits
v1.2.0-beta1
...
v1.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
| e6f38d9481 | |||
| 910d426ad4 | |||
| 72ad02de4b | |||
| 50285a3d51 | |||
| ff31e3d48b | |||
| 3d944afeb4 | |||
| 8e625107c1 | |||
| df034863c7 | |||
| 70cdfc9fd8 | |||
| a0bd776abb | |||
| afcafa6031 | |||
| 07c85697f3 | |||
| a882b5aadb | |||
| bad82fb41b | |||
| 5aca9008cb | |||
| 712a4c6c57 | |||
| 78f0d21b73 | |||
| 19cac58692 | |||
| 7d77b6fbc8 | |||
| e4e08091af | |||
| 292d43b154 | |||
| eae4422ad8 | |||
| 53d463b8b5 | |||
| 827644bb32 | |||
| 21e8672a12 | |||
| 70910c2f6d | |||
| 9ddea4c6e1 | |||
| 576b22e4c9 | |||
| 0035879a87 | |||
| 97298bcd39 | |||
| 0504058c01 | |||
| 750ccbd68f | |||
| d815700e58 | |||
| 9d809bbbe4 | |||
| 74a26e004f | |||
| 65710e2791 | |||
| ac310735df | |||
| 5611de0e2e | |||
| 4196539169 | |||
| 08cfcceb6a | |||
| e427d5802c | |||
| fc5cc1fee2 | |||
| fb3c19d3bf | |||
| 24aeb72549 | |||
| c799da58e1 | |||
| e24f932c0f | |||
| 7e2bf3430e | |||
| e1c8c2c515 | |||
| ae1e59ce30 | |||
| 03089ed1b3 | |||
| 97b13063d9 | |||
| 9260de5f25 | |||
| 505a24bf99 | |||
| 0e88b8c6f5 | |||
| 519486fe57 | |||
| 4086168515 | |||
| a13bd9f0dc | |||
| 2c3950b522 | |||
| 653f54add6 | |||
| 8e0d792d26 | |||
| 81af737204 | |||
| 81845d47f2 | |||
| cf475657cf | |||
| 90a15ceddb | |||
| 981ce0d62f | |||
| 154b679464 | |||
| ee51cb7e2a | |||
| 40120c7027 | |||
| cfd5b8a4f3 | |||
| 3b93e328d5 | |||
| 1fd858287d | |||
| e370f5bbb7 | |||
| 3e492475c0 | |||
| c454e85ad4 | |||
| 487322abb5 |
@@ -24,6 +24,9 @@ OPENAI_ORGANIZATION=
|
|||||||
MARKET_DATA_PROVIDER=yahoo
|
MARKET_DATA_PROVIDER=yahoo
|
||||||
ALPHAVANTAGE_API_KEY=
|
ALPHAVANTAGE_API_KEY=
|
||||||
FINNHUB_API_KEY=
|
FINNHUB_API_KEY=
|
||||||
|
ALPACA_API_KEY=
|
||||||
|
ALPACA_API_SECRET=
|
||||||
|
TWELVEDATA_API_SECRET=
|
||||||
|
|
||||||
# Cadence to refresh market data (in minutes)
|
# Cadence to refresh market data (in minutes)
|
||||||
MARKET_DATA_REFRESH=30
|
MARKET_DATA_REFRESH=30
|
||||||
|
|||||||
@@ -61,4 +61,6 @@ jobs:
|
|||||||
file: ./docker/Dockerfile
|
file: ./docker/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.extract-version.outputs.tags }}
|
tags: ${{ steps.extract-version.outputs.tags }}
|
||||||
|
build-args: |
|
||||||
|
VERSION=${{ github.ref_name }}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Investbrain is a smart open-source investment tracker that helps you manage, tra
|
|||||||
|
|
||||||
## Under the hood
|
## Under the hood
|
||||||
|
|
||||||
Investbrain is a Laravel PHP web application that leverages Livewire and Tailwind for its frontend. Most databases should work, including MySQL and SQLite. Out of the box, we feature three market data providers: [Yahoo Finance](https://finance.yahoo.com/), [Finnhub](https://finnhub.io/pricing-stock-api-market-data), and [Alpha Vantage](https://www.alphavantage.co/support/). But we also offer an extensible market data provider interface for intrepid developers to create their own! We also offer integrations with OpenAI and Ollama for our ["chat with your holdings"](#chat-with-your-holdings) capability. Finally, of course we have robust support for i18n, a11y, and dark mode.
|
Investbrain is a Laravel PHP web application that has an extensible market data provider interface. Out of the box, we feature many market data providers. But intrepid developers can [create their own providers](#custom-providers)! We also offer integrations with OpenAI and Ollama for our ["chat with your holdings"](#chat-with-your-holdings) capability. Finally, of course we have robust support for i18n, a11y, and dark mode.
|
||||||
|
|
||||||
## Self hosting
|
## Self hosting
|
||||||
|
|
||||||
@@ -50,8 +50,6 @@ curl -O https://raw.githubusercontent.com/investbrainapp/investbrain/main/docker
|
|||||||
|
|
||||||
Adjust the `environment` properties in the compose file to your preferences.
|
Adjust the `environment` properties in the compose file to your preferences.
|
||||||
|
|
||||||
**Importantly**, you need to set the `APP_KEY` value. If you're unsure, Investbrain will generate an `APP_KEY` for you on first run, but it will not persist. You must _manually_ update your environment configuration with this generated value!
|
|
||||||
|
|
||||||
**3. Run `docker compose up`**
|
**3. Run `docker compose up`**
|
||||||
|
|
||||||
It might take a few minutes to pull the Docker images. But assuming everything worked as expected, you should now be able to access Investbrain in the browser by visiting:
|
It might take a few minutes to pull the Docker images. But assuming everything worked as expected, you should now be able to access Investbrain in the browser by visiting:
|
||||||
@@ -74,7 +72,7 @@ Always keep in mind the limitations of LLMs. When in doubt, consult a licensed i
|
|||||||
|
|
||||||
## Market data providers
|
## Market data providers
|
||||||
|
|
||||||
Investbrain includes an extensible market data provider interface that allows you to retrieve stock market data from multiple providers, such as Yahoo Finance, Alpha Vantage, or Finnhub. The interface includes a built-in fallback mechanism to ensure reliable data access, even if a provider fails.
|
Investbrain includes an extensible market data provider interface that allows you to retrieve stock market data from multiple providers, such as [Yahoo Finance](https://finance.yahoo.com/), [Twelve Data](https://twelvedata.com), [Finnhub](https://finnhub.io/pricing-stock-api-market-data), [Alpaca](https://alpaca.markets/), and [Alpha Vantage](https://www.alphavantage.co/support/). The interface includes a built-in fallback mechanism to ensure reliable data access, even if a provider fails.
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
@@ -92,7 +90,7 @@ Your selected providers should be listed in your environment variables. Each sho
|
|||||||
MARKET_DATA_PROVIDER=yahoo,alphavantage
|
MARKET_DATA_PROVIDER=yahoo,alphavantage
|
||||||
```
|
```
|
||||||
|
|
||||||
In the above example, Yahoo Finance will be attempted first and the Alpha Vantage provider will be used as the fallback. If Yahoo Finance fails to retrieve market data, the application will automatically try Alpha Vantage.
|
In the above example, Yahoo Finance will be attempted first. If Yahoo Finance fails to retrieve market data, the application will automatically try Alpha Vantage.
|
||||||
|
|
||||||
### Custom providers
|
### Custom providers
|
||||||
|
|
||||||
@@ -137,10 +135,13 @@ There are several optional configurations available when installing using the re
|
|||||||
| ------------- | ------------- | ------------- |
|
| ------------- | ------------- | ------------- |
|
||||||
| APP_URL | The URL where your Investbrain installation will be accessible | http://localhost |
|
| APP_URL | The URL where your Investbrain installation will be accessible | http://localhost |
|
||||||
| APP_PORT | The HTTP port exposed by the NGINX container | 8000 |
|
| APP_PORT | The HTTP port exposed by the NGINX container | 8000 |
|
||||||
| APP_KEY | Must be set during install - encryption key for various security-related functions | `null` |
|
| APP_KEY | Encryption key for various security-related functions | Set automatically during install |
|
||||||
| MARKET_DATA_PROVIDER | The market data provider to use (either `yahoo`, `alphavantage`, or `finnhub`) | yahoo |
|
| MARKET_DATA_PROVIDER | The market data provider to use (either `yahoo`, `twelvedata`, `alphavantage`, `alpaca`, or `finnhub`) | yahoo |
|
||||||
| ALPHAVANTAGE_API_KEY | If using the Alpha Vantage provider | `null` |
|
| ALPHAVANTAGE_API_KEY | If using the Alpha Vantage provider | `null` |
|
||||||
| FINNHUB_API_KEY | If using the Finnhub provider | `null` |
|
| FINNHUB_API_KEY | If using the Finnhub provider | `null` |
|
||||||
|
| ALPACA_API_KEY | If using the Alpaca provider | `null` |
|
||||||
|
| ALPACA_API_SECRET | If using the Alpaca provider | `null` |
|
||||||
|
| TWELVEDATA_API_SECRET | If using the Twelve Data provider | `null` |
|
||||||
| MARKET_DATA_REFRESH | Cadence to refresh market data in minutes | 30 |
|
| MARKET_DATA_REFRESH | Cadence to refresh market data in minutes | 30 |
|
||||||
| APP_TIMEZONE | Timezone for the application, including daily change captures | UTC |
|
| APP_TIMEZONE | Timezone for the application, including daily change captures | UTC |
|
||||||
| AI_CHAT_ENABLED | Whether to enable AI chat features | `false` |
|
| AI_CHAT_ENABLED | Whether to enable AI chat features | `false` |
|
||||||
@@ -178,7 +179,7 @@ Easy as that!
|
|||||||
|
|
||||||
## Command line utilities
|
## Command line utilities
|
||||||
|
|
||||||
Investbrain comes bundled with several helpful command line utilities to make managing your portfolios and holdings more efficient. Keep in mind these commands are extremely powerful and can make irreversable changes to your holdings.
|
Investbrain comes bundled with several helpful command line utilities to make managing your portfolios and holdings more efficient. Keep in mind these commands are extremely powerful and can make irreversable changes to your holdings. Just to be safe, we recommend backing up your portfolios before using these commands.
|
||||||
|
|
||||||
To run these commands, you can use `docker exec` like this:
|
To run these commands, you can use `docker exec` like this:
|
||||||
|
|
||||||
@@ -186,7 +187,12 @@ To run these commands, you can use `docker exec` like this:
|
|||||||
docker exec -it investbrain-app php artisan <replace with command you want to run>
|
docker exec -it investbrain-app php artisan <replace with command you want to run>
|
||||||
```
|
```
|
||||||
|
|
||||||
Just to be safe, we recommend backing up your portfolios before using these commands:
|
If you need more details on what the command does, you can take a look at the options available using the `help` option:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
<command you want to run> --help
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ------------- | ------------- |
|
| ------------- | ------------- |
|
||||||
@@ -195,8 +201,9 @@ Just to be safe, we recommend backing up your portfolios before using these comm
|
|||||||
| refresh:split-data | Refreshes splits data with your configured market data provider. Will also create new transactions to account for any splits. |
|
| refresh:split-data | Refreshes splits data with your configured market data provider. Will also create new transactions to account for any splits. |
|
||||||
| refresh:currency-data | Grabs the latest daily currency exchange rate data and persists to the database. |
|
| refresh:currency-data | Grabs the latest daily currency exchange rate data and persists to the database. |
|
||||||
| capture:daily-change | Captures a snapshot of each portfolio's daily performance. |
|
| capture:daily-change | Captures a snapshot of each portfolio's daily performance. |
|
||||||
| sync:daily-change | Re-calculates daily snapshots of your portfolio's daily performance. Useful to fill in gaps in your portfolio charts. (Note: this is an extremely resource intensive query.) |
|
| sync:daily-change | Syncs daily snapshots of your portfolio's daily performance. Useful to fill in gaps in your portfolio charts. (Note: this is an extremely resource intensive query.) |
|
||||||
| sync:holdings | Re-calculates performance of holdings with related transactions (i.e. dividends, realized gains, etc). |
|
| sync:holdings | Syncs performance of holdings with related transactions (i.e. dividends, realized gains, etc). |
|
||||||
|
| fix:cost-basis-for-sales | Utility to automatically re-calculates cost basis for sale transactions. |
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
| ------- | ------------------ |
|
| ------- | ------------------ |
|
||||||
| 1.1.x | :white_check_mark: |
|
| 1.2.x | :white_check_mark: |
|
||||||
|
| 1.1.x | :x: |
|
||||||
| 1.0.x | :x: |
|
| 1.0.x | :x: |
|
||||||
| < 1.0.0 | :x: |
|
| < 1.0.0 | :x: |
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,18 @@ class EnsureCostBasisAddedToSale
|
|||||||
// cost basis is required for sales to calculate realized gains
|
// cost basis is required for sales to calculate realized gains
|
||||||
if ($model->transaction_type == 'SELL') {
|
if ($model->transaction_type == 'SELL') {
|
||||||
|
|
||||||
$average_cost_basis = Transaction::where([
|
$cost_basis = Transaction::where([
|
||||||
'portfolio_id' => $model->portfolio_id,
|
'portfolio_id' => $model->portfolio_id,
|
||||||
'symbol' => $model->symbol,
|
'symbol' => $model->symbol,
|
||||||
'transaction_type' => 'BUY',
|
'transaction_type' => 'BUY',
|
||||||
])->whereDate('date', '<=', $model->date)
|
])->whereDate('date', '<=', $model->date)
|
||||||
->average('cost_basis');
|
->selectRaw('SUM(transactions.cost_basis * transactions.quantity) as total_cost_basis')
|
||||||
|
->selectRaw('SUM(transactions.quantity) as total_quantity')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$average_cost_basis = empty($cost_basis->total_quantity)
|
||||||
|
? 0
|
||||||
|
: $cost_basis->total_cost_basis / $cost_basis->total_quantity;
|
||||||
|
|
||||||
$model->cost_basis = $average_cost_basis ?? 0;
|
$model->cost_basis = $average_cost_basis ?? 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Actions\Jetstream;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Laravel\Jetstream\Contracts\DeletesUsers;
|
|
||||||
|
|
||||||
class DeleteUser implements DeletesUsers
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Delete the given user.
|
|
||||||
*/
|
|
||||||
public function delete(User $user): void
|
|
||||||
{
|
|
||||||
$user->deleteProfilePhoto();
|
|
||||||
$user->tokens->each->delete();
|
|
||||||
$user->delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -49,16 +49,9 @@ class CaptureDailyChange extends Command
|
|||||||
->portfolio($portfolio->id)
|
->portfolio($portfolio->id)
|
||||||
->getPortfolioMetrics(config('investbrain.base_currency'));
|
->getPortfolioMetrics(config('investbrain.base_currency'));
|
||||||
|
|
||||||
$total_cost_basis = $metrics->get('total_cost_basis');
|
|
||||||
$total_market_value = $metrics->get('total_market_value');
|
|
||||||
|
|
||||||
$portfolio->daily_change()->create([
|
$portfolio->daily_change()->create([
|
||||||
'date' => now(),
|
'date' => now(),
|
||||||
'total_market_value' => $total_market_value,
|
'total_market_value' => $metrics->get('total_market_value'),
|
||||||
'total_cost_basis' => $total_cost_basis,
|
|
||||||
'total_gain' => $total_market_value - $total_cost_basis,
|
|
||||||
'total_dividends_earned' => $metrics->get('total_dividends_earned'),
|
|
||||||
'realized_gains' => $metrics->get('realized_gain_dollars'),
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Portfolio;
|
||||||
|
use App\Models\Transaction;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class FixCostBasisForSales extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'fix:cost-basis-for-sales
|
||||||
|
{--portfolio= : The ID of the portfolio to fix.}
|
||||||
|
{--user= : The user ID of transactions to fix.}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Fixes broken costs basis for sale transactions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new command instance.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
|
||||||
|
if (empty($this->option('user')) && empty($this->option('portfolio'))) {
|
||||||
|
|
||||||
|
$this->error('Must provide at least a user or portfolio.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$transactions = Transaction::where(['transaction_type' => 'SELL']);
|
||||||
|
|
||||||
|
if ($this->option('user')) {
|
||||||
|
|
||||||
|
$portfolios = Portfolio::fullAccess($this->option('user'))->get('id')
|
||||||
|
->pluck('id')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$transactions->whereIn('portfolio_id', $portfolios);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
$transactions->where(['portfolio_id' => $this->option('portfolio')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$transactions = $transactions->get();
|
||||||
|
|
||||||
|
$this->line("Fixing cost basis for {$transactions->count()} sale transactions...");
|
||||||
|
|
||||||
|
$transactions->chunk(10)->each(function ($chunk) {
|
||||||
|
|
||||||
|
dispatch(function () use ($chunk) {
|
||||||
|
|
||||||
|
$chunk->each(function ($transaction) {
|
||||||
|
|
||||||
|
$cost_basis = Transaction::where([
|
||||||
|
'portfolio_id' => $transaction->portfolio_id,
|
||||||
|
'symbol' => $transaction->symbol,
|
||||||
|
'transaction_type' => 'BUY',
|
||||||
|
])->whereDate('date', '<=', $transaction->date)
|
||||||
|
->selectRaw('SUM(transactions.cost_basis * transactions.quantity) as total_cost_basis')
|
||||||
|
->selectRaw('SUM(transactions.quantity) as total_quantity')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$average_cost_basis = empty($cost_basis->total_quantity)
|
||||||
|
? 0
|
||||||
|
: $cost_basis->total_cost_basis / $cost_basis->total_quantity;
|
||||||
|
|
||||||
|
$transaction->cost_basis = $average_cost_basis ?? 0;
|
||||||
|
|
||||||
|
$transaction->save();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->line('Done!');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,9 +22,8 @@ class DailyChangesSheet implements FromCollection, WithHeadings, WithTitle
|
|||||||
'Portfolio ID',
|
'Portfolio ID',
|
||||||
'Total Market Value',
|
'Total Market Value',
|
||||||
'Total Cost Basis',
|
'Total Cost Basis',
|
||||||
'Total Gain',
|
|
||||||
'Total Dividends Earned',
|
|
||||||
'Realized Gains',
|
'Realized Gains',
|
||||||
|
'Total Dividends Earned',
|
||||||
'Annotation',
|
'Annotation',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Routing\Controller;
|
||||||
|
|
||||||
|
class ApiTokenController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Show the user API token screen.
|
||||||
|
*
|
||||||
|
* @return \Illuminate\View\View
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
return view('api.index', [
|
||||||
|
'request' => $request,
|
||||||
|
'user' => $request->user(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -124,7 +124,7 @@ class ConnectedAccountController extends Controller
|
|||||||
'title' => __('Your :provider account has been connected.', ['provider' => config("services.{$connected_account->provider}.name")]),
|
'title' => __('Your :provider account has been connected.', ['provider' => config("services.{$connected_account->provider}.name")]),
|
||||||
'description' => null,
|
'description' => null,
|
||||||
'css' => 'alert-success',
|
'css' => 'alert-success',
|
||||||
'icon' => Blade::render("<x-mary-icon class='w-7 h-7' name='o-check-circle' />"),
|
'icon' => Blade::render("<x-ui.icon class='w-7 h-7' name='o-check-circle' />"),
|
||||||
'position' => 'toast-top toast-end',
|
'position' => 'toast-top toast-end',
|
||||||
'timeout' => '5000',
|
'timeout' => '5000',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Traits\HasLocalizedMarkdown;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Routing\Controller;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class PrivacyPolicyController extends Controller
|
||||||
|
{
|
||||||
|
use HasLocalizedMarkdown;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the privacy policy for the application.
|
||||||
|
*
|
||||||
|
* @return \Illuminate\View\View
|
||||||
|
*/
|
||||||
|
public function show(Request $request)
|
||||||
|
{
|
||||||
|
$policyFile = $this->localizedMarkdownPath('policy.md');
|
||||||
|
|
||||||
|
return view('policy', [
|
||||||
|
'policy' => Str::markdown(file_get_contents($policyFile)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Traits\HasLocalizedMarkdown;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Routing\Controller;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class TermsOfServiceController extends Controller
|
||||||
|
{
|
||||||
|
use HasLocalizedMarkdown;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the terms of service for the application.
|
||||||
|
*
|
||||||
|
* @return \Illuminate\View\View
|
||||||
|
*/
|
||||||
|
public function show(Request $request)
|
||||||
|
{
|
||||||
|
$termsFile = $this->localizedMarkdownPath('terms.md');
|
||||||
|
|
||||||
|
return view('terms', [
|
||||||
|
'terms' => Str::markdown(file_get_contents($termsFile)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Routing\Controller;
|
||||||
|
|
||||||
|
class UserProfileController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Show the user profile screen.
|
||||||
|
*
|
||||||
|
* @return \Illuminate\View\View
|
||||||
|
*/
|
||||||
|
public function show(Request $request)
|
||||||
|
{
|
||||||
|
return view('profile.show', [
|
||||||
|
'request' => $request,
|
||||||
|
'user' => $request->user(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,10 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Number;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Foundation\Events\LocaleUpdated;
|
use Illuminate\Support\Number;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class LocalizationMiddleware
|
class LocalizationMiddleware
|
||||||
{
|
{
|
||||||
@@ -24,9 +23,7 @@ class LocalizationMiddleware
|
|||||||
|
|
||||||
$locale = auth()->user()->getLocale();
|
$locale = auth()->user()->getLocale();
|
||||||
|
|
||||||
config(['app.locale' => $locale]);
|
app()->setLocale(Str::before($locale, '_'));
|
||||||
app('translator')->setLocale(Str::before($locale, '_'));
|
|
||||||
app('events')->dispatch(new LocaleUpdated($locale));
|
|
||||||
|
|
||||||
Number::useLocale($locale);
|
Number::useLocale($locale);
|
||||||
Number::useCurrency(auth()->user()->getCurrency());
|
Number::useCurrency(auth()->user()->getCurrency());
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ class TransactionRequest extends FormRequest
|
|||||||
$this->input('portfolio'),
|
$this->input('portfolio'),
|
||||||
$this->requestOrModelValue('symbol', 'transaction'),
|
$this->requestOrModelValue('symbol', 'transaction'),
|
||||||
$this->requestOrModelValue('transaction_type', 'transaction'),
|
$this->requestOrModelValue('transaction_type', 'transaction'),
|
||||||
$this->requestOrModelValue('date', 'transaction')
|
$this->requestOrModelValue('date', 'transaction'),
|
||||||
|
$this->transaction
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
'currency' => ['required', 'exists:currencies,currency'],
|
'currency' => ['required', 'exists:currencies,currency'],
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Interfaces\MarketData;
|
||||||
|
|
||||||
|
use App\Interfaces\MarketData\Types\Dividend;
|
||||||
|
use App\Interfaces\MarketData\Types\Ohlc;
|
||||||
|
use App\Interfaces\MarketData\Types\Quote;
|
||||||
|
use App\Interfaces\MarketData\Types\Split;
|
||||||
|
use Carbon\CarbonInterval;
|
||||||
|
use Illuminate\Http\Client\PendingRequest;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
class AlpacaMarketData implements MarketDataInterface
|
||||||
|
{
|
||||||
|
public PendingRequest $client;
|
||||||
|
|
||||||
|
public string $dataBaseUrl = 'https://data.alpaca.markets/';
|
||||||
|
|
||||||
|
public string $apiBaseUrl = 'https://api.alpaca.markets/';
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createNewClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createNewClient()
|
||||||
|
{
|
||||||
|
$this->client = Http::withOptions([
|
||||||
|
'headers' => [
|
||||||
|
'content-type' => 'application/json',
|
||||||
|
'accept' => 'application/json',
|
||||||
|
'Apca-Api-Key-Id' => config('alpaca.key'),
|
||||||
|
'Apca-Api-Secret-Key' => config('alpaca.secret'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exists(string $symbol): bool
|
||||||
|
{
|
||||||
|
return (bool) $this->quote($symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function quote(string $symbol): Quote
|
||||||
|
{
|
||||||
|
$response = $this->client->baseUrl($this->dataBaseUrl)->get("v2/stocks/{$symbol}/trades/latest");
|
||||||
|
|
||||||
|
$quote = $response->json('trade');
|
||||||
|
|
||||||
|
throw_if(empty(Arr::get($quote, 'p')), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
|
||||||
|
|
||||||
|
$fundamental = cache()->remember(
|
||||||
|
'ap-symbol-'.$symbol,
|
||||||
|
1440,
|
||||||
|
function () use ($symbol) {
|
||||||
|
|
||||||
|
$this->createNewClient();
|
||||||
|
|
||||||
|
$basic = $this->client->baseUrl($this->apiBaseUrl)->get("v2/assets/{$symbol}")->json();
|
||||||
|
$fifty_two_week = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
|
||||||
|
'timeframe' => '12M',
|
||||||
|
'start' => now()->subWeeks(53)->format('Y-m-d'),
|
||||||
|
'end' => now()->subWeeks(1)->format('Y-m-d'), // todo: can't query recent SIP data
|
||||||
|
])->get("v2/stocks/{$symbol}/bars")->json();
|
||||||
|
|
||||||
|
return array_merge($fifty_two_week, $basic);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Quote([
|
||||||
|
'name' => Arr::get($fundamental, 'name'),
|
||||||
|
'symbol' => $symbol,
|
||||||
|
'currency' => 'USD', // Alpaca only has US equitities
|
||||||
|
'market_value' => Arr::get($quote, 'p'),
|
||||||
|
'fifty_two_week_high' => Arr::get($fundamental, 'bars.0.h'),
|
||||||
|
'fifty_two_week_low' => Arr::get($fundamental, 'bars.0.l'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dividends(string $symbol, $startDate, $endDate): Collection
|
||||||
|
{
|
||||||
|
$response = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
|
||||||
|
'symbols' => $symbol,
|
||||||
|
'limit' => 1000,
|
||||||
|
'sort' => 'asc',
|
||||||
|
'types' => 'cash_dividend',
|
||||||
|
'start' => $startDate->format('Y-m-d'),
|
||||||
|
'end' => $endDate->format('Y-m-d'),
|
||||||
|
])->get('v1/corporate-actions');
|
||||||
|
|
||||||
|
$dividends = $response->json('corporate_actions.cash_dividends');
|
||||||
|
|
||||||
|
return collect($dividends)
|
||||||
|
->map(function ($dividend) use ($symbol) {
|
||||||
|
|
||||||
|
return new Dividend([
|
||||||
|
'symbol' => $symbol,
|
||||||
|
'date' => Carbon::parse(Arr::get($dividend, 'ex_date')),
|
||||||
|
'dividend_amount' => Arr::get($dividend, 'rate'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function splits(string $symbol, $startDate, $endDate): Collection
|
||||||
|
{
|
||||||
|
$response = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
|
||||||
|
'symbols' => $symbol,
|
||||||
|
'limit' => 1000,
|
||||||
|
'sort' => 'asc',
|
||||||
|
'types' => 'forward_split',
|
||||||
|
'start' => $startDate->format('Y-m-d'),
|
||||||
|
'end' => $endDate->format('Y-m-d'),
|
||||||
|
])->get('v1/corporate-actions');
|
||||||
|
|
||||||
|
$splits = $response->json('corporate_actions.forward_splits');
|
||||||
|
|
||||||
|
return collect($splits)
|
||||||
|
->map(function ($split) use ($symbol) {
|
||||||
|
|
||||||
|
return new Split([
|
||||||
|
'symbol' => $symbol,
|
||||||
|
'date' => Carbon::parse(Arr::get($split, 'ex_date')),
|
||||||
|
'split_amount' => Arr::get($split, 'new_rate') / Arr::get($split, 'old_rate'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function history(string $symbol, $startDate, $endDate): Collection
|
||||||
|
{
|
||||||
|
$startDate = Carbon::parse($startDate);
|
||||||
|
$endDate = Carbon::parse($endDate)->subHours(36); // alpaca has sip data limits
|
||||||
|
|
||||||
|
$allHistory = collect();
|
||||||
|
|
||||||
|
$chunks = 1000;
|
||||||
|
|
||||||
|
$period = CarbonInterval::days($chunks)->toPeriod($startDate, $endDate);
|
||||||
|
foreach ($period as $startDate) {
|
||||||
|
|
||||||
|
$chunkEnd = $startDate->copy()->addDays($chunks - 1);
|
||||||
|
|
||||||
|
if ($chunkEnd->gt($endDate)) {
|
||||||
|
$chunkEnd = $endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->createNewClient();
|
||||||
|
|
||||||
|
$response = $this->client->baseUrl($this->dataBaseUrl)->withQueryParameters([
|
||||||
|
'timeframe' => '1D',
|
||||||
|
'start' => $startDate->format('Y-m-d'),
|
||||||
|
'end' => $chunkEnd->format('Y-m-d'),
|
||||||
|
])->get("v2/stocks/{$symbol}/bars");
|
||||||
|
|
||||||
|
$history = $response->json('bars');
|
||||||
|
|
||||||
|
throw_if(empty($history), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
|
||||||
|
|
||||||
|
$chunkedHistory = collect($history)
|
||||||
|
->mapWithKeys(function ($history) use ($symbol) {
|
||||||
|
|
||||||
|
$date = Carbon::parse($history['t'])->format('Y-m-d');
|
||||||
|
|
||||||
|
return [$date => new Ohlc([
|
||||||
|
'symbol' => $symbol,
|
||||||
|
'date' => $date,
|
||||||
|
'close' => Arr::get($history, 'c'),
|
||||||
|
])];
|
||||||
|
});
|
||||||
|
|
||||||
|
$allHistory = $allHistory->merge($chunkedHistory);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $allHistory;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,7 +69,7 @@ class AlphaVantageMarketData implements MarketDataInterface
|
|||||||
? Arr::get($fundamental, 'DividendDate')
|
? Arr::get($fundamental, 'DividendDate')
|
||||||
: null,
|
: null,
|
||||||
'dividend_yield' => Arr::get($fundamental, 'DividendYield') != 'None'
|
'dividend_yield' => Arr::get($fundamental, 'DividendYield') != 'None'
|
||||||
? Arr::get($fundamental, 'DividendYield') * 100
|
? ((float) Arr::get($fundamental, 'DividendYield')) * 100
|
||||||
: null,
|
: null,
|
||||||
'meta_data' => [
|
'meta_data' => [
|
||||||
'industry' => Arr::get($fundamental, 'Industry'),
|
'industry' => Arr::get($fundamental, 'Industry'),
|
||||||
@@ -145,7 +145,7 @@ class AlphaVantageMarketData implements MarketDataInterface
|
|||||||
return [$date => new Ohlc([
|
return [$date => new Ohlc([
|
||||||
'symbol' => $symbol,
|
'symbol' => $symbol,
|
||||||
'date' => $date,
|
'date' => $date,
|
||||||
'close' => Arr::get($history, '4. close'),
|
'close' => (float) Arr::get($history, '4. close'),
|
||||||
])];
|
])];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Interfaces\MarketData;
|
||||||
|
|
||||||
|
use App\Interfaces\MarketData\Types\Dividend;
|
||||||
|
use App\Interfaces\MarketData\Types\Ohlc;
|
||||||
|
use App\Interfaces\MarketData\Types\Quote;
|
||||||
|
use App\Interfaces\MarketData\Types\Split;
|
||||||
|
use Illuminate\Http\Client\PendingRequest;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
class TwelveDataMarketData implements MarketDataInterface
|
||||||
|
{
|
||||||
|
public PendingRequest $client;
|
||||||
|
|
||||||
|
public string $apiBaseUrl = 'https://api.twelvedata.com/';
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createNewClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createNewClient()
|
||||||
|
{
|
||||||
|
$this->client = Http::withOptions([
|
||||||
|
'headers' => [
|
||||||
|
'content-type' => 'application/json',
|
||||||
|
'accept' => 'application/json',
|
||||||
|
],
|
||||||
|
])->withQueryParameters([
|
||||||
|
'apikey' => config('twelvedata.secret'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exists(string $symbol): bool
|
||||||
|
{
|
||||||
|
|
||||||
|
return (bool) $this->quote($symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function quote(string $symbol): Quote
|
||||||
|
{
|
||||||
|
|
||||||
|
$response = $this->client
|
||||||
|
->baseUrl($this->apiBaseUrl)
|
||||||
|
->withQueryParameters(['symbol' => $symbol])
|
||||||
|
->get('price');
|
||||||
|
|
||||||
|
$quote = $response->json();
|
||||||
|
|
||||||
|
throw_if(empty(Arr::get($quote, 'price')), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
|
||||||
|
|
||||||
|
$current_market_value = Arr::get($quote, 'price');
|
||||||
|
|
||||||
|
$fundamental = cache()->remember(
|
||||||
|
'twelve-data-symbol-'.$symbol,
|
||||||
|
1440,
|
||||||
|
function () use ($symbol) {
|
||||||
|
|
||||||
|
$this->createNewClient();
|
||||||
|
|
||||||
|
$response = $this->client
|
||||||
|
->baseUrl($this->apiBaseUrl)
|
||||||
|
->withQueryParameters(['symbol' => $symbol])
|
||||||
|
->get('quote');
|
||||||
|
|
||||||
|
return $response->json();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Quote([
|
||||||
|
'name' => Arr::get($fundamental, 'name'),
|
||||||
|
'symbol' => $symbol,
|
||||||
|
'currency' => Arr::get($fundamental, 'currency'),
|
||||||
|
'market_value' => (float) $current_market_value,
|
||||||
|
'fifty_two_week_high' => (float) Arr::get($fundamental, 'fifty_two_week.high'),
|
||||||
|
'fifty_two_week_low' => (float) Arr::get($fundamental, 'fifty_two_week.low'),
|
||||||
|
'meta_data' => [
|
||||||
|
'exchange' => Arr::get($fundamental, 'exchange'),
|
||||||
|
'source' => 'twelvedata',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dividends(string $symbol, $startDate, $endDate): Collection
|
||||||
|
{
|
||||||
|
|
||||||
|
$response = $this->client
|
||||||
|
->baseUrl($this->apiBaseUrl)
|
||||||
|
->withQueryParameters([
|
||||||
|
'symbol' => $symbol,
|
||||||
|
'start_date' => Carbon::parse($startDate)->toDateString(),
|
||||||
|
'end_date' => Carbon::parse($endDate)->toDateString(),
|
||||||
|
])
|
||||||
|
->get('dividends');
|
||||||
|
|
||||||
|
$dividends = $response->json('dividends');
|
||||||
|
|
||||||
|
return collect($dividends)
|
||||||
|
->map(function ($dividend) use ($symbol) {
|
||||||
|
|
||||||
|
return new Dividend([
|
||||||
|
'symbol' => $symbol,
|
||||||
|
'date' => Arr::get($dividend, 'ex_date'),
|
||||||
|
'dividend_amount' => Arr::get($dividend, 'amount'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function splits(string $symbol, $startDate, $endDate): Collection
|
||||||
|
{
|
||||||
|
|
||||||
|
$response = $this->client
|
||||||
|
->baseUrl($this->apiBaseUrl)
|
||||||
|
->withQueryParameters([
|
||||||
|
'symbol' => $symbol,
|
||||||
|
'start_date' => Carbon::parse($startDate)->toDateString(),
|
||||||
|
'end_date' => Carbon::parse($endDate)->toDateString(),
|
||||||
|
])
|
||||||
|
->get('splits');
|
||||||
|
|
||||||
|
$splits = $response->json('splits');
|
||||||
|
|
||||||
|
return collect($splits)
|
||||||
|
->map(function ($split) use ($symbol) {
|
||||||
|
|
||||||
|
return new Split([
|
||||||
|
'symbol' => $symbol,
|
||||||
|
'date' => Arr::get($split, 'date'),
|
||||||
|
'split_amount' => Arr::get($split, 'from_factor') / Arr::get($split, 'to_factor'),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function history(string $symbol, $startDate, $endDate): Collection
|
||||||
|
{
|
||||||
|
|
||||||
|
$response = $this->client
|
||||||
|
->baseUrl($this->apiBaseUrl)
|
||||||
|
->withQueryParameters([
|
||||||
|
'symbol' => $symbol,
|
||||||
|
'interval' => '1day',
|
||||||
|
'start_date' => Carbon::parse($startDate)->toDateString(),
|
||||||
|
'end_date' => Carbon::parse($endDate)->toDateString(),
|
||||||
|
])
|
||||||
|
->get('time_series');
|
||||||
|
|
||||||
|
$history = $response->json('values');
|
||||||
|
|
||||||
|
throw_if(empty($history), NotFoundHttpException::class, "Symbol `{$symbol}` was not found");
|
||||||
|
|
||||||
|
return collect($history)
|
||||||
|
->mapWithKeys(function ($history) use ($symbol) {
|
||||||
|
|
||||||
|
$date = Carbon::parse(Arr::get($history, 'datetime'))->toDateString();
|
||||||
|
|
||||||
|
return [$date => new Ohlc([
|
||||||
|
'symbol' => $symbol,
|
||||||
|
'date' => $date,
|
||||||
|
'close' => (float) Arr::get($history, 'close'),
|
||||||
|
])];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,14 @@ class Quote extends MarketDataType
|
|||||||
|
|
||||||
public function setCurrency(string $currency): self
|
public function setCurrency(string $currency): self
|
||||||
{
|
{
|
||||||
|
// need to standardize to ISO 4217
|
||||||
|
$currency = match ($currency) {
|
||||||
|
'US' => 'USD',
|
||||||
|
'CA' => 'CAD',
|
||||||
|
'GBp' => 'GBX',
|
||||||
|
default => $currency
|
||||||
|
};
|
||||||
|
|
||||||
$this->items['currency'] = strtoupper((string) $currency);
|
$this->items['currency'] = strtoupper((string) $currency);
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ class YahooMarketData implements MarketDataInterface
|
|||||||
{
|
{
|
||||||
|
|
||||||
// create yahoo finance client factory
|
// create yahoo finance client factory
|
||||||
$this->client = YahooFinance::createApiClient();
|
$this->client = YahooFinance::createApiClient(
|
||||||
|
clientOptions: ['headers' => ['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36']],
|
||||||
|
cache: app('cache.psr6')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function exists(string $symbol): bool
|
public function exists(string $symbol): bool
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Datatables;
|
||||||
|
|
||||||
|
use App\Models\Holding;
|
||||||
|
use Illuminate\Support\Number;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Rappasoft\LaravelLivewireTables\Views\Column;
|
||||||
|
use Rappasoft\LaravelLivewireTables\DataTableComponent;
|
||||||
|
|
||||||
|
class HoldingsTable extends DataTableComponent
|
||||||
|
{
|
||||||
|
public $portfolio;
|
||||||
|
public array $hiddenColumns = [];
|
||||||
|
|
||||||
|
public function mount ($portfolio): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public function builder(): Builder
|
||||||
|
{
|
||||||
|
return Holding::query()
|
||||||
|
->portfolio($this->portfolio->id)
|
||||||
|
->with(['market_data'])
|
||||||
|
->withCount(['transactions as num_transactions' => function ($query) {
|
||||||
|
return $query->whereRaw('transactions.symbol = holdings.symbol');
|
||||||
|
}])
|
||||||
|
->withPerformance();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configure(): void
|
||||||
|
{
|
||||||
|
$this->hiddenColumns = ['name', 'average_cost_basis', 'market_value', 'fifty_two_week_low', 'fifty_two_week_high'];
|
||||||
|
|
||||||
|
$this->setTableWrapperAttributes([
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => false,
|
||||||
|
'default-colors' => false,
|
||||||
|
'class' => 'overflow-scroll'
|
||||||
|
]);
|
||||||
|
$this->setTableAttributes([
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => false,
|
||||||
|
'default-colors' => false,
|
||||||
|
'class' => 'table',
|
||||||
|
]);
|
||||||
|
$this->setTheadAttributes([
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => true,
|
||||||
|
'default-colors' => false,
|
||||||
|
]);
|
||||||
|
$this->setThAttributes(function(Column $column) {
|
||||||
|
|
||||||
|
$attributes = [
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => false,
|
||||||
|
'default-colors' => false,
|
||||||
|
'class' => 'text-xs font-medium whitespace-nowrap uppercase tracking-wider text-nowrap'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (in_array($column->getField(), $this->hiddenColumns)) {
|
||||||
|
$attributes['class'] = $attributes['class'] . ' hidden md:table-cell';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attributes;
|
||||||
|
});
|
||||||
|
$this->setThSortButtonAttributes(fn() => [
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => true,
|
||||||
|
'default-colors' => false,
|
||||||
|
'class' => 'cursor-pointer'
|
||||||
|
]);
|
||||||
|
$this->setTbodyAttributes([
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => true,
|
||||||
|
'default-colors' => false,
|
||||||
|
]);
|
||||||
|
$this->setTrAttributes(fn() => [
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => true,
|
||||||
|
'default-colors' => false,
|
||||||
|
'class' => 'cursor-pointer hover:bg-neutral/25'
|
||||||
|
]);
|
||||||
|
$this->setTdAttributes(function(Column $column) {
|
||||||
|
|
||||||
|
$attributes = [
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => false,
|
||||||
|
'default-colors' => false,
|
||||||
|
'class' => 'text-nowrap'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (in_array($column->getField(), $this->hiddenColumns)) {
|
||||||
|
$attributes['class'] = $attributes['class'] . ' hidden md:table-cell';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attributes;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->setDefaultSort('symbol', 'asc');
|
||||||
|
|
||||||
|
$this->setToolsDisabled();
|
||||||
|
$this->setFooterDisabled();
|
||||||
|
$this->setPaginationDisabled();
|
||||||
|
$this->setDisplayPaginationDetailsDisabled();
|
||||||
|
|
||||||
|
$this->setPrimaryKey('id');
|
||||||
|
|
||||||
|
$this->setTableRowUrl(function($row) {
|
||||||
|
return route('holding.show', ['portfolio' => $row->portfolio_id, 'symbol' => $row->symbol]);
|
||||||
|
|
||||||
|
})->setTableRowUrlTarget(function($row) {
|
||||||
|
|
||||||
|
return 'navigate';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function columns(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Column::make(__('Symbol'), 'symbol')
|
||||||
|
->sortable(),
|
||||||
|
Column::make(__('Name'), 'market_data.name')
|
||||||
|
->sortable(),
|
||||||
|
Column::make(__('Quantity'), 'quantity')
|
||||||
|
->sortable(),
|
||||||
|
Column::make(__('Average Cost Basis'), 'average_cost_basis')
|
||||||
|
->sortable()
|
||||||
|
->format(fn($value, $row) => Number::currency($value ?? 0, $row->market_data->currency) ),
|
||||||
|
Column::make(__('Total Cost Basis'), 'total_cost_basis')
|
||||||
|
->sortable()
|
||||||
|
->format(fn($value, $row) => Number::currency($value ?? 0, $row->market_data->currency) ),
|
||||||
|
Column::make(__('Market Value'), 'market_data.market_value')
|
||||||
|
->sortable()
|
||||||
|
->format(fn($value, $row) => Number::currency($value ?? 0, $row->market_data->currency) ),
|
||||||
|
Column::make(__('Total Market Value'))
|
||||||
|
->sortable(fn (Builder $query, string $direction) => $query->orderBy('total_market_value', $direction))
|
||||||
|
->label(fn ($row) => Number::currency($row->total_market_value ?? 0, $row->market_data->currency)),
|
||||||
|
Column::make(__('Market Gain/Loss'))
|
||||||
|
->html()
|
||||||
|
->label(fn($row) => Number::currency($row->market_gain_dollars ?? 0, $row->market_data->currency) . view('components.ui.gain-loss-arrow-badge', [
|
||||||
|
'costBasis' => $row->average_cost_basis,
|
||||||
|
'marketValue' => $row->market_data->market_value,
|
||||||
|
'small' => true,
|
||||||
|
]))
|
||||||
|
->sortable(fn (Builder $query, string $direction) => $query->orderBy('market_gain_dollars', $direction)),
|
||||||
|
Column::make(__('Realized Gain/Loss'), 'realized_gain_dollars')
|
||||||
|
->sortable()
|
||||||
|
->format(fn($value, $row) => Number::currency($value ?? 0, $row->market_data->currency) )
|
||||||
|
->format(fn($value, $row) => Number::currency($value ?? 0, $row->market_data->currency) ),
|
||||||
|
Column::make(__('Dividends Earned'), 'dividends_earned')
|
||||||
|
->sortable()
|
||||||
|
->format(fn($value, $row) => Number::currency($value ?? 0, $row->market_data->currency) ),
|
||||||
|
Column::make(__('52 week low'), 'market_data.fifty_two_week_low')
|
||||||
|
->sortable()
|
||||||
|
->format(fn($value, $row) => Number::currency($value ?? 0, $row->market_data->currency) ),
|
||||||
|
Column::make(__('52 week high'), 'market_data.fifty_two_week_high')
|
||||||
|
->sortable()
|
||||||
|
->format(fn($value, $row) => Number::currency($value ?? 0, $row->market_data->currency) ),
|
||||||
|
Column::make(__('Number of Transactions'))
|
||||||
|
->sortable(fn (Builder $query, string $direction) => $query->orderBy('num_transactions', $direction))
|
||||||
|
->label(fn ($row) => $row->num_transactions),
|
||||||
|
Column::make(__('Last Refreshed'), 'market_data.updated_at')
|
||||||
|
->sortable()
|
||||||
|
->format(fn($value) => \Carbon\Carbon::parse($value)->diffForHumans() )
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Datatables;
|
||||||
|
|
||||||
|
use App\Models\Transaction;
|
||||||
|
use Illuminate\Support\Number;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Rappasoft\LaravelLivewireTables\Views\Column;
|
||||||
|
use Rappasoft\LaravelLivewireTables\DataTableComponent;
|
||||||
|
|
||||||
|
class TransactionsTable extends DataTableComponent
|
||||||
|
{
|
||||||
|
public array $hiddenColumns = [];
|
||||||
|
|
||||||
|
public function mount (): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public function builder(): Builder
|
||||||
|
{
|
||||||
|
return Transaction::query()
|
||||||
|
->with(['portfolio', 'market_data'])
|
||||||
|
->myTransactions()
|
||||||
|
->addSelect(['portfolio_id', 'transaction_type', 'split'])
|
||||||
|
->selectRaw('
|
||||||
|
CASE
|
||||||
|
WHEN transaction_type = \'SELL\'
|
||||||
|
THEN COALESCE(transactions.sale_price - transactions.cost_basis, 0)
|
||||||
|
ELSE COALESCE(market_data.market_value - transactions.cost_basis, 0)
|
||||||
|
END AS gain_dollars');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configure(): void
|
||||||
|
{
|
||||||
|
$this->hiddenColumns = ['name', 'cost_basis', 'gain_dollars'];
|
||||||
|
|
||||||
|
$this->setTableWrapperAttributes([
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => false,
|
||||||
|
'default-colors' => false,
|
||||||
|
'class' => 'overflow-scroll'
|
||||||
|
]);
|
||||||
|
$this->setTableAttributes([
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => false,
|
||||||
|
'default-colors' => false,
|
||||||
|
'class' => 'table',
|
||||||
|
]);
|
||||||
|
$this->setTheadAttributes([
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => true,
|
||||||
|
'default-colors' => false,
|
||||||
|
]);
|
||||||
|
$this->setThAttributes(function(Column $column) {
|
||||||
|
|
||||||
|
$attributes = [
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => false,
|
||||||
|
'default-colors' => false,
|
||||||
|
'class' => 'text-xs font-medium whitespace-nowrap uppercase tracking-wider text-nowrap'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (in_array($column->getField(), $this->hiddenColumns)) {
|
||||||
|
$attributes['class'] = $attributes['class'] . ' hidden md:table-cell';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attributes;
|
||||||
|
});
|
||||||
|
$this->setThSortButtonAttributes(fn() => [
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => true,
|
||||||
|
'default-colors' => false,
|
||||||
|
'class' => 'cursor-pointer'
|
||||||
|
]);
|
||||||
|
$this->setTbodyAttributes([
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => true,
|
||||||
|
'default-colors' => false,
|
||||||
|
]);
|
||||||
|
$this->setTrAttributes(fn() => [
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => true,
|
||||||
|
'default-colors' => false,
|
||||||
|
'class' => 'cursor-pointer hover:bg-neutral/25'
|
||||||
|
]);
|
||||||
|
$this->setTdAttributes(function(Column $column) {
|
||||||
|
|
||||||
|
$attributes = [
|
||||||
|
'default' => false,
|
||||||
|
'default-styling' => false,
|
||||||
|
'default-colors' => false,
|
||||||
|
'class' => 'text-nowrap'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (in_array($column->getField(), $this->hiddenColumns)) {
|
||||||
|
$attributes['class'] = $attributes['class'] . ' hidden md:table-cell';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attributes;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->setDefaultSort('date', 'desc');
|
||||||
|
|
||||||
|
$this->setPerPageAccepted([10, 15, 20]);
|
||||||
|
$this->setPerPage(15);
|
||||||
|
$this->setSearchDisabled();
|
||||||
|
$this->setColumnSelectDisabled();
|
||||||
|
$this->setPerPageVisibilityDisabled();
|
||||||
|
$this->setFooterDisabled();
|
||||||
|
|
||||||
|
$this->setPrimaryKey('id');
|
||||||
|
|
||||||
|
$this->setTableRowUrl(function($row) {
|
||||||
|
return route('holding.show', ['portfolio' => $row->portfolio_id, 'symbol' => $row->symbol]);
|
||||||
|
|
||||||
|
})->setTableRowUrlTarget(function($row) {
|
||||||
|
|
||||||
|
return 'navigate';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function columns(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
|
||||||
|
Column::make(__('Date'), 'date')
|
||||||
|
->sortable()
|
||||||
|
->format(fn($value) => \Carbon\Carbon::parse($value)->format('M d, Y') ),
|
||||||
|
Column::make(__('Portfolio'), 'portfolio.title')
|
||||||
|
->sortable(),
|
||||||
|
Column::make(__('Symbol'), 'symbol')
|
||||||
|
->sortable(),
|
||||||
|
Column::make(__('Name'), 'market_data.name')
|
||||||
|
->sortable(),
|
||||||
|
Column::make(__('Type'), 'transaction_type')
|
||||||
|
->label(fn($row) => view('components.ui.badge', [
|
||||||
|
'value' => $row->split ? 'SPLIT'
|
||||||
|
: ($row->reinvested_dividend
|
||||||
|
? 'REINVEST'
|
||||||
|
: $row->transaction_type),
|
||||||
|
'class' => ($row->transaction_type == 'BUY'
|
||||||
|
? 'badge-success'
|
||||||
|
: 'badge-error') . ' badge-sm mr-3',
|
||||||
|
]))
|
||||||
|
->sortable(fn (Builder $query, string $direction) => $query->orderBy('transaction_type', $direction)),
|
||||||
|
Column::make(__('Quantity'), 'quantity')
|
||||||
|
->sortable(),
|
||||||
|
Column::make(__('Cost Basis'), 'cost_basis')
|
||||||
|
->sortable(fn (Builder $query, string $direction) => $query->orderBy('cost_basis', $direction))
|
||||||
|
->label(fn ($row) => Number::currency($row->cost_basis ?? 0, $row->market_data->currency)),
|
||||||
|
Column::make(__('Gain/Loss'), 'gain_dollars')
|
||||||
|
->sortable(fn (Builder $query, string $direction) => $query->orderBy('gain_dollars', $direction))
|
||||||
|
->label(fn ($row) => Number::currency($row->gain_dollars ?? 0, $row->market_data->currency)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -134,9 +134,11 @@ class CurrencyRate extends Model
|
|||||||
|
|
||||||
if (is_array($currency)) {
|
if (is_array($currency)) {
|
||||||
|
|
||||||
|
$i = 1;
|
||||||
foreach ($currency as $curr) {
|
foreach ($currency as $curr) {
|
||||||
|
|
||||||
dispatch(fn () => self::timeSeriesRates($curr, $start, $end));
|
dispatch(fn () => self::timeSeriesRates($curr, $start, $end))->delay(now()->addSeconds(30 * $i));
|
||||||
|
$i++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class DailyChange extends Model
|
|||||||
'date' => 'datetime',
|
'date' => 'datetime',
|
||||||
'total_market_value' => 'float',
|
'total_market_value' => 'float',
|
||||||
'total_cost_basis' => 'float',
|
'total_cost_basis' => 'float',
|
||||||
'total_gain' => 'float',
|
'total_market_gain' => 'float',
|
||||||
'realized_gain_dollars' => 'float',
|
'realized_gain_dollars' => 'float',
|
||||||
'total_dividends_earned' => 'float',
|
'total_dividends_earned' => 'float',
|
||||||
];
|
];
|
||||||
@@ -42,9 +42,9 @@ class DailyChange extends Model
|
|||||||
return $query->where('daily_change.portfolio_id', $portfolio);
|
return $query->where('daily_change.portfolio_id', $portfolio);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function scopeMyDailyChanges()
|
public function scopeMyDailyChanges($query)
|
||||||
{
|
{
|
||||||
return $this->whereHas('portfolio', function ($query) {
|
return $query->whereHas('portfolio', function ($query) {
|
||||||
$query->whereHas('users', function ($query) {
|
$query->whereHas('users', function ($query) {
|
||||||
return $query->where('id', auth()->id());
|
return $query->where('id', auth()->id());
|
||||||
});
|
});
|
||||||
@@ -86,113 +86,81 @@ class DailyChange extends Model
|
|||||||
AS total_dividends_earned")
|
AS total_dividends_earned")
|
||||||
->groupBy(['holdings.portfolio_id', 'dividends.date', 'tx.transaction_type', 'tx.quantity']);
|
->groupBy(['holdings.portfolio_id', 'dividends.date', 'tx.transaction_type', 'tx.quantity']);
|
||||||
|
|
||||||
$totalCostBasisSub = DB::table('transactions as tx1')
|
$transactionTotals = DB::table('transactions')
|
||||||
|
->select(['transactions.portfolio_id', 'transactions.date'])
|
||||||
|
->selectRaw("
|
||||||
|
SUM(
|
||||||
|
(CASE WHEN transactions.transaction_type = 'BUY' THEN 1 ELSE -1 END)
|
||||||
|
* transactions.quantity
|
||||||
|
* transactions.cost_basis_base
|
||||||
|
* COALESCE(cr.rate, 1)
|
||||||
|
) AS daily_cost_basis
|
||||||
|
")
|
||||||
|
->selectRaw("
|
||||||
|
SUM(
|
||||||
|
(CASE
|
||||||
|
WHEN transactions.transaction_type = 'SELL'
|
||||||
|
THEN ( transactions.sale_price_base - transactions.cost_basis_base )
|
||||||
|
* transactions.quantity
|
||||||
|
* COALESCE(cr.rate, 1)
|
||||||
|
END)
|
||||||
|
) AS daily_realized_gains
|
||||||
|
")
|
||||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||||
$join->on('cr.date', '=', 'tx1.date')
|
$join
|
||||||
->where('cr.currency', '=', $currency);
|
->on(DB::raw('DATE(cr.date)'), '=', DB::raw('DATE(transactions.date)'))
|
||||||
|
->where('cr.currency', $currency);
|
||||||
})
|
})
|
||||||
->select([
|
->groupBy('transactions.portfolio_id', 'transactions.date');
|
||||||
'tx1.portfolio_id',
|
|
||||||
'tx1.date',
|
$cumulativeCostBasis = DB::table(DB::raw("({$transactionTotals->toSql()}) AS transaction_totals"))
|
||||||
'tx1.symbol',
|
->mergeBindings($transactionTotals)
|
||||||
'tx1.transaction_type',
|
->select(['portfolio_id', 'date'])
|
||||||
'tx1.quantity',
|
->selectRaw('SUM(daily_cost_basis) AS cumulative_cost_basis')
|
||||||
])
|
->selectRaw('SUM(daily_realized_gains) AS cumulative_realized_gains')
|
||||||
->selectRaw("(CASE
|
->groupBy('portfolio_id', 'date');
|
||||||
WHEN tx1.transaction_type = 'BUY'
|
|
||||||
THEN COALESCE(cr.rate, 1)
|
|
||||||
ELSE (
|
|
||||||
SELECT
|
|
||||||
SUM(COALESCE(cr2.rate, 1) * buy.cost_basis_base)
|
|
||||||
/ SUM(buy.cost_basis_base)
|
|
||||||
FROM transactions as buy
|
|
||||||
LEFT JOIN currency_rates as cr2
|
|
||||||
ON cr2.date = buy.date
|
|
||||||
AND cr2.currency = '{$currency}'
|
|
||||||
WHERE buy.symbol = tx1.symbol
|
|
||||||
AND buy.portfolio_id = tx1.portfolio_id
|
|
||||||
AND buy.transaction_type = 'BUY'
|
|
||||||
AND buy.date <= tx1.date
|
|
||||||
) END)
|
|
||||||
AS rate")
|
|
||||||
->selectRaw(
|
|
||||||
"(CASE
|
|
||||||
WHEN tx1.transaction_type = 'BUY'
|
|
||||||
THEN AVG(tx1.cost_basis_base)
|
|
||||||
ELSE (
|
|
||||||
SELECT
|
|
||||||
AVG(-buy.cost_basis_base)
|
|
||||||
FROM transactions as buy
|
|
||||||
WHERE buy.symbol = tx1.symbol
|
|
||||||
AND buy.portfolio_id = tx1.portfolio_id
|
|
||||||
AND buy.transaction_type = 'BUY'
|
|
||||||
AND buy.date <= tx1.date
|
|
||||||
) END)
|
|
||||||
AS cost_basis_base")
|
|
||||||
->selectRaw(
|
|
||||||
"(CASE
|
|
||||||
WHEN tx1.transaction_type = 'SELL'
|
|
||||||
THEN tx1.sale_price_base - tx1.cost_basis_base
|
|
||||||
ELSE 0 END)
|
|
||||||
* tx1.quantity
|
|
||||||
* COALESCE(cr.rate, 1)
|
|
||||||
AS realized_gain_dollars")
|
|
||||||
->groupBy([
|
|
||||||
'tx1.portfolio_id',
|
|
||||||
'tx1.date',
|
|
||||||
'tx1.symbol',
|
|
||||||
'tx1.transaction_type',
|
|
||||||
'tx1.cost_basis_base',
|
|
||||||
'tx1.quantity',
|
|
||||||
'cr.rate',
|
|
||||||
'tx1.sale_price_base',
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $query
|
return $query
|
||||||
->select(['daily_change.date', 'daily_change.portfolio_id'])
|
->select(['daily_change.portfolio_id', 'daily_change.date'])
|
||||||
->leftJoinSub($totalCostBasisSub, 'cost_basis_display', function ($join) {
|
->selectRaw('daily_change.total_market_value * COALESCE(cr.rate, 1) AS total_market_value')
|
||||||
$join->on('daily_change.date', '>=', 'cost_basis_display.date')
|
->selectRaw('SUM(COALESCE(ccb.cumulative_cost_basis, 0)) AS total_cost_basis')
|
||||||
->whereColumn('daily_change.portfolio_id', '=', 'cost_basis_display.portfolio_id');
|
->selectRaw('daily_change.total_market_value * COALESCE(cr.rate, 1)
|
||||||
})
|
- SUM(COALESCE(ccb.cumulative_cost_basis, 0))
|
||||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
AS total_market_gain')
|
||||||
$join->on('cr.date', '=', 'daily_change.date')
|
->selectRaw('SUM(COALESCE(ccb.cumulative_realized_gains, 0)) AS realized_gain_dollars')
|
||||||
->where('cr.currency', '=', $currency);
|
|
||||||
})
|
|
||||||
->selectRaw('
|
|
||||||
SUM(
|
|
||||||
cost_basis_display.cost_basis_base
|
|
||||||
* cost_basis_display.quantity
|
|
||||||
* cost_basis_display.rate
|
|
||||||
) as total_cost_basis')
|
|
||||||
->selectRaw('(
|
|
||||||
daily_change.total_market_value * COALESCE(cr.rate, 1)
|
|
||||||
) - SUM(
|
|
||||||
cost_basis_display.cost_basis_base
|
|
||||||
* cost_basis_display.quantity
|
|
||||||
* cost_basis_display.rate
|
|
||||||
) as total_gain')
|
|
||||||
->selectRaw('(
|
|
||||||
daily_change.total_market_value * COALESCE(cr.rate, 1)
|
|
||||||
) as total_market_value')
|
|
||||||
->selectRaw('
|
|
||||||
SUM(
|
|
||||||
cost_basis_display.realized_gain_dollars
|
|
||||||
) as realized_gain_dollars')
|
|
||||||
->selectSub(function ($query) use ($dividendSub) {
|
->selectSub(function ($query) use ($dividendSub) {
|
||||||
$query->fromSub($dividendSub, 'd')
|
$query->fromSub($dividendSub, 'd')
|
||||||
->selectRaw('SUM(d.total_dividends_earned)')
|
->selectRaw('SUM(d.total_dividends_earned)')
|
||||||
->whereColumn('d.date', '<=', 'daily_change.date')
|
->whereColumn('d.date', '<=', 'daily_change.date')
|
||||||
->whereColumn('d.portfolio_id', '=', 'daily_change.portfolio_id');
|
->whereColumn('d.portfolio_id', '=', 'daily_change.portfolio_id');
|
||||||
}, 'total_dividends_earned')
|
}, 'total_dividends_earned')
|
||||||
->groupBy([
|
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||||
'daily_change.date',
|
$join
|
||||||
'cr.rate',
|
->on(DB::raw('DATE(cr.date)'), '=', DB::raw('DATE(daily_change.date)'))
|
||||||
'daily_change.total_market_value',
|
->where('cr.currency', $currency);
|
||||||
'daily_change.portfolio_id',
|
})
|
||||||
])
|
->leftJoinSub($cumulativeCostBasis, 'ccb', function ($join) {
|
||||||
|
$join
|
||||||
|
->on('ccb.portfolio_id', '=', 'daily_change.portfolio_id')
|
||||||
|
->whereRaw('ccb.date <= daily_change.date');
|
||||||
|
})
|
||||||
|
->groupBy(['daily_change.date', 'daily_change.portfolio_id', 'cr.rate'])
|
||||||
->orderBy('daily_change.date');
|
->orderBy('daily_change.date');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function scopeWithMultipleDailyPerformance($query)
|
||||||
|
{
|
||||||
|
return DB::table(DB::raw("({$query->toSql()}) AS daily_query"))
|
||||||
|
->addBinding($query->getQuery()->getBindings(), 'join')
|
||||||
|
->select('date')
|
||||||
|
->selectRaw('SUM(total_market_value) AS total_market_value')
|
||||||
|
->selectRaw('SUM(total_cost_basis) AS total_cost_basis')
|
||||||
|
->selectRaw('SUM(total_market_gain) AS total_market_gain')
|
||||||
|
->selectRaw('SUM(realized_gain_dollars) AS realized_gain_dollars')
|
||||||
|
->selectRaw('SUM(total_dividends_earned) AS total_dividends_earned')
|
||||||
|
->groupBy('date');
|
||||||
|
}
|
||||||
|
|
||||||
public function portfolio()
|
public function portfolio()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Portfolio::class);
|
return $this->belongsTo(Portfolio::class);
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class Holding extends Model
|
|||||||
'total_cost_basis' => 'float',
|
'total_cost_basis' => 'float',
|
||||||
'realized_gain_dollars' => 'float',
|
'realized_gain_dollars' => 'float',
|
||||||
'dividends_earned' => 'float',
|
'dividends_earned' => 'float',
|
||||||
'total_gain_dollars' => 'float',
|
'total_market_gain_dollars' => 'float',
|
||||||
'market_gain_dollars' => 'float',
|
'market_gain_dollars' => 'float',
|
||||||
'total_market_value' => 'float',
|
'total_market_value' => 'float',
|
||||||
'total_dividends_earned' => 'float',
|
'total_dividends_earned' => 'float',
|
||||||
@@ -228,7 +228,7 @@ class Holding extends Model
|
|||||||
return collect([
|
return collect([
|
||||||
'total_cost_basis' => $result->sum('total_cost_basis'),
|
'total_cost_basis' => $result->sum('total_cost_basis'),
|
||||||
'total_market_value' => $result->sum('total_market_value'),
|
'total_market_value' => $result->sum('total_market_value'),
|
||||||
'total_gain_dollars' => $result->sum('total_gain_dollars'),
|
'total_market_gain_dollars' => $result->sum('total_market_gain_dollars'),
|
||||||
'realized_gain_dollars' => $result->sum('realized_gain_dollars'),
|
'realized_gain_dollars' => $result->sum('realized_gain_dollars'),
|
||||||
'total_dividends_earned' => $result->sum('total_dividends_earned'),
|
'total_dividends_earned' => $result->sum('total_dividends_earned'),
|
||||||
]);
|
]);
|
||||||
@@ -243,11 +243,113 @@ class Holding extends Model
|
|||||||
{
|
{
|
||||||
$currency = $currency ?? auth()->user()->getCurrency();
|
$currency = $currency ?? auth()->user()->getCurrency();
|
||||||
|
|
||||||
|
$cost_basis_sub = DB::table('transactions')
|
||||||
|
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||||
|
$join
|
||||||
|
->on('cr.date', '=', 'transactions.date')
|
||||||
|
->where('cr.currency', '=', $currency);
|
||||||
|
})
|
||||||
|
->select([
|
||||||
|
'transactions.id',
|
||||||
|
'transactions.symbol',
|
||||||
|
'transactions.portfolio_id',
|
||||||
|
])
|
||||||
|
->leftJoinSub(
|
||||||
|
DB::table('transactions')
|
||||||
|
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||||
|
$join
|
||||||
|
->on('cr.date', '=', 'transactions.date')
|
||||||
|
->where('cr.currency', '=', $currency);
|
||||||
|
})
|
||||||
|
->select([
|
||||||
|
'transactions.symbol',
|
||||||
|
'transactions.portfolio_id',
|
||||||
|
'transactions.quantity',
|
||||||
|
'transactions.cost_basis_base',
|
||||||
|
'transactions.date',
|
||||||
|
])
|
||||||
|
->selectRaw("
|
||||||
|
(CASE
|
||||||
|
WHEN
|
||||||
|
transactions.transaction_type = 'BUY'
|
||||||
|
OR SUM(transactions.cost_basis_base) = 0
|
||||||
|
THEN
|
||||||
|
COALESCE(cr.rate, 1)
|
||||||
|
ELSE (
|
||||||
|
SELECT
|
||||||
|
SUM(COALESCE(cr2.rate, 1) * buy.cost_basis_base)
|
||||||
|
/ SUM(buy.cost_basis_base)
|
||||||
|
FROM transactions as buy
|
||||||
|
LEFT JOIN currency_rates as cr2
|
||||||
|
ON cr2.date = buy.date
|
||||||
|
AND cr2.currency = '{$currency}'
|
||||||
|
WHERE buy.symbol = transactions.symbol
|
||||||
|
AND buy.portfolio_id = transactions.portfolio_id
|
||||||
|
AND buy.transaction_type = 'BUY'
|
||||||
|
AND buy.date <= transactions.date
|
||||||
|
) END)
|
||||||
|
AS rate")
|
||||||
|
->groupBy([
|
||||||
|
'transactions.id',
|
||||||
|
'transactions.symbol',
|
||||||
|
'transactions.date',
|
||||||
|
'transactions.portfolio_id',
|
||||||
|
'transactions.transaction_type',
|
||||||
|
'transactions.cost_basis_base',
|
||||||
|
'transactions.quantity',
|
||||||
|
'cr.rate',
|
||||||
|
]),
|
||||||
|
'cost_basis_display',
|
||||||
|
function ($join) {
|
||||||
|
$join
|
||||||
|
->on('transactions.symbol', '=', 'cost_basis_display.symbol')
|
||||||
|
->on(
|
||||||
|
'transactions.portfolio_id',
|
||||||
|
'=',
|
||||||
|
'cost_basis_display.portfolio_id'
|
||||||
|
)
|
||||||
|
->on('transactions.date', '=', 'cost_basis_display.date');
|
||||||
|
}
|
||||||
|
)
|
||||||
|
->selectRaw(
|
||||||
|
"CASE WHEN transactions.transaction_type = 'SELL' THEN (transactions.sale_price_base - transactions.cost_basis_base) * transactions.quantity * COALESCE(cr.rate, 1) END AS realized_gain_dollars"
|
||||||
|
)
|
||||||
|
->selectRaw(
|
||||||
|
"CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.cost_basis_base * transactions.quantity * cost_basis_display.rate END AS total_cost_basis"
|
||||||
|
)
|
||||||
|
->selectRaw(
|
||||||
|
"CASE WHEN transactions.transaction_type = 'BUY' THEN transactions.quantity END AS total_purchases"
|
||||||
|
)
|
||||||
|
->groupBy([
|
||||||
|
'transactions.id',
|
||||||
|
'transactions.symbol',
|
||||||
|
'transactions.portfolio_id',
|
||||||
|
'transactions.cost_basis_base',
|
||||||
|
'transactions.quantity',
|
||||||
|
'cost_basis_display.rate',
|
||||||
|
'cr.rate',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dividends_sub = DB::table('dividends')
|
||||||
|
->join('transactions as tx', function ($join) {
|
||||||
|
$join
|
||||||
|
->on('tx.symbol', '=', 'dividends.symbol')
|
||||||
|
->on('tx.date', '<=', 'dividends.date');
|
||||||
|
})
|
||||||
|
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
||||||
|
$join
|
||||||
|
->on('cr.date', '=', 'dividends.date')
|
||||||
|
->where('cr.currency', '=', $currency);
|
||||||
|
})
|
||||||
|
->select(['dividends.symbol', 'tx.portfolio_id'])
|
||||||
|
->selectRaw(
|
||||||
|
"SUM(((CASE WHEN transaction_type = 'BUY' THEN tx.quantity ELSE 0 END) - (CASE WHEN transaction_type = 'SELL' THEN tx.quantity ELSE 0 END)) * dividends.dividend_amount_base * COALESCE(cr.rate, 1)) AS total_dividends_earned"
|
||||||
|
)
|
||||||
|
->groupBy(['dividends.symbol', 'tx.portfolio_id']);
|
||||||
|
|
||||||
return $query->select([
|
return $query->select([
|
||||||
'holdings.symbol',
|
'holdings.symbol',
|
||||||
'holdings.portfolio_id',
|
'holdings.portfolio_id',
|
||||||
'transactions_display.total_cost_basis',
|
|
||||||
'transactions_display.realized_gain_dollars',
|
|
||||||
'dividends_display.total_dividends_earned',
|
'dividends_display.total_dividends_earned',
|
||||||
])
|
])
|
||||||
->groupBy([
|
->groupBy([
|
||||||
@@ -255,8 +357,6 @@ class Holding extends Model
|
|||||||
'holdings.quantity',
|
'holdings.quantity',
|
||||||
'holdings.portfolio_id',
|
'holdings.portfolio_id',
|
||||||
'cr.rate',
|
'cr.rate',
|
||||||
'transactions_display.total_cost_basis',
|
|
||||||
'transactions_display.realized_gain_dollars',
|
|
||||||
'dividends_display.total_dividends_earned',
|
'dividends_display.total_dividends_earned',
|
||||||
'market_data.market_value_base',
|
'market_data.market_value_base',
|
||||||
])
|
])
|
||||||
@@ -264,121 +364,49 @@ class Holding extends Model
|
|||||||
$join->where('cr.currency', '=', $currency);
|
$join->where('cr.currency', '=', $currency);
|
||||||
|
|
||||||
if (config('database.default') === 'sqlite') {
|
if (config('database.default') === 'sqlite') {
|
||||||
|
$join->whereRaw("strftime('%Y-%m-%d', cr.date) = ?", [
|
||||||
$join->whereRaw("strftime('%Y-%m-%d', cr.date) = ?", [now()->toDateString()]);
|
now()->toDateString(),
|
||||||
|
]);
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
$join->on('cr.date', '=', DB::raw("'".now()->toDateString()."'"));
|
$join->on('cr.date', '=', DB::raw("'".now()->toDateString()."'"));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
->leftJoin('market_data', function ($join) {
|
->leftJoin('market_data', function ($join) {
|
||||||
$join->on('market_data.symbol', '=', 'holdings.symbol');
|
$join->on('market_data.symbol', '=', 'holdings.symbol');
|
||||||
})
|
})
|
||||||
->selectRaw(
|
->selectRaw('
|
||||||
'holdings.quantity * market_data.market_value_base * COALESCE(cr.rate, 1) AS total_market_value'
|
|
||||||
)
|
|
||||||
->selectRaw('(
|
|
||||||
holdings.quantity * market_data.market_value_base * COALESCE(cr.rate, 1)
|
holdings.quantity * market_data.market_value_base * COALESCE(cr.rate, 1)
|
||||||
) - transactions_display.total_cost_basis as total_gain_dollars')
|
AS total_market_value
|
||||||
->leftJoinSub(
|
')
|
||||||
DB::table('transactions')
|
->selectRaw('
|
||||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
SUM(transactions_display.realized_gain_dollars)
|
||||||
$join->on('cr.date', '=', 'transactions.date')
|
AS realized_gain_dollars
|
||||||
->where('cr.currency', '=', $currency);
|
')
|
||||||
})
|
->selectRaw('
|
||||||
->select(['transactions.symbol', 'transactions.portfolio_id'])
|
(SUM(transactions_display.total_cost_basis) / SUM(transactions_display.total_purchases))
|
||||||
->leftJoinSub(
|
* holdings.quantity
|
||||||
DB::table('transactions')
|
AS total_cost_basis
|
||||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
')
|
||||||
$join
|
->selectRaw('
|
||||||
->on('cr.date', '=', 'transactions.date')
|
(holdings.quantity * market_data.market_value_base * COALESCE(cr.rate, 1))
|
||||||
->where('cr.currency', '=', $currency);
|
- (SUM(transactions_display.total_cost_basis) / SUM(transactions_display.total_purchases))
|
||||||
})
|
* holdings.quantity
|
||||||
->select([
|
AS total_market_gain_dollars
|
||||||
'transactions.symbol',
|
')
|
||||||
'transactions.portfolio_id',
|
->leftJoinSub($cost_basis_sub, 'transactions_display',
|
||||||
'transactions.quantity',
|
|
||||||
'transactions.date',
|
|
||||||
])
|
|
||||||
->selectRaw(
|
|
||||||
"(CASE
|
|
||||||
WHEN transactions.transaction_type = 'BUY'
|
|
||||||
THEN COALESCE(cr.rate, 1)
|
|
||||||
ELSE (
|
|
||||||
SELECT
|
|
||||||
SUM(COALESCE(cr2.rate, 1) * buy.cost_basis_base)
|
|
||||||
/ SUM(buy.cost_basis_base)
|
|
||||||
FROM transactions as buy
|
|
||||||
LEFT JOIN currency_rates as cr2
|
|
||||||
ON cr2.date = buy.date
|
|
||||||
AND cr2.currency = '{$currency}'
|
|
||||||
WHERE buy.symbol = transactions.symbol
|
|
||||||
AND buy.portfolio_id = transactions.portfolio_id
|
|
||||||
AND buy.transaction_type = 'BUY'
|
|
||||||
AND buy.date <= transactions.date
|
|
||||||
) END)
|
|
||||||
AS rate"
|
|
||||||
)
|
|
||||||
->selectRaw(
|
|
||||||
"(CASE
|
|
||||||
WHEN transactions.transaction_type = 'BUY'
|
|
||||||
THEN AVG(transactions.cost_basis_base)
|
|
||||||
ELSE (
|
|
||||||
SELECT
|
|
||||||
AVG(-buy.cost_basis_base)
|
|
||||||
FROM transactions as buy
|
|
||||||
WHERE buy.symbol = transactions.symbol
|
|
||||||
AND buy.portfolio_id = transactions.portfolio_id
|
|
||||||
AND buy.transaction_type = 'BUY'
|
|
||||||
AND buy.date <= transactions.date
|
|
||||||
) END)
|
|
||||||
AS cost_basis_base"
|
|
||||||
)
|
|
||||||
->groupBy([
|
|
||||||
'transactions.symbol',
|
|
||||||
'transactions.date',
|
|
||||||
'transactions.portfolio_id',
|
|
||||||
'transactions.transaction_type',
|
|
||||||
'transactions.quantity',
|
|
||||||
'cr.rate',
|
|
||||||
]), 'cost_basis_display', function ($join) {
|
|
||||||
$join->on('transactions.symbol', '=', 'cost_basis_display.symbol')
|
|
||||||
->on('transactions.portfolio_id', '=', 'cost_basis_display.portfolio_id')
|
|
||||||
->on('transactions.date', '=', 'cost_basis_display.date');
|
|
||||||
})
|
|
||||||
->selectRaw(
|
|
||||||
"SUM(CASE WHEN transactions.transaction_type = 'SELL' THEN (transactions.sale_price_base - transactions.cost_basis_base) * transactions.quantity * COALESCE(cr.rate, 1) ELSE 0 END) AS realized_gain_dollars"
|
|
||||||
)
|
|
||||||
->selectRaw(
|
|
||||||
'SUM(cost_basis_display.cost_basis_base * cost_basis_display.quantity * cost_basis_display.rate) AS total_cost_basis'
|
|
||||||
)
|
|
||||||
->groupBy(['transactions.symbol', 'transactions.portfolio_id']),
|
|
||||||
'transactions_display',
|
|
||||||
function ($join) {
|
function ($join) {
|
||||||
$join->on('holdings.symbol', '=', 'transactions_display.symbol')
|
$join
|
||||||
|
->on('holdings.symbol', '=', 'transactions_display.symbol')
|
||||||
->on('holdings.portfolio_id', '=', 'transactions_display.portfolio_id');
|
->on('holdings.portfolio_id', '=', 'transactions_display.portfolio_id');
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
->leftJoinSub(
|
->leftJoinSub($dividends_sub, 'dividends_display',
|
||||||
DB::table('dividends')
|
|
||||||
->join('transactions as tx', function ($join) {
|
|
||||||
$join->on('tx.symbol', '=', 'dividends.symbol')
|
|
||||||
->on('tx.date', '<=', 'dividends.date');
|
|
||||||
})
|
|
||||||
->leftJoin('currency_rates as cr', function ($join) use ($currency) {
|
|
||||||
$join->on('cr.date', '=', 'dividends.date')
|
|
||||||
->where('cr.currency', '=', $currency);
|
|
||||||
})
|
|
||||||
->select(['dividends.symbol'])
|
|
||||||
->selectRaw(
|
|
||||||
"SUM(((CASE WHEN transaction_type = 'BUY' THEN tx.quantity ELSE 0 END) - (CASE WHEN transaction_type = 'SELL' THEN tx.quantity ELSE 0 END)) * dividends.dividend_amount_base * COALESCE(cr.rate, 1)) AS total_dividends_earned"
|
|
||||||
)
|
|
||||||
->groupBy(['dividends.symbol']),
|
|
||||||
'dividends_display',
|
|
||||||
function ($join) {
|
function ($join) {
|
||||||
$join->on('holdings.symbol', '=', 'dividends_display.symbol');
|
$join->on('holdings.symbol', '=', 'dividends_display.symbol') // todo: this isnt limiting to port ids
|
||||||
|
->on('holdings.portfolio_id', '=', 'dividends_display.portfolio_id');
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function syncTransactionsAndDividends()
|
public function syncTransactionsAndDividends()
|
||||||
@@ -393,6 +421,14 @@ class Holding extends Model
|
|||||||
->selectRaw("SUM(CASE WHEN transaction_type = 'BUY' THEN (quantity * cost_basis) ELSE 0 END) AS total_cost_basis")
|
->selectRaw("SUM(CASE WHEN transaction_type = 'BUY' THEN (quantity * cost_basis) ELSE 0 END) AS total_cost_basis")
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
// delete holding if no transactions
|
||||||
|
if (empty($query->qty_purchases + $query->qty_sales)) {
|
||||||
|
|
||||||
|
$this->delete();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$total_quantity = round($query->qty_purchases - $query->qty_sales, 4);
|
$total_quantity = round($query->qty_purchases - $query->qty_sales, 4);
|
||||||
|
|
||||||
$average_cost_basis = (
|
$average_cost_basis = (
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ class Portfolio extends Model
|
|||||||
$total_performance = [];
|
$total_performance = [];
|
||||||
|
|
||||||
// get unique currencies for holdings
|
// get unique currencies for holdings
|
||||||
|
$currency_rates = [];
|
||||||
foreach ($holdings->groupBy('market_data.currency')->keys() as $currency) {
|
foreach ($holdings->groupBy('market_data.currency')->keys() as $currency) {
|
||||||
$currency_rates[$currency] = CurrencyRate::timeSeriesRates($currency, $holdings->min('first_transaction_date'), now());
|
$currency_rates[$currency] = CurrencyRate::timeSeriesRates($currency, $holdings->min('first_transaction_date'), now());
|
||||||
}
|
}
|
||||||
@@ -217,6 +218,9 @@ class Portfolio extends Model
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cache()->forget('graph-YTD-'.$this->id);
|
||||||
|
cache()->forget('graph-YTD-'.request()->user()?->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getMostRecentCloseData($history, $date, $i = 0, $max_attempts = 5)
|
protected function getMostRecentCloseData($history, $date, $i = 0, $max_attempts = 5)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Traits\HasConnectedAccounts;
|
use App\Traits\HasConnectedAccounts;
|
||||||
|
use App\Traits\HasProfilePhoto;
|
||||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -12,7 +13,6 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
|
|||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
use Laravel\Jetstream\HasProfilePhoto;
|
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
use Staudenmeir\EloquentHasManyDeep\HasManyDeep;
|
use Staudenmeir\EloquentHasManyDeep\HasManyDeep;
|
||||||
use Staudenmeir\EloquentHasManyDeep\HasRelationships;
|
use Staudenmeir\EloquentHasManyDeep\HasRelationships;
|
||||||
@@ -99,7 +99,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
return Arr::get($this->options, 'locale') ?? request()->getPreferredLanguage($available_locales) ?? config('app.locale');
|
return Arr::get($this->options, 'locale') ?? request()->getPreferredLanguage($available_locales) ?? config('app.locale');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setOption(mixed $key, string $value): self
|
public function setOption(mixed $key, ?string $value = null): self
|
||||||
{
|
{
|
||||||
|
|
||||||
$options = is_array($key) ? $key : [$key => $value];
|
$options = is_array($key) ? $key : [$key => $value];
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class FortifyServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
Fortify::viewPrefix('auth.');
|
||||||
|
|
||||||
Fortify::createUsersUsing(CreateNewUser::class);
|
Fortify::createUsersUsing(CreateNewUser::class);
|
||||||
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
|
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Providers;
|
|
||||||
|
|
||||||
use App\Actions\Jetstream\DeleteUser;
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
|
||||||
use Laravel\Jetstream\Jetstream;
|
|
||||||
|
|
||||||
class JetstreamServiceProvider extends ServiceProvider
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Register any application services.
|
|
||||||
*/
|
|
||||||
public function register(): void
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bootstrap any application services.
|
|
||||||
*/
|
|
||||||
public function boot(): void
|
|
||||||
{
|
|
||||||
|
|
||||||
$this->configurePermissions();
|
|
||||||
|
|
||||||
Jetstream::deleteUsersUsing(DeleteUser::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure the permissions that are available within the application.
|
|
||||||
*/
|
|
||||||
protected function configurePermissions(): void
|
|
||||||
{
|
|
||||||
Jetstream::defaultApiTokenPermissions([
|
|
||||||
// 'portfolio:read',
|
|
||||||
// 'portfolio:write',
|
|
||||||
// 'holding:read',
|
|
||||||
// 'holding:write',
|
|
||||||
// 'transaction:read',
|
|
||||||
// 'transaction:write',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Jetstream::permissions([
|
|
||||||
// 'Read Portfolios' => 'portfolio:read',
|
|
||||||
// 'Create Portfolios' => 'portfolio:write',
|
|
||||||
// 'Read Holdings' => 'holding:read',
|
|
||||||
// 'Update Holdings' => 'holding:write',
|
|
||||||
// 'Read Transactions' => 'transaction:read',
|
|
||||||
// 'Create Transactions' => 'transaction:write',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
|
||||||
use Livewire\Volt\Volt;
|
use Livewire\Volt\Volt;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class VoltServiceProvider extends ServiceProvider
|
class VoltServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -26,9 +26,11 @@ class VoltServiceProvider extends ServiceProvider
|
|||||||
// config('livewire.view_path', resource_path('views/livewire')),
|
// config('livewire.view_path', resource_path('views/livewire')),
|
||||||
resource_path('views/components'),
|
resource_path('views/components'),
|
||||||
resource_path('views/profile'),
|
resource_path('views/profile'),
|
||||||
|
resource_path('views/api'),
|
||||||
resource_path('views/holding'),
|
resource_path('views/holding'),
|
||||||
resource_path('views/transaction'),
|
resource_path('views/transaction'),
|
||||||
resource_path('views/portfolio'),
|
resource_path('views/portfolio'),
|
||||||
|
resource_path('views/import-export'),
|
||||||
resource_path('views/auth'),
|
resource_path('views/auth'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ namespace App\Rules;
|
|||||||
|
|
||||||
use App\Models\Portfolio;
|
use App\Models\Portfolio;
|
||||||
use App\Models\Transaction;
|
use App\Models\Transaction;
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
class QuantityValidationRule implements ValidationRule
|
class QuantityValidationRule implements ValidationRule
|
||||||
{
|
{
|
||||||
@@ -20,8 +20,9 @@ class QuantityValidationRule implements ValidationRule
|
|||||||
protected ?Portfolio $portfolio,
|
protected ?Portfolio $portfolio,
|
||||||
protected ?string $symbol,
|
protected ?string $symbol,
|
||||||
protected ?string $transactionType,
|
protected ?string $transactionType,
|
||||||
protected string|Carbon|null $date
|
protected string|Carbon|null $date,
|
||||||
) { }
|
protected ?Transaction $transaction
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate the attribute.
|
* Validate the attribute.
|
||||||
@@ -42,6 +43,7 @@ class QuantityValidationRule implements ValidationRule
|
|||||||
->sum('quantity');
|
->sum('quantity');
|
||||||
|
|
||||||
$sales_qty = (float) $this->portfolio->transactions()
|
$sales_qty = (float) $this->portfolio->transactions()
|
||||||
|
->where('id', '!=', $this->transaction?->id)
|
||||||
->symbol($this->symbol)
|
->symbol($this->symbol)
|
||||||
->sell()
|
->sell()
|
||||||
->whereDate('date', '<', $this->date)
|
->whereDate('date', '<', $this->date)
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\Currency;
|
|
||||||
|
|
||||||
if (! function_exists('currency')) {
|
|
||||||
|
|
||||||
// /**
|
|
||||||
// * Returns an instance of the currency model
|
|
||||||
// * */
|
|
||||||
// function currency(): Currency
|
|
||||||
// {
|
|
||||||
// return new Currency;
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Traits;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Auth\StatefulGuard;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Laravel\Fortify\Actions\ConfirmPassword;
|
||||||
|
|
||||||
|
trait ConfirmsPasswords
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Indicates if the user's password is being confirmed.
|
||||||
|
*
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
public $confirmingPassword = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ID of the operation being confirmed.
|
||||||
|
*
|
||||||
|
* @var string|null
|
||||||
|
*/
|
||||||
|
public $confirmableId = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's password.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
public $confirmablePassword = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start confirming the user's password.
|
||||||
|
*
|
||||||
|
* @param string $confirmableId
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function startConfirmingPassword(string $confirmableId)
|
||||||
|
{
|
||||||
|
$this->resetErrorBag();
|
||||||
|
|
||||||
|
if ($this->passwordIsConfirmed()) {
|
||||||
|
return $this->dispatch('password-confirmed',
|
||||||
|
id: $confirmableId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->confirmingPassword = true;
|
||||||
|
$this->confirmableId = $confirmableId;
|
||||||
|
$this->confirmablePassword = '';
|
||||||
|
|
||||||
|
$this->dispatch('confirming-password');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop confirming the user's password.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function stopConfirmingPassword()
|
||||||
|
{
|
||||||
|
$this->confirmingPassword = false;
|
||||||
|
$this->confirmableId = null;
|
||||||
|
$this->confirmablePassword = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm the user's password.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function confirmPassword()
|
||||||
|
{
|
||||||
|
if (! app(ConfirmPassword::class)(app(StatefulGuard::class), Auth::user(), $this->confirmablePassword)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'confirmable_password' => [__('This password does not match our records.')],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
session(['auth.password_confirmed_at' => time()]);
|
||||||
|
|
||||||
|
$this->dispatch('password-confirmed',
|
||||||
|
id: $this->confirmableId,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->stopConfirmingPassword();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure that the user's password has been recently confirmed.
|
||||||
|
*
|
||||||
|
* @param int|null $maximumSecondsSinceConfirmation
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function ensurePasswordIsConfirmed($maximumSecondsSinceConfirmation = null)
|
||||||
|
{
|
||||||
|
$maximumSecondsSinceConfirmation = $maximumSecondsSinceConfirmation ?: config('auth.password_timeout', 900);
|
||||||
|
|
||||||
|
$this->passwordIsConfirmed($maximumSecondsSinceConfirmation) ? null : abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the user's password has been recently confirmed.
|
||||||
|
*
|
||||||
|
* @param int|null $maximumSecondsSinceConfirmation
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
protected function passwordIsConfirmed($maximumSecondsSinceConfirmation = null)
|
||||||
|
{
|
||||||
|
$maximumSecondsSinceConfirmation = $maximumSecondsSinceConfirmation ?: config('auth.password_timeout', 900);
|
||||||
|
|
||||||
|
return (time() - session('auth.password_confirmed_at', 0)) < $maximumSecondsSinceConfirmation;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Traits;
|
||||||
|
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
trait HasLocalizedMarkdown
|
||||||
|
{
|
||||||
|
public function localizedMarkdownPath($name)
|
||||||
|
{
|
||||||
|
$localName = preg_replace('#(\.md)$#i', '.'.app()->getLocale().'$1', $name);
|
||||||
|
|
||||||
|
return Arr::first([
|
||||||
|
resource_path('markdown/'.$localName),
|
||||||
|
resource_path('markdown/'.$name),
|
||||||
|
], function ($path) {
|
||||||
|
return file_exists($path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Traits;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
trait HasProfilePhoto
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Update the user's profile photo.
|
||||||
|
*
|
||||||
|
* @param string $storagePath
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function updateProfilePhoto(UploadedFile $photo, $storagePath = 'profile-photos')
|
||||||
|
{
|
||||||
|
tap($this->profile_photo_path, function ($previous) use ($photo, $storagePath) {
|
||||||
|
$this->forceFill([
|
||||||
|
'profile_photo_path' => $photo->storePublicly(
|
||||||
|
$storagePath, ['disk' => 'public']
|
||||||
|
),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
if ($previous) {
|
||||||
|
Storage::disk('public')->delete($previous);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the user's profile photo.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function deleteProfilePhoto()
|
||||||
|
{
|
||||||
|
if (is_null($this->profile_photo_path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Storage::disk('public')->delete($this->profile_photo_path);
|
||||||
|
|
||||||
|
$this->forceFill([
|
||||||
|
'profile_photo_path' => null,
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the URL to the user's profile photo.
|
||||||
|
*/
|
||||||
|
protected function profilePhotoUrl(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::get(function (): string {
|
||||||
|
return $this->profile_photo_path
|
||||||
|
? Storage::disk('public')->url($this->profile_photo_path)
|
||||||
|
: $this->defaultProfilePhotoUrl();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default profile photo URL if no profile photo has been uploaded.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function defaultProfilePhotoUrl()
|
||||||
|
{
|
||||||
|
$name = trim(collect(explode(' ', $this->name))->map(function ($segment) {
|
||||||
|
return mb_substr($segment, 0, 1);
|
||||||
|
})->join(' '));
|
||||||
|
|
||||||
|
return 'https://ui-avatars.com/api/?name='.urlencode($name).'&color=7F9CF5&background=EBF4FF';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Traits;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
|
||||||
|
trait Toast
|
||||||
|
{
|
||||||
|
public function toast(
|
||||||
|
string $type,
|
||||||
|
string $title,
|
||||||
|
?string $description = null,
|
||||||
|
?string $position = null,
|
||||||
|
string $icon = 'o-information-circle',
|
||||||
|
string $css = 'alert-info',
|
||||||
|
int $timeout = 3000,
|
||||||
|
?string $redirectTo = null
|
||||||
|
) {
|
||||||
|
$toast = [
|
||||||
|
'type' => $type,
|
||||||
|
'title' => $title,
|
||||||
|
'description' => $description,
|
||||||
|
'position' => $position,
|
||||||
|
'icon' => Blade::render("<x-ui.icon class='w-7 h-7' name='".$icon."' />"),
|
||||||
|
'css' => $css,
|
||||||
|
'timeout' => $timeout,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->js('toast('.json_encode(['toast' => $toast]).')');
|
||||||
|
|
||||||
|
// session()->flash('ib.toast.title', $title);
|
||||||
|
// session()->flash('ib.toast.description', $description);
|
||||||
|
|
||||||
|
if ($redirectTo) {
|
||||||
|
return $this->redirect($redirectTo, navigate: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function success(
|
||||||
|
string $title,
|
||||||
|
?string $description = null,
|
||||||
|
?string $position = null,
|
||||||
|
string $icon = 'o-check-circle',
|
||||||
|
string $css = 'alert-success',
|
||||||
|
int $timeout = 3000,
|
||||||
|
?string $redirectTo = null
|
||||||
|
) {
|
||||||
|
return $this->toast('success', $title, $description, $position, $icon, $css, $timeout, $redirectTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function warning(
|
||||||
|
string $title,
|
||||||
|
?string $description = null,
|
||||||
|
?string $position = null,
|
||||||
|
string $icon = 'o-exclamation-triangle',
|
||||||
|
string $css = 'alert-warning',
|
||||||
|
int $timeout = 3000,
|
||||||
|
?string $redirectTo = null
|
||||||
|
) {
|
||||||
|
return $this->toast('warning', $title, $description, $position, $icon, $css, $timeout, $redirectTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function error(
|
||||||
|
string $title,
|
||||||
|
?string $description = null,
|
||||||
|
?string $position = null,
|
||||||
|
string $icon = 'o-x-circle',
|
||||||
|
string $css = 'alert-error',
|
||||||
|
int $timeout = 3000,
|
||||||
|
?string $redirectTo = null
|
||||||
|
) {
|
||||||
|
return $this->toast('error', $title, $description, $position, $icon, $css, $timeout, $redirectTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function info(
|
||||||
|
string $title,
|
||||||
|
?string $description = null,
|
||||||
|
?string $position = null,
|
||||||
|
string $icon = 'o-information-circle',
|
||||||
|
string $css = 'alert-info',
|
||||||
|
int $timeout = 3000,
|
||||||
|
?string $redirectTo = null
|
||||||
|
) {
|
||||||
|
return $this->toast('info', $title, $description, $position, $icon, $css, $timeout, $redirectTo);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\View\Components;
|
|
||||||
|
|
||||||
use Illuminate\View\Component;
|
|
||||||
|
|
||||||
class AppLayout extends Component
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Get the view / contents that represents the component.
|
|
||||||
*/
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return <<<'HTML'
|
|
||||||
<x-main-layout>
|
|
||||||
<x-slot:body class="min-h-screen font-sans antialiased bg-base-200/50 dark:bg-base-200" x-data>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
|
|
||||||
<x-partials.nav-bar />
|
|
||||||
|
|
||||||
<x-partials.main with-nav full-width>
|
|
||||||
|
|
||||||
<x-slot:sidebar drawer="main-drawer" class="bg-base-100 lg:bg-inherit">
|
|
||||||
|
|
||||||
@livewire('partials.side-bar')
|
|
||||||
|
|
||||||
</x-slot:sidebar>
|
|
||||||
|
|
||||||
<x-slot:content>
|
|
||||||
|
|
||||||
{{ $slot }}
|
|
||||||
</x-slot:content>
|
|
||||||
|
|
||||||
</x-partials.main>
|
|
||||||
|
|
||||||
@if(session('toast'))
|
|
||||||
<script lang="text/javascript">
|
|
||||||
window.addEventListener('DOMContentLoaded', function () {
|
|
||||||
window.toast(JSON.parse(@json(session('toast'))))
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@endif
|
|
||||||
<x-toast />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</x-slot:body>
|
|
||||||
</x-main-layout>
|
|
||||||
HTML;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\View\Components;
|
|
||||||
|
|
||||||
use Illuminate\View\Component;
|
|
||||||
|
|
||||||
class GuestLayout extends Component
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Get the view / contents that represents the component.
|
|
||||||
*/
|
|
||||||
public function render()
|
|
||||||
{
|
|
||||||
return <<<'HTML'
|
|
||||||
<x-main-layout>
|
|
||||||
<x-slot:body class="font-sans text-gray-900 dark:text-gray-100 antialiased">
|
|
||||||
|
|
||||||
{{ $slot }}
|
|
||||||
|
|
||||||
<x-theme-toggle class="hidden" darkTheme="business" lightTheme="corporate"/>
|
|
||||||
|
|
||||||
</x-slot:body>
|
|
||||||
</x-main-layout>
|
|
||||||
HTML;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\View\Components;
|
|
||||||
|
|
||||||
use Illuminate\View\Component;
|
|
||||||
use Illuminate\View\View;
|
|
||||||
|
|
||||||
class MainLayout extends Component
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
|
|
||||||
// Slots
|
|
||||||
public mixed $body = null,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the view / contents that represents the component.
|
|
||||||
*/
|
|
||||||
public function render(): View
|
|
||||||
{
|
|
||||||
return view('layouts.main-layout');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,5 @@ declare(strict_types=1);
|
|||||||
return [
|
return [
|
||||||
App\Providers\AppServiceProvider::class,
|
App\Providers\AppServiceProvider::class,
|
||||||
App\Providers\FortifyServiceProvider::class,
|
App\Providers\FortifyServiceProvider::class,
|
||||||
App\Providers\JetstreamServiceProvider::class,
|
|
||||||
App\Providers\VoltServiceProvider::class,
|
App\Providers\VoltServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,18 +2,25 @@
|
|||||||
"name": "investbrainapp/investbrain",
|
"name": "investbrainapp/investbrain",
|
||||||
"type": "project",
|
"type": "project",
|
||||||
"description": "A smart open-source tool that consolidates and tracks portfolios from your different brokerages",
|
"description": "A smart open-source tool that consolidates and tracks portfolios from your different brokerages",
|
||||||
"keywords": ["stocks", "dividends", "investments", "tracking"],
|
"keywords": [
|
||||||
|
"stocks",
|
||||||
|
"dividends",
|
||||||
|
"investments",
|
||||||
|
"tracking"
|
||||||
|
],
|
||||||
"license": "CC-BY-NC 4.0",
|
"license": "CC-BY-NC 4.0",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.3",
|
"php": "^8.3",
|
||||||
"ext-gd": "*",
|
"ext-gd": "*",
|
||||||
|
"ext-json": "*",
|
||||||
"ext-mbstring": "*",
|
"ext-mbstring": "*",
|
||||||
"ext-zip": "*",
|
"ext-zip": "*",
|
||||||
|
"blade-ui-kit/blade-heroicons": "^2.6",
|
||||||
"finnhub/client": "master@dev",
|
"finnhub/client": "master@dev",
|
||||||
"hackeresq/filter-models": "dev-main",
|
"hackeresq/filter-models": "dev-main",
|
||||||
"investbrainapp/frankfurter-client": "dev-main",
|
"investbrainapp/frankfurter-client": "dev-main",
|
||||||
"laravel/framework": "^11.35",
|
"laravel/fortify": "^1.30.0",
|
||||||
"laravel/jetstream": "^5.1",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/sanctum": "^4.0",
|
"laravel/sanctum": "^4.0",
|
||||||
"laravel/socialite": "^5.16",
|
"laravel/socialite": "^5.16",
|
||||||
"laravel/tinker": "^2.9",
|
"laravel/tinker": "^2.9",
|
||||||
@@ -23,9 +30,10 @@
|
|||||||
"maatwebsite/excel": "^3.1",
|
"maatwebsite/excel": "^3.1",
|
||||||
"openai-php/client": "^0.10.3",
|
"openai-php/client": "^0.10.3",
|
||||||
"predis/predis": "^2.2",
|
"predis/predis": "^2.2",
|
||||||
"robsontenorio/mary": "^1.35",
|
"rappasoft/laravel-livewire-tables": "^3.7",
|
||||||
"scheb/yahoo-finance-api": "^4.11",
|
"scheb/yahoo-finance-api": "^5.0",
|
||||||
"staudenmeir/eloquent-has-many-deep": "^1.20",
|
"staudenmeir/eloquent-has-many-deep": "^1.20",
|
||||||
|
"symfony/cache": "^7.3",
|
||||||
"tschucki/alphavantage-laravel": "^0.0"
|
"tschucki/alphavantage-laravel": "^0.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
@@ -34,7 +42,7 @@
|
|||||||
"laravel/sail": "^1.26",
|
"laravel/sail": "^1.26",
|
||||||
"mockery/mockery": "^1.6",
|
"mockery/mockery": "^1.6",
|
||||||
"nunomaduro/collision": "^8.0",
|
"nunomaduro/collision": "^8.0",
|
||||||
"phpunit/phpunit": "^11.0.1"
|
"phpunit/phpunit": "^11.0"
|
||||||
},
|
},
|
||||||
"repositories": [
|
"repositories": [
|
||||||
{
|
{
|
||||||
@@ -54,9 +62,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"files": [
|
|
||||||
"app/Support/Helpers.php"
|
|
||||||
],
|
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"App\\": "app/",
|
"App\\": "app/",
|
||||||
"Database\\Factories\\": "database/factories/",
|
"Database\\Factories\\": "database/factories/",
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'key' => env('ALPACA_API_KEY'),
|
||||||
|
'secret' => env('ALPACA_API_SECRET'),
|
||||||
|
];
|
||||||
@@ -11,7 +11,9 @@ return [
|
|||||||
'interfaces' => [
|
'interfaces' => [
|
||||||
'yahoo' => App\Interfaces\MarketData\YahooMarketData::class,
|
'yahoo' => App\Interfaces\MarketData\YahooMarketData::class,
|
||||||
'alphavantage' => App\Interfaces\MarketData\AlphaVantageMarketData::class,
|
'alphavantage' => App\Interfaces\MarketData\AlphaVantageMarketData::class,
|
||||||
|
'alpaca' => App\Interfaces\MarketData\AlpacaMarketData::class,
|
||||||
'finnhub' => App\Interfaces\MarketData\FinnhubMarketData::class,
|
'finnhub' => App\Interfaces\MarketData\FinnhubMarketData::class,
|
||||||
|
'twelvedata' => App\Interfaces\MarketData\TwelveDataMarketData::class,
|
||||||
'fake' => App\Interfaces\MarketData\FakeMarketData::class,
|
'fake' => App\Interfaces\MarketData\FakeMarketData::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Laravel\Jetstream\Features;
|
|
||||||
use Laravel\Jetstream\Http\Middleware\AuthenticateSession;
|
|
||||||
|
|
||||||
return [
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Jetstream Stack
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| This configuration value informs Jetstream which "stack" you will be
|
|
||||||
| using for your application. In general, this value is set for you
|
|
||||||
| during installation and will not need to be changed after that.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'stack' => 'livewire',
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Jetstream Route Middleware
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Here you may specify which middleware Jetstream will assign to the routes
|
|
||||||
| that it registers with the application. When necessary, you may modify
|
|
||||||
| these middleware; however, this default value is usually sufficient.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'middleware' => ['web'],
|
|
||||||
|
|
||||||
'auth_session' => AuthenticateSession::class,
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Jetstream Guard
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Here you may specify the authentication guard Jetstream will use while
|
|
||||||
| authenticating users. This value should correspond with one of your
|
|
||||||
| guards that is already present in your "auth" configuration file.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'guard' => 'sanctum',
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Features
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Some of Jetstream's features are optional. You may disable the features
|
|
||||||
| by removing them from this array. You're free to only remove some of
|
|
||||||
| these features or you can even remove all of these if you need to.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'features' => [
|
|
||||||
Features::profilePhotos(),
|
|
||||||
Features::api(),
|
|
||||||
Features::accountDeletion(),
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Profile Photo Disk
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| This configuration value determines the default disk that will be used
|
|
||||||
| when storing profile photos for your application's users. Typically
|
|
||||||
| this will be the "public" disk but you may adjust this if needed.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
|
|
||||||
'profile_photo_disk' => env('JETSTREAM_PROFILE_PHOTO_DISK', 'public'),
|
|
||||||
|
|
||||||
];
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'theme' => 'tailwind',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or Disable automatic injection of core assets
|
||||||
|
*/
|
||||||
|
'inject_core_assets_enabled' => false,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or Disable automatic injection of third-party assets
|
||||||
|
*/
|
||||||
|
'inject_third_party_assets_enabled' => false,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable Blade Directives (Not required if automatically injecting or using bundler approaches)
|
||||||
|
*/
|
||||||
|
'enable_blade_directives ' => false,
|
||||||
|
|
||||||
|
];
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
return [
|
|
||||||
/**
|
|
||||||
* Default component prefix.
|
|
||||||
*
|
|
||||||
* Make sure to clear view cache after renaming with `php artisan view:clear`
|
|
||||||
*
|
|
||||||
* prefix => ''
|
|
||||||
* <x-button />
|
|
||||||
* <x-card />
|
|
||||||
*
|
|
||||||
* prefix => 'mary-'
|
|
||||||
* <x-mary-button />
|
|
||||||
* <x-mary-card />
|
|
||||||
*/
|
|
||||||
'prefix' => '',
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default route prefix.
|
|
||||||
*
|
|
||||||
* Some maryUI components make network request to its internal routes.
|
|
||||||
*
|
|
||||||
* route_prefix => ''
|
|
||||||
* - Spotlight: '/mary/spotlight'
|
|
||||||
* - Editor: '/mary/upload'
|
|
||||||
* - ...
|
|
||||||
*
|
|
||||||
* route_prefix => 'my-components'
|
|
||||||
* - Spotlight: '/my-components/mary/spotlight'
|
|
||||||
* - Editor: '/my-components/mary/upload'
|
|
||||||
* - ...
|
|
||||||
*/
|
|
||||||
'route_prefix' => '',
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Components settings
|
|
||||||
*/
|
|
||||||
'components' => [
|
|
||||||
'spotlight' => [
|
|
||||||
'class' => 'App\Support\Spotlight',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'secret' => env('TWELVEDATA_API_SECRET'),
|
||||||
|
];
|
||||||
@@ -59,6 +59,20 @@ class TransactionFactory extends Factory
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function sixMonthsAgo(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'date' => now()->subMonths(6)->toDateString(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function today(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'date' => now()->toDateString(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function recent(): static
|
public function recent(): static
|
||||||
{
|
{
|
||||||
return $this->state(fn (array $attributes) => [
|
return $this->state(fn (array $attributes) => [
|
||||||
@@ -108,19 +122,21 @@ class TransactionFactory extends Factory
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function buy(): static
|
public function buy($quantity = 1): static
|
||||||
{
|
{
|
||||||
return $this->state(fn (array $attributes) => [
|
return $this->state(fn (array $attributes) => [
|
||||||
'transaction_type' => 'BUY',
|
'transaction_type' => 'BUY',
|
||||||
|
'quantity' => $quantity,
|
||||||
'cost_basis' => $this->faker->randomFloat(2, 10, 500),
|
'cost_basis' => $this->faker->randomFloat(2, 10, 500),
|
||||||
'sale_price' => null,
|
'sale_price' => null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function sell(): static
|
public function sell($quantity = 1): static
|
||||||
{
|
{
|
||||||
return $this->state(fn (array $attributes) => [
|
return $this->state(fn (array $attributes) => [
|
||||||
'transaction_type' => 'SELL',
|
'transaction_type' => 'SELL',
|
||||||
|
'quantity' => $quantity,
|
||||||
'sale_price' => $this->faker->randomFloat(2, 10, 500),
|
'sale_price' => $this->faker->randomFloat(2, 10, 500),
|
||||||
'cost_basis' => null,
|
'cost_basis' => null,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Models\Transaction;
|
|||||||
use Database\Seeders\CurrencySeeder;
|
use Database\Seeders\CurrencySeeder;
|
||||||
use Database\Seeders\MarketDataSeeder;
|
use Database\Seeders\MarketDataSeeder;
|
||||||
use Illuminate\Database\Migrations\Migration;
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Query\Expression;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -24,10 +25,15 @@ return new class extends Migration
|
|||||||
* Add options column to users table
|
* Add options column to users table
|
||||||
*/
|
*/
|
||||||
Schema::table('users', function (Blueprint $table) {
|
Schema::table('users', function (Blueprint $table) {
|
||||||
$table->json('options')->default(json_encode([
|
|
||||||
'locale' => config('app.locale', 'en'),
|
$locale = config('app.locale', 'en');
|
||||||
'display_currency' => config('investbrain.base_currency', 'USD'),
|
$currency = config('investbrain.base_currency', 'USD');
|
||||||
]))->after('profile_photo_path');
|
|
||||||
|
$default = config('database.default') === 'mysql'
|
||||||
|
? new Expression("(JSON_OBJECT('locale', '{$locale}', 'display_currency', '{$currency}'))")
|
||||||
|
: json_encode(['locale' => $locale, 'display_currency' => $currency]);
|
||||||
|
|
||||||
|
$table->json('options')->default($default)->after('profile_photo_path');
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ services:
|
|||||||
- 8000:80
|
- 8000:80
|
||||||
environment: # You can either use these properties OR an .env file. Do not use both!
|
environment: # You can either use these properties OR an .env file. Do not use both!
|
||||||
APP_URL: "http://localhost:8000"
|
APP_URL: "http://localhost:8000"
|
||||||
DB_CONNECTION: mysql
|
DB_CONNECTION: pgsql
|
||||||
DB_HOST: investbrain-mysql
|
DB_HOST: investbrain-pgsql
|
||||||
DB_PORT: 3306
|
DB_PORT: 5432
|
||||||
DB_DATABASE: investbrain
|
DB_DATABASE: investbrain
|
||||||
DB_USERNAME: investbrain
|
DB_USERNAME: investbrain
|
||||||
DB_PASSWORD: investbrain
|
DB_PASSWORD: investbrain
|
||||||
@@ -25,7 +25,7 @@ services:
|
|||||||
- investbrain-storage:/var/app/storage # You can use a volume...
|
- investbrain-storage:/var/app/storage # You can use a volume...
|
||||||
# - /path/to/storage:/var/app/storage:delegated # ...or you can use a path on host
|
# - /path/to/storage:/var/app/storage:delegated # ...or you can use a path on host
|
||||||
depends_on:
|
depends_on:
|
||||||
- mysql
|
- pgsql
|
||||||
- redis
|
- redis
|
||||||
networks:
|
networks:
|
||||||
- investbrain-network
|
- investbrain-network
|
||||||
@@ -40,22 +40,22 @@ services:
|
|||||||
- investbrain-redis:/data
|
- investbrain-redis:/data
|
||||||
networks:
|
networks:
|
||||||
- investbrain-network
|
- investbrain-network
|
||||||
mysql:
|
pgsql:
|
||||||
image: mysql:8.0
|
image: postgres:15-alpine
|
||||||
container_name: investbrain-mysql
|
container_name: investbrain-pgsql
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
environment:
|
environment:
|
||||||
MYSQL_DATABASE: ${DB_DATABASE:-investbrain}
|
POSTGRES_DB: ${DB_DATABASE:-investbrain}
|
||||||
MYSQL_USER: ${DB_USERNAME:-investbrain}
|
POSTGRES_USER: ${DB_USERNAME:-investbrain}
|
||||||
MYSQL_PASSWORD: ${DB_PASSWORD:-investbrain}
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-investbrain}
|
||||||
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-investbrain}
|
command: postgres -c log_min_messages=error
|
||||||
command:
|
|
||||||
- --cte-max-recursion-depth=25000
|
|
||||||
volumes:
|
volumes:
|
||||||
- investbrain-mysql:/var/lib/mysql
|
- investbrain-pgsql:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- investbrain-network
|
- investbrain-network
|
||||||
volumes:
|
volumes:
|
||||||
investbrain-storage:
|
investbrain-storage:
|
||||||
investbrain-redis:
|
investbrain-redis:
|
||||||
investbrain-mysql:
|
investbrain-pgsql:
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ FROM php:8.3-fpm-alpine
|
|||||||
# Set the working directory
|
# Set the working directory
|
||||||
WORKDIR /var/app
|
WORKDIR /var/app
|
||||||
|
|
||||||
|
ARG VERSION=dev
|
||||||
|
ENV VERSION=$VERSION
|
||||||
|
|
||||||
# Copy necessary files from the builder stage
|
# Copy necessary files from the builder stage
|
||||||
COPY --from=builder /var/app /var/app
|
COPY --from=builder /var/app /var/app
|
||||||
COPY --from=builder /usr/local/etc/php/conf.d /usr/local/etc/php/conf.d
|
COPY --from=builder /usr/local/etc/php/conf.d /usr/local/etc/php/conf.d
|
||||||
@@ -62,7 +65,7 @@ RUN apk add --no-cache \
|
|||||||
bash \
|
bash \
|
||||||
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
|
||||||
&& docker-php-ext-install -j$(nproc) \
|
&& docker-php-ext-install -j$(nproc) \
|
||||||
gd pgsql zip pdo_mysql mysqli intl
|
gd pgsql zip pdo_mysql pdo_pgsql mysqli intl
|
||||||
|
|
||||||
# Remove default nginx config
|
# Remove default nginx config
|
||||||
RUN rm -rf /var/www/html \
|
RUN rm -rf /var/www/html \
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
cd /var/app
|
cd /var/app
|
||||||
|
|
||||||
# Starting Investbrain
|
# Starting Investbrain
|
||||||
echo "CiAgKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioKICAqICBJSUkgICBOICAgTiAgViAgIFYgIEVFRUVFICBTU1NTICBUVFRUVCAgQkJCQkIgICBSUlJSICAgIEFBQUFBICBJSUkgICBOICAgTiAgKgogICogICBJICAgIE5OICBOICBWICAgViAgRSAgICAgIFMgICAgICAgVCAgICBCICAgIEIgIFIgICBSICAgQSAgIEEgICBJICAgIE5OICBOICAqCiAgKiAgIEkgICAgTiBOIE4gIFYgICBWICBFRUVFICAgU1NTUyAgICBUICAgIEJCQkJCICAgUlJSUiAgICBBQUFBQSAgIEkgICAgTiBOIE4gICoKICAqICAgSSAgICBOICBOTiAgViAgIFYgIEUgICAgICAgICAgUyAgIFQgICAgQiAgICBCICBSICBSICAgIEEgICBBICAgSSAgICBOICBOTiAgKgogICogIElJSSAgIE4gICBOICAgVlZWICAgRUVFRUUgIFNTU1MgICAgVCAgICBCQkJCQiAgIFIgICBSICAgQSAgIEEgIElJSSAgIE4gICBOICAqCiAgKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKioKICA=" | base64 -d
|
echo "CuKWhOKWliAgICAgICAg4paXIOKWjCAgICAg4paYICAK4paQIOKWm+KWjOKWjOKWjOKWiOKWjOKWm+KWmOKWnOKWmOKWm+KWjOKWm+KWmOKWgOKWjOKWjOKWm+KWjArilp/ilpbilozilozilprilpjilpnilpbiloTilozilpDilpbilpnilozilowg4paI4paM4paM4paM4paMCg==" | base64 -d
|
||||||
|
printf "%15s$VERSION\n"
|
||||||
|
|
||||||
echo -e "\n====================== Validating environment... ====================== "
|
echo -e "\n====================== Validating environment... ====================== "
|
||||||
|
|
||||||
@@ -54,7 +55,6 @@ RETRIES=12
|
|||||||
DELAY=5
|
DELAY=5
|
||||||
run_migrations() {
|
run_migrations() {
|
||||||
sleep $DELAY
|
sleep $DELAY
|
||||||
# php artisan migrate --force
|
|
||||||
output=$(php artisan migrate --force 2>/dev/null)
|
output=$(php artisan migrate --force 2>/dev/null)
|
||||||
if [[ $? -eq 0 ]]; then
|
if [[ $? -eq 0 ]]; then
|
||||||
echo "$output"
|
echo "$output"
|
||||||
@@ -72,7 +72,18 @@ until run_migrations; do
|
|||||||
echo -e "\n > Waiting for database to be ready... retrying in $DELAY seconds. \n"
|
echo -e "\n > Waiting for database to be ready... retrying in $DELAY seconds. \n"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
echo -e "\n====================== Cleaning up... ====================== \n"
|
||||||
|
|
||||||
|
# Clear caches
|
||||||
|
echo $(php artisan cache:clear)
|
||||||
|
echo $(php artisan view:clear)
|
||||||
|
echo $(php artisan route:clear)
|
||||||
|
echo $(php artisan event:clear)
|
||||||
|
|
||||||
|
# Re-create caches
|
||||||
|
echo $(php artisan route:cache)
|
||||||
|
echo $(php artisan event:cache)
|
||||||
|
|
||||||
echo -e "\n====================== Spinning up Supervisor daemon... ====================== \n"
|
echo -e "\n====================== Spinning up Supervisor daemon... ====================== \n"
|
||||||
|
|
||||||
exec supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
exec supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"Cancel": "Cancel",
|
"Cancel": "Cancel",
|
||||||
"Save": "Save",
|
"Save": "Save",
|
||||||
"Close": "Close",
|
"Close": "Close",
|
||||||
|
"Dismiss": "Dismiss",
|
||||||
"or": "or",
|
"or": "or",
|
||||||
"and": "and",
|
"and": "and",
|
||||||
"Yes": "Yes",
|
"Yes": "Yes",
|
||||||
@@ -28,21 +29,14 @@
|
|||||||
"Permanently delete your account.": "Permanently delete your account.",
|
"Permanently delete your account.": "Permanently delete your account.",
|
||||||
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.",
|
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.",
|
||||||
"Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.",
|
"Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.",
|
||||||
"Browser Sessions": "Browser Sessions",
|
|
||||||
"Manage and log out your active sessions on other browsers and devices.": "Manage and log out your active sessions on other browsers and devices.",
|
|
||||||
"If necessary, you may log out of all of your other browser sessions across all of your devices. Some of your recent sessions are listed below; however, this list may not be exhaustive. If you feel your account has been compromised, you should also update your password.": "If necessary, you may log out of all of your other browser sessions across all of your devices. Some of your recent sessions are listed below; however, this list may not be exhaustive. If you feel your account has been compromised, you should also update your password.",
|
|
||||||
"This device": "This device",
|
|
||||||
"Last active": "Last active",
|
|
||||||
"Log Out Other Browser Sessions": "Log Out Other Browser Sessions",
|
|
||||||
"Please enter your password to confirm you would like to log out of your other browser sessions across all of your devices.": "Please enter your password to confirm you would like to log out of your other browser sessions across all of your devices.",
|
|
||||||
"Two Factor Authentication": "Two Factor Authentication",
|
"Two Factor Authentication": "Two Factor Authentication",
|
||||||
"Add additional security to your account using two factor authentication.": "Add additional security to your account using two factor authentication.",
|
"Add additional security to your account using two factor authentication.": "Add additional security to your account using two factor authentication.",
|
||||||
"Finish enabling two factor authentication.": "Finish enabling two factor authentication.",
|
"Finish enabling two factor authentication.": "Finish enabling two factor authentication.",
|
||||||
"You have enabled two factor authentication.": "You have enabled two factor authentication.",
|
"You have enabled two factor authentication.": "You have enabled two factor authentication.",
|
||||||
"You have not enabled two factor authentication.": "You have not enabled two factor authentication.",
|
"You have not enabled two factor authentication.": "You have not enabled two factor authentication.",
|
||||||
"When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve this token from your phone\\'s Google Authenticator application.": "When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve this token from your phone\\'s Google Authenticator application.",
|
"When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve this token from your phone's Google Authenticator application.": "When two factor authentication is enabled, you will be prompted for a secure, random token during authentication. You may retrieve this token from your phone's Google Authenticator application.",
|
||||||
"To finish enabling two factor authentication, scan the following QR code using your phone\\'s authenticator application or enter the setup key and provide the generated OTP code.": "To finish enabling two factor authentication, scan the following QR code using your phone\\'s authenticator application or enter the setup key and provide the generated OTP code.",
|
"To finish enabling two factor authentication, scan the following QR code using your phone's authenticator application or enter the setup key and provide the generated OTP code.": "To finish enabling two factor authentication, scan the following QR code using your phone's authenticator application or enter the setup key and provide the generated OTP code.",
|
||||||
"Two factor authentication is now enabled. Scan the following QR code using your phone\\'s authenticator application or enter the setup key.": "Two factor authentication is now enabled. Scan the following QR code using your phone\\'s authenticator application or enter the setup key.",
|
"Two factor authentication is now enabled. Scan the following QR code using your phone's authenticator application or enter the setup key.": "Two factor authentication is now enabled. Scan the following QR code using your phone's authenticator application or enter the setup key.",
|
||||||
"Setup Key": "Setup Key",
|
"Setup Key": "Setup Key",
|
||||||
"Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two factor authentication device is lost.": "Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two factor authentication device is lost.",
|
"Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two factor authentication device is lost.": "Store these recovery codes in a secure password manager. They can be used to recover access to your account if your two factor authentication device is lost.",
|
||||||
"Regenerate Recovery Codes": "Regenerate Recovery Codes",
|
"Regenerate Recovery Codes": "Regenerate Recovery Codes",
|
||||||
@@ -57,7 +51,7 @@
|
|||||||
"Your :provider account has been connected.": "Your :provider account has been connected.",
|
"Your :provider account has been connected.": "Your :provider account has been connected.",
|
||||||
"Account already exists. Check your email to connect your :provider account.": "Account already exists. Check your email to connect your :provider account.",
|
"Account already exists. Check your email to connect your :provider account.": "Account already exists. Check your email to connect your :provider account.",
|
||||||
"Could not login using :provider. Try again later.": "Could not login using :provider. Try again later.",
|
"Could not login using :provider. Try again later.": "Could not login using :provider. Try again later.",
|
||||||
"Update your account\\'s profile information and email address.": "Update your account\\'s profile information and email address.",
|
"Update your account's profile information and email address.": "Update your account's profile information and email address.",
|
||||||
"Photo": "Photo",
|
"Photo": "Photo",
|
||||||
"Select A New Photo": "Select A New Photo",
|
"Select A New Photo": "Select A New Photo",
|
||||||
"Remove Photo": "Remove Photo",
|
"Remove Photo": "Remove Photo",
|
||||||
@@ -68,7 +62,9 @@
|
|||||||
"Last used": "Last used",
|
"Last used": "Last used",
|
||||||
"Delete": "Delete",
|
"Delete": "Delete",
|
||||||
"API Token": "API Token",
|
"API Token": "API Token",
|
||||||
"Please copy your new API token. For your security, it won\\'t be shown again.": "Please copy your new API token. For your security, it won\\'t be shown again.",
|
"Please copy your new API token. For your security, it won't be shown again.": "Please copy your new API token. For your security, it won't be shown again.",
|
||||||
|
"Copy to clipboard": "Copy to clipboard",
|
||||||
|
"Successfully copied!": "Successfully copied!",
|
||||||
"API Token Permissions": "API Token Permissions",
|
"API Token Permissions": "API Token Permissions",
|
||||||
"API tokens allow third-party services to authenticate with Investbrain on your behalf.": "API tokens allow third-party services to authenticate with Investbrain on your behalf.",
|
"API tokens allow third-party services to authenticate with Investbrain on your behalf.": "API tokens allow third-party services to authenticate with Investbrain on your behalf.",
|
||||||
"Delete API Token": "Delete API Token",
|
"Delete API Token": "Delete API Token",
|
||||||
@@ -96,7 +92,7 @@
|
|||||||
"Recovery Code": "Recovery Code",
|
"Recovery Code": "Recovery Code",
|
||||||
"Use a recovery code": "Use a recovery code",
|
"Use a recovery code": "Use a recovery code",
|
||||||
"Use an authentication code": "Use an authentication code",
|
"Use an authentication code": "Use an authentication code",
|
||||||
"Before continuing, could you verify your email address by clicking on the link we just emailed to you? If you didn\\'t receive the email, we will gladly send you another.": "Before continuing, could you verify your email address by clicking on the link we just emailed to you? If you didn\\'t receive the email, we will gladly send you another.",
|
"Before continuing, could you verify your email address by clicking on the link we just emailed to you? If you didn't receive the email, we will gladly send you another.": "Before continuing, could you verify your email address by clicking on the link we just emailed to you? If you didn't receive the email, we will gladly send you another.",
|
||||||
"A new verification link has been sent to the email address you provided in your profile settings.": "A new verification link has been sent to the email address you provided in your profile settings.",
|
"A new verification link has been sent to the email address you provided in your profile settings.": "A new verification link has been sent to the email address you provided in your profile settings.",
|
||||||
"Resend Verification Email": "Resend Verification Email",
|
"Resend Verification Email": "Resend Verification Email",
|
||||||
"Edit Profile": "Edit Profile",
|
"Edit Profile": "Edit Profile",
|
||||||
@@ -114,8 +110,9 @@
|
|||||||
"The provided password does not match your current password.": "The provided password does not match your current password.",
|
"The provided password does not match your current password.": "The provided password does not match your current password.",
|
||||||
|
|
||||||
"Documentation": "Documentation",
|
"Documentation": "Documentation",
|
||||||
"We\\'re open source!": "We\\'re open source!",
|
"We're open source!": "We're open source!",
|
||||||
"Toggle Theme": "Toggle Theme",
|
"Toggle Theme": "Toggle Theme",
|
||||||
|
"Toggle Sidebar": "Toggle Sidebar",
|
||||||
|
|
||||||
"Dashboard": "Dashboard",
|
"Dashboard": "Dashboard",
|
||||||
"Gain/Loss": "Gain/Loss",
|
"Gain/Loss": "Gain/Loss",
|
||||||
@@ -333,7 +330,7 @@
|
|||||||
"passwords.sent": "We have emailed your password reset link.",
|
"passwords.sent": "We have emailed your password reset link.",
|
||||||
"passwords.throttled": "Please wait before retrying.",
|
"passwords.throttled": "Please wait before retrying.",
|
||||||
"passwords.token": "This password reset token is invalid.",
|
"passwords.token": "This password reset token is invalid.",
|
||||||
"passwords.user": "We can\\'t find a user with that email address.",
|
"passwords.user": "We can't find a user with that email address.",
|
||||||
|
|
||||||
"pagination.previous": "« Previous",
|
"pagination.previous": "« Previous",
|
||||||
"pagination.next": "Next »",
|
"pagination.next": "Next »",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"Cancel": "Cancelar",
|
"Cancel": "Cancelar",
|
||||||
"Save": "Guardar",
|
"Save": "Guardar",
|
||||||
"Close": "Cerrar",
|
"Close": "Cerrar",
|
||||||
|
"Dismiss": "Despedir",
|
||||||
"or": "o",
|
"or": "o",
|
||||||
"and": "y",
|
"and": "y",
|
||||||
"Yes": "Sí",
|
"Yes": "Sí",
|
||||||
@@ -28,13 +29,6 @@
|
|||||||
"Permanently delete your account.": "Elimina tu cuenta de forma permanente.",
|
"Permanently delete your account.": "Elimina tu cuenta de forma permanente.",
|
||||||
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "Una vez que tu cuenta sea eliminada, todos sus recursos y datos serán eliminados de forma permanente. Antes de eliminar tu cuenta, descarga cualquier dato o información que desees conservar.",
|
"Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.": "Una vez que tu cuenta sea eliminada, todos sus recursos y datos serán eliminados de forma permanente. Antes de eliminar tu cuenta, descarga cualquier dato o información que desees conservar.",
|
||||||
"Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "¿Estás seguro de que deseas eliminar tu cuenta? Una vez que tu cuenta sea eliminada, todos sus recursos y datos serán eliminados de forma permanente. Introduce tu contraseña para confirmar que deseas eliminar tu cuenta de forma permanente.",
|
"Are you sure you want to delete your account? Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.": "¿Estás seguro de que deseas eliminar tu cuenta? Una vez que tu cuenta sea eliminada, todos sus recursos y datos serán eliminados de forma permanente. Introduce tu contraseña para confirmar que deseas eliminar tu cuenta de forma permanente.",
|
||||||
"Browser Sessions": "Sesiones del Navegador",
|
|
||||||
"Manage and log out your active sessions on other browsers and devices.": "Gestiona y cierra sesión en tus sesiones activas en otros navegadores y dispositivos.",
|
|
||||||
"If necessary, you may log out of all of your other browser sessions across all of your devices. Some of your recent sessions are listed below; however, this list may not be exhaustive. If you feel your account has been compromised, you should also update your password.": "Si es necesario, puedes cerrar sesión en todas tus otras sesiones del navegador en todos tus dispositivos. Algunas de tus sesiones recientes se enumeran a continuación; sin embargo, esta lista puede no ser exhaustiva. Si sientes que tu cuenta ha sido comprometida, también deberías actualizar tu contraseña.",
|
|
||||||
"This device": "Este dispositivo",
|
|
||||||
"Last active": "Última actividad",
|
|
||||||
"Log Out Other Browser Sessions": "Cerrar Sesión en Otras Sesiones del Navegador",
|
|
||||||
"Please enter your password to confirm you would like to log out of your other browser sessions across all of your devices.": "Introduce tu contraseña para confirmar que deseas cerrar sesión en tus otras sesiones del navegador en todos tus dispositivos.",
|
|
||||||
"Two Factor Authentication": "Autenticación de Dos Factores",
|
"Two Factor Authentication": "Autenticación de Dos Factores",
|
||||||
"Add additional security to your account using two factor authentication.": "Añade seguridad adicional a tu cuenta utilizando autenticación de dos factores.",
|
"Add additional security to your account using two factor authentication.": "Añade seguridad adicional a tu cuenta utilizando autenticación de dos factores.",
|
||||||
"Finish enabling two factor authentication.": "Finaliza la activación de la autenticación de dos factores.",
|
"Finish enabling two factor authentication.": "Finaliza la activación de la autenticación de dos factores.",
|
||||||
@@ -69,6 +63,8 @@
|
|||||||
"Delete": "Eliminar",
|
"Delete": "Eliminar",
|
||||||
"API Token": "Token API",
|
"API Token": "Token API",
|
||||||
"Please copy your new API token. For your security, it won't be shown again.": "Por favor, copia tu nuevo token API. Por seguridad, no se mostrará nuevamente.",
|
"Please copy your new API token. For your security, it won't be shown again.": "Por favor, copia tu nuevo token API. Por seguridad, no se mostrará nuevamente.",
|
||||||
|
"Copy to clipboard": "Copiar al portapapeles",
|
||||||
|
"Successfully copied!": "Copiado con éxito!",
|
||||||
"API Token Permissions": "Permisos del Token API",
|
"API Token Permissions": "Permisos del Token API",
|
||||||
"API tokens allow third-party services to authenticate with Investbrain on your behalf.": "Los tokens API permiten que servicios de terceros se autentiquen con Investbrain en tu nombre.",
|
"API tokens allow third-party services to authenticate with Investbrain on your behalf.": "Los tokens API permiten que servicios de terceros se autentiquen con Investbrain en tu nombre.",
|
||||||
"Delete API Token": "Eliminar Token API",
|
"Delete API Token": "Eliminar Token API",
|
||||||
@@ -116,6 +112,7 @@
|
|||||||
"Documentation": "Documentación",
|
"Documentation": "Documentación",
|
||||||
"We're open source!": "¡Somos de código abierto!",
|
"We're open source!": "¡Somos de código abierto!",
|
||||||
"Toggle Theme": "Cambiar Tema",
|
"Toggle Theme": "Cambiar Tema",
|
||||||
|
"Toggle Sidebar": "Alternar Navegación Lateral",
|
||||||
|
|
||||||
"Dashboard": "Tablero",
|
"Dashboard": "Tablero",
|
||||||
"Gain/Loss": "Ganancia/Pérdida",
|
"Gain/Loss": "Ganancia/Pérdida",
|
||||||
|
|||||||
@@ -8,15 +8,20 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.7",
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
"@tailwindcss/typography": "^0.5.10",
|
"@tailwindcss/typography": "^0.5.10",
|
||||||
|
"@tailwindcss/vite": "^4.1.13",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"axios": "^1.7.4",
|
"axios": "^1.7.4",
|
||||||
"daisyui": "^4.12.10",
|
"daisyui": "^5.1.14",
|
||||||
"laravel-vite-plugin": "^1.0",
|
"laravel-vite-plugin": "^1.0",
|
||||||
"postcss": "^8.4.40",
|
"postcss": "^8.4.40",
|
||||||
"tailwindcss": "^3.4.7",
|
"tailwindcss": "^4.1.13",
|
||||||
"vite": "^5.4"
|
"vite": "^5.4"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@alpinejs/focus": "^3.15.0",
|
||||||
|
"@alpinejs/persist": "^3.14.9",
|
||||||
|
"@alpinejs/resize": "^3.14.9",
|
||||||
|
"alpinejs": "^3.14.9",
|
||||||
"apexcharts": "^3.51.0"
|
"apexcharts": "^3.51.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:svgjs="http://svgjs.dev/svgjs" viewBox="0 0 700 700" width="700" height="700" opacity="0.25">
|
||||||
|
<defs>
|
||||||
|
<filter id="nnnoise-filter" x="-20%" y="-20%" width="140%" height="140%" filterUnits="objectBoundingBox"
|
||||||
|
primitiveUnits="userSpaceOnUse" color-interpolation-filters="linearRGB">
|
||||||
|
<feTurbulence type="fractalNoise" baseFrequency="0.2" numOctaves="4" seed="15" stitchTiles="stitch" x="0%"
|
||||||
|
y="0%" width="100%" height="100%" result="turbulence"></feTurbulence>
|
||||||
|
<feSpecularLighting surfaceScale="5" specularConstant="1" specularExponent="20" lighting-color="#3939A8"
|
||||||
|
x="0%" y="0%" width="100%" height="100%" in="turbulence" result="specularLighting">
|
||||||
|
<feDistantLight azimuth="3" elevation="18"></feDistantLight>
|
||||||
|
</feSpecularLighting>
|
||||||
|
<feColorMatrix type="saturate" values="0" x="0%" y="0%" width="100%" height="100%" in="specularLighting"
|
||||||
|
result="colormatrix"></feColorMatrix>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<rect width="700" height="700" fill="transparent"></rect>
|
||||||
|
<rect width="700" height="700" class="fill-current" filter="url(#nnnoise-filter)"></rect>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -1,11 +1,129 @@
|
|||||||
@tailwind base;
|
@import url("https://fonts.bunny.net/css?family=Inter:400,500,600&display=swap");
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
[x-cloak] {
|
@import "tailwindcss";
|
||||||
display: none;
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: "Inter", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@plugin "daisyui" {
|
||||||
|
themes: light, dark --default --prefersdark;
|
||||||
|
}
|
||||||
|
|
||||||
|
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
|
||||||
|
|
||||||
|
@plugin "daisyui/theme" {
|
||||||
|
name: "dark";
|
||||||
|
default: true;
|
||||||
|
prefersdark: true;
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
--animation-input: 0.15s;
|
||||||
|
|
||||||
|
--radius-selector: 0.15rem;
|
||||||
|
--radius-field: 0.25rem;
|
||||||
|
--radius-box: 0.5rem;
|
||||||
|
|
||||||
|
--size-selector: 0.25rem;
|
||||||
|
--size-field: 0.25rem;
|
||||||
|
|
||||||
|
--color-primary: "#78716c";
|
||||||
|
--color-primary-content: "#e3e1e0";
|
||||||
|
--color-secondary: "#7a7a7a";
|
||||||
|
--color-secondary-content: "#d1d1d5";
|
||||||
|
--color-accent: "#8c9ae3";
|
||||||
|
--color-accent-content: "#d3d4dd";
|
||||||
|
--color-neutral: "#302f3c";
|
||||||
|
--color-neutral-content: "#d1d1d5";
|
||||||
|
--color-base-100: "#20202A";
|
||||||
|
--color-base-200: "#1a1a23";
|
||||||
|
--color-base-300: "#15151c";
|
||||||
|
--color-base-content: "#cecdd0";
|
||||||
|
--color-info: "#1e40af";
|
||||||
|
--color-info-content: "#ced9f2";
|
||||||
|
--color-success: "#166534";
|
||||||
|
--color-success-content: "#d1dfd3";
|
||||||
|
--color-warning: "#a16207";
|
||||||
|
--color-warning-content: "#eddfd1";
|
||||||
|
--color-error: "#991b1b";
|
||||||
|
--color-error-content: "#efd3cf";
|
||||||
|
--depth: 0;
|
||||||
|
--noise: 0;
|
||||||
|
}
|
||||||
|
@plugin "daisyui/theme" {
|
||||||
|
name: "light";
|
||||||
|
prefersdark: false;
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
--animation-input: 0.15s;
|
||||||
|
|
||||||
|
--radius-selector: 0.15rem;
|
||||||
|
--radius-field: 0.25rem;
|
||||||
|
--radius-box: 0.5rem;
|
||||||
|
|
||||||
|
--size-selector: 0.25rem;
|
||||||
|
--size-field: 0.25rem;
|
||||||
|
|
||||||
|
--color-primary: "#d6d3d1";
|
||||||
|
--color-primary-content: "#101010";
|
||||||
|
--color-secondary: "#9ca3af";
|
||||||
|
--color-secondary-content: "#090a0b";
|
||||||
|
--color-accent: "#525783";
|
||||||
|
--color-accent-content: "#110c16";
|
||||||
|
--color-neutral: "#6b7280";
|
||||||
|
--color-neutral-content: "#e0e1e4";
|
||||||
|
--color-base-100: "oklch(100% 0 0)";
|
||||||
|
--color-base-200: "oklch(97.466% 0.011 259.822)";
|
||||||
|
--color-base-300: "oklch(0.95 0.016 244.89)";
|
||||||
|
--color-base-content: "#161616";
|
||||||
|
--color-info: "#60a5fa";
|
||||||
|
--color-info-content: "#030a15";
|
||||||
|
--color-success: "#10b981";
|
||||||
|
--color-success-content: "#000d06";
|
||||||
|
--color-warning: "#fb923c";
|
||||||
|
--color-warning-content: "#150801";
|
||||||
|
--color-error: "#ef4444";
|
||||||
|
--color-error-content: "#140202";
|
||||||
|
--depth: 0;
|
||||||
|
--noise: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@source "../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php";
|
||||||
|
@source "../../storage/framework/views/*.php";
|
||||||
|
@source "../**/*.blade.php";
|
||||||
|
|
||||||
|
/* Tool tip for apex charts */
|
||||||
|
[data-theme=dark] .apexcharts-tooltip-title {
|
||||||
|
background: #292933 !important;
|
||||||
|
border-bottom: 1px solid #393939 !important;
|
||||||
|
}
|
||||||
|
[data-theme=dark] .apexcharts-tooltip {
|
||||||
|
background: #20202A !important;
|
||||||
|
border-color: #393939 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wiggle animation */
|
||||||
|
@keyframes wiggle {
|
||||||
|
10%, 90% {
|
||||||
|
transform: translate3d(-1px, 0, 0);
|
||||||
|
}
|
||||||
|
20%, 80% {
|
||||||
|
transform: translate3d(2px, 0, 0);
|
||||||
|
}
|
||||||
|
30%, 50%, 70% {
|
||||||
|
transform: translate3d(-4px, 0, 0);
|
||||||
|
}
|
||||||
|
40%, 60% {
|
||||||
|
transform: translate3d(4px, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wiggle {
|
||||||
|
animation: wiggle 0.5s cubic-bezier(.36,.07,.19,.97) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AI chat styles */
|
||||||
.ai-chat ul, .ai-chat ol, .ai-chat ol li > ul {
|
.ai-chat ul, .ai-chat ol, .ai-chat ol li > ul {
|
||||||
margin-left: 1.1rem;
|
margin-left: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import ApexCharts from 'apexcharts'
|
import ApexCharts from 'apexcharts'
|
||||||
window.ApexCharts = ApexCharts;
|
window.ApexCharts = ApexCharts;
|
||||||
|
|
||||||
|
import '../../vendor/rappasoft/laravel-livewire-tables/resources/imports/laravel-livewire-tables.js';
|
||||||
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
window.axios = axios;
|
window.axios = axios;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,195 @@
|
|||||||
<div>
|
<?php
|
||||||
<!-- Generate API Token -->
|
|
||||||
|
use App\Traits\Toast;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Livewire\Volt\Component;
|
||||||
|
|
||||||
|
new class extends Component
|
||||||
|
{
|
||||||
|
use Toast;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The create API token form state.
|
||||||
|
*
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
public $createApiTokenForm = [
|
||||||
|
'name' => '',
|
||||||
|
'permissions' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if the plain text token is being displayed to the user.
|
||||||
|
*
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
public $displayingToken = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plain text token value.
|
||||||
|
*
|
||||||
|
* @var string|null
|
||||||
|
*/
|
||||||
|
public $plainTextToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if the user is currently managing an API token's permissions.
|
||||||
|
*
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
public $managingApiTokenPermissions = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The token that is currently having its permissions managed.
|
||||||
|
*
|
||||||
|
* @var \Laravel\Sanctum\PersonalAccessToken|null
|
||||||
|
*/
|
||||||
|
public $managingPermissionsFor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The update API token form state.
|
||||||
|
*
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
public $updateApiTokenForm = [
|
||||||
|
'permissions' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates if the application is confirming if an API token should be deleted.
|
||||||
|
*
|
||||||
|
* @var bool
|
||||||
|
*/
|
||||||
|
public $confirmingApiTokenDeletion = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ID of the API token being deleted.
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
public $apiTokenIdBeingDeleted;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount the component.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function mount()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new API token.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function createApiToken()
|
||||||
|
{
|
||||||
|
$this->resetErrorBag();
|
||||||
|
|
||||||
|
Validator::make([
|
||||||
|
'name' => $this->createApiTokenForm['name'],
|
||||||
|
], [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
])->validateWithBag('createApiToken');
|
||||||
|
|
||||||
|
$this->displayTokenValue($this->user->createToken(
|
||||||
|
$this->createApiTokenForm['name']
|
||||||
|
));
|
||||||
|
|
||||||
|
$this->createApiTokenForm['name'] = '';
|
||||||
|
|
||||||
|
$this->dispatch('created');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the token value to the user.
|
||||||
|
*
|
||||||
|
* @param \Laravel\Sanctum\NewAccessToken $token
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function displayTokenValue($token)
|
||||||
|
{
|
||||||
|
$this->displayingToken = true;
|
||||||
|
|
||||||
|
$this->plainTextToken = explode('|', $token->plainTextToken, 2)[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allow the given token's permissions to be managed.
|
||||||
|
*
|
||||||
|
* @param int $tokenId
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function manageApiTokenPermissions($tokenId)
|
||||||
|
{
|
||||||
|
$this->managingApiTokenPermissions = true;
|
||||||
|
|
||||||
|
$this->managingPermissionsFor = $this->user->tokens()->where(
|
||||||
|
'id', $tokenId
|
||||||
|
)->firstOrFail();
|
||||||
|
|
||||||
|
$this->updateApiTokenForm['permissions'] = $this->managingPermissionsFor->abilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the API token's permissions.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function updateApiToken()
|
||||||
|
{
|
||||||
|
$this->managingPermissionsFor->forceFill([
|
||||||
|
'abilities' => [],
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$this->managingApiTokenPermissions = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm that the given API token should be deleted.
|
||||||
|
*
|
||||||
|
* @param int $tokenId
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function confirmApiTokenDeletion($tokenId)
|
||||||
|
{
|
||||||
|
$this->confirmingApiTokenDeletion = true;
|
||||||
|
|
||||||
|
$this->apiTokenIdBeingDeleted = $tokenId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the API token.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function deleteApiToken()
|
||||||
|
{
|
||||||
|
$this->user->tokens()->where('id', $this->apiTokenIdBeingDeleted)->first()->delete();
|
||||||
|
|
||||||
|
$this->user->load('tokens');
|
||||||
|
|
||||||
|
$this->confirmingApiTokenDeletion = false;
|
||||||
|
|
||||||
|
$this->managingPermissionsFor = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current user of the application.
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function getUserProperty()
|
||||||
|
{
|
||||||
|
return Auth::user();
|
||||||
|
}
|
||||||
|
}; ?>
|
||||||
|
|
||||||
|
<div x-data>
|
||||||
|
{{-- Generate API Token --}}
|
||||||
<x-forms.form-section submit="createApiToken">
|
<x-forms.form-section submit="createApiToken">
|
||||||
<x-slot name="title">
|
<x-slot name="title">
|
||||||
{{ __('Create API Token') }}
|
{{ __('Create API Token') }}
|
||||||
@@ -10,13 +200,13 @@
|
|||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-slot name="form">
|
<x-slot name="form">
|
||||||
<!-- Token Name -->
|
{{-- Token Name --}}
|
||||||
<div class="col-span-6 sm:col-span-4">
|
<div class="col-span-6 sm:col-span-4">
|
||||||
<x-input id="name" label="{{ __('Token Name') }}" type="text" class="mt-1 block w-full" wire:model="createApiTokenForm.name" autofocus />
|
<x-ui.input id="name" label="{{ __('Token Name') }}" type="text" class="mt-1 block w-full" wire:model="createApiTokenForm.name" autofocus />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Token Permissions -->
|
{{-- Token Permissions --}}
|
||||||
@if (Laravel\Jetstream\Jetstream::hasPermissions())
|
@if (false)
|
||||||
<div class="col-span-6">
|
<div class="col-span-6">
|
||||||
<label class="pt-0 label label-text font-semibold">
|
<label class="pt-0 label label-text font-semibold">
|
||||||
<span>
|
<span>
|
||||||
@@ -25,15 +215,63 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@foreach (Laravel\Jetstream\Jetstream::$permissions as $label => $permission)
|
@foreach ([] as $label => $permission)
|
||||||
<label class="flex items-center">
|
<label class="flex items-center">
|
||||||
<x-checkbox wire:model="createApiTokenForm.permissions" :value="$permission"/>
|
<x-ui.checkbox wire:model="createApiTokenForm.permissions" :value="$permission"/>
|
||||||
<span class="ms-2 text-sm text-gray-600 dark:text-gray-400">{{ $label }}</span>
|
<span class="ms-2 text-sm text-gray-600 dark:text-gray-400">{{ $label }}</span>
|
||||||
</label>
|
</label>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
{{-- Token Value Modal --}}
|
||||||
|
<x-ui.modal
|
||||||
|
persistent
|
||||||
|
key="token-display-modal"
|
||||||
|
wire:model.live="displayingToken"
|
||||||
|
title="{{ __('API Token') }}"
|
||||||
|
>
|
||||||
|
<div class="mt-2 text-sm text-secondary-content">
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
{{ __('Please copy your new API token. For your security, it won\'t be shown again.') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-ui.input
|
||||||
|
x-ref="plaintextToken"
|
||||||
|
type="text"
|
||||||
|
readonly
|
||||||
|
:value="$plainTextToken"
|
||||||
|
class="font-mono break-all focus:outline-none focus:ring-0"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
>
|
||||||
|
<x-slot:suffix>
|
||||||
|
<x-ui.button
|
||||||
|
title="{{ __('Copy to clipboard') }}"
|
||||||
|
class="btn-circle btn-sm btn-ghost me-2"
|
||||||
|
icon="o-clipboard"
|
||||||
|
@click="
|
||||||
|
navigator.clipboard.writeText($wire.plainTextToken);
|
||||||
|
$wire.$set('displayingToken', false);
|
||||||
|
$wire.success('{{ __('Successfully copied!') }}')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</x-slot:suffix>
|
||||||
|
</x-ui.input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-row items-center justify-end mt-8 text-end">
|
||||||
|
<x-ui.button class="btn-outline" wire:click="$set('displayingToken', false)" wire:loading.attr="disabled">
|
||||||
|
{{ __('Close') }}
|
||||||
|
</x-ui.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</x-ui.modal>
|
||||||
|
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-slot name="actions">
|
<x-slot name="actions">
|
||||||
@@ -41,16 +279,16 @@
|
|||||||
{{ __('Created.') }}
|
{{ __('Created.') }}
|
||||||
</x-forms.action-message>
|
</x-forms.action-message>
|
||||||
|
|
||||||
<x-button type="submit">
|
<x-ui.button type="submit">
|
||||||
{{ __('Create') }}
|
{{ __('Create') }}
|
||||||
</x-button>
|
</x-ui.button>
|
||||||
</x-slot>
|
</x-slot>
|
||||||
</x-forms.form-section>
|
</x-forms.form-section>
|
||||||
|
|
||||||
@if ($this->user->tokens->isNotEmpty())
|
@if ($this->user->tokens->isNotEmpty())
|
||||||
<x-section-border hide-on-mobile />
|
<x-ui.section-border hide-on-mobile />
|
||||||
|
|
||||||
<!-- Manage API Tokens -->
|
{{-- Manage API Tokens --}}
|
||||||
<div class="mt-10 sm:mt-0">
|
<div class="mt-10 sm:mt-0">
|
||||||
<x-forms.action-section>
|
<x-forms.action-section>
|
||||||
<x-slot name="title">
|
<x-slot name="title">
|
||||||
@@ -61,12 +299,12 @@
|
|||||||
{{ __('You may delete any of your existing tokens if they are no longer needed.') }}
|
{{ __('You may delete any of your existing tokens if they are no longer needed.') }}
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<!-- API Token List -->
|
{{-- API Token List --}}
|
||||||
<x-slot name="content">
|
<x-slot name="content">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
@foreach ($this->user->tokens->sortBy('name') as $token)
|
@foreach ($this->user->tokens->sortBy('name') as $token)
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="break-all dark:text-white">
|
<div class="break-all">
|
||||||
{{ $token->name }}
|
{{ $token->name }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -77,7 +315,7 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if (Laravel\Jetstream\Jetstream::hasPermissions())
|
@if (false)
|
||||||
<button class="cursor-pointer ms-6 text-sm text-gray-400 underline" wire:click="manageApiTokenPermissions({{ $token->id }})">
|
<button class="cursor-pointer ms-6 text-sm text-gray-400 underline" wire:click="manageApiTokenPermissions({{ $token->id }})">
|
||||||
{{ __('Permissions') }}
|
{{ __('Permissions') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -95,42 +333,17 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<!-- Token Value Modal -->
|
{{-- API Token Permissions Modal --}}
|
||||||
<x-dialog-modal wire:model.live="displayingToken">
|
<x-ui.dialog-modal key="manage-permission-modal" wire:model.live="managingApiTokenPermissions">
|
||||||
<x-slot name="title">
|
|
||||||
{{ __('API Token') }}
|
|
||||||
</x-slot>
|
|
||||||
|
|
||||||
<x-slot name="content">
|
|
||||||
<div>
|
|
||||||
{{ __('Please copy your new API token. For your security, it won\'t be shown again.') }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-input x-ref="plaintextToken" type="text" readonly :value="$plainTextToken"
|
|
||||||
class="mt-4 px-4 py-2 rounded font-mono text-sm w-full break-all"
|
|
||||||
autofocus autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
|
|
||||||
@showing-token-modal.window="setTimeout(() => $refs.plaintextToken.select(), 250)"
|
|
||||||
/>
|
|
||||||
</x-slot>
|
|
||||||
|
|
||||||
<x-slot name="footer">
|
|
||||||
<x-button class="btn-outline" wire:click="$set('displayingToken', false)" wire:loading.attr="disabled">
|
|
||||||
{{ __('Close') }}
|
|
||||||
</x-button>
|
|
||||||
</x-slot>
|
|
||||||
</x-dialog-modal>
|
|
||||||
|
|
||||||
<!-- API Token Permissions Modal -->
|
|
||||||
<x-dialog-modal wire:model.live="managingApiTokenPermissions">
|
|
||||||
<x-slot name="title">
|
<x-slot name="title">
|
||||||
{{ __('API Token Permissions') }}
|
{{ __('API Token Permissions') }}
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-slot name="content">
|
<x-slot name="content">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@foreach (Laravel\Jetstream\Jetstream::$permissions as $label => $permission)
|
@foreach ([] as $label => $permission)
|
||||||
<label class="flex items-center">
|
<label class="flex items-center">
|
||||||
<x-checkbox wire:model="updateApiTokenForm.permissions" :value="$permission"/>
|
<x-ui.checkbox wire:model="updateApiTokenForm.permissions" :value="$permission"/>
|
||||||
<span class="ms-2 text-sm text-gray-600 dark:text-gray-400">{{ $label }}</span>
|
<span class="ms-2 text-sm text-gray-600 dark:text-gray-400">{{ $label }}</span>
|
||||||
</label>
|
</label>
|
||||||
@endforeach
|
@endforeach
|
||||||
@@ -138,18 +351,18 @@
|
|||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-slot name="footer">
|
<x-slot name="footer">
|
||||||
<x-button class="btn-outline" wire:click="$set('managingApiTokenPermissions', false)" wire:loading.attr="disabled">
|
<x-ui.button class="btn-outline" wire:click="$set('managingApiTokenPermissions', false)" wire:loading.attr="disabled">
|
||||||
{{ __('Cancel') }}
|
{{ __('Cancel') }}
|
||||||
</x-button>
|
</x-ui.button>
|
||||||
|
|
||||||
<x-button type="submit" class="ms-3" wire:click="updateApiToken" wire:loading.attr="disabled">
|
<x-ui.button type="submit" class="ms-3" wire:click="updateApiToken" wire:loading.attr="disabled">
|
||||||
{{ __('Save') }}
|
{{ __('Save') }}
|
||||||
</x-button>
|
</x-ui.button>
|
||||||
</x-slot>
|
</x-slot>
|
||||||
</x-dialog-modal>
|
</x-ui.dialog-modal>
|
||||||
|
|
||||||
<!-- Delete Token Confirmation Modal -->
|
{{-- Delete Token Confirmation Modal --}}
|
||||||
<x-confirmation-modal wire:model.live="confirmingApiTokenDeletion">
|
<x-ui.confirmation-modal key="confirm-deletion-modal" wire:model.live="confirmingApiTokenDeletion">
|
||||||
<x-slot name="title">
|
<x-slot name="title">
|
||||||
{{ __('Delete API Token') }}
|
{{ __('Delete API Token') }}
|
||||||
</x-slot>
|
</x-slot>
|
||||||
@@ -159,13 +372,13 @@
|
|||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-slot name="footer">
|
<x-slot name="footer">
|
||||||
<x-button class="btn-outline" wire:click="$toggle('confirmingApiTokenDeletion')" wire:loading.attr="disabled">
|
<x-ui.button class="btn-outline" wire:click="$toggle('confirmingApiTokenDeletion')" wire:loading.attr="disabled">
|
||||||
{{ __('Cancel') }}
|
{{ __('Cancel') }}
|
||||||
</x-button>
|
</x-ui.button>
|
||||||
|
|
||||||
<x-button class="ms-3 btn-error text-white" wire:click="deleteApiToken" wire:loading.attr="disabled">
|
<x-ui.button class="ms-3 btn-error text-white" wire:click="deleteApiToken" wire:loading.attr="disabled">
|
||||||
{{ __('Delete') }}
|
{{ __('Delete') }}
|
||||||
</x-button>
|
</x-ui.button>
|
||||||
</x-slot>
|
</x-slot>
|
||||||
</x-confirmation-modal>
|
</x-ui.confirmation-modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
<x-app-layout>
|
<x-layouts.app>
|
||||||
<x-slot name="header">
|
|
||||||
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
|
|
||||||
{{ __('API Tokens') }}
|
|
||||||
</h2>
|
|
||||||
</x-slot>
|
|
||||||
|
|
||||||
<div>
|
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
|
||||||
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
|
@livewire('api-token-manager')
|
||||||
@livewire('api.api-token-manager')
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</x-app-layout>
|
|
||||||
|
</x-layouts.app>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<x-guest-layout>
|
<x-layouts.guest>
|
||||||
<x-authentication-card>
|
<x-ui.authentication-card>
|
||||||
<x-slot name="logo">
|
<x-slot name="logo">
|
||||||
<div class="w-24 mb-10">
|
<div class="w-24 mb-10">
|
||||||
<x-glyph-only-logo />
|
<x-ui.logo />
|
||||||
</div>
|
</div>
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
@@ -10,20 +10,20 @@
|
|||||||
{{ __('This is a secure area of the application. Please confirm your password before continuing.') }}
|
{{ __('This is a secure area of the application. Please confirm your password before continuing.') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-errors class="mb-4" />
|
<x-ui.errors class="mb-4" />
|
||||||
|
|
||||||
<form method="POST" action="{{ route('password.confirm') }}">
|
<form method="POST" action="{{ route('password.confirm') }}">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<x-input id="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" name="password" required autocomplete="current-password" autofocus />
|
<x-ui.input id="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" name="password" required autocomplete="current-password" autofocus />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end mt-4">
|
<div class="flex justify-end mt-4">
|
||||||
<x-button type="submit" class="btn-primary ms-4">
|
<x-ui.button type="submit" class="btn-primary ms-4">
|
||||||
{{ __('Confirm') }}
|
{{ __('Confirm') }}
|
||||||
</x-button>
|
</x-ui.button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</x-authentication-card>
|
</x-ui.authentication-card>
|
||||||
</x-guest-layout>
|
</x-layouts.guest>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<x-guest-layout>
|
<x-layouts.guest>
|
||||||
<x-authentication-card>
|
<x-ui.authentication-card>
|
||||||
<x-slot name="logo">
|
<x-slot name="logo">
|
||||||
<div class="w-24 mb-10">
|
<div class="w-24 mb-10">
|
||||||
<x-glyph-only-logo />
|
<x-ui.logo />
|
||||||
</div>
|
</div>
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
@@ -11,26 +11,26 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@session('status')
|
@session('status')
|
||||||
<x-alert icon="o-envelope" class="alert-success mb-4">
|
<x-ui.alert icon="o-envelope" class="alert-success mb-4">
|
||||||
{{ $value }}
|
{{ $value }}
|
||||||
</x-alert>
|
</x-ui.alert>
|
||||||
@endsession
|
@endsession
|
||||||
|
|
||||||
<x-errors class="mb-4" />
|
<x-ui.errors class="mb-4" />
|
||||||
|
|
||||||
<form method="POST" action="{{ route('password.email') }}">
|
<form method="POST" action="{{ route('password.email') }}">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<div class="block">
|
<div class="block">
|
||||||
|
|
||||||
<x-input id="email" label="{{ __('Email') }}" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
|
<x-ui.input id="email" label="{{ __('Email') }}" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-end mt-4">
|
<div class="flex items-center justify-end mt-4">
|
||||||
<x-button class="btn-primary" type="submit">
|
<x-ui.button class="btn-primary" type="submit">
|
||||||
{{ __('Email Password Reset Link') }}
|
{{ __('Email Password Reset Link') }}
|
||||||
</x-button>
|
</x-ui.button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</x-authentication-card>
|
</x-ui.authentication-card>
|
||||||
</x-guest-layout>
|
</x-layouts.guest>
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ use Illuminate\Support\Facades\Auth;
|
|||||||
use Livewire\Attributes\Rule;
|
use Livewire\Attributes\Rule;
|
||||||
use Livewire\Volt\Component;
|
use Livewire\Volt\Component;
|
||||||
|
|
||||||
new class extends Component {
|
new class extends Component
|
||||||
|
{
|
||||||
// props
|
// props
|
||||||
public Portfolio $portfolio;
|
public Portfolio $portfolio;
|
||||||
|
|
||||||
public User $user;
|
public User $user;
|
||||||
|
|
||||||
#[Rule('required|string')]
|
#[Rule('required|string')]
|
||||||
@@ -41,30 +42,29 @@ new class extends Component {
|
|||||||
|
|
||||||
return redirect(route('portfolio.show', ['portfolio' => $this->portfolio->id]));
|
return redirect(route('portfolio.show', ['portfolio' => $this->portfolio->id]));
|
||||||
}
|
}
|
||||||
|
|
||||||
}; ?>
|
}; ?>
|
||||||
|
|
||||||
<x-form wire:submit="updateUserInformation" class="">
|
<x-form wire:submit="updateUserInformation" class="">
|
||||||
|
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
|
|
||||||
<x-input wire:model="name" label="{{ __('Name') }}" class="block mt-1 w-full" required autofocus />
|
<x-ui.input wire:model="name" label="{{ __('Name') }}" class="block mt-1 w-full" required autofocus />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
|
|
||||||
<x-input wire:model="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" required autocomplete="new-password" />
|
<x-ui.input wire:model="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" required autocomplete="new-password" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
|
|
||||||
<x-input wire:model="password_confirmation" label="{{ __('Confirm Password') }}" class="block mt-1 w-full" type="password" required autocomplete="new-password" />
|
<x-ui.input wire:model="password_confirmation" label="{{ __('Confirm Password') }}" class="block mt-1 w-full" type="password" required autocomplete="new-password" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-end mt-2">
|
<div class="flex items-center justify-end mt-2">
|
||||||
|
|
||||||
<x-button class="btn-primary" type="submit">
|
<x-ui.button class="btn-primary" type="submit">
|
||||||
{{ __('Get Started') }}
|
{{ __('Get Started') }}
|
||||||
</x-button>
|
</x-ui.button>
|
||||||
</div>
|
</div>
|
||||||
</x-form>
|
</x-form>
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<x-guest-layout>
|
<x-layouts.guest>
|
||||||
<x-authentication-card>
|
<x-ui.authentication-card>
|
||||||
<x-slot:logo>
|
<x-slot:logo>
|
||||||
<div class="w-24 mb-10">
|
<div class="w-24 mb-10">
|
||||||
<x-glyph-only-logo />
|
<x-ui.logo />
|
||||||
</div>
|
</div>
|
||||||
</x-slot:logo>
|
</x-slot:logo>
|
||||||
|
|
||||||
@@ -14,5 +14,5 @@
|
|||||||
'user' => $user,
|
'user' => $user,
|
||||||
])
|
])
|
||||||
|
|
||||||
</x-authentication-card>
|
</x-ui.authentication-card>
|
||||||
</x-guest-layout>
|
</x-layouts.guest>
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<x-guest-layout>
|
<x-layouts.guest>
|
||||||
<x-authentication-card>
|
<x-ui.authentication-card>
|
||||||
<x-slot name="logo">
|
<x-slot name="logo">
|
||||||
<div class="w-24 mb-10">
|
<div class="w-24 mb-10">
|
||||||
<x-glyph-only-logo />
|
<x-ui.logo />
|
||||||
</div>
|
</div>
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-errors class="mb-4" />
|
<x-ui.errors class="mb-4" />
|
||||||
|
|
||||||
@session('status')
|
@session('status')
|
||||||
<x-alert icon="o-envelope" class="alert-success mb-4">
|
<x-ui.alert icon="o-envelope" class="alert-success mb-4">
|
||||||
{{ $value }}
|
{{ $value }}
|
||||||
</x-alert>
|
</x-ui.alert>
|
||||||
@endsession
|
@endsession
|
||||||
|
|
||||||
<form method="POST" action="{{ route('login') }}">
|
<form method="POST" action="{{ route('login') }}">
|
||||||
@@ -19,16 +19,16 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
<x-input id="email" label="{{ __('Email') }}" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
|
<x-ui.input id="email" label="{{ __('Email') }}" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
|
|
||||||
<x-input id="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" name="password" required autocomplete="current-password" />
|
<x-ui.input id="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" name="password" required autocomplete="current-password" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="block mt-4">
|
<div class="block mt-4">
|
||||||
<x-checkbox id="remember_me" name="remember" class="text-sm" label="{{ __('Remember me') }}" />
|
<x-ui.checkbox id="remember_me" name="remember" class="text-sm" label="{{ __('Remember me') }}" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-end mt-4">
|
<div class="flex items-center justify-end mt-4">
|
||||||
@@ -38,26 +38,26 @@
|
|||||||
</a>
|
</a>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<x-button type="submit" class="btn-primary ms-4" >
|
<x-ui.button type="submit" class="btn-primary ms-4" >
|
||||||
{{ __('Log in') }}
|
{{ __('Log in') }}
|
||||||
</x-button>
|
</x-ui.button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (\Laravel\Fortify\Features::enabled('registration'))
|
@if (\Laravel\Fortify\Features::enabled('registration'))
|
||||||
|
|
||||||
<x-section-border />
|
<x-ui.section-border />
|
||||||
|
|
||||||
<x-connected-accounts-login />
|
<x-social.connected-accounts-login />
|
||||||
|
|
||||||
<x-button
|
<x-ui.button
|
||||||
link="{{ route('register') }}"
|
link="{{ route('register') }}"
|
||||||
class="btn-sm btn-block btn-outline btn-secondary my-1"
|
class="btn-sm btn-block btn-outline btn-secondary my-1"
|
||||||
>
|
>
|
||||||
{{ __('Sign up with email') }}
|
{{ __('Sign up with email') }}
|
||||||
</x-button>
|
</x-ui.button>
|
||||||
|
|
||||||
@endif
|
@endif
|
||||||
</form>
|
</form>
|
||||||
</x-authentication-card>
|
</x-ui.authentication-card>
|
||||||
</x-guest-layout>
|
</x-layouts.guest>
|
||||||
|
|||||||
@@ -1,41 +1,41 @@
|
|||||||
<x-guest-layout>
|
<x-layouts.guest>
|
||||||
<x-authentication-card>
|
<x-ui.authentication-card>
|
||||||
<x-slot name="logo">
|
<x-slot name="logo">
|
||||||
<div class="w-24 mb-10">
|
<div class="w-24 mb-10">
|
||||||
<x-glyph-only-logo />
|
<x-ui.logo />
|
||||||
</div>
|
</div>
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-errors class="mb-4" />
|
<x-ui.errors class="mb-4" />
|
||||||
|
|
||||||
<form method="POST" action="{{ route('register') }}">
|
<form method="POST" action="{{ route('register') }}">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
<x-input id="name" label="{{ __('Name') }}" class="block mt-1 w-full" type="text" name="name" :value="old('name')" required autofocus autocomplete="name" />
|
<x-ui.input id="name" label="{{ __('Name') }}" class="block mt-1 w-full" type="text" name="name" :value="old('name')" required autofocus autocomplete="name" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
|
|
||||||
<x-input id="email" label="{{ __('Email') }}" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autocomplete="username" />
|
<x-ui.input id="email" label="{{ __('Email') }}" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autocomplete="username" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
|
|
||||||
<x-input id="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" />
|
<x-ui.input id="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
|
|
||||||
<x-input id="password_confirmation" label="{{ __('Confirm Password') }}" class="block mt-1 w-full" type="password" name="password_confirmation" required autocomplete="new-password" />
|
<x-ui.input id="password_confirmation" label="{{ __('Confirm Password') }}" class="block mt-1 w-full" type="password" name="password_confirmation" required autocomplete="new-password" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (! config('investbrain.self_hosted'))
|
@if (! config('investbrain.self_hosted'))
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<label>
|
<label>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<x-checkbox name="terms" id="terms" required />
|
<x-ui.checkbox name="terms" id="terms" required />
|
||||||
|
|
||||||
<div class="ms-2 text-sm">
|
<div class="ms-2 text-sm">
|
||||||
{!! __('I agree to the :terms_of_service and :privacy_policy', [
|
{!! __('I agree to the :terms_of_service and :privacy_policy', [
|
||||||
@@ -53,10 +53,10 @@
|
|||||||
{{ __('Already registered?') }}
|
{{ __('Already registered?') }}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<x-button type="submit" class="btn-primary ms-4">
|
<x-ui.button type="submit" class="btn-primary ms-4">
|
||||||
{{ __('Register') }}
|
{{ __('Register') }}
|
||||||
</x-button>
|
</x-ui.button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</x-authentication-card>
|
</x-ui.authentication-card>
|
||||||
</x-guest-layout>
|
</x-layouts.guest>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<x-guest-layout>
|
<x-layouts.guest>
|
||||||
<x-authentication-card>
|
<x-ui.authentication-card>
|
||||||
<x-slot name="logo">
|
<x-slot name="logo">
|
||||||
<div class="w-24 mb-10">
|
<div class="w-24 mb-10">
|
||||||
<x-glyph-only-logo />
|
<x-ui.logo />
|
||||||
</div>
|
</div>
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-errors class="mb-4" />
|
<x-ui.errors class="mb-4" />
|
||||||
|
|
||||||
<form method="POST" action="{{ route('password.update') }}">
|
<form method="POST" action="{{ route('password.update') }}">
|
||||||
@csrf
|
@csrf
|
||||||
@@ -15,24 +15,24 @@
|
|||||||
|
|
||||||
<div class="block">
|
<div class="block">
|
||||||
|
|
||||||
<x-input id="email" label="{{ __('Email') }}" class="block mt-1 w-full" type="email" name="email" :value="old('email', $request->email)" required autofocus autocomplete="username" />
|
<x-ui.input id="email" label="{{ __('Email') }}" class="block mt-1 w-full" type="email" name="email" :value="old('email', $request->email)" required autofocus autocomplete="username" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
|
|
||||||
<x-input id="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" />
|
<x-ui.input id="password" label="{{ __('Password') }}" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
|
|
||||||
<x-input id="password_confirmation" label="{{ __('Confirm Password') }}" class="block mt-1 w-full" type="password" name="password_confirmation" required autocomplete="new-password" />
|
<x-ui.input id="password_confirmation" label="{{ __('Confirm Password') }}" class="block mt-1 w-full" type="password" name="password_confirmation" required autocomplete="new-password" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-end mt-4">
|
<div class="flex items-center justify-end mt-4">
|
||||||
<x-button class="btn-primary" type="submit">
|
<x-ui.button class="btn-primary" type="submit">
|
||||||
{{ __('Reset Password') }}
|
{{ __('Reset Password') }}
|
||||||
</x-button>
|
</x-ui.button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</x-authentication-card>
|
</x-ui.authentication-card>
|
||||||
</x-guest-layout>
|
</x-layouts.guest>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<x-guest-layout>
|
<x-layouts.guest>
|
||||||
<x-authentication-card>
|
<x-ui.authentication-card>
|
||||||
<x-slot name="logo">
|
<x-slot name="logo">
|
||||||
<div class="w-24 mb-10">
|
<div class="w-24 mb-10">
|
||||||
<x-glyph-only-logo />
|
<x-ui.logo />
|
||||||
</div>
|
</div>
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
@@ -15,19 +15,19 @@
|
|||||||
{{ __('Please confirm access to your account by entering one of your emergency recovery codes.') }}
|
{{ __('Please confirm access to your account by entering one of your emergency recovery codes.') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-errors class="mb-4" />
|
<x-ui.errors class="mb-4" />
|
||||||
|
|
||||||
<form method="POST" action="{{ route('two-factor.login') }}">
|
<form method="POST" action="{{ route('two-factor.login') }}">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<div class="mt-4" x-show="! recovery">
|
<div class="mt-4" x-show="! recovery">
|
||||||
|
|
||||||
<x-input id="code" label="{{ __('Code') }}" class="block mt-1 w-full" type="text" inputmode="numeric" name="code" autofocus x-ref="code" autocomplete="one-time-code" />
|
<x-ui.input id="code" label="{{ __('Code') }}" class="block mt-1 w-full" type="text" inputmode="numeric" name="code" autofocus x-ref="code" autocomplete="one-time-code" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4" x-cloak x-show="recovery">
|
<div class="mt-4" x-cloak x-show="recovery">
|
||||||
|
|
||||||
<x-input id="recovery_code" label="{{ __('Recovery Code') }}" class="block mt-1 w-full" type="text" name="recovery_code" x-ref="recovery_code" autocomplete="one-time-code" />
|
<x-ui.input id="recovery_code" label="{{ __('Recovery Code') }}" class="block mt-1 w-full" type="text" name="recovery_code" x-ref="recovery_code" autocomplete="one-time-code" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-end mt-4">
|
<div class="flex items-center justify-end mt-4">
|
||||||
@@ -50,11 +50,11 @@
|
|||||||
{{ __('Use an authentication code') }}
|
{{ __('Use an authentication code') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<x-button type="submit" class="btn-primary ms-4">
|
<x-ui.button type="submit" class="btn-primary ms-4">
|
||||||
{{ __('Log in') }}
|
{{ __('Log in') }}
|
||||||
</x-button>
|
</x-ui.button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</x-authentication-card>
|
</x-ui.authentication-card>
|
||||||
</x-guest-layout>
|
</x-layouts.guest>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<x-guest-layout>
|
<x-layouts.guest>
|
||||||
<x-authentication-card>
|
<x-ui.authentication-card>
|
||||||
<x-slot name="logo">
|
<x-slot name="logo">
|
||||||
<div class="w-24 mb-10">
|
<div class="w-24 mb-10">
|
||||||
<x-glyph-only-logo />
|
<x-ui.logo />
|
||||||
</div>
|
</div>
|
||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
@@ -11,9 +11,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (session('status') == 'verification-link-sent')
|
@if (session('status') == 'verification-link-sent')
|
||||||
<x-alert icon="o-envelope" class="alert-success mb-4">
|
<x-ui.alert icon="o-envelope" class="alert-success mb-4">
|
||||||
{{ __('A new verification link has been sent to the email address you provided in your profile settings.') }}
|
{{ __('A new verification link has been sent to the email address you provided in your profile settings.') }}
|
||||||
</x-alert>
|
</x-ui.alert>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="mt-4 flex items-center justify-between">
|
<div class="mt-4 flex items-center justify-between">
|
||||||
@@ -21,9 +21,9 @@
|
|||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<x-button type="submit" type="submit" class="btn-primary">
|
<x-ui.button label="{{ __('Resend Verification Email') }}" type="submit" class="bg-primary hover:bg-secondary focus:ring-2 focus:ring-offset-2 focus:ring-neutral-900 focus:shadow-outline focus:outline-none" />
|
||||||
{{ __('Resend Verification Email') }}
|
|
||||||
</x-button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -43,5 +43,5 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</x-authentication-card>
|
</x-ui.authentication-card>
|
||||||
</x-guest-layout>
|
</x-layouts.guest>
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
<div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100 dark:bg-gray-900">
|
|
||||||
<div>
|
|
||||||
{{ $logo }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white dark:bg-gray-800 shadow-md overflow-hidden sm:rounded-lg">
|
|
||||||
{{ $slot }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
@props(['id' => null, 'maxWidth' => null])
|
|
||||||
|
|
||||||
<x-ib-livewire-modal :id="$id" :maxWidth="$maxWidth" {{ $attributes }} :showClose="false">
|
|
||||||
<div class="p-2">
|
|
||||||
<div class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{{ $title }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{{ $content }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-row items-center justify-end mt-3 p-2 text-end">
|
|
||||||
{{ $footer }}
|
|
||||||
</div>
|
|
||||||
</x-ib-livewire-modal>
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
</x-forms.section-title>
|
</x-forms.section-title>
|
||||||
|
|
||||||
<div class="mt-5 md:mt-0 md:col-span-2">
|
<div class="mt-5 md:mt-0 md:col-span-2">
|
||||||
<div class="px-4 py-5 sm:p-6 bg-white dark:bg-gray-800 shadow sm:rounded-lg">
|
<div class="px-4 py-5 sm:p-6 bg-base-100 shadow sm:rounded-lg">
|
||||||
{{ $content }}
|
{{ $content }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
@once
|
@once
|
||||||
<x-dialog-modal wire:model.live="confirmingPassword">
|
<x-ui.dialog-modal wire:model.live="confirmingPassword">
|
||||||
<x-slot name="title">
|
<x-slot name="title">
|
||||||
{{ $title }}
|
{{ $title }}
|
||||||
</x-slot>
|
</x-slot>
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
<div class="mt-4" x-data="{}" x-on:confirming-password.window="setTimeout(() => $refs.confirmable_password.focus(), 250)">
|
<div class="mt-4" x-data="{}" x-on:confirming-password.window="setTimeout(() => $refs.confirmable_password.focus(), 250)">
|
||||||
|
|
||||||
<x-input type="password" class="mt-1 block w-3/4" placeholder="{{ __('Password') }}" autocomplete="current-password"
|
<x-ui.input type="password" class="mt-1 block w-3/4" placeholder="{{ __('Password') }}" autocomplete="current-password"
|
||||||
x-ref="confirmable_password"
|
x-ref="confirmable_password"
|
||||||
wire:model="confirmablePassword"
|
wire:model="confirmablePassword"
|
||||||
wire:keydown.enter="confirmPassword"
|
wire:keydown.enter="confirmPassword"
|
||||||
@@ -36,13 +36,13 @@
|
|||||||
</x-slot>
|
</x-slot>
|
||||||
|
|
||||||
<x-slot name="footer">
|
<x-slot name="footer">
|
||||||
<x-button class="btn-outline" wire:click="stopConfirmingPassword" wire:loading.attr="disabled">
|
<x-ui.button class="btn-outline" wire:click="stopConfirmingPassword" wire:loading.attr="disabled">
|
||||||
{{ __('Cancel') }}
|
{{ __('Cancel') }}
|
||||||
</x-button>
|
</x-ui.button>
|
||||||
|
|
||||||
<x-button type="submit" class="ms-3" dusk="confirm-password-button" wire:click="confirmPassword" wire:loading.attr="disabled">
|
<x-ui.button type="submit" class="ms-3" dusk="confirm-password-button" wire:click="confirmPassword" wire:loading.attr="disabled">
|
||||||
{{ $button }}
|
{{ $button }}
|
||||||
</x-button>
|
</x-ui.button>
|
||||||
</x-slot>
|
</x-slot>
|
||||||
</x-dialog-modal>
|
</x-ui.dialog-modal>
|
||||||
@endonce
|
@endonce
|
||||||
|
|||||||
@@ -8,14 +8,14 @@
|
|||||||
|
|
||||||
<div class="mt-5 md:mt-0 md:col-span-2">
|
<div class="mt-5 md:mt-0 md:col-span-2">
|
||||||
<form wire:submit="{{ $submit }}">
|
<form wire:submit="{{ $submit }}">
|
||||||
<div class="px-4 py-5 bg-white dark:bg-gray-800 sm:p-6 shadow {{ isset($actions) ? 'sm:rounded-tl-md sm:rounded-tr-md' : 'sm:rounded-md' }}">
|
<div class="px-4 py-5 bg-base-100 sm:p-6 shadow {{ isset($actions) ? 'sm:rounded-tl-md sm:rounded-tr-md' : 'sm:rounded-md' }}">
|
||||||
<div class="grid grid-cols-6 gap-6">
|
<div class="grid grid-cols-6 gap-6">
|
||||||
{{ $form }}
|
{{ $form }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (isset($actions))
|
@if (isset($actions))
|
||||||
<div class="flex items-center justify-end px-4 py-3 bg-gray-50 dark:bg-gray-800 text-end sm:px-6 shadow sm:rounded-bl-md sm:rounded-br-md">
|
<div class="flex items-center justify-end px-4 py-3 bg-base-100 text-end sm:px-6 shadow sm:rounded-bl-md sm:rounded-br-md">
|
||||||
{{ $actions }}
|
{{ $actions }}
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
|
|
||||||
@php
|
|
||||||
if (isset($percent)) {
|
|
||||||
|
|
||||||
$isUp = $percent > 0;
|
|
||||||
|
|
||||||
} else {
|
|
||||||
|
|
||||||
$isUp = $costBasis <= $marketValue;
|
|
||||||
$percent = $costBasis ? (($marketValue - $costBasis) / $costBasis) * 100 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
@if(!empty($percent))
|
|
||||||
|
|
||||||
<x-badge class="badge-sm {{ $isUp ? 'badge-success' : 'badge-error' }} badge-outline ml-2">
|
|
||||||
<x-slot:value>
|
|
||||||
{!! $isUp ? '▲' :'▼' !!}
|
|
||||||
{{ Number::percentage(
|
|
||||||
$percent,
|
|
||||||
$percent < 1 ? 2 : 0
|
|
||||||
) }}
|
|
||||||
</x-slot:value>
|
|
||||||
</x-badge>
|
|
||||||
|
|
||||||
@endif
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<a href="/" title="Investbrain">
|
|
||||||
|
|
||||||
<svg width="100%" height="100%" id="Layer_1" class="fill-current" data-name="Layer 1" viewBox="0 0 1001 783" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" >
|
|
||||||
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M553.875,632.571L567.884,627.131C567.884,627.131 588.94,642.044 611.341,650.542C660.03,669.007 666.181,693.68 670.67,711.697C671.541,715.201 672.368,718.512 673.431,721.293C679.103,736.17 685.326,746.904 694.882,758.003L737.893,737.748C730.866,729.455 721.087,714.1 721.273,693.007C721.419,676.837 731.456,663.936 740.313,652.55C749.261,641.048 756.99,631.115 754.689,619.792C754.428,618.501 750.205,606.681 683.457,589.378C664.971,584.588 632.955,577.931 632.955,577.931C632.955,577.931 635.967,564.803 636.504,564.91C650.287,567.669 668.765,571.64 687.293,576.443C757.295,594.586 767.837,608.754 769.682,617.83C773.076,634.571 762.843,647.722 752.948,660.444C744.551,671.243 736.616,681.44 736.507,693.555C736.275,719.609 754.703,734.781 754.889,734.933L762.945,741.43L691.292,775.182L687.22,770.803C674.012,756.601 666.101,743.826 659.014,725.231C657.68,721.736 656.764,718.071 655.801,714.191C651.633,697.476 641.744,677.506 600.566,661.887C573.409,651.585 553.875,632.571 553.875,632.571Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M469.894,617.03C491.9,625.608 537.785,632.498 578.066,616.912C606.757,605.811 625.69,585.647 634.343,556.967L635.932,544.895L650.678,549.582L650.547,550.556L649.213,560.348C639.567,592.821 617.208,616.664 584.546,629.301C557.593,639.728 525.875,641.415 498.98,637.874C484.116,635.917 475.069,632.909 464.77,628.35L469.894,617.03Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M756.363,647.659C766.597,652.226 735.904,647.812 749.88,647.831C752.018,647.834 754.181,647.777 756.363,647.659ZM756.363,647.659L759.121,630.608C776.312,645.1 814.041,614.388 822.007,607.977C847.271,587.646 859.429,573.432 865.582,531.56L857.892,525.97L871.854,519.291L871.902,520.701L871.646,533.167C868.374,581.138 854.724,595.681 826.924,619.726C805.922,637.888 779.998,646.378 756.363,647.659Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M278.425,160.523C277.934,160.447 277.438,160.37 276.948,160.286C277.44,160.359 277.93,160.439 278.425,160.523Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M56.427,281.384L53.276,278.254C53.915,253.987 64.477,230.809 81.468,211.975C77.818,216.38 74.378,221.228 71.199,226.564C59.438,246.327 55.735,264.917 56.427,281.384ZM125.55,179.848C146.913,170.054 171.349,165.376 196.488,168.027C196.773,169.34 196.992,170.047 196.992,170.047L196.819,170.025C194.125,169.67 160.165,165.575 125.55,179.848ZM876.465,148.788C875.253,140.191 872.604,131.249 868.55,122.317C872.79,131.336 875.507,140.302 876.465,148.788ZM636.968,67.078C632.455,59.767 626.37,52.471 618.591,45.74C627.058,52.458 633.412,59.75 636.968,67.078ZM830.233,73.639C814.235,60.456 794.248,49.212 770.413,41.637C761.569,38.826 752.892,36.944 744.475,35.836C730.805,34.037 717.803,34.272 705.832,35.868C719.737,33.548 733.39,33.475 746.563,35.21C778.577,39.424 807.696,54.337 830.233,73.639Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M546.485,261.415C546.455,261.429 546.424,261.444 546.394,261.458C546.389,261.461 546.389,261.461 546.385,261.46L546.485,261.415ZM546.485,261.415C547.832,260.79 549.176,260.186 550.513,259.608L546.485,261.415Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M951.448,338.201C967.066,346.748 978.94,360.259 984.357,371.461C991.028,385.267 1009.88,464.746 938.38,509.182C874.197,549.074 824.465,524.364 805.506,511.387C784.609,543.131 735.375,571.199 677.333,565.042C620.575,558.985 591.016,530.312 576.286,507.76C561.52,528.554 541.685,537.599 526.615,541.516C511.159,545.534 494.383,545.742 479.342,542.424C490.897,565.908 498.729,604.571 467.806,641.145C445.634,667.37 414.398,675.189 392.099,677.128C370.311,679.025 349.702,675.811 337.482,671.143C324.069,682.794 288.704,704.331 243.88,698.43C233.085,697.009 221.742,694 210.023,688.874C158.439,666.305 147.126,623.344 154.77,594.547C111.783,593.541 77.23,581.082 51.934,557.44C20.86,528.397 4.976,481.26 9.445,431.35C14.204,378.198 55.606,354.896 74.754,346.889C60.508,328.89 30.467,280.339 64.437,223.27C98.829,165.496 163.458,161.805 188.094,162.668C186.255,144.263 189.011,101.243 245.926,69.359C313.249,31.644 373.035,54.386 397.781,70.781C414.353,45.661 452.387,10.329 510.461,7.925C585.849,4.8 622.364,35.827 637.949,55.854C660.603,36.887 713.288,16.387 772.778,35.297C839.209,56.412 875.616,104.13 883.278,143.714C947.161,162.729 985.435,206.49 988.544,264.393C990.859,307.537 971.373,327.501 951.448,338.201ZM929.36,498.056C991.957,459.153 975.932,387.913 970.382,376.422C965.636,366.6 951.481,350.075 931.943,344.8L911.518,339.288L930.807,332.122C953.179,323.814 975.727,309.291 973.33,264.596C971.652,233.374 956.575,177.653 874.439,155.23L869.539,153.891L868.907,149.406C863.81,113.255 830.649,67.872 768.052,47.976C704.505,27.778 653.597,58.332 643.204,71.132L636.222,79.733L630.312,70.164C620.597,54.426 589.582,18.173 511.669,21.398C440.094,24.362 408.415,81.914 407.103,84.363L402.557,92.834L394.883,85.976C379.908,72.608 321.954,42.965 254.369,80.828C189.472,117.183 204.179,167.969 204.337,168.478L207.359,178.33L196.014,176.705C192.739,176.258 115.819,166.267 77.965,229.863C39.999,293.638 91.82,344.913 92.351,345.421L100.424,353.227L89.31,356.328C86.907,357.008 29.915,373.837 24.63,432.853C20.503,478.949 34.782,522.122 62.831,548.336C87.022,570.947 121.342,581.965 164.831,581.071L176.905,580.826L172.295,590.588C161.421,613.62 167.914,655.605 216.409,676.818C274.503,702.221 322.493,666.638 329.535,658.577L333.699,653.813L339.597,657.183C352.785,664.72 419.879,674.98 455.506,632.841C490.702,591.211 468.011,546.744 456.297,533.124L432.423,505.365L466.09,523.416C481.09,531.455 502.99,533.478 521.89,528.564C536.577,524.747 556.747,514.997 569.395,490.245L576.355,476.635L583.311,490.646C593.267,510.709 618.898,545.364 678.656,551.649C737.765,557.918 782.891,523.943 796.055,497.816L800.455,489.081L808.318,496.037C816.887,503.614 862.973,539.314 929.36,498.056Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M164.45,581.093C168.821,580.604 195.946,573.542 211.278,553.997C221.78,540.606 224.635,523.998 219.76,504.639C219.76,504.639 247.605,532.462 221.626,564.658C202.884,586.185 171.934,594.399 165.717,594.554L164.791,581.073C164.703,581.077 164.591,581.078 164.45,581.093ZM164.45,581.093C164.448,581.093 164.447,581.094 164.445,581.094C164.447,581.094 164.448,581.093 164.45,581.093Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M213.575,553.555C222.582,551.317 231.34,551.047 239.325,552.226C255.233,554.572 268.058,562.655 273.619,572.298C273.619,572.298 245.37,554.286 218.136,566.552L213.575,553.555Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M310.41,312.188C362.408,280.533 430.339,286.541 475.605,326.802L464.987,336.16C425.111,300.696 365.213,295.443 319.316,323.38C275.107,350.295 256.595,417.442 280.48,464.26C307.052,516.356 357.867,539.991 394.332,536.42C394.332,536.42 358.776,552.43 324.184,531.648C299.807,517 279.907,495.504 266.63,469.474C239.543,416.376 259.999,342.877 310.41,312.188Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M330.494,520.536C338.379,526.429 342.731,534.458 344.7,543.023C348.021,570.08 326.463,580.957 326.463,580.957C333.323,569.603 338.064,541.076 322.267,529.275L330.494,520.536Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M170.498,451.168C205.064,437.661 243.653,440.477 276.359,458.882L268.492,470.196C240.229,454.292 206.893,451.865 177.027,463.541C153.301,472.81 139.19,488.122 137.509,498.456C137.509,498.456 134.627,468.393 170.498,451.168Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M122.051,418.399C124.266,425.291 153.678,451.572 184.056,447.87L186.251,459.326C180.771,459.993 175.279,459.883 169.892,459.174C162.413,458.189 155.13,456.047 148.357,453.205C119.151,440.422 122.051,418.399 122.051,418.399Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M83.89,343.578C93.152,340.443 119.954,335.902 149.393,342.543C189.622,353.407 200.664,388.305 200.664,388.305C157.717,337.275 90.386,355.973 89.732,356.197L83.89,343.578Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M403.057,462.599C412.109,479.736 430.231,504.115 465.183,522.924L466.14,523.442L458.465,534.863L457.586,534.391C419.225,513.745 399.276,486.879 389.295,467.98C380.508,451.336 378.406,434.828 379.826,419.97C384.383,385.186 414.727,373.954 414.727,373.954C402.573,387.065 383.727,425.985 403.057,462.599Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M322.397,408.686C322.397,408.686 360.323,391.713 392.009,422.14L382.306,429.591C369.382,416.512 339.485,407.613 322.397,408.686Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M690.853,274.028C735.995,311.348 753.013,373.417 740.587,407.967C734.08,426.056 723.599,439.419 712.073,449.263C685.57,471.905 649.16,462.799 649.16,462.799C651.515,462.516 707.46,455.197 726.054,403.494C736.835,373.524 720.838,316.972 680.654,283.744C656.633,263.884 614.846,244.873 552.154,267.086C489.758,289.191 475.391,326.522 474.322,353.951C472.725,395.045 499.768,434.915 514.438,442.054C514.438,442.054 481.579,436.735 467.588,400.693C461.928,385.953 458.458,369.521 459.1,353.048C460.315,321.737 476.396,279.231 546.1,254.536C616.21,229.702 663.52,251.425 690.853,274.028Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M574.041,405.452C577.929,399.868 582.591,394.925 588.017,390.626C622.566,366.135 666.154,382.688 666.154,382.688C627.495,382.845 600.885,392.975 587.077,412.802C566.225,442.734 580.371,485.117 583.223,490.479L569.482,495.902C565.551,488.513 549.669,440.434 574.041,405.452Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M586.976,325.57C586.976,325.57 612.868,328.978 618.672,355.583C621.052,372.019 615.594,386.416 603.3,396.116L594.44,387.377C606.107,378.168 607.013,365.603 605.724,356.686C603.595,342.023 594.518,329.351 586.976,325.57Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M708.183,440.658C738.696,458.976 721.687,492.93 721.687,492.93C725.772,471.083 707.178,453.368 701.816,450.565L708.183,440.658Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M793.227,434.541L805.895,427.418C827.426,456.566 813.55,497.025 809.929,503.784L796.126,497.678C798.539,493.179 810.551,457.997 793.227,434.541Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M843.106,432.778C872.723,432.517 895.844,420.562 906.531,399.983C906.531,399.983 907.929,428.423 880.172,440.043C869.358,444.009 857.092,446.148 843.754,446.269C837.448,446.323 831.171,445.926 825.002,445.114C790.398,440.559 759.118,422.884 743.733,398.136C712.965,348.644 746.651,293.716 782.802,273.093C802.223,262.012 823.22,255.668 842.44,254.262C876.032,253.772 883.4,276.414 883.4,276.414C864.944,262.029 824.51,265.578 791.345,284.501C760.84,301.905 731.064,350.173 757.023,391.933C772.169,416.297 807.583,433.1 843.106,432.778Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M901.023,427.592C919.082,437.647 914.375,459.502 914.375,459.502C914.382,455.074 911.352,449.929 905.845,445.02C898.256,438.251 889.946,435.226 888.088,435.21C888.281,435.213 882.328,424.936 882.328,424.936C886.457,422.572 892.743,423.469 901.023,427.592Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M841.501,320.927C869.016,346.791 923.886,334.774 930.727,332.154L937.161,344.56C930.833,346.984 903.788,353.308 876.093,349.661C844.437,342.756 841.501,320.927 841.501,320.927Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M856.8,381.78C856.8,381.78 854.677,356.846 873.976,346.331C881.356,342.487 889.96,339.571 899.7,338.12L902.276,349.522C873.542,353.801 859.967,372.495 856.8,381.78Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M609.849,376.237C641.704,348.782 649.339,320.22 649.339,320.22C651.29,328.519 647.387,350.181 634.449,367.575C629.624,374.059 623.544,379.948 616.045,384.331L609.849,376.237Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M834.92,234.947C840.865,227.575 848.327,221.775 857.035,217.633C881.755,204.76 908.313,222.518 908.313,222.518C888.901,219.963 862.264,221.245 845.624,241.87L834.92,234.947Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M831.928,192.93C831.928,192.93 855.516,211.969 848.036,239.116C840.815,259.861 823.515,271.361 810.178,275.615L804.608,262.904C806.453,262.317 849.598,247.868 831.928,192.93Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M730.919,99.579C730.919,99.579 763.085,113.201 772.811,151.075C776.7,170.409 776.278,191.455 771.605,214.455C761.626,263.558 702.305,281.445 679.251,280.632L679.359,267.156C693.228,267.644 747.798,255.006 756.611,211.647C765.902,165.932 757.739,130.323 730.919,99.579Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M762.546,160.453C800.164,134.33 864.452,138.777 878.343,142.312L874.583,155.268C864.309,152.659 804.878,148.468 772.188,171.172L762.546,160.453Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M630.258,70.076L643.679,64.083C644.1,64.79 673.076,114.431 662.623,161.021C654.783,197.124 613.774,203.603 613.774,203.603C683.262,160.803 630.799,70.977 630.258,70.076Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M659.485,165.881C663.907,186.681 682.077,207.853 695.622,213.847C695.622,213.847 651.903,202.29 646.659,167.659L659.485,165.881Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M539.576,186.699C541.122,210.599 554.444,240.07 576.078,247.622L570.893,260.15C542.825,250.352 526.242,215.953 524.367,187.035C523.494,173.554 526.287,151.23 536.041,131.967C553.129,100.656 581.582,110.001 581.582,110.001C548.902,118.029 538.098,163.918 539.576,186.699Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M493.292,103.391C493.292,103.391 525.2,108.011 535.526,135.513C538.424,143.235 540.253,152.053 540.204,161.981L527.145,161.553C527.336,122.786 493.632,103.579 493.292,103.391Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M276.948,160.286C277.399,160.303 311.64,161.895 336.502,187.86C344.788,196.516 352.036,207.878 356.639,222.792C374.898,281.945 349.413,306.144 319.144,323.482C309.497,329.013 295.604,330.674 280.619,328.701C269.215,327.2 257.176,323.595 245.876,317.989C231.133,310.674 218.952,300.706 209.701,288.689C189.323,255.458 204.276,225.968 204.276,225.968C204.379,274.859 234.842,297.268 252.991,306.273C276.57,317.97 300.259,318.002 310.581,312.087C335.503,297.81 358.45,279.395 341.951,225.95C326.56,176.089 276.948,160.286 276.948,160.286Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M351.881,137.831C351.881,137.831 334.475,159.829 340.948,195.549L328.057,197.016C320.399,154.765 351.881,137.831 351.881,137.831Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M153.949,295.541C153.949,295.541 171.351,271.232 201.075,276.773C207.935,278.053 215.116,280.367 222.406,284.126L216.118,294.07C184.328,277.698 154.249,295.363 153.949,295.541Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M197.109,163.312C203.351,163.568 225.129,169.866 245.94,182.735C292.963,211.559 273.952,244.742 273.952,244.742C272.306,198.284 202.893,177.207 196.827,176.78L197.109,163.312Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M405.259,76.415C409.176,79.606 443.528,109.023 443.781,158.317C444.017,204.496 406.987,221.615 406.987,221.615C407.205,221.492 428.805,207.829 428.547,157.937C428.324,114.494 398.54,88.97 395.144,86.2L405.259,76.415Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M442.447,165.707C445.65,183.47 472.051,205.798 482.678,207.829C482.678,207.829 438.374,205.199 429.554,167.162L442.447,165.707Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M528.269,208.508L543.276,206.633C543.59,208.225 550.613,245.979 521.528,275.478C495.28,302.101 458.033,314.248 418.869,309.091C417.654,308.932 416.445,308.757 415.226,308.561C378.701,302.768 354.675,288.29 347.06,276.512L360.223,270.105C364.706,277.043 384.206,290.018 417.431,295.288C452.627,300.856 486.324,290.348 509.913,266.423C534.272,241.715 528.331,208.838 528.269,208.508Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M346.454,612.287C346.454,612.287 364.459,630.137 350.49,655.895C346.095,662.684 342.024,666.615 341.627,666.989L330.334,657.619C330.525,657.439 349.385,639.196 346.454,612.287Z"/>
|
|
||||||
<path {{ $attributes->merge(['class' => 'text-primary']) }} d="M746.169,21.499C756.017,22.795 765.878,25.001 775.478,28.052C846.582,50.652 881.597,100.292 890.828,137.976C955.354,158.86 994.038,204.652 997.236,264.281C999.012,297.296 988.628,322.18 966.359,338.423C979.244,347.991 988.032,359.715 992.343,368.627C996.839,377.939 1002.72,402.821 999.332,429.929C996.187,455.119 983.953,490.419 943.534,515.538C921.02,529.53 898.079,537.216 875.214,538.444C874.524,557.481 866.669,577.821 852.635,596.528C833.067,622.623 803.096,642.513 769.991,651.49C766.899,656.306 763.357,660.861 760.17,664.955C752.191,675.212 745.299,684.072 745.21,693.867C745.015,715.994 760.064,728.832 760.639,729.311L768.69,735.812C770.809,737.518 771.894,740.002 771.599,742.476C771.308,744.951 769.679,747.105 767.227,748.26L695.574,782.012C693.919,782.79 692.07,783.039 690.288,782.805C688.153,782.524 686.117,781.546 684.646,779.964L680.573,775.585C666.603,760.562 658.245,747.077 650.775,727.481C649.287,723.582 648.322,719.717 647.301,715.615C643.246,699.342 639.415,683.975 602.531,669.984C582.529,662.395 566.583,652.959 555.043,641.893C536.815,644.647 517.346,644.794 498.397,642.299C492.15,641.477 486.064,640.365 480.225,638.984C478.528,641.351 476.724,643.657 474.834,645.894C450.844,674.272 417.216,682.715 393.226,684.799C381.934,685.782 370.071,685.527 358.921,684.059C351.917,683.137 345.364,681.766 339.649,680.041C319.088,696.008 283.075,711.345 242.877,706.053C230.564,704.432 218.283,700.97 206.372,695.761C181.07,684.694 162.338,667.791 152.203,646.89C145.306,632.663 142.634,616.673 144.507,601.785C139.806,601.472 135.2,601.017 130.725,600.428C96.286,595.894 67.686,583.183 45.707,562.646C12.903,531.987 -3.897,482.581 0.767,430.49C5.257,380.339 40.511,354.902 62.285,343.855C46.313,320.624 24.457,273.681 56.708,219.503C89.53,164.361 147.812,155.406 179.02,154.885C179.027,132.85 187.435,92.873 241.101,62.81C273.272,44.788 306.984,37.939 341.311,42.462C365.525,45.65 384.1,53.896 395.041,59.951C402.448,50.197 414.871,36.551 433.253,24.573C456.287,9.566 482.031,1.374 509.768,0.225C523.796,-0.356 537.288,0.189 549.873,1.846C595.648,7.874 623.192,27.2 639.177,44.655C661.749,29.219 700.506,15.487 746.169,21.499ZM745.165,29.122C697.38,22.831 657.024,39.885 637.949,55.854C625.287,39.581 598.795,16.044 548.865,9.469C537.348,7.953 524.59,7.339 510.461,7.925C452.387,10.329 414.353,45.661 397.781,70.781C385.798,62.844 365.594,53.414 340.307,50.085C313.364,46.538 280.651,49.905 245.926,69.359C189.011,101.247 186.254,144.267 188.094,162.668C163.458,161.805 98.829,165.496 64.437,223.27C30.466,280.339 60.508,328.89 74.754,346.889C55.606,354.896 14.204,378.198 9.445,431.35C4.976,481.26 20.86,528.397 51.934,557.44C72.538,576.699 99.287,588.534 131.729,592.805C139.117,593.778 146.795,594.358 154.77,594.547C147.126,623.344 158.439,666.305 210.023,688.874C221.738,693.999 233.085,697.009 243.88,698.43C288.704,704.331 324.069,682.794 337.482,671.143C343.311,673.373 351.058,675.269 359.925,676.436C369.639,677.715 380.709,678.122 392.095,677.127C414.398,675.189 445.634,667.37 467.806,641.145C470.947,637.429 473.669,633.693 476.048,629.954C483.187,631.956 491.044,633.576 499.401,634.676C517.749,637.092 538.341,637.065 558.132,633.497C566.971,643.025 581.569,653.745 605.736,662.914C646.914,678.533 651.633,697.476 655.797,714.19C656.764,718.071 657.68,721.736 659.01,725.23C666.101,743.826 674.012,756.601 687.22,770.803L691.292,775.182L762.945,741.43L754.889,734.933C754.703,734.781 736.275,719.609 736.507,693.555C736.616,681.44 744.547,671.243 752.948,660.444C756.865,655.411 760.818,650.309 763.97,644.946C800.832,635.856 828.982,613.927 845.338,592.12C860.499,571.901 867.745,550.109 866.404,530.89C873.884,531.027 881.855,530.414 890.261,528.779C905.055,525.902 921.196,519.861 938.38,509.182C1009.87,464.746 991.028,385.267 984.357,371.461C978.94,360.259 967.066,346.748 951.448,338.201C971.373,327.501 990.859,307.537 988.544,264.393C985.435,206.49 947.161,162.729 883.274,143.713C875.616,104.13 839.209,56.412 772.778,35.297C763.372,32.308 754.136,30.303 745.165,29.122Z"/>
|
|
||||||
</svg>
|
|
||||||
<span class="sr-only">Investbrain</span>
|
|
||||||
|
|
||||||
</a>
|
|
||||||
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
@props([
|
|
||||||
'key' => 'modal',
|
|
||||||
'showClose' => true,
|
|
||||||
'closeOnEscape' => true,
|
|
||||||
'title' => null,
|
|
||||||
'subtitle' => null,
|
|
||||||
'persistent' => false
|
|
||||||
])
|
|
||||||
|
|
||||||
<div>
|
|
||||||
@teleport('body')
|
|
||||||
<dialog
|
|
||||||
x-data="{ open: false }"
|
|
||||||
x-on:toggle-{{ $key }}.window="open = !open"
|
|
||||||
class="relative z-50 w-auto h-auto"
|
|
||||||
@if($closeOnEscape)
|
|
||||||
@keydown.window.escape="open = false"
|
|
||||||
@endif
|
|
||||||
>
|
|
||||||
<template x-teleport="body">
|
|
||||||
<div x-transition.opacity x-show="open" class="fixed top-0 left-0 z-[99] flex items-center justify-center w-full h-full">
|
|
||||||
<div
|
|
||||||
@if(!$persistent)
|
|
||||||
@click="open=false"
|
|
||||||
@endif
|
|
||||||
class="absolute inset-0 w-full h-full bg-black bg-opacity-40"
|
|
||||||
x-show="open"
|
|
||||||
x-cloak
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<x-card
|
|
||||||
x-trap.inert.noscroll="open"
|
|
||||||
:title="$title"
|
|
||||||
:subtitle="$subtitle"
|
|
||||||
{{ $attributes->merge(['class' => 'relative transform overflow-hidden rounded-md ext-left shadow-xl w-full sm:w-2/3 lg:w-1/3 m-2 sm:m-0']) }}
|
|
||||||
x-show="open"
|
|
||||||
x-cloak
|
|
||||||
>
|
|
||||||
@if ($showClose)
|
|
||||||
<x-button
|
|
||||||
icon="o-x-mark"
|
|
||||||
title="{{ __('Close') }}"
|
|
||||||
class="absolute top-4 right-4 btn-ghost btn-circle btn-sm"
|
|
||||||
@click="open = false"
|
|
||||||
/>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
{{ $slot }}
|
|
||||||
|
|
||||||
</x-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</dialog>
|
|
||||||
@endteleport
|
|
||||||
</div>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
@props(['title' => ''])
|
|
||||||
|
|
||||||
<x-card
|
|
||||||
{{ $attributes->merge(['class' => 'bg-slate-100 dark:bg-base-200 rounded-lg']) }}
|
|
||||||
>
|
|
||||||
|
|
||||||
<h2 class="text-xl mb-2 flex items-center truncate"> {{ $title }} </h2>
|
|
||||||
|
|
||||||
{{ $slot }}
|
|
||||||
</x-card>
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
@props([
|
|
||||||
'key' => 'drawer',
|
|
||||||
'showClose' => true,
|
|
||||||
'closeOnEscape' => true,
|
|
||||||
'title' => null,
|
|
||||||
'subtitle' => null
|
|
||||||
])
|
|
||||||
|
|
||||||
<div
|
|
||||||
x-data="{ open: false }"
|
|
||||||
x-on:toggle-{{ $key }}.window="open = !open"
|
|
||||||
x-show="open"
|
|
||||||
@if($closeOnEscape)
|
|
||||||
@keydown.window.escape="open = false"
|
|
||||||
@endif
|
|
||||||
x-trap="open"
|
|
||||||
x-bind:inert="!open"
|
|
||||||
class="fixed inset-0 flex justify-end z-50"
|
|
||||||
x-transition.opacity
|
|
||||||
x-cloak
|
|
||||||
>
|
|
||||||
|
|
||||||
<div @click="open = false" class="fixed inset-0 bg-black opacity-50"></div>
|
|
||||||
|
|
||||||
<x-card
|
|
||||||
{{ $attributes->merge(['class' => 'min-h-screen w-full md:w-3/4 xl:w-3/5 rounded-none px-8 transition overflow-y-scroll']) }}
|
|
||||||
>
|
|
||||||
@if($title)
|
|
||||||
<x-slot:title>
|
|
||||||
{!! strip_tags($title) !!}
|
|
||||||
</x-slot:title>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if($subtitle)
|
|
||||||
<x-slot:subtitle>
|
|
||||||
{!! strip_tags($subtitle) !!}
|
|
||||||
</x-slot:subtitle>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($showClose)
|
|
||||||
<x-button icon="o-x-mark" title="{{ __('Close') }}" class="btn-ghost btn-circle btn-sm absolute top-4 right-4 " @click="open = false" />
|
|
||||||
@endif
|
|
||||||
|
|
||||||
{{ $slot }}
|
|
||||||
|
|
||||||
</x-card>
|
|
||||||
</div>
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
@props([
|
|
||||||
'showClose' => true,
|
|
||||||
'closeOnEscape' => true,
|
|
||||||
'title' => null,
|
|
||||||
'subtitle' => null,
|
|
||||||
'persistent' => false
|
|
||||||
])
|
|
||||||
|
|
||||||
<div>
|
|
||||||
@teleport('body')
|
|
||||||
<dialog
|
|
||||||
{{ $attributes->except('wire:model')->class(["modal"]) }}
|
|
||||||
x-data="{open: @entangle($attributes->wire('model')).live }"
|
|
||||||
:class="{'modal-open !animate-none': open}"
|
|
||||||
:open="open"
|
|
||||||
@if($closeOnEscape)
|
|
||||||
@keydown.escape.window = "$wire.{{ $attributes->wire('model')->value() }} = false"
|
|
||||||
@endif
|
|
||||||
>
|
|
||||||
<x-card
|
|
||||||
:title="$title"
|
|
||||||
:subtitle="$subtitle"
|
|
||||||
{{ $attributes->merge(['class' => 'modal-box relative transform overflow-hidden rounded-md ext-left shadow-xl w-full sm:w-2/3 lg:w-1/3 m-2 sm:m-0']) }}
|
|
||||||
>
|
|
||||||
@if ($showClose)
|
|
||||||
<x-button
|
|
||||||
icon="o-x-mark"
|
|
||||||
title="{{ __('Close') }}"
|
|
||||||
class="absolute top-4 right-4 btn-ghost btn-circle btn-sm"
|
|
||||||
@click="$wire.{{ $attributes->wire('model')->value() }} = false"
|
|
||||||
/>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
{{ $slot }}
|
|
||||||
|
|
||||||
</x-card>
|
|
||||||
|
|
||||||
<div class="modal-backdrop" method="dialog">
|
|
||||||
<a
|
|
||||||
@if(!$persistent)
|
|
||||||
@click="$wire.{{ $attributes->wire('model')->value() }} = false"
|
|
||||||
@endif
|
|
||||||
type="button"
|
|
||||||
title="{{ __('Close') }}"
|
|
||||||
>
|
|
||||||
{{ __('Close') }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
|
||||||
@endteleport
|
|
||||||
</div>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<div role="status" class="flex w-full animate-pulse" wire:loading.delay>
|
|
||||||
<div class="h-2.5 bg-gray-200 rounded-full dark:bg-gray-700 w-48 mb-4"></div>
|
|
||||||
<div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[360px] mb-2.5"></div>
|
|
||||||
<div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700 mb-2.5"></div>
|
|
||||||
<div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[330px] mb-2.5"></div>
|
|
||||||
<div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[300px] mb-2.5"></div>
|
|
||||||
<div class="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[360px]"></div>
|
|
||||||
<span class="sr-only">Loading...</span>
|
|
||||||
</div>
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="bg-base-200">
|
||||||
|
<head>
|
||||||
|
@include('components.partials.head')
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="font-sans antialiased scroll-smooth" x-data="{ sideBarOpen: false }">
|
||||||
|
|
||||||
|
@livewire('partials.nav-bar')
|
||||||
|
|
||||||
|
@livewire('partials.side-bar')
|
||||||
|
|
||||||
|
<main class="py-5 px-6 md:px-8 md:py-0 md:ml-68 mb-14">
|
||||||
|
{{ $slot }}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
@if(session('toast'))
|
||||||
|
<script lang="text/javascript">
|
||||||
|
window.addEventListener('DOMContentLoaded', function () {
|
||||||
|
window.toast(JSON.parse(@json(session('toast'))))
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@endif
|
||||||
|
<x-ui.toast />
|
||||||
|
|
||||||
|
@livewireScripts
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||||
|
<head>
|
||||||
|
@include('components.partials.head')
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="font-sans antialiased scroll-smooth min-h-screen" x-data="{}">
|
||||||
|
|
||||||
|
<main class="">
|
||||||
|
<x-ui.theme-selector hidden="true" />
|
||||||
|
|
||||||
|
{{ $slot }}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
@livewireScripts
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
|
<link rel="icon" href="{{ asset('favicon.svg') }}">
|
||||||
|
|
||||||
|
<title>{{ config('app.name', 'Investbrain') }}</title>
|
||||||
|
|
||||||
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
|
|
||||||
|
@livewireStyles
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
@props([
|
|
||||||
'sidebar' => null,
|
|
||||||
'content' => null,
|
|
||||||
'footer' => null,
|
|
||||||
'fullWidth' => false,
|
|
||||||
'withNav' => false,
|
|
||||||
'collapseText' => 'Collapse',
|
|
||||||
'collapseIcon' => 'o-bars-3-bottom-right',
|
|
||||||
'collapsible' => false,
|
|
||||||
'url' => route('mary.toogle-sidebar', absolute: false),
|
|
||||||
])
|
|
||||||
|
|
||||||
<main class="{{ !$fullWidth ? 'max-w-screen-2xl' : '' }} w-full mx-auto">
|
|
||||||
<div class="drawer {{ $sidebar?->attributes['right'] ? 'drawer-end' : '' }} lg:drawer-open">
|
|
||||||
<input id="{{ $sidebar?->attributes['drawer'] }}" type="checkbox" class="drawer-toggle" />
|
|
||||||
|
|
||||||
<div {{ $content->attributes->class(["drawer-content w-full mx-auto p-5 lg:px-10 lg:py-5"]) }}>
|
|
||||||
{{-- MAIN CONTENT --}}
|
|
||||||
{{ $content }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- SIDEBAR --}}
|
|
||||||
@if($sidebar)
|
|
||||||
<div
|
|
||||||
x-data="{
|
|
||||||
collapsed: {{ session('mary-sidebar-collapsed', 'false') }},
|
|
||||||
collapseText: '{{ $collapseText }}',
|
|
||||||
toggle() {
|
|
||||||
this.collapsed = !this.collapsed;
|
|
||||||
fetch('{{ $url }}?collapsed=' + this.collapsed);
|
|
||||||
this.$dispatch('sidebar-toggled', this.collapsed);
|
|
||||||
}
|
|
||||||
}"
|
|
||||||
@menu-sub-clicked="if(collapsed) { toggle() }"
|
|
||||||
@class(["drawer-side z-20 lg:z-auto", "top-0 lg:top-[73px] lg:h-[calc(100vh-73px)]" => $withNav])
|
|
||||||
>
|
|
||||||
<label for="{{ $sidebar?->attributes['drawer'] }}" aria-label="close sidebar" class="drawer-overlay"></label>
|
|
||||||
{{-- SIDEBAR CONTENT --}}
|
|
||||||
<div>
|
|
||||||
|
|
||||||
{{ $sidebar }}
|
|
||||||
|
|
||||||
{{-- SIDEBAR COLLAPSE --}}
|
|
||||||
@if($sidebar->attributes['collapsible'])
|
|
||||||
<x-mary-menu class="hidden !bg-inherit lg:block">
|
|
||||||
<x-mary-menu-item
|
|
||||||
@click="toggle"
|
|
||||||
icon="{{ $sidebar->attributes['collapse-icon'] ?? $collapseIcon }}"
|
|
||||||
title="{{ $sidebar->attributes['collapse-text'] ?? $collapseText }}" />
|
|
||||||
</x-mary-menu>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
{{-- END SIDEBAR--}}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{{-- FOOTER --}}
|
|
||||||
@if($footer)
|
|
||||||
<footer {{ $footer?->attributes->class(["mx-auto w-full", "max-w-screen-2xl" => !$fullWidth ]) }}>
|
|
||||||
{{ $footer }}
|
|
||||||
</footer>
|
|
||||||
@endif
|
|
||||||
@@ -18,34 +18,31 @@ new class extends Component
|
|||||||
// methods
|
// methods
|
||||||
|
|
||||||
}; ?>
|
}; ?>
|
||||||
<div class="bg-base-100 border-base-300 border-b sticky top-0 z-10">
|
|
||||||
<div class="flex justify-between items-center px-7 py-3 gap-4 mx-auto">
|
|
||||||
<div class="flex flex-0 items-center">
|
|
||||||
|
|
||||||
<label for="main-drawer" class="lg:hidden mr-3">
|
<nav class="z-10 p-5 ml-0 md:ml-68 md:border-0 border-b border-zinc-200 dark:border-zinc-800">
|
||||||
<x-icon name="o-bars-3" class="cursor-pointer" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="hidden md:block" style="height:3.1em">
|
<div class="flex flex-wrap justify-between items-center">
|
||||||
<x-application-logo />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
<div class="flex">
|
||||||
<div class="flex flex-1 justify-center" x-data>
|
<x-ui.button
|
||||||
|
aria-controls="drawer-navigation"
|
||||||
<x-spotlight
|
title="{{ __('Toggle Sidebar') }}"
|
||||||
shortcut="slash"
|
class="btn-circle btn-ghost btn-sm block md:hidden"
|
||||||
search-text="{{ __('Search holdings, portfolios, or anything else...') }}"
|
icon="o-bars-3"
|
||||||
no-results-text="{{ __('Darn! Nothing found for that search.') }}"
|
@click="sideBarOpen = true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<x-button
|
<div class="ml-3 w-8 hidden sm:block md:hidden"> <x-ui.logo /> </div>
|
||||||
@click.stop="$dispatch('mary-search-open')"
|
</div>
|
||||||
class="btn-sm flex-1 justify-start md:flex-none"
|
|
||||||
|
<div>
|
||||||
|
<x-ui.button
|
||||||
|
@click.stop="$dispatch('toggle-spotlight')"
|
||||||
|
class="btn-sm btn-ghost bg-base-300 flex-1 justify-start md:flex-none border-none"
|
||||||
>
|
>
|
||||||
<x-slot:label>
|
<x-slot:label>
|
||||||
<span class="flex items-center text-gray-400">
|
<span class="flex items-center text-gray-400">
|
||||||
<x-icon name="o-magnifying-glass" class="mr-2" />
|
<x-ui.icon name="o-magnifying-glass" class="mr-2" />
|
||||||
<span class=" truncate hidden sm:block">
|
<span class=" truncate hidden sm:block">
|
||||||
@lang('Click or press :key to search', ['key' => '<kbd class="kbd kbd-sm">/</kbd>'])
|
@lang('Click or press :key to search', ['key' => '<kbd class="kbd kbd-sm">/</kbd>'])
|
||||||
</span>
|
</span>
|
||||||
@@ -54,35 +51,41 @@ new class extends Component
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</x-slot:label>
|
</x-slot:label>
|
||||||
</x-button>
|
</x-ui.button>
|
||||||
|
|
||||||
|
<x-ui.spotlight
|
||||||
|
search-text="{{ __('Search holdings, portfolios, or anything else...') }}"
|
||||||
|
no-results-text="{{ __('Darn! Nothing found for that search.') }}"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-0 items-center gap-4">
|
<div class="flex flex-0 items-center gap-4">
|
||||||
|
|
||||||
<x-button
|
<x-ui.button
|
||||||
title="{{ __('Documentation') }}"
|
title="{{ __('Documentation') }}"
|
||||||
icon="o-book-open"
|
icon="o-book-open"
|
||||||
class="btn-circle btn-ghost btn-sm"
|
class="btn-circle btn-ghost btn-sm"
|
||||||
link="https://github.com/investbrainapp/investbrain"
|
link="https://github.com/investbrainapp/investbrain"
|
||||||
external
|
external
|
||||||
>
|
>
|
||||||
</x-button>
|
</x-ui.button>
|
||||||
|
|
||||||
<x-button
|
<x-ui.button
|
||||||
title="{{ __('We\'re open source!') }}"
|
title="{{ __('We\'re open source!') }}"
|
||||||
class="btn-circle btn-ghost btn-sm"
|
class="btn-circle btn-ghost btn-sm"
|
||||||
link="https://github.com/investbrainapp/investbrain"
|
link="https://github.com/investbrainapp/investbrain"
|
||||||
external
|
external
|
||||||
>
|
>
|
||||||
<x-github-icon />
|
<x-social.github-icon />
|
||||||
</x-button>
|
</x-ui.button>
|
||||||
|
|
||||||
<x-theme-toggle
|
<x-ui.theme-selector
|
||||||
|
id="theme-selector"
|
||||||
title="{{ __('Toggle Theme') }}"
|
title="{{ __('Toggle Theme') }}"
|
||||||
class="btn-circle btn-ghost btn-sm"
|
class="btn-circle btn-ghost btn-sm"
|
||||||
darkTheme="business"
|
|
||||||
lightTheme="corporate"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</nav>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,76 +19,97 @@ new class extends Component
|
|||||||
|
|
||||||
}; ?>
|
}; ?>
|
||||||
|
|
||||||
<div class="
|
<div
|
||||||
flex
|
aria-label="Sidebar"
|
||||||
flex-col
|
style="background-image: url('{{ asset('images/noise.svg') }}')"
|
||||||
!transition-all
|
class="
|
||||||
!duration-100
|
h-full
|
||||||
ease-out
|
bg-base-300
|
||||||
overflow-x-hidden
|
border-r
|
||||||
overflow-y-auto
|
border-base-100
|
||||||
h-screen
|
fixed
|
||||||
lg:h-[calc(100vh-73px)]
|
top-0
|
||||||
bg-base-100
|
left-0
|
||||||
lg:bg-inherit
|
z-50
|
||||||
{{ session('mary-sidebar-collapsed') == 'true' ? 'w-[70px] [&>*_summary::after]:hidden [&_.mary-hideable]:hidden [&_.display-when-collapsed]:block [&_.hidden-when-collapsed]:hidden' : null }}
|
md:w-68
|
||||||
{{ session('mary-sidebar-collapsed') != 'true' ? 'w-[270px] [&>*_summary::after]:block [&_.mary-hideable]:block [&_.hidden-when-collapsed]:block [&_.display-when-collapsed]:hidden' : null }}
|
w-3/4
|
||||||
">
|
transition-transform
|
||||||
<div class="flex-1">
|
-translate-x-full
|
||||||
<x-menu activate-by-route>
|
md:translate-x-0
|
||||||
|
"
|
||||||
|
:class="{'translate-x-0': sideBarOpen, '-translate-x-full': !sideBarOpen}"
|
||||||
|
x-data="{
|
||||||
|
responsiveSidebar() {
|
||||||
|
if (window.innerWidth >= 768) {
|
||||||
|
this.sideBarOpen = true
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.sideBarOpen = false
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
@resize.window="responsiveSidebar"
|
||||||
|
@keyup.escape.window="sideBarOpen = false"
|
||||||
|
>
|
||||||
|
<template x-teleport="body">
|
||||||
|
<div
|
||||||
|
aria-label="Overlay"
|
||||||
|
class="block md:hidden z-10 fixed w-screen h-screen inset-0 bg-black/20 backdrop-blur-sm"
|
||||||
|
x-on:click="sideBarOpen=false"
|
||||||
|
x-show="sideBarOpen"
|
||||||
|
x-cloak
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<x-menu-item title="{{ __('Dashboard') }}" icon="o-home" link="{{ route('dashboard') }}" />
|
<div class="h-full px-1 overflow-y-auto flex flex-col ">
|
||||||
<x-menu-sub title="{{ __('Portfolios') }}" icon="o-document-duplicate">
|
|
||||||
@foreach (auth()->user()->portfolios as $portfolio)
|
|
||||||
<x-menu-item icon="o-document" link="{{ route('portfolio.show', ['portfolio' => $portfolio->id ]) }}" >
|
|
||||||
<x-slot:title>
|
|
||||||
{{ $portfolio->title }}
|
|
||||||
@if($portfolio->wishlist)
|
|
||||||
<x-badge value="{{ __('Wishlist') }}" class="badge-secondary badge-sm ml-2" />
|
|
||||||
@endif
|
|
||||||
</x-slot:title>
|
|
||||||
|
|
||||||
</x-menu-item>
|
<div class="w-10 m-5"> <x-ui.logo /> </div>
|
||||||
@endforeach
|
|
||||||
|
|
||||||
<x-menu-item title="{{ __('Create Portfolio') }}" icon="o-document-plus" link="{{ route('portfolio.create') }}" />
|
<x-ui.menu class="space-y-2 text-wrap w-full overflow-x-hidden" activate-by-route="true">
|
||||||
</x-menu-sub>
|
<x-ui.menu-item icon="o-home" title="{{ __('Dashboard') }}" link="{{ route('dashboard') }}" class="font-medium text-md" />
|
||||||
<x-menu-item title="{{ __('Transactions') }}" icon="o-banknotes" link="{{ route('transaction.index') }}" />
|
|
||||||
{{-- <x-menu-item title="{{ __('Reporting') }}" icon="o-chart-bar-square" link="####" /> --}}
|
|
||||||
|
|
||||||
</x-menu>
|
@foreach (auth()->user()->portfolios as $portfolio)
|
||||||
|
<x-ui.menu-item
|
||||||
|
:title="$portfolio->title"
|
||||||
|
icon="o-document"
|
||||||
|
:badge="$portfolio->wishlist ? __('Wishlist') : null"
|
||||||
|
badge-classes="badge-secondary badge-outline"
|
||||||
|
link="{{ route('portfolio.show', ['portfolio' => $portfolio->id ]) }}"
|
||||||
|
class="font-medium text-md"
|
||||||
|
/>
|
||||||
|
@endforeach
|
||||||
|
|
||||||
</div>
|
<x-ui.menu-item icon="o-document-plus" title="{{ __('Create Portfolio') }}" link="{{ route('portfolio.create') }}" class="font-medium text-md" />
|
||||||
|
|
||||||
<div class="px-3">
|
<x-ui.menu-item icon="o-banknotes" title="{{ __('Transactions') }}" link="{{ route('transaction.index') }}" class="font-medium text-md" />
|
||||||
|
|
||||||
<x-section-border />
|
</x-ui.menu>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<x-list-item :item="$user" avatar="profile_photo_url" value="name" sub-value="email" no-separator no-hover class="mb-3 !-mt-3 rounded">
|
<x-ui.list-item :item="$user" avatar="profile_photo_url" value="name" sub-value="email" no-separator no-hover class="rounded mb-2">
|
||||||
<x-slot:actions>
|
<x-slot:actions>
|
||||||
<x-dropdown>
|
<x-ui.dropdown>
|
||||||
<x-slot:trigger>
|
<x-slot:trigger>
|
||||||
<x-button icon="o-cog-6-tooth" class="btn-circle btn-ghost btn-xs" />
|
<x-ui.button icon="o-cog-6-tooth" class="btn-circle btn-ghost btn-sm relative transition-transform focus:rotate-90" />
|
||||||
</x-slot:trigger>
|
</x-slot:trigger>
|
||||||
|
|
||||||
<x-menu-item title="{{ __('Manage Profile') }}" icon="o-user" link="{{ @route('profile.show') }}" />
|
<x-ui.menu-item title="{{ __('Manage Profile') }}" icon="o-user" link="{{ @route('profile.show') }}" />
|
||||||
<x-menu-item title="{{ __('API Tokens') }}" icon="o-command-line" link="{{ @route('api-tokens.index') }}" />
|
<x-ui.menu-item title="{{ __('API Tokens') }}" icon="o-command-line" link="{{ @route('api-tokens.index') }}" />
|
||||||
<x-menu-item title="{{ __('Import / Export Data') }}" icon="o-cloud-arrow-down" link="{{ @route('import-export') }}" />
|
<x-ui.menu-item title="{{ __('Import / Export Data') }}" icon="o-cloud-arrow-down" link="{{ @route('import-export') }}" />
|
||||||
|
|
||||||
<x-section-border class="py-1" />
|
<x-ui.section-border class="py-1" />
|
||||||
|
|
||||||
<x-menu-item title="{{ __('Log Out') }}" icon="o-power" onclick="event.preventDefault(); document.getElementById('logout').submit();" />
|
<x-ui.menu-item title="{{ __('Log Out') }}" icon="o-power" onclick="event.preventDefault(); document.getElementById('logout').submit();" />
|
||||||
<form id="logout" action="{{ route('logout') }}" method="POST" style="display: none;">
|
<form id="logout" action="{{ route('logout') }}" method="POST" style="display: none;">
|
||||||
{{ csrf_field() }}
|
@csrf
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
</x-dropdown>
|
</x-ui.dropdown>
|
||||||
|
|
||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
</x-list-item>
|
</x-ui.list-item>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
<div>
|
<div>
|
||||||
@if(!empty(config('services.enabled_login_providers')))
|
@if(!empty(config('services.enabled_login_providers')))
|
||||||
@foreach(explode(',', config('services.enabled_login_providers')) as $provider)
|
@foreach(explode(',', config('services.enabled_login_providers')) as $provider)
|
||||||
<x-button
|
<x-ui.button
|
||||||
link="{{ route('oauth.redirect', ['provider' => $provider]) }}"
|
link="{{ route('oauth.redirect', ['provider' => $provider]) }}"
|
||||||
class="btn-sm btn-block my-1"
|
class="btn-sm btn-block my-1 text-white"
|
||||||
style='background-color: {{ config("services.$provider.color") }}'
|
style='background-color: {{ config("services.$provider.color") }}'
|
||||||
no-wire-navigate
|
no-wire-navigate
|
||||||
>
|
>
|
||||||
@include("components.$provider-icon")
|
@include("components.social.$provider-icon")
|
||||||
|
|
||||||
{{ __('Login with') }} {{ config("services.$provider.name") }}
|
{{ __('Login with') }} {{ config("services.$provider.name") }}
|
||||||
</x-button>
|
</x-ui.button>
|
||||||
@endforeach
|
@endforeach
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
Before Width: | Height: | Size: 472 B After Width: | Height: | Size: 472 B |
|
Before Width: | Height: | Size: 871 B After Width: | Height: | Size: 871 B |
|
Before Width: | Height: | Size: 582 B After Width: | Height: | Size: 582 B |
|
Before Width: | Height: | Size: 676 B After Width: | Height: | Size: 676 B |
@@ -1,25 +1,24 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Mary\Traits\Toast;
|
|
||||||
use App\Models\AiChat;
|
use App\Models\AiChat;
|
||||||
use App\Models\Holding;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Livewire\Volt\Component;
|
use Livewire\Volt\Component;
|
||||||
use OpenAI\Factory;
|
|
||||||
use OpenAI\Responses\StreamResponse;
|
|
||||||
|
|
||||||
new class extends Component {
|
|
||||||
|
|
||||||
use Toast;
|
|
||||||
|
|
||||||
|
new class extends Component
|
||||||
|
{
|
||||||
// props
|
// props
|
||||||
public Model $chatable;
|
public Model $chatable;
|
||||||
|
|
||||||
public string $system_prompt = 'You are an investment portfolio assistant providing advice to an investor. Use the following information to provide relevant recommendations. Use the words \'likely\' or \'may\' instead of concrete statements (except for obvious statements of fact or common sense). Use github style markdown for any formatting.';
|
public string $system_prompt = 'You are an investment portfolio assistant providing advice to an investor. Use the following information to provide relevant recommendations. Use the words \'likely\' or \'may\' instead of concrete statements (except for obvious statements of fact or common sense). Use github style markdown for any formatting.';
|
||||||
|
|
||||||
public array $suggested_prompts = [];
|
public array $suggested_prompts = [];
|
||||||
|
|
||||||
public array $messages = [];
|
public array $messages = [];
|
||||||
|
|
||||||
public ?string $prompt = null;
|
public ?string $prompt = null;
|
||||||
|
|
||||||
public ?string $answer = null;
|
public ?string $answer = null;
|
||||||
|
|
||||||
public bool $streaming = false;
|
public bool $streaming = false;
|
||||||
|
|
||||||
// methods
|
// methods
|
||||||
@@ -34,9 +33,10 @@ new class extends Component {
|
|||||||
if ($this->isRateLimited() || $this->streaming) {
|
if ($this->isRateLimited() || $this->streaming) {
|
||||||
array_push($this->messages, [
|
array_push($this->messages, [
|
||||||
'role' => 'assistant',
|
'role' => 'assistant',
|
||||||
'content' => __('Hang on! You\'re doing that too much.')
|
'content' => __('Hang on! You\'re doing that too much.'),
|
||||||
]);
|
]);
|
||||||
$this->js('scrollChatWindow(250)');
|
$this->js('scrollChatWindow(250)');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ new class extends Component {
|
|||||||
['role' => 'system', 'content' => "Today's date is "
|
['role' => 'system', 'content' => "Today's date is "
|
||||||
.now()->toDateString()
|
.now()->toDateString()
|
||||||
.".\n\n".$this->system_prompt],
|
.".\n\n".$this->system_prompt],
|
||||||
...array_slice($this->messages, -10)
|
...array_slice($this->messages, -10),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@@ -83,14 +83,15 @@ new class extends Component {
|
|||||||
$this->chatable->chats()->save(new AiChat(['role' => 'assistant', 'content' => $e->getMessage()]));
|
$this->chatable->chats()->save(new AiChat(['role' => 'assistant', 'content' => $e->getMessage()]));
|
||||||
array_push($this->messages, ['role' => 'assistant', 'content' => $e->getMessage()]);
|
array_push($this->messages, ['role' => 'assistant', 'content' => $e->getMessage()]);
|
||||||
$this->resetPrompt();
|
$this->resetPrompt();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->stream(to: "answer", content: '', replace: true);
|
$this->stream(to: 'answer', content: '', replace: true);
|
||||||
|
|
||||||
foreach($stream as $response){
|
foreach ($stream as $response) {
|
||||||
|
|
||||||
if(!empty($response->choices[0]->delta->content)) {
|
if (! empty($response->choices[0]->delta->content)) {
|
||||||
$this->stream(to: 'answer', content: $response->choices[0]->delta->content, replace: false);
|
$this->stream(to: 'answer', content: $response->choices[0]->delta->content, replace: false);
|
||||||
$this->answer .= $response->choices[0]->delta->content;
|
$this->answer .= $response->choices[0]->delta->content;
|
||||||
}
|
}
|
||||||
@@ -116,34 +117,34 @@ new class extends Component {
|
|||||||
'name' => 'suggested_prompts_schema',
|
'name' => 'suggested_prompts_schema',
|
||||||
'strict' => true,
|
'strict' => true,
|
||||||
'schema' => [
|
'schema' => [
|
||||||
"type" => "object",
|
'type' => 'object',
|
||||||
"properties" => [
|
'properties' => [
|
||||||
"suggested_prompts" => [
|
'suggested_prompts' => [
|
||||||
"type" => "array",
|
'type' => 'array',
|
||||||
"items" => [
|
'items' => [
|
||||||
"type" => "object",
|
'type' => 'object',
|
||||||
"properties" => [
|
'properties' => [
|
||||||
"text" => [
|
'text' => [
|
||||||
"type" => "string",
|
'type' => 'string',
|
||||||
"description" => "The suggested prompt question (no more than 5 words)"
|
'description' => 'The suggested prompt question (no more than 5 words)',
|
||||||
|
],
|
||||||
|
'value' => [
|
||||||
|
'type' => 'string',
|
||||||
|
'description' => 'The detailed version of the question',
|
||||||
],
|
],
|
||||||
"value" => [
|
|
||||||
"type" => "string",
|
|
||||||
"description" => "The detailed version of the question"
|
|
||||||
]
|
|
||||||
],
|
],
|
||||||
"required" => ["text", "value"],
|
'required' => ['text', 'value'],
|
||||||
"additionalProperties" => false
|
'additionalProperties' => false,
|
||||||
]
|
],
|
||||||
]
|
],
|
||||||
],
|
],
|
||||||
"required" => ["suggested_prompts"],
|
'required' => ['suggested_prompts'],
|
||||||
"additionalProperties" => false
|
'additionalProperties' => false,
|
||||||
]
|
],
|
||||||
]
|
],
|
||||||
],
|
],
|
||||||
'messages' => [
|
'messages' => [
|
||||||
['role' => 'system', 'content' => "
|
['role' => 'system', 'content' => '
|
||||||
Your role is to assist investors in asking thoughtful questions of their investment advisors.
|
Your role is to assist investors in asking thoughtful questions of their investment advisors.
|
||||||
|
|
||||||
When you help investors ask good questions, you should ensure the you questions you recommend
|
When you help investors ask good questions, you should ensure the you questions you recommend
|
||||||
@@ -155,12 +156,12 @@ new class extends Component {
|
|||||||
explanation.
|
explanation.
|
||||||
|
|
||||||
Your response should only include valid JSON.
|
Your response should only include valid JSON.
|
||||||
"],
|
'],
|
||||||
['role' => 'user', 'content' => "
|
['role' => 'user', 'content' => "
|
||||||
Generate between 1 and 5 (no more than 5) follow up questions a savvy investor might ask their
|
Generate between 1 and 5 (no more than 5) follow up questions a savvy investor might ask their
|
||||||
advisor based on the following conversation:
|
advisor based on the following conversation:
|
||||||
\n\n
|
\n\n
|
||||||
".json_encode(array_slice($this->messages, -4))
|
".json_encode(array_slice($this->messages, -4)),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
@@ -171,6 +172,7 @@ new class extends Component {
|
|||||||
|
|
||||||
$this->suggested_prompts = [];
|
$this->suggested_prompts = [];
|
||||||
$this->error($e->getMessage());
|
$this->error($e->getMessage());
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,7 +186,7 @@ new class extends Component {
|
|||||||
|
|
||||||
public function isRateLimited(): bool
|
public function isRateLimited(): bool
|
||||||
{
|
{
|
||||||
$rateLimitKey = auth()->id() . '/' . $this->chatable->id;
|
$rateLimitKey = auth()->id().'/'.$this->chatable->id;
|
||||||
|
|
||||||
if (RateLimiter::tooManyAttempts($rateLimitKey, 20)) {
|
if (RateLimiter::tooManyAttempts($rateLimitKey, 20)) {
|
||||||
|
|
||||||
@@ -210,7 +212,6 @@ new class extends Component {
|
|||||||
->withBaseUri($baseUri)
|
->withBaseUri($baseUri)
|
||||||
->make();
|
->make();
|
||||||
}
|
}
|
||||||
|
|
||||||
}; ?>
|
}; ?>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -227,15 +228,16 @@ new class extends Component {
|
|||||||
class="fixed z-50 bottom-8 right-8"
|
class="fixed z-50 bottom-8 right-8"
|
||||||
>
|
>
|
||||||
{{-- toggle button --}}
|
{{-- toggle button --}}
|
||||||
<x-button
|
<x-ui.button
|
||||||
x-show="!open"
|
x-show="!open"
|
||||||
@click="$dispatch('toggle-ai-chat')"
|
@click="$dispatch('toggle-ai-chat')"
|
||||||
|
@keyup.escape.window="open = false"
|
||||||
class="flex btn btn-circle md:btn-lg btn-primary"
|
class="flex btn btn-circle md:btn-lg btn-primary"
|
||||||
>
|
>
|
||||||
<x-slot:label>
|
<x-slot:label>
|
||||||
<x-icon name="o-sparkles" class="w-6 h-6 md:w-8 md:h-8"></x-icon>
|
<x-ui.icon name="o-sparkles" class="w-6 h-6 md:w-8 md:h-8"></x-ui.icon>
|
||||||
</x-slot:label>
|
</x-slot:label>
|
||||||
</x-button>
|
</x-ui.button>
|
||||||
|
|
||||||
{{-- popup --}}
|
{{-- popup --}}
|
||||||
<div
|
<div
|
||||||
@@ -251,17 +253,17 @@ new class extends Component {
|
|||||||
x-transition:leave-end="opacity-0 transform translate-y-full"
|
x-transition:leave-end="opacity-0 transform translate-y-full"
|
||||||
x-cloak
|
x-cloak
|
||||||
key="ai-chat"
|
key="ai-chat"
|
||||||
class="fixed bg-base-100 shadow-2xl rounded-none md:rounded-lg
|
class="fixed bg-base-300 shadow-2xl rounded-none md:rounded-lg
|
||||||
inset-0 h-screen w-full
|
inset-0 h-screen w-full md:inset-auto md:right-6
|
||||||
md:inset-auto md:right-6 md:bottom-6 md:w-[32rem] md:h-[46rem]"
|
md:bottom-6 md:w-[32rem] md:h-[46rem]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 flex flex-col overflow-hidden p-4"
|
class="absolute inset-0 flex flex-col overflow-hidden p-4"
|
||||||
x-intersect="scrollChatWindow()"
|
x-intersect="scrollChatWindow()"
|
||||||
>
|
>
|
||||||
<div class="flex grow-0 justify-between items-center pb-4 ">
|
<div class="flex grow-0 justify-between items-center pb-4 ">
|
||||||
<h2 class="text-lg text-bold">{{ __('AI Chat') }}</h2>
|
<h2 class="text-lg text-bold select-none">{{ __('AI Chat') }}</h2>
|
||||||
<x-button
|
<x-ui.button
|
||||||
icon="o-x-mark"
|
icon="o-x-mark"
|
||||||
class="absolute top-5 right-4 btn-ghost btn-circle btn-sm"
|
class="absolute top-5 right-4 btn-ghost btn-circle btn-sm"
|
||||||
title="{{ __('Close') }}"
|
title="{{ __('Close') }}"
|
||||||
@@ -284,7 +286,7 @@ new class extends Component {
|
|||||||
bg-slate-200
|
bg-slate-200
|
||||||
dark:bg-slate-800
|
dark:bg-slate-800
|
||||||
">
|
">
|
||||||
<x-icon name="o-sparkles" class="h-auto p-1 w-10" />
|
<x-ui.icon name="o-sparkles" class="h-auto p-1 w-10" />
|
||||||
</span>
|
</span>
|
||||||
<p class="leading-relaxed w-full">
|
<p class="leading-relaxed w-full">
|
||||||
<span class="block font-bold">AI</span> {{ __('Hi, how can I help?') }}
|
<span class="block font-bold">AI</span> {{ __('Hi, how can I help?') }}
|
||||||
@@ -298,7 +300,7 @@ new class extends Component {
|
|||||||
<div class="flex gap-3 mb-5 flex-1">
|
<div class="flex gap-3 mb-5 flex-1">
|
||||||
<span class="relative flex shrink-0 overflow-hidden rounded-full w-10 h-10">
|
<span class="relative flex shrink-0 overflow-hidden rounded-full w-10 h-10">
|
||||||
|
|
||||||
<x-avatar :image="auth()->user()->profile_photo_url" class="!w-10" />
|
<x-ui.avatar :image="auth()->user()->profile_photo_url" class="!w-10" />
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
<p class="leading-relaxed">
|
<p class="leading-relaxed">
|
||||||
@@ -319,7 +321,7 @@ new class extends Component {
|
|||||||
bg-slate-200
|
bg-slate-200
|
||||||
dark:bg-slate-800
|
dark:bg-slate-800
|
||||||
">
|
">
|
||||||
<x-icon name="o-sparkles" class="h-auto p-1 w-10" />
|
<x-ui.icon name="o-sparkles" class="h-auto p-1 w-10" />
|
||||||
</span>
|
</span>
|
||||||
<div class="leading-relaxed" >
|
<div class="leading-relaxed" >
|
||||||
<span class="block font-bold ">AI </span> {!! Str::markdown($message['content']) !!}
|
<span class="block font-bold ">AI </span> {!! Str::markdown($message['content']) !!}
|
||||||
@@ -342,7 +344,7 @@ new class extends Component {
|
|||||||
bg-slate-200
|
bg-slate-200
|
||||||
dark:bg-slate-800
|
dark:bg-slate-800
|
||||||
">
|
">
|
||||||
<x-icon name="o-sparkles" class="h-auto p-1 w-10" />
|
<x-ui.icon name="o-sparkles" class="h-auto p-1 w-10" />
|
||||||
</span>
|
</span>
|
||||||
<p class="leading-relaxed" >
|
<p class="leading-relaxed" >
|
||||||
<span class="block font-bold ">AI </span> <span wire:stream="answer">{{ $answer }}</span>
|
<span class="block font-bold ">AI </span> <span wire:stream="answer">{{ $answer }}</span>
|
||||||
@@ -353,40 +355,42 @@ new class extends Component {
|
|||||||
|
|
||||||
{{-- prompt input --}}
|
{{-- prompt input --}}
|
||||||
<div class="mt-3 grow-0">
|
<div class="mt-3 grow-0">
|
||||||
<form submit="startCompletion" >
|
<form submit="startCompletion">
|
||||||
<div class="">
|
<div class="">
|
||||||
@foreach($suggested_prompts as $prompt)
|
@foreach($suggested_prompts as $prompt)
|
||||||
<x-button
|
<x-ui.button
|
||||||
class="btn-xs btn-primary btn-outline mr-1 mb-2"
|
class="btn-xs btn-primary btn-outline mr-1 mb-2"
|
||||||
wire:click="startCompletion('{{ addslashes($prompt['value']) }}')"
|
wire:click="startCompletion('{{ addslashes($prompt['value']) }}')"
|
||||||
>{{ $prompt['text'] }}</x-button>
|
>{{ $prompt['text'] }}</x-ui.button>
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-between align-bottom space-x-2 mt-1">
|
<div class="flex justify-between align-bottom space-x-2 mt-1">
|
||||||
|
|
||||||
<div class="w-full">
|
<div class="w-full" >
|
||||||
|
|
||||||
<x-textarea
|
<x-ui.textarea
|
||||||
wire:model="prompt"
|
wire:model="prompt"
|
||||||
class="h-24 resize-none "
|
class="h-18 resize-none bg-base-200"
|
||||||
placeholder="{{ __('Have a question? AI might be able to help...') }}"
|
placeholder="{{ __('Have a question? AI might be able to help...') }}"
|
||||||
wire:keydown.enter.prevent="startCompletion"
|
wire:keydown.enter.prevent="startCompletion"
|
||||||
autofocus
|
autofocus
|
||||||
></x-textarea>
|
@toggle-ai-chat.window="setTimeout(() => $el.focus(), 250)"
|
||||||
|
></x-ui.textarea>
|
||||||
|
{{-- --}}
|
||||||
</div>
|
</div>
|
||||||
<x-button
|
<x-ui.button
|
||||||
spinner="generateCompletion"
|
spinner="generateCompletion"
|
||||||
wire:click="startCompletion"
|
wire:click="startCompletion"
|
||||||
class="btn btn-ghost h-24"
|
class="btn btn-ghost h-32"
|
||||||
icon="o-paper-airplane"
|
icon="o-paper-airplane"
|
||||||
></x-button>
|
></x-ui.button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full mt-2">
|
<div class="w-full mt-2">
|
||||||
<p class="text-xs text-secondary leading-tight">{{ __('Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.') }} </p>
|
<p class="text-xs text-secondary leading-tight select-none">{{ __('Advice generated by AI may contain errors. Use at your own risk. Always consult a licensed investment advisor.') }} </p>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
@props([
|
||||||
|
'id' => null,
|
||||||
|
'title' => null
|
||||||
|
'icon' => null,
|
||||||
|
'description' => null,
|
||||||
|
'shadow' => false,
|
||||||
|
'dismissable' => false
|
||||||
|
])
|
||||||
|
|
||||||
|
<div
|
||||||
|
wire:key="{{ $id }}"
|
||||||
|
{{ $attributes->whereDoesntStartWith('class') }}
|
||||||
|
{{ $attributes->class(['alert rounded-md', 'shadow-md' => $shadow])}}
|
||||||
|
x-data="{ show: true }" x-show="show"
|
||||||
|
>
|
||||||
|
@if($icon)
|
||||||
|
<x-icon :name="$icon" class="self-center" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($title)
|
||||||
|
<div>
|
||||||
|
<div @class(["font-bold" => $description])>{{ $title }}</div>
|
||||||
|
<div class="text-xs">{{ $description }}</div>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<span>{{ $slot }}</span>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{{ $actions }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($dismissible)
|
||||||
|
<x-button icon="o-x-mark" @click="show = false" class="btn-xs btn-circle btn-ghost static self-start end-0" />
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||