Compare commits
9 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c19684159 | |||
| 5e7032c270 | |||
| 17b7ea4aea | |||
| 2adb690d28 | |||
| 24143d53ac | |||
| d0f54fcf9c | |||
| 502d84cc37 | |||
| 861d469082 | |||
| 9aaae974d6 |
31 changed files with 1254 additions and 75 deletions
12
.env.example
12
.env.example
|
|
@ -20,12 +20,12 @@ LOG_STACK=single
|
||||||
LOG_DEPRECATIONS_CHANNEL=null
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
DB_CONNECTION=sqlite
|
DB_CONNECTION=mysql
|
||||||
# DB_HOST=127.0.0.1
|
DB_HOST=db
|
||||||
# DB_PORT=3306
|
DB_PORT=3306
|
||||||
# DB_DATABASE=laravel
|
DB_DATABASE=incr
|
||||||
# DB_USERNAME=root
|
DB_USERNAME=incr_user
|
||||||
# DB_PASSWORD=
|
DB_PASSWORD=incr_password
|
||||||
|
|
||||||
SESSION_DRIVER=database
|
SESSION_DRIVER=database
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
|
|
|
||||||
117
README.md
117
README.md
|
|
@ -1,6 +1,29 @@
|
||||||
# incr
|
<div align="center">
|
||||||
|
|
||||||
A minimalist one-page Laravel + React application for tracking VWCE shares with milestone reinforcement and optional financial insights.
|
# 📈 incr
|
||||||
|
|
||||||
|
**A minimalist investment tracker for VWCE shares with milestone-driven progress**
|
||||||
|
|
||||||
|
*Track your portfolio growth with visual progress indicators and milestone reinforcement*
|
||||||
|
|
||||||
|
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||||
|
[](https://www.docker.com/)
|
||||||
|
[](https://laravel.com/)
|
||||||
|
[](https://reactjs.org/)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**[Introduction](#introduction) • [Features](#features) • [Tech Stack](#tech-stack) • [Getting Started](#getting-started) • [Development](#development) • [Contributing](#contributing) • [License](#license)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
Incr is a minimalist, one-page investment tracking application designed specifically for VWCE (Vanguard FTSE All-World UCITS ETF) shareholders. It combines the satisfaction of visual progress tracking with practical portfolio management, featuring a distinctive LED-style digital display and milestone-based goal setting.
|
||||||
|
|
||||||
|
The application emphasizes simplicity and focus, providing just what you need to track your investment journey without overwhelming complexity.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|
@ -17,24 +40,36 @@ ## Tech Stack
|
||||||
- **Styling**: Tailwind CSS 4 with shadcn/ui components
|
- **Styling**: Tailwind CSS 4 with shadcn/ui components
|
||||||
- **Deployment**: Docker with multi-stage builds
|
- **Deployment**: Docker with multi-stage builds
|
||||||
|
|
||||||
## Quick Start (Production)
|
## Getting Started
|
||||||
|
|
||||||
Run the application using Docker Compose:
|
### Quick Start (Production)
|
||||||
|
|
||||||
|
#### Docker
|
||||||
|
|
||||||
|
Clone the repository and run with Docker Compose:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone the repository
|
git clone https://github.com/your-username/incr.git
|
||||||
git clone https://codeberg.org/lvl0/incr.git
|
|
||||||
cd incr
|
cd incr
|
||||||
|
|
||||||
# Start the application
|
|
||||||
docker compose -f docker/docker-compose.yml up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The application will be available at `http://localhost`.
|
Run the application using the provided docker-compose configuration:
|
||||||
|
|
||||||
**Default credentials**: You'll need to register a new account on first visit.
|
```bash
|
||||||
|
# Using Docker Compose
|
||||||
|
docker-compose -f docker/production/docker-compose.yml up --build
|
||||||
|
|
||||||
## Development Setup
|
# Or using Podman Compose
|
||||||
|
podman-compose -f docker/production/docker-compose.yml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The application will be available at `http://localhost:5001`.
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
#### Local Development Setup
|
||||||
|
|
||||||
|
**Option 1: Laravel Sail (Docker)**
|
||||||
|
|
||||||
For local development with Laravel Sail:
|
For local development with Laravel Sail:
|
||||||
|
|
||||||
|
|
@ -54,15 +89,69 @@ # Run migrations
|
||||||
sail artisan migrate
|
sail artisan migrate
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Option 2: Podman Development**
|
||||||
|
|
||||||
|
For Fedora Atomic or other Podman-based systems:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Quick start with helper script
|
||||||
|
bash docker/dev/podman/start-dev.sh
|
||||||
|
|
||||||
|
# Or manually:
|
||||||
|
# Install podman-compose if not available
|
||||||
|
pip3 install --user podman-compose
|
||||||
|
|
||||||
|
# Start development environment
|
||||||
|
podman-compose -f docker/dev/podman/docker-compose.yml up -d
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
podman exec incr-dev-app php artisan migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option 3: Sail with Podman (Compatibility Layer)**
|
||||||
|
|
||||||
|
To use Laravel Sail commands with Podman:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Source the alias script
|
||||||
|
source docker/dev/podman/podman-sail-alias.sh
|
||||||
|
|
||||||
|
# Now you can use sail commands as normal
|
||||||
|
sail up -d
|
||||||
|
sail artisan migrate
|
||||||
|
sail npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
The development server will be available at `http://localhost` with hot reload enabled.
|
The development server will be available at `http://localhost` with hot reload enabled.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
- `app/` - Laravel backend (controllers, models, services)
|
- `app/` - Laravel backend (controllers, models, services)
|
||||||
- `resources/js/` - React frontend components and pages
|
- `resources/js/` - React frontend components and pages
|
||||||
- `docker/` - Production Docker configuration
|
- `docker/production/` - Production Docker configuration
|
||||||
|
- `docker/dev/podman/` - Development Podman configuration
|
||||||
- `database/migrations/` - Database schema definitions
|
- `database/migrations/` - Database schema definitions
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
We welcome contributions to incr! Whether you're reporting bugs, suggesting features, or submitting pull requests, your input helps make this project better.
|
||||||
|
|
||||||
|
### How to Contribute
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
||||||
|
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||||
|
5. Open a Pull Request
|
||||||
|
|
||||||
|
### Bug Reports
|
||||||
|
|
||||||
|
If you find a bug, please create an issue with:
|
||||||
|
- A clear description of the problem
|
||||||
|
- Steps to reproduce the issue
|
||||||
|
- Expected vs actual behavior
|
||||||
|
- Your environment details
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
GPLv3 License. See LICENSE file for details.
|
This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
|
||||||
106
app/Http/Controllers/AssetController.php
Normal file
106
app/Http/Controllers/AssetController.php
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Asset;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class AssetController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
$assets = Asset::orderBy('symbol')->get();
|
||||||
|
|
||||||
|
return response()->json($assets);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function current(): JsonResponse
|
||||||
|
{
|
||||||
|
// Get the first/default user (since no auth)
|
||||||
|
$user = \App\Models\User::first();
|
||||||
|
$asset = $user ? $user->asset : null;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'asset' => $asset,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCurrent(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'symbol' => 'required|string|max:10',
|
||||||
|
'full_name' => 'nullable|string|max:255',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$asset = Asset::findOrCreateBySymbol(
|
||||||
|
$validated['symbol'],
|
||||||
|
$validated['full_name'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get or create the first/default user (since no auth)
|
||||||
|
$user = \App\Models\User::first();
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
// Create a default user if none exists
|
||||||
|
$user = \App\Models\User::create([
|
||||||
|
'name' => 'Default User',
|
||||||
|
'email' => 'user@example.com',
|
||||||
|
'password' => 'password', // This will be hashed automatically
|
||||||
|
'asset_id' => $asset->id,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$user->update(['asset_id' => $asset->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'Asset set successfully!');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'symbol' => 'required|string|max:10|unique:assets,symbol',
|
||||||
|
'full_name' => 'nullable|string|max:255',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$asset = Asset::create([
|
||||||
|
'symbol' => strtoupper($validated['symbol']),
|
||||||
|
'full_name' => $validated['full_name'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Asset created successfully!',
|
||||||
|
'asset' => $asset,
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Asset $asset): JsonResponse
|
||||||
|
{
|
||||||
|
$asset->load('assetPrices');
|
||||||
|
$currentPrice = $asset->currentPrice();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'asset' => $asset,
|
||||||
|
'current_price' => $currentPrice,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function search(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$query = $request->get('q');
|
||||||
|
|
||||||
|
if (!$query) {
|
||||||
|
return response()->json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$assets = Asset::where('symbol', 'like', "%{$query}%")
|
||||||
|
->orWhere('full_name', 'like', "%{$query}%")
|
||||||
|
->orderBy('symbol')
|
||||||
|
->limit(10)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return response()->json($assets);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,11 @@ class PricingController extends Controller
|
||||||
{
|
{
|
||||||
public function current(): JsonResponse
|
public function current(): JsonResponse
|
||||||
{
|
{
|
||||||
$price = AssetPrice::current();
|
// Get the first/default user (since no auth)
|
||||||
|
$user = \App\Models\User::first();
|
||||||
|
$assetId = $user ? $user->asset_id : null;
|
||||||
|
|
||||||
|
$price = AssetPrice::current($assetId);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'current_price' => $price,
|
'current_price' => $price,
|
||||||
|
|
@ -25,22 +29,37 @@ public function update(Request $request)
|
||||||
'price' => 'required|numeric|min:0.0001',
|
'price' => 'required|numeric|min:0.0001',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$assetPrice = AssetPrice::updatePrice($validated['date'], $validated['price']);
|
// Get the first/default user (since no auth)
|
||||||
|
$user = \App\Models\User::first();
|
||||||
|
|
||||||
|
if (!$user || !$user->asset_id) {
|
||||||
|
return back()->withErrors(['asset' => 'Please set an asset first.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$assetPrice = AssetPrice::updatePrice($user->asset_id, $validated['date'], $validated['price']);
|
||||||
|
|
||||||
return back()->with('success', 'Asset price updated successfully!');
|
return back()->with('success', 'Asset price updated successfully!');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function history(Request $request): JsonResponse
|
public function history(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
// Get the first/default user (since no auth)
|
||||||
|
$user = \App\Models\User::first();
|
||||||
|
$assetId = $user ? $user->asset_id : null;
|
||||||
|
|
||||||
$limit = $request->get('limit', 30);
|
$limit = $request->get('limit', 30);
|
||||||
$history = AssetPrice::history($limit);
|
$history = AssetPrice::history($assetId, $limit);
|
||||||
|
|
||||||
return response()->json($history);
|
return response()->json($history);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function forDate(Request $request, string $date): JsonResponse
|
public function forDate(Request $request, string $date): JsonResponse
|
||||||
{
|
{
|
||||||
$price = AssetPrice::forDate($date);
|
// Get the first/default user (since no auth)
|
||||||
|
$user = \App\Models\User::first();
|
||||||
|
$assetId = $user ? $user->asset_id : null;
|
||||||
|
|
||||||
|
$price = AssetPrice::forDate($date, $assetId);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'date' => $date,
|
'date' => $date,
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ public function index(): JsonResponse
|
||||||
return response()->json($purchases);
|
return response()->json($purchases);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
public function store(Request $request)
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'date' => 'required|date|before_or_equal:today',
|
'date' => 'required|date|before_or_equal:today',
|
||||||
|
|
@ -41,10 +41,7 @@ public function store(Request $request): JsonResponse
|
||||||
'total_cost' => $validated['total_cost'],
|
'total_cost' => $validated['total_cost'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json([
|
return back()->with('success', 'Purchase added successfully!');
|
||||||
'success' => true,
|
|
||||||
'message' => 'Purchase added successfully!',
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function summary()
|
public function summary()
|
||||||
|
|
|
||||||
67
app/Models/Asset.php
Normal file
67
app/Models/Asset.php
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @method static create(array $array)
|
||||||
|
* @method static where(string $string, string $value)
|
||||||
|
* @method static find(int $id)
|
||||||
|
* @method static orderBy(string $string)
|
||||||
|
* @property int $id
|
||||||
|
* @property string $symbol
|
||||||
|
* @property string|null $full_name
|
||||||
|
*/
|
||||||
|
class Asset extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'symbol',
|
||||||
|
'full_name',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'symbol' => 'string',
|
||||||
|
'full_name' => 'string',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function assetPrices(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Pricing\AssetPrice::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function users(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function currentPrice(): ?float
|
||||||
|
{
|
||||||
|
$latestPrice = $this->assetPrices()->latest('date')->first();
|
||||||
|
|
||||||
|
return $latestPrice ? $latestPrice->price : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function findBySymbol(string $symbol): ?self
|
||||||
|
{
|
||||||
|
return static::where('symbol', strtoupper($symbol))->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function findOrCreateBySymbol(string $symbol, ?string $fullName = null): self
|
||||||
|
{
|
||||||
|
$asset = static::findBySymbol($symbol);
|
||||||
|
|
||||||
|
if (! $asset) {
|
||||||
|
$asset = static::create([
|
||||||
|
'symbol' => strtoupper($symbol),
|
||||||
|
'full_name' => $fullName,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $asset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -20,6 +21,7 @@ class AssetPrice extends Model
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
'asset_id',
|
||||||
'date',
|
'date',
|
||||||
'price',
|
'price',
|
||||||
];
|
];
|
||||||
|
|
@ -29,32 +31,54 @@ class AssetPrice extends Model
|
||||||
'price' => 'decimal:4',
|
'price' => 'decimal:4',
|
||||||
];
|
];
|
||||||
|
|
||||||
public static function current(): ?float
|
public function asset(): BelongsTo
|
||||||
{
|
{
|
||||||
$latestPrice = static::latest('date')->first();
|
return $this->belongsTo(\App\Models\Asset::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function current(int $assetId = null): ?float
|
||||||
|
{
|
||||||
|
$query = static::latest('date');
|
||||||
|
|
||||||
|
if ($assetId) {
|
||||||
|
$query->where('asset_id', $assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$latestPrice = $query->first();
|
||||||
|
|
||||||
return $latestPrice ? $latestPrice->price : null;
|
return $latestPrice ? $latestPrice->price : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function forDate(string $date): ?float
|
public static function forDate(string $date, int $assetId = null): ?float
|
||||||
{
|
{
|
||||||
$price = static::where('date', '<=', $date)
|
$query = static::where('date', '<=', $date)
|
||||||
->orderBy('date', 'desc')
|
->orderBy('date', 'desc');
|
||||||
->first();
|
|
||||||
|
if ($assetId) {
|
||||||
|
$query->where('asset_id', $assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$price = $query->first();
|
||||||
|
|
||||||
return $price ? $price->price : null;
|
return $price ? $price->price : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function updatePrice(string $date, float $price): self
|
public static function updatePrice(int $assetId, string $date, float $price): self
|
||||||
{
|
{
|
||||||
return static::updateOrCreate(
|
return static::updateOrCreate(
|
||||||
['date' => $date],
|
['asset_id' => $assetId, 'date' => $date],
|
||||||
['price' => $price]
|
['price' => $price]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function history(int $limit = 30): Collection
|
public static function history(int $assetId = null, int $limit = 30): Collection
|
||||||
{
|
{
|
||||||
return static::orderBy('date', 'desc')->limit($limit)->get();
|
$query = static::orderBy('date', 'desc')->limit($limit);
|
||||||
|
|
||||||
|
if ($assetId) {
|
||||||
|
$query->where('asset_id', $assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->get();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,13 @@
|
||||||
|
|
||||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $asset_id
|
||||||
|
*/
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||||
|
|
@ -21,6 +25,7 @@ class User extends Authenticatable
|
||||||
'name',
|
'name',
|
||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
|
'asset_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -33,11 +38,6 @@ class User extends Authenticatable
|
||||||
'remember_token',
|
'remember_token',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the attributes that should be cast.
|
|
||||||
*
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|
@ -45,4 +45,27 @@ protected function casts(): array
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function asset(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Asset::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasCompletedOnboarding(): bool
|
||||||
|
{
|
||||||
|
// Check if user has asset, purchases, and milestones
|
||||||
|
return $this->asset_id !== null
|
||||||
|
&& $this->hasPurchases()
|
||||||
|
&& $this->hasMilestones();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasPurchases(): bool
|
||||||
|
{
|
||||||
|
return \App\Models\Transactions\Purchase::totalShares() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasMilestones(): bool
|
||||||
|
{
|
||||||
|
return \App\Models\Milestone::count() > 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('assets', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('symbol')->unique();
|
||||||
|
$table->string('full_name')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('symbol');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('assets');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -17,8 +17,11 @@ public function up(): void
|
||||||
$table->string('email')->unique();
|
$table->string('email')->unique();
|
||||||
$table->timestamp('email_verified_at')->nullable();
|
$table->timestamp('email_verified_at')->nullable();
|
||||||
$table->string('password');
|
$table->string('password');
|
||||||
|
$table->foreignId('asset_id')->nullable()->constrained()->onDelete('set null');
|
||||||
$table->rememberToken();
|
$table->rememberToken();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('asset_id');
|
||||||
});
|
});
|
||||||
|
|
||||||
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
||||||
|
|
@ -10,11 +10,13 @@ public function up(): void
|
||||||
{
|
{
|
||||||
Schema::create('asset_prices', function (Blueprint $table) {
|
Schema::create('asset_prices', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
|
$table->foreignId('asset_id')->constrained()->onDelete('cascade');
|
||||||
$table->date('date');
|
$table->date('date');
|
||||||
$table->decimal('price', 10, 4);
|
$table->decimal('price', 10, 4);
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
|
|
||||||
$table->unique('date');
|
$table->unique(['asset_id', 'date']);
|
||||||
|
$table->index('asset_id');
|
||||||
$table->index('date');
|
$table->index('date');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
49
docker/dev/podman/Dockerfile
Normal file
49
docker/dev/podman/Dockerfile
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
FROM docker.io/library/php:8.2-fpm
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
libpng-dev \
|
||||||
|
libonig-dev \
|
||||||
|
libxml2-dev \
|
||||||
|
zip \
|
||||||
|
unzip \
|
||||||
|
nodejs \
|
||||||
|
npm \
|
||||||
|
default-mysql-client \
|
||||||
|
&& docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd
|
||||||
|
|
||||||
|
# Install Composer
|
||||||
|
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
|
# Install Node.js 20.x (for better compatibility)
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
|
&& apt-get install -y nodejs
|
||||||
|
|
||||||
|
# Copy composer files and install PHP dependencies
|
||||||
|
COPY composer.json composer.lock ./
|
||||||
|
RUN composer install --no-dev --optimize-autoloader --no-scripts
|
||||||
|
|
||||||
|
# Copy package.json and install Node dependencies
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Set permissions
|
||||||
|
RUN chown -R www-data:www-data /var/www/html \
|
||||||
|
&& chmod -R 755 /var/www/html/storage \
|
||||||
|
&& chmod -R 755 /var/www/html/bootstrap/cache
|
||||||
|
|
||||||
|
# Copy and set up container start script
|
||||||
|
COPY docker/dev/podman/container-start.sh /usr/local/bin/container-start.sh
|
||||||
|
RUN chmod +x /usr/local/bin/container-start.sh
|
||||||
|
|
||||||
|
EXPOSE 8000 5173
|
||||||
|
|
||||||
|
CMD ["/usr/local/bin/container-start.sh"]
|
||||||
28
docker/dev/podman/container-start.sh
Normal file
28
docker/dev/podman/container-start.sh
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Create .env file if it doesn't exist
|
||||||
|
if [ ! -f /var/www/html/.env ]; then
|
||||||
|
cp /var/www/html/.env.example /var/www/html/.env 2>/dev/null || touch /var/www/html/.env
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fix database name to match compose file
|
||||||
|
sed -i 's/DB_DATABASE=incr$/DB_DATABASE=incr_dev/' /var/www/html/.env
|
||||||
|
|
||||||
|
# Generate app key if not set or empty
|
||||||
|
if ! grep -q "APP_KEY=base64:" /var/www/html/.env; then
|
||||||
|
# Generate a new key and set it directly
|
||||||
|
NEW_KEY=$(php -r "echo 'base64:' . base64_encode(random_bytes(32));")
|
||||||
|
sed -i "s/APP_KEY=/APP_KEY=$NEW_KEY/" /var/www/html/.env
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
php artisan migrate --force
|
||||||
|
|
||||||
|
# Start Laravel development server in background
|
||||||
|
php artisan serve --host=0.0.0.0 --port=8000 &
|
||||||
|
|
||||||
|
# Start Vite development server
|
||||||
|
npm run dev -- --host 0.0.0.0
|
||||||
|
|
||||||
|
# Wait for background processes
|
||||||
|
wait
|
||||||
72
docker/dev/podman/docker-compose.yml
Normal file
72
docker/dev/podman/docker-compose.yml
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: ../../..
|
||||||
|
dockerfile: docker/dev/podman/Dockerfile
|
||||||
|
container_name: incr-dev-app
|
||||||
|
restart: unless-stopped
|
||||||
|
working_dir: /var/www/html
|
||||||
|
environment:
|
||||||
|
- APP_ENV=local
|
||||||
|
- APP_DEBUG=true
|
||||||
|
- APP_KEY=base64:YOUR_APP_KEY_HERE
|
||||||
|
- DB_CONNECTION=mysql
|
||||||
|
- DB_HOST=db
|
||||||
|
- DB_PORT=3306
|
||||||
|
- DB_DATABASE=incr_dev
|
||||||
|
- DB_USERNAME=incr_user
|
||||||
|
- DB_PASSWORD=incr_password
|
||||||
|
- VITE_PORT=5173
|
||||||
|
volumes:
|
||||||
|
- ../../../:/var/www/html:Z
|
||||||
|
- /var/www/html/node_modules
|
||||||
|
- /var/www/html/vendor
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
- "5173:5173"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- incr-dev-network
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: docker.io/library/mysql:8.0
|
||||||
|
container_name: incr-dev-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- MYSQL_DATABASE=incr_dev
|
||||||
|
- MYSQL_USER=incr_user
|
||||||
|
- MYSQL_PASSWORD=incr_password
|
||||||
|
- MYSQL_ROOT_PASSWORD=root_password
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/mysql
|
||||||
|
ports:
|
||||||
|
- "3307:3306"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "incr_user", "-pincr_password"]
|
||||||
|
timeout: 10s
|
||||||
|
retries: 10
|
||||||
|
interval: 10s
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- incr-dev-network
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: docker.io/library/redis:7-alpine
|
||||||
|
container_name: incr-dev-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
networks:
|
||||||
|
- incr-dev-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
incr-dev-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
|
driver: local
|
||||||
26
docker/dev/podman/podman-sail-alias.sh
Normal file
26
docker/dev/podman/podman-sail-alias.sh
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Podman aliases for Laravel Sail compatibility
|
||||||
|
# Source this file to use Sail commands with Podman
|
||||||
|
# Usage: source docker/dev/podman/podman-sail-alias.sh
|
||||||
|
|
||||||
|
# Create docker alias pointing to podman
|
||||||
|
alias docker='podman'
|
||||||
|
|
||||||
|
# Create docker-compose alias pointing to podman-compose
|
||||||
|
alias docker-compose='podman-compose'
|
||||||
|
|
||||||
|
# Sail wrapper function that uses podman-compose
|
||||||
|
sail() {
|
||||||
|
if [[ -f docker/dev/podman/docker-compose.yml ]]; then
|
||||||
|
podman-compose -f docker/dev/podman/docker-compose.yml "$@"
|
||||||
|
else
|
||||||
|
echo "❌ Podman compose file not found at docker/dev/podman/docker-compose.yml"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "✅ Podman aliases set up for Laravel Sail compatibility"
|
||||||
|
echo "🐳 'docker' → 'podman'"
|
||||||
|
echo "🔧 'docker-compose' → 'podman-compose'"
|
||||||
|
echo "⛵ 'sail' → uses podman-compose with dev configuration"
|
||||||
52
docker/dev/podman/start-dev.sh
Normal file
52
docker/dev/podman/start-dev.sh
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Podman development environment startup script for incr
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🚀 Starting incr development environment with Podman..."
|
||||||
|
|
||||||
|
# Check if .env exists
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo "📋 Creating .env file from .env.example..."
|
||||||
|
cp .env.example .env
|
||||||
|
echo "⚠️ Please update your .env file with appropriate values, especially APP_KEY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if podman-compose is available
|
||||||
|
if ! command -v podman-compose &> /dev/null; then
|
||||||
|
echo "❌ podman-compose not found. Installing..."
|
||||||
|
pip3 install --user podman-compose
|
||||||
|
echo "✅ podman-compose installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start services
|
||||||
|
echo "🔧 Starting services..."
|
||||||
|
podman-compose -f docker/dev/podman/docker-compose.yml up -d
|
||||||
|
|
||||||
|
# Wait for database to be ready
|
||||||
|
echo "⏳ Waiting for database to be ready..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# Check if APP_KEY is set
|
||||||
|
if grep -q "APP_KEY=base64:YOUR_APP_KEY_HERE" .env || grep -q "APP_KEY=$" .env; then
|
||||||
|
echo "🔑 Generating application key..."
|
||||||
|
podman exec incr-dev-app php artisan key:generate
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
echo "🗃️ Running database migrations..."
|
||||||
|
podman exec incr-dev-app php artisan migrate
|
||||||
|
|
||||||
|
# Install/update dependencies if needed
|
||||||
|
echo "📦 Installing dependencies..."
|
||||||
|
podman exec incr-dev-app composer install
|
||||||
|
podman exec incr-dev-app npm install
|
||||||
|
|
||||||
|
echo "✅ Development environment is ready!"
|
||||||
|
echo "🌐 Application: http://localhost:8000"
|
||||||
|
echo "🔥 Vite dev server: http://localhost:5173"
|
||||||
|
echo "💾 Database: localhost:3307"
|
||||||
|
echo ""
|
||||||
|
echo "To stop: podman-compose -f docker/dev/podman/docker-compose.yml down"
|
||||||
|
echo "To view logs: podman-compose -f docker/dev/podman/docker-compose.yml logs -f"
|
||||||
|
|
@ -2,7 +2,10 @@ version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: codeberg.org/lvl0/incr:0.1.14
|
image: codeberg.org/lvl0/incr:latest
|
||||||
|
# build:
|
||||||
|
# context: ..
|
||||||
|
# dockerfile: docker/Dockerfile
|
||||||
container_name: incr-app
|
container_name: incr-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
working_dir: /var/www/html
|
working_dir: /var/www/html
|
||||||
|
|
@ -15,18 +18,17 @@ services:
|
||||||
- DB_DATABASE=incr
|
- DB_DATABASE=incr
|
||||||
- DB_USERNAME=incr_user
|
- DB_USERNAME=incr_user
|
||||||
- DB_PASSWORD=incr_password
|
- DB_PASSWORD=incr_password
|
||||||
volumes:
|
volumes: []
|
||||||
- ../storage:/var/www/html/storage
|
|
||||||
- ../public:/var/www/html/public
|
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "5001:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
db:
|
||||||
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- incr-network
|
- incr-network
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: mysql:8.0
|
image: docker.io/library/mysql:8.0
|
||||||
container_name: incr-db
|
container_name: incr-db
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -38,6 +40,12 @@ services:
|
||||||
- db_data:/var/lib/mysql
|
- db_data:/var/lib/mysql
|
||||||
ports:
|
ports:
|
||||||
- "3306:3306"
|
- "3306:3306"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "incr_user", "-pincr_password"]
|
||||||
|
timeout: 10s
|
||||||
|
retries: 10
|
||||||
|
interval: 10s
|
||||||
|
start_period: 10s
|
||||||
networks:
|
networks:
|
||||||
- incr-network
|
- incr-network
|
||||||
|
|
||||||
29
docker/production/start-app.sh
Normal file
29
docker/production/start-app.sh
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Copy public files to shared volume if needed
|
||||||
|
cp -r /var/www/html/public/. /var/www/html/public_shared/ 2>/dev/null || true
|
||||||
|
|
||||||
|
# Create .env file if it doesn't exist
|
||||||
|
if [ ! -f /var/www/html/.env ]; then
|
||||||
|
cp /var/www/html/.env.example /var/www/html/.env 2>/dev/null || touch /var/www/html/.env
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wait for database to be ready
|
||||||
|
echo "Waiting for database..."
|
||||||
|
until php artisan tinker --execute="DB::connection()->getPdo();" 2>/dev/null; do
|
||||||
|
echo "Database not ready, waiting..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "Database is ready!"
|
||||||
|
|
||||||
|
# Generate app key if not set
|
||||||
|
php artisan key:generate --force
|
||||||
|
|
||||||
|
# Laravel optimizations
|
||||||
|
php artisan config:cache
|
||||||
|
php artisan route:cache
|
||||||
|
php artisan view:cache
|
||||||
|
php artisan migrate --force
|
||||||
|
|
||||||
|
# Start supervisor to manage nginx and php-fpm
|
||||||
|
supervisord -c /etc/supervisord.conf
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
|
|
||||||
# Copy public files to shared volume if needed
|
|
||||||
cp -r /var/www/html/public/. /var/www/html/public_shared/ 2>/dev/null || true
|
|
||||||
|
|
||||||
# Laravel optimizations
|
|
||||||
php artisan config:cache
|
|
||||||
php artisan route:cache
|
|
||||||
php artisan view:cache
|
|
||||||
php artisan migrate --force
|
|
||||||
|
|
||||||
# Start supervisor to manage nginx and php-fpm
|
|
||||||
supervisord -c /etc/supervisord.conf
|
|
||||||
156
resources/js/components/Assets/AssetSetupForm.tsx
Normal file
156
resources/js/components/Assets/AssetSetupForm.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import InputError from '@/components/InputError';
|
||||||
|
import { useForm } from '@inertiajs/react';
|
||||||
|
import { LoaderCircle } from 'lucide-react';
|
||||||
|
import { FormEventHandler, useState, useEffect } from 'react';
|
||||||
|
import ComponentTitle from '@/components/ui/ComponentTitle';
|
||||||
|
|
||||||
|
interface AssetFormData {
|
||||||
|
symbol: string;
|
||||||
|
full_name: string;
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssetSetupFormProps {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AssetSetupForm({ onSuccess, onCancel }: AssetSetupFormProps) {
|
||||||
|
const { data, setData, post, processing, errors } = useForm<AssetFormData>({
|
||||||
|
symbol: '',
|
||||||
|
full_name: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load existing asset data on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCurrentAsset = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/assets/current');
|
||||||
|
if (response.ok) {
|
||||||
|
const assetData = await response.json();
|
||||||
|
if (assetData.asset) {
|
||||||
|
setData({
|
||||||
|
symbol: assetData.asset.symbol || '',
|
||||||
|
full_name: assetData.asset.full_name || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch current asset:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCurrentAsset();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [suggestions] = useState([
|
||||||
|
{ symbol: 'VWCE', full_name: 'Vanguard FTSE All-World UCITS ETF' },
|
||||||
|
{ symbol: 'VTI', full_name: 'Vanguard Total Stock Market ETF' },
|
||||||
|
{ symbol: 'SPY', full_name: 'SPDR S&P 500 ETF Trust' },
|
||||||
|
{ symbol: 'QQQ', full_name: 'Invesco QQQ Trust' },
|
||||||
|
{ symbol: 'IWDA', full_name: 'iShares Core MSCI World UCITS ETF' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const submit: FormEventHandler = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
post(route('assets.set-current'), {
|
||||||
|
onSuccess: () => {
|
||||||
|
if (onSuccess) onSuccess();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSuggestionClick = (suggestion: { symbol: string; full_name: string }) => {
|
||||||
|
setData({
|
||||||
|
symbol: suggestion.symbol,
|
||||||
|
full_name: suggestion.full_name,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<ComponentTitle>SET ASSET</ComponentTitle>
|
||||||
|
<p className="text-sm text-red-400/60 font-mono">
|
||||||
|
[SYSTEM] Specify the asset you want to track
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Quick suggestions */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-red-400 font-mono text-xs uppercase tracking-wider">> Quick Select</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{suggestions.map((suggestion) => (
|
||||||
|
<button
|
||||||
|
key={suggestion.symbol}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSuggestionClick(suggestion)}
|
||||||
|
className="px-3 py-1 bg-black border border-red-500/50 text-red-400 hover:border-red-400 hover:text-red-300 font-mono text-xs uppercase tracking-wider transition-all rounded-none"
|
||||||
|
>
|
||||||
|
{suggestion.symbol}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={submit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="symbol" className="text-red-400 font-mono text-xs uppercase tracking-wider">> Asset Symbol</Label>
|
||||||
|
<Input
|
||||||
|
id="symbol"
|
||||||
|
type="text"
|
||||||
|
placeholder="VWCE"
|
||||||
|
value={data.symbol}
|
||||||
|
onChange={(e) => setData('symbol', e.target.value.toUpperCase())}
|
||||||
|
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none focus:shadow-[0_0_10px_rgba(239,68,68,0.5)] placeholder:text-red-400/40 transition-all"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-red-400/60 mt-1 font-mono">
|
||||||
|
[REQUIRED] ticker symbol (e.g. VWCE, VTI, SPY)
|
||||||
|
</p>
|
||||||
|
<InputError message={errors.symbol} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="full_name" className="text-red-400 font-mono text-xs uppercase tracking-wider">> Full Name (Optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="full_name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Vanguard FTSE All-World UCITS ETF"
|
||||||
|
value={data.full_name}
|
||||||
|
onChange={(e) => setData('full_name', e.target.value)}
|
||||||
|
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none focus:shadow-[0_0_10px_rgba(239,68,68,0.5)] placeholder:text-red-400/40 transition-all"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-red-400/60 mt-1 font-mono">
|
||||||
|
[OPTIONAL] human-readable asset name
|
||||||
|
</p>
|
||||||
|
<InputError message={errors.full_name} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={processing || !data.symbol}
|
||||||
|
className="flex-1 bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red"
|
||||||
|
>
|
||||||
|
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
[EXECUTE]
|
||||||
|
</Button>
|
||||||
|
{onCancel && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex-1 bg-black border-red-500 text-red-400 hover:bg-red-950 hover:text-red-300 font-mono text-sm font-bold rounded-none border-2 uppercase tracking-wider transition-all glow-red"
|
||||||
|
>
|
||||||
|
[ABORT]
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
234
resources/js/components/Onboarding/OnboardingFlow.tsx
Normal file
234
resources/js/components/Onboarding/OnboardingFlow.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import AssetSetupForm from '@/components/Assets/AssetSetupForm';
|
||||||
|
import AddPurchaseForm from '@/components/Transactions/AddPurchaseForm';
|
||||||
|
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
|
||||||
|
import UpdatePriceForm from '@/components/Pricing/UpdatePriceForm';
|
||||||
|
|
||||||
|
interface OnboardingStep {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
completed: boolean;
|
||||||
|
required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OnboardingFlowProps {
|
||||||
|
onComplete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [steps, setSteps] = useState<OnboardingStep[]>([
|
||||||
|
{
|
||||||
|
id: 'asset',
|
||||||
|
title: 'SET ASSET',
|
||||||
|
description: 'Choose the asset you want to track',
|
||||||
|
completed: false,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'purchases',
|
||||||
|
title: 'ADD PURCHASES',
|
||||||
|
description: 'Enter your current holdings',
|
||||||
|
completed: false,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'milestones',
|
||||||
|
title: 'SET MILESTONES',
|
||||||
|
description: 'Define your investment goals',
|
||||||
|
completed: false,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'price',
|
||||||
|
title: 'CURRENT PRICE',
|
||||||
|
description: 'Set current asset price',
|
||||||
|
completed: false,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Check onboarding status on mount
|
||||||
|
useEffect(() => {
|
||||||
|
checkOnboardingStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkOnboardingStatus = async () => {
|
||||||
|
try {
|
||||||
|
// Check asset
|
||||||
|
const assetResponse = await fetch('/assets/current');
|
||||||
|
const assetData = await assetResponse.json();
|
||||||
|
const hasAsset = !!assetData.asset;
|
||||||
|
|
||||||
|
// Check purchases
|
||||||
|
const purchaseResponse = await fetch('/purchases/summary');
|
||||||
|
const purchaseData = await purchaseResponse.json();
|
||||||
|
const hasPurchases = purchaseData.total_shares > 0;
|
||||||
|
|
||||||
|
// Check milestones
|
||||||
|
const milestonesResponse = await fetch('/milestones');
|
||||||
|
const milestonesData = await milestonesResponse.json();
|
||||||
|
const hasMilestones = milestonesData.length > 0;
|
||||||
|
|
||||||
|
// Check current price
|
||||||
|
const priceResponse = await fetch('/pricing/current');
|
||||||
|
const priceData = await priceResponse.json();
|
||||||
|
const hasPrice = !!priceData.current_price;
|
||||||
|
|
||||||
|
setSteps(prev => prev.map(step => ({
|
||||||
|
...step,
|
||||||
|
completed:
|
||||||
|
(step.id === 'asset' && hasAsset) ||
|
||||||
|
(step.id === 'purchases' && hasPurchases) ||
|
||||||
|
(step.id === 'milestones' && hasMilestones) ||
|
||||||
|
(step.id === 'price' && hasPrice)
|
||||||
|
})));
|
||||||
|
|
||||||
|
// Find first incomplete required step
|
||||||
|
const firstIncompleteStep = steps.findIndex(step =>
|
||||||
|
step.required && !step.completed
|
||||||
|
);
|
||||||
|
|
||||||
|
if (firstIncompleteStep !== -1) {
|
||||||
|
setCurrentStep(firstIncompleteStep);
|
||||||
|
} else {
|
||||||
|
// All required steps complete, check if we should call onComplete
|
||||||
|
const allRequiredComplete = steps.filter(s => s.required).every(s => s.completed);
|
||||||
|
if (allRequiredComplete && onComplete) {
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check onboarding status:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStepComplete = async () => {
|
||||||
|
// Mark current step as completed
|
||||||
|
setSteps(prev => prev.map((step, index) =>
|
||||||
|
index === currentStep ? { ...step, completed: true } : step
|
||||||
|
));
|
||||||
|
|
||||||
|
// Refresh onboarding status
|
||||||
|
await checkOnboardingStatus();
|
||||||
|
|
||||||
|
// Move to next incomplete step or complete onboarding
|
||||||
|
const nextIncompleteStep = steps.findIndex((step, index) =>
|
||||||
|
index > currentStep && step.required && !step.completed
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nextIncompleteStep !== -1) {
|
||||||
|
setCurrentStep(nextIncompleteStep);
|
||||||
|
} else {
|
||||||
|
// All required steps complete
|
||||||
|
const allRequiredComplete = steps.filter(s => s.required).every(s => s.completed);
|
||||||
|
if (allRequiredComplete && onComplete) {
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStepSelect = (stepIndex: number) => {
|
||||||
|
setCurrentStep(stepIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStepContent = () => {
|
||||||
|
const step = steps[currentStep];
|
||||||
|
|
||||||
|
switch (step.id) {
|
||||||
|
case 'asset':
|
||||||
|
return (
|
||||||
|
<AssetSetupForm
|
||||||
|
onSuccess={handleStepComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'purchases':
|
||||||
|
return (
|
||||||
|
<AddPurchaseForm
|
||||||
|
onSuccess={handleStepComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'milestones':
|
||||||
|
return (
|
||||||
|
<AddMilestoneForm
|
||||||
|
onSuccess={handleStepComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'price':
|
||||||
|
return (
|
||||||
|
<UpdatePriceForm
|
||||||
|
onSuccess={handleStepComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black flex items-center justify-center p-4">
|
||||||
|
<div className="w-full max-w-4xl">
|
||||||
|
{/* Terminal-style border with red glow */}
|
||||||
|
<div className="border-2 border-red-500 bg-black shadow-[0_0_20px_rgba(239,68,68,0.3)] p-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-red-400 font-mono text-2xl font-bold uppercase tracking-wider mb-2">
|
||||||
|
[SYSTEM] ONBOARDING SEQUENCE
|
||||||
|
</h1>
|
||||||
|
<p className="text-red-400/60 font-mono text-sm">
|
||||||
|
Initialize your asset tracking system
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress indicator */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<button
|
||||||
|
key={step.id}
|
||||||
|
onClick={() => handleStepSelect(index)}
|
||||||
|
className={`flex-1 px-4 py-2 font-mono text-xs uppercase tracking-wider border border-red-500/50 transition-all ${
|
||||||
|
index === currentStep
|
||||||
|
? 'bg-red-500 text-black border-red-500'
|
||||||
|
: step.completed
|
||||||
|
? 'bg-red-950/50 text-red-300 border-red-400'
|
||||||
|
: 'bg-black text-red-400/60 hover:text-red-400 hover:border-red-400'
|
||||||
|
} ${index > 0 ? 'ml-2' : ''}`}
|
||||||
|
>
|
||||||
|
{step.completed ? '[✓]' : step.required ? '[REQ]' : '[OPT]'} {step.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-red-400 font-mono text-sm">
|
||||||
|
{steps[currentStep].description}
|
||||||
|
</p>
|
||||||
|
<p className="text-red-400/60 font-mono text-xs mt-1">
|
||||||
|
STEP {currentStep + 1}/{steps.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step content */}
|
||||||
|
<div className="border border-red-500/30 bg-black/50 p-6">
|
||||||
|
{renderStepContent()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status footer */}
|
||||||
|
<div className="mt-6 pt-4 border-t border-red-500/30">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<p className="text-red-400/60 font-mono text-xs">
|
||||||
|
[STATUS] {steps.filter(s => s.completed).length}/{steps.length} STEPS COMPLETE
|
||||||
|
</p>
|
||||||
|
<p className="text-red-400/60 font-mono text-xs">
|
||||||
|
{steps.filter(s => s.required && !s.completed).length} REQUIRED REMAINING
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,7 @@ interface UpdatePriceFormProps {
|
||||||
export default function UpdatePriceForm({ currentPrice, className, onSuccess, onCancel }: UpdatePriceFormProps) {
|
export default function UpdatePriceForm({ currentPrice, className, onSuccess, onCancel }: UpdatePriceFormProps) {
|
||||||
const { data, setData, post, processing, errors } = useForm<PriceUpdateFormData>({
|
const { data, setData, post, processing, errors } = useForm<PriceUpdateFormData>({
|
||||||
date: new Date().toISOString().split('T')[0], // Today's date in YYYY-MM-DD format
|
date: new Date().toISOString().split('T')[0], // Today's date in YYYY-MM-DD format
|
||||||
price: currentPrice?.toString() || '',
|
price: currentPrice?.toString() || '100.00',
|
||||||
});
|
});
|
||||||
|
|
||||||
const submit: FormEventHandler = (e) => {
|
const submit: FormEventHandler = (e) => {
|
||||||
|
|
@ -33,8 +33,13 @@ export default function UpdatePriceForm({ currentPrice, className, onSuccess, on
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Keep the date, reset only price if needed
|
// Keep the date, reset only price if needed
|
||||||
// User might want to update same day multiple times
|
// User might want to update same day multiple times
|
||||||
if (onSuccess) onSuccess();
|
if (onSuccess) {
|
||||||
|
onSuccess();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
onError: (errors) => {
|
||||||
|
console.error('Price update failed:', errors);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { Label } from '@/components/ui/label';
|
||||||
import InputError from '@/components/InputError';
|
import InputError from '@/components/InputError';
|
||||||
import { useForm } from '@inertiajs/react';
|
import { useForm } from '@inertiajs/react';
|
||||||
import { LoaderCircle } from 'lucide-react';
|
import { LoaderCircle } from 'lucide-react';
|
||||||
import { FormEventHandler, useEffect } from 'react';
|
import { FormEventHandler, useEffect, useState } from 'react';
|
||||||
import ComponentTitle from '@/components/ui/ComponentTitle';
|
import ComponentTitle from '@/components/ui/ComponentTitle';
|
||||||
|
|
||||||
interface PurchaseFormData {
|
interface PurchaseFormData {
|
||||||
|
|
@ -20,6 +20,12 @@ interface AddPurchaseFormProps {
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PurchaseSummary {
|
||||||
|
total_shares: number;
|
||||||
|
total_investment: number;
|
||||||
|
average_cost_per_share: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function AddPurchaseForm({ onSuccess, onCancel }: AddPurchaseFormProps) {
|
export default function AddPurchaseForm({ onSuccess, onCancel }: AddPurchaseFormProps) {
|
||||||
const { data, setData, post, processing, errors, reset } = useForm<PurchaseFormData>({
|
const { data, setData, post, processing, errors, reset } = useForm<PurchaseFormData>({
|
||||||
date: new Date().toISOString().split('T')[0], // Today's date in YYYY-MM-DD format
|
date: new Date().toISOString().split('T')[0], // Today's date in YYYY-MM-DD format
|
||||||
|
|
@ -28,6 +34,25 @@ export default function AddPurchaseForm({ onSuccess, onCancel }: AddPurchaseForm
|
||||||
total_cost: '',
|
total_cost: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [currentHoldings, setCurrentHoldings] = useState<PurchaseSummary | null>(null);
|
||||||
|
|
||||||
|
// Load existing holdings data on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchCurrentHoldings = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/purchases/summary');
|
||||||
|
if (response.ok) {
|
||||||
|
const summary = await response.json();
|
||||||
|
setCurrentHoldings(summary);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch current holdings:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCurrentHoldings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Auto-calculate total cost when shares or price changes
|
// Auto-calculate total cost when shares or price changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data.shares && data.price_per_share) {
|
if (data.shares && data.price_per_share) {
|
||||||
|
|
@ -59,6 +84,11 @@ export default function AddPurchaseForm({ onSuccess, onCancel }: AddPurchaseForm
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ComponentTitle>ADD PURCHASE</ComponentTitle>
|
<ComponentTitle>ADD PURCHASE</ComponentTitle>
|
||||||
|
{currentHoldings && currentHoldings.total_shares > 0 && (
|
||||||
|
<p className="text-sm text-red-400/60 font-mono">
|
||||||
|
[CURRENT] {currentHoldings.total_shares.toFixed(6)} shares • €{currentHoldings.total_investment.toFixed(2)} invested
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<form onSubmit={submit} className="space-y-4">
|
<form onSubmit={submit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="date" className="text-red-400 font-mono text-xs uppercase tracking-wider">> Purchase Date</Label>
|
<Label htmlFor="date" className="text-red-400 font-mono text-xs uppercase tracking-wider">> Purchase Date</Label>
|
||||||
|
|
|
||||||
57
resources/js/components/ui/TerminalSpinner.tsx
Normal file
57
resources/js/components/ui/TerminalSpinner.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface TerminalSpinnerProps {
|
||||||
|
text?: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
fullScreen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TerminalSpinner({
|
||||||
|
text = 'LOADING',
|
||||||
|
size = 'md',
|
||||||
|
fullScreen = false
|
||||||
|
}: TerminalSpinnerProps) {
|
||||||
|
const [dots, setDots] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setDots(prev => (prev + 1) % 4);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getDots = () => {
|
||||||
|
switch (dots) {
|
||||||
|
case 0: return ' ';
|
||||||
|
case 1: return '. ';
|
||||||
|
case 2: return '.. ';
|
||||||
|
case 3: return '...';
|
||||||
|
default: return ' ';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'text-sm',
|
||||||
|
md: 'text-lg',
|
||||||
|
lg: 'text-xl'
|
||||||
|
};
|
||||||
|
|
||||||
|
const spinner = (
|
||||||
|
<div className="border border-red-500/30 bg-black p-6">
|
||||||
|
<span className={`text-red-500 font-mono ${sizeClasses[size]} uppercase tracking-wider`}>
|
||||||
|
[SYSTEM] {text}<span className="inline-block w-8">{getDots()}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fullScreen) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black flex items-center justify-center">
|
||||||
|
{spinner}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return spinner;
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,8 @@ import LedDisplay from '@/components/Display/LedDisplay';
|
||||||
import InlineForm from '@/components/Display/InlineForm';
|
import InlineForm from '@/components/Display/InlineForm';
|
||||||
import ProgressBar from '@/components/Display/ProgressBar';
|
import ProgressBar from '@/components/Display/ProgressBar';
|
||||||
import StatsBox from '@/components/Display/StatsBox';
|
import StatsBox from '@/components/Display/StatsBox';
|
||||||
|
import OnboardingFlow from '@/components/Onboarding/OnboardingFlow';
|
||||||
|
import TerminalSpinner from '@/components/ui/TerminalSpinner';
|
||||||
import { Head } from '@inertiajs/react';
|
import { Head } from '@inertiajs/react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
|
@ -38,15 +40,18 @@ export default function Dashboard() {
|
||||||
const [showStatsBox, setShowStatsBox] = useState(false);
|
const [showStatsBox, setShowStatsBox] = useState(false);
|
||||||
const [activeForm, setActiveForm] = useState<'purchase' | 'milestone' | 'price' | null>(null);
|
const [activeForm, setActiveForm] = useState<'purchase' | 'milestone' | 'price' | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
||||||
|
const [currentAsset, setCurrentAsset] = useState<any>(null);
|
||||||
|
|
||||||
// Fetch purchase summary, current price, and milestones
|
// Fetch purchase summary, current price, milestones, and check onboarding
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const [purchaseResponse, priceResponse, milestonesResponse] = await Promise.all([
|
const [purchaseResponse, priceResponse, milestonesResponse, assetResponse] = await Promise.all([
|
||||||
fetch('/purchases/summary'),
|
fetch('/purchases/summary'),
|
||||||
fetch('/pricing/current'),
|
fetch('/pricing/current'),
|
||||||
fetch('/milestones'),
|
fetch('/milestones'),
|
||||||
|
fetch('/assets/current'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (purchaseResponse.ok) {
|
if (purchaseResponse.ok) {
|
||||||
|
|
@ -63,6 +68,14 @@ export default function Dashboard() {
|
||||||
const milestonesData = await milestonesResponse.json();
|
const milestonesData = await milestonesResponse.json();
|
||||||
setMilestones(milestonesData);
|
setMilestones(milestonesData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (assetResponse.ok) {
|
||||||
|
const assetData = await assetResponse.json();
|
||||||
|
setCurrentAsset(assetData.asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if onboarding is needed after all data is loaded
|
||||||
|
await checkOnboardingStatus();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch data:', error);
|
console.error('Failed to fetch data:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -73,6 +86,33 @@ export default function Dashboard() {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Check if user needs onboarding
|
||||||
|
const checkOnboardingStatus = async () => {
|
||||||
|
try {
|
||||||
|
const [assetResponse, purchaseResponse, milestonesResponse] = await Promise.all([
|
||||||
|
fetch('/assets/current'),
|
||||||
|
fetch('/purchases/summary'),
|
||||||
|
fetch('/milestones'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const assetData = await assetResponse.json();
|
||||||
|
const purchaseData = await purchaseResponse.json();
|
||||||
|
const milestonesData = await milestonesResponse.json();
|
||||||
|
|
||||||
|
const hasAsset = !!assetData.asset;
|
||||||
|
const hasPurchases = purchaseData.total_shares > 0;
|
||||||
|
const hasMilestones = milestonesData.length > 0;
|
||||||
|
|
||||||
|
// User needs onboarding if any required step is missing
|
||||||
|
const needsOnboarding = !hasAsset || !hasPurchases || !hasMilestones;
|
||||||
|
setNeedsOnboarding(needsOnboarding);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check onboarding status:', error);
|
||||||
|
// If we can't check, assume onboarding is needed
|
||||||
|
setNeedsOnboarding(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Refresh data after successful purchase
|
// Refresh data after successful purchase
|
||||||
const handlePurchaseSuccess = async () => {
|
const handlePurchaseSuccess = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -147,11 +187,7 @@ export default function Dashboard() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="Dashboard" />
|
<Head title="Dashboard" />
|
||||||
<div className="min-h-screen bg-black flex items-center justify-center">
|
<TerminalSpinner fullScreen />
|
||||||
<div className="text-red-500 font-mono text-lg animate-pulse">
|
|
||||||
LOADING...
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -171,6 +207,50 @@ export default function Dashboard() {
|
||||||
setActiveForm(null)
|
setActiveForm(null)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle onboarding completion
|
||||||
|
const handleOnboardingComplete = async () => {
|
||||||
|
// Refresh all data and check onboarding status
|
||||||
|
await checkOnboardingStatus();
|
||||||
|
|
||||||
|
// Refresh individual data sets
|
||||||
|
const [purchaseResponse, priceResponse, milestonesResponse, assetResponse] = await Promise.all([
|
||||||
|
fetch('/purchases/summary'),
|
||||||
|
fetch('/pricing/current'),
|
||||||
|
fetch('/milestones'),
|
||||||
|
fetch('/assets/current'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (purchaseResponse.ok) {
|
||||||
|
const purchases = await purchaseResponse.json();
|
||||||
|
setPurchaseData(purchases);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priceResponse.ok) {
|
||||||
|
const price = await priceResponse.json();
|
||||||
|
setPriceData(price);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (milestonesResponse.ok) {
|
||||||
|
const milestonesData = await milestonesResponse.json();
|
||||||
|
setMilestones(milestonesData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assetResponse.ok) {
|
||||||
|
const assetData = await assetResponse.json();
|
||||||
|
setCurrentAsset(assetData.asset);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show onboarding if needed
|
||||||
|
if (needsOnboarding) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title="Asset Tracker - Setup" />
|
||||||
|
<OnboardingFlow onComplete={handleOnboardingComplete} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="VWCE Tracker" />
|
<Head title="VWCE Tracker" />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\AssetController;
|
||||||
use App\Http\Controllers\Transactions\PurchaseController;
|
use App\Http\Controllers\Transactions\PurchaseController;
|
||||||
use App\Http\Controllers\Pricing\PricingController;
|
use App\Http\Controllers\Pricing\PricingController;
|
||||||
use App\Http\Controllers\Milestones\MilestoneController;
|
use App\Http\Controllers\Milestones\MilestoneController;
|
||||||
|
|
@ -14,6 +15,16 @@
|
||||||
return Inertia::render('dashboard');
|
return Inertia::render('dashboard');
|
||||||
})->name('dashboard');
|
})->name('dashboard');
|
||||||
|
|
||||||
|
// Asset routes
|
||||||
|
Route::prefix('assets')->name('assets.')->group(function () {
|
||||||
|
Route::get('/', [AssetController::class, 'index'])->name('index');
|
||||||
|
Route::post('/', [AssetController::class, 'store'])->name('store');
|
||||||
|
Route::get('/current', [AssetController::class, 'current'])->name('current');
|
||||||
|
Route::post('/set-current', [AssetController::class, 'setCurrent'])->name('set-current');
|
||||||
|
Route::get('/search', [AssetController::class, 'search'])->name('search');
|
||||||
|
Route::get('/{asset}', [AssetController::class, 'show'])->name('show');
|
||||||
|
});
|
||||||
|
|
||||||
// Purchase routes
|
// Purchase routes
|
||||||
Route::prefix('purchases')->name('purchases.')->group(function () {
|
Route::prefix('purchases')->name('purchases.')->group(function () {
|
||||||
Route::get('/', [PurchaseController::class, 'index'])->name('index');
|
Route::get('/', [PurchaseController::class, 'index'])->name('index');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue