Compare commits
10 commits
7c19684159
...
36678b4b57
| Author | SHA1 | Date | |
|---|---|---|---|
| 36678b4b57 | |||
| 476073bc00 | |||
| 16b579eceb | |||
| c388452942 | |||
| dd5f1c514e | |||
| c6a1681876 | |||
| 818e8b2276 | |||
| 0861cff8b4 | |||
| ed17529906 | |||
| ba6bb62e73 |
45 changed files with 723 additions and 867 deletions
|
|
@ -40,13 +40,6 @@ QUEUE_CONNECTION=database
|
||||||
CACHE_STORE=database
|
CACHE_STORE=database
|
||||||
# CACHE_PREFIX=
|
# CACHE_PREFIX=
|
||||||
|
|
||||||
MEMCACHED_HOST=127.0.0.1
|
|
||||||
|
|
||||||
REDIS_CLIENT=phpredis
|
|
||||||
REDIS_HOST=127.0.0.1
|
|
||||||
REDIS_PASSWORD=null
|
|
||||||
REDIS_PORT=6379
|
|
||||||
|
|
||||||
MAIL_MAILER=log
|
MAIL_MAILER=log
|
||||||
MAIL_SCHEME=null
|
MAIL_SCHEME=null
|
||||||
MAIL_HOST=127.0.0.1
|
MAIL_HOST=127.0.0.1
|
||||||
|
|
|
||||||
11
.env.testing
Normal file
11
.env.testing
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
APP_ENV=testing
|
||||||
|
APP_KEY=base64:+7T2RuonhTIij1yLp3rTOv2uQlYJh0TQulu20MlCA+s=
|
||||||
|
|
||||||
|
DB_CONNECTION=mysql
|
||||||
|
DB_HOST=db
|
||||||
|
DB_DATABASE=testing
|
||||||
|
DB_USERNAME=incr_user
|
||||||
|
DB_PASSWORD=incr_password
|
||||||
|
|
||||||
|
SESSION_DRIVER=array
|
||||||
|
CACHE_STORE=array
|
||||||
141
.forgejo/workflows/ci.yml
Normal file
141
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['release/*']
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: mysql:8.0
|
||||||
|
env:
|
||||||
|
MYSQL_DATABASE: testing
|
||||||
|
MYSQL_USER: incr_user
|
||||||
|
MYSQL_PASSWORD: incr_password
|
||||||
|
MYSQL_ROOT_PASSWORD: root_password
|
||||||
|
options: --health-cmd="mysqladmin ping -u root -proot_password" --health-interval=10s --health-timeout=5s --health-retries=10
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up PHP
|
||||||
|
uses: https://github.com/shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.3'
|
||||||
|
extensions: pdo_mysql, mbstring, xml, dom, bcmath, gd, exif, pcntl
|
||||||
|
coverage: pcov
|
||||||
|
|
||||||
|
- name: Cache Composer dependencies
|
||||||
|
uses: https://data.forgejo.org/actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.composer/cache
|
||||||
|
key: composer-${{ hashFiles('composer.lock') }}
|
||||||
|
restore-keys: composer-
|
||||||
|
|
||||||
|
- name: Install PHP dependencies
|
||||||
|
run: composer install --no-interaction --prefer-dist --no-progress
|
||||||
|
|
||||||
|
- name: Prepare environment
|
||||||
|
run: cp .env.testing .env
|
||||||
|
|
||||||
|
- name: Run migrations
|
||||||
|
run: php artisan migrate --force
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: vendor/bin/pint --test
|
||||||
|
|
||||||
|
- name: Tests
|
||||||
|
run: php artisan test --coverage-clover coverage.xml --coverage-text
|
||||||
|
|
||||||
|
- name: Parse coverage
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
id: coverage
|
||||||
|
run: |
|
||||||
|
COVERAGE=$(php -r '
|
||||||
|
$xml = simplexml_load_file("coverage.xml");
|
||||||
|
if ($xml === false || !isset($xml->project->metrics)) {
|
||||||
|
echo "0";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$metrics = $xml->project->metrics;
|
||||||
|
$statements = (int) $metrics["statements"];
|
||||||
|
$covered = (int) $metrics["coveredstatements"];
|
||||||
|
echo $statements > 0 ? round(($covered / $statements) * 100, 2) : 0;
|
||||||
|
')
|
||||||
|
echo "percentage=$COVERAGE" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Comment coverage on PR
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
env:
|
||||||
|
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
COVERAGE: ${{ steps.coverage.outputs.percentage }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
SERVER_URL: ${{ github.server_url }}
|
||||||
|
COMMIT_SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
API_URL="${SERVER_URL}/api/v1/repos/${REPO}/issues/${PR_NUMBER}/comments"
|
||||||
|
MARKER="<!-- incr-ci-coverage-report -->"
|
||||||
|
|
||||||
|
BODY="${MARKER}
|
||||||
|
## Code Coverage Report
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| **Line Coverage** | ${COVERAGE}% |
|
||||||
|
|
||||||
|
_Updated by CI — commit ${COMMIT_SHA}_"
|
||||||
|
|
||||||
|
EXISTING=$(curl -sf -H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||||
|
"${API_URL}?limit=50" | \
|
||||||
|
php -r '
|
||||||
|
$comments = json_decode(file_get_contents("php://stdin"), true);
|
||||||
|
if (!is_array($comments)) exit;
|
||||||
|
foreach ($comments as $c) {
|
||||||
|
if (str_contains($c["body"], "<!-- incr-ci-coverage-report -->")) {
|
||||||
|
echo $c["id"];
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
' || true)
|
||||||
|
|
||||||
|
if [ -n "$EXISTING" ]; then
|
||||||
|
curl -sf -X PATCH \
|
||||||
|
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(php -r 'echo json_encode(["body" => $argv[1]]);' "$BODY")" \
|
||||||
|
"${SERVER_URL}/api/v1/repos/${REPO}/issues/comments/${EXISTING}" > /dev/null
|
||||||
|
else
|
||||||
|
curl -sf -X POST \
|
||||||
|
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(php -r 'echo json_encode(["body" => $argv[1]]);' "$BODY")" \
|
||||||
|
"${API_URL}" > /dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
build:
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: https://data.forgejo.org/actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install JS dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build assets
|
||||||
|
run: npm run build
|
||||||
195
README.md
195
README.md
|
|
@ -1,157 +1,108 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
# 📈 incr
|
# incr
|
||||||
|
|
||||||
**A minimalist investment tracker for VWCE shares with milestone-driven progress**
|
**A minimalist counter with milestone-driven progress**
|
||||||
|
|
||||||
*Track your portfolio growth with visual progress indicators and milestone reinforcement*
|
*Track anything you accumulate — with visual progress and milestone reinforcement*
|
||||||
|
|
||||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||||
[](https://www.docker.com/)
|
|
||||||
[](https://laravel.com/)
|
[](https://laravel.com/)
|
||||||
[](https://reactjs.org/)
|
[](https://reactjs.org/)
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**[Introduction](#introduction) • [Features](#features) • [Tech Stack](#tech-stack) • [Getting Started](#getting-started) • [Development](#development) • [Contributing](#contributing) • [License](#license)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## Introduction
|
## About
|
||||||
|
|
||||||
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.
|
incr is a minimalist, self-hosted tracking app. Pick something you want to accumulate — shares, books, workouts, anything — log your progress, and watch milestones fall.
|
||||||
|
|
||||||
The application emphasizes simplicity and focus, providing just what you need to track your investment journey without overwhelming complexity.
|
It features a distinctive LED-style digital display, configurable milestones, and optional price tracking.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **LED-style display**: Large red digital counter showing current share count
|
- **LED-style display** — large red digital counter
|
||||||
- **Progress tracking**: Visual progress bar toward configurable milestones
|
- **Milestone tracking** — set targets, cycle through them as you progress
|
||||||
- **Purchase management**: Add and track share purchases with historical data
|
- **Purchase logging** — add entries with date and optional price
|
||||||
- **Financial insights**: Portfolio value and withdrawal estimates
|
- **Price tracking** — optional; log a current price to see portfolio value and P/L
|
||||||
- **Milestone cycling**: Track progress toward multiple investment goals (1500→3000→4500→6000)
|
- **Self-hosted** — your data stays on your server
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Backend**: Laravel 12 (PHP 8.2+) with MySQL database
|
- **Backend**: Laravel 12 (PHP 8.3+) with MySQL
|
||||||
- **Frontend**: React 19 + TypeScript with Inertia.js
|
- **Frontend**: React 19 + TypeScript with Inertia.js
|
||||||
- **Styling**: Tailwind CSS 4 with shadcn/ui components
|
- **Styling**: Tailwind CSS 4 with shadcn/ui components
|
||||||
- **Deployment**: Docker with multi-stage builds
|
- **Deployment**: Docker / Podman with multi-stage builds
|
||||||
|
|
||||||
## Getting Started
|
## Self-hosting
|
||||||
|
|
||||||
### Quick Start (Production)
|
```yaml
|
||||||
|
# docker-compose.yml
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: forge.lvl0.xyz/lvl0/incr:latest
|
||||||
|
container_name: incr-app
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- APP_ENV=production
|
||||||
|
- APP_DEBUG=false
|
||||||
|
- APP_KEY=base64:YOUR_APP_KEY_HERE
|
||||||
|
- DB_CONNECTION=mysql
|
||||||
|
- DB_HOST=db
|
||||||
|
- DB_PORT=3306
|
||||||
|
- DB_DATABASE=incr
|
||||||
|
- DB_USERNAME=incr_user
|
||||||
|
- DB_PASSWORD=change_me
|
||||||
|
ports:
|
||||||
|
- "5001:80"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- incr-network
|
||||||
|
|
||||||
#### Docker
|
db:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: incr-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- MYSQL_DATABASE=incr
|
||||||
|
- MYSQL_USER=incr_user
|
||||||
|
- MYSQL_PASSWORD=change_me
|
||||||
|
- MYSQL_ROOT_PASSWORD=change_me_root
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "incr_user", "-pchange_me"]
|
||||||
|
timeout: 10s
|
||||||
|
retries: 10
|
||||||
|
interval: 10s
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- incr-network
|
||||||
|
|
||||||
Clone the repository and run with Docker Compose:
|
networks:
|
||||||
|
incr-network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
```bash
|
volumes:
|
||||||
git clone https://github.com/your-username/incr.git
|
db_data:
|
||||||
cd incr
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the application using the provided docker-compose configuration:
|
Generate an app key with: `php artisan key:generate --show`
|
||||||
|
|
||||||
|
The app will be available at `http://localhost:5001`.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Requires [Nix](https://nixos.org/download/). Enter the dev shell:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Using Docker Compose
|
nix-shell
|
||||||
docker-compose -f docker/production/docker-compose.yml up --build
|
dev-up
|
||||||
|
|
||||||
# Or using Podman Compose
|
|
||||||
podman-compose -f docker/production/docker-compose.yml up --build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The application will be available at `http://localhost:5001`.
|
Available commands inside the shell: `dev-up`, `dev-down`, `dev-rebuild`, `dev-logs`, `dev-shell`, `dev-artisan`, `dev-composer`.
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
#### Local Development Setup
|
|
||||||
|
|
||||||
**Option 1: Laravel Sail (Docker)**
|
|
||||||
|
|
||||||
For local development with Laravel Sail:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install Laravel Sail
|
|
||||||
composer install
|
|
||||||
sail artisan sail:install
|
|
||||||
|
|
||||||
# Start development environment
|
|
||||||
sail up -d
|
|
||||||
|
|
||||||
# Install frontend dependencies and build assets
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# Run migrations
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
- `app/` - Laravel backend (controllers, models, services)
|
|
||||||
- `resources/js/` - React frontend components and pages
|
|
||||||
- `docker/production/` - Production Docker configuration
|
|
||||||
- `docker/dev/podman/` - Development Podman configuration
|
|
||||||
- `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
|
||||||
|
|
||||||
This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details.
|
GNU General Public License v3.0 — see [LICENSE](LICENSE).
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Asset;
|
use App\Models\Asset;
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
@ -19,11 +20,12 @@ public function index(): JsonResponse
|
||||||
public function current(): JsonResponse
|
public function current(): JsonResponse
|
||||||
{
|
{
|
||||||
// Get the first/default user (since no auth)
|
// Get the first/default user (since no auth)
|
||||||
$user = \App\Models\User::first();
|
$user = User::first();
|
||||||
$asset = $user ? $user->asset : null;
|
$asset = $user ? $user->asset : null;
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'asset' => $asset,
|
'asset' => $asset,
|
||||||
|
'price_tracking_enabled' => $user?->price_tracking_enabled ?? false,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -40,11 +42,11 @@ public function setCurrent(Request $request)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get or create the first/default user (since no auth)
|
// Get or create the first/default user (since no auth)
|
||||||
$user = \App\Models\User::first();
|
$user = User::first();
|
||||||
|
|
||||||
if (!$user) {
|
if (! $user) {
|
||||||
// Create a default user if none exists
|
// Create a default user if none exists
|
||||||
$user = \App\Models\User::create([
|
$user = User::create([
|
||||||
'name' => 'Default User',
|
'name' => 'Default User',
|
||||||
'email' => 'user@example.com',
|
'email' => 'user@example.com',
|
||||||
'password' => 'password', // This will be hashed automatically
|
'password' => 'password', // This will be hashed automatically
|
||||||
|
|
@ -90,8 +92,8 @@ public function show(Asset $asset): JsonResponse
|
||||||
public function search(Request $request): JsonResponse
|
public function search(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$query = $request->get('q');
|
$query = $request->get('q');
|
||||||
|
|
||||||
if (!$query) {
|
if (! $query) {
|
||||||
return response()->json([]);
|
return response()->json([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,4 +105,4 @@ public function search(Request $request): JsonResponse
|
||||||
|
|
||||||
return response()->json($assets);
|
return response()->json($assets);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ public function create(Request $request): Response
|
||||||
/**
|
/**
|
||||||
* Handle an incoming new password request.
|
* Handle an incoming new password request.
|
||||||
*
|
*
|
||||||
* @throws \Illuminate\Validation\ValidationException
|
* @throws ValidationException
|
||||||
*/
|
*/
|
||||||
public function store(Request $request): RedirectResponse
|
public function store(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Password;
|
use Illuminate\Support\Facades\Password;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
|
|
@ -24,7 +25,7 @@ public function create(Request $request): Response
|
||||||
/**
|
/**
|
||||||
* Handle an incoming password reset link request.
|
* Handle an incoming password reset link request.
|
||||||
*
|
*
|
||||||
* @throws \Illuminate\Validation\ValidationException
|
* @throws ValidationException
|
||||||
*/
|
*/
|
||||||
public function store(Request $request): RedirectResponse
|
public function store(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Validation\Rules;
|
use Illuminate\Validation\Rules;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
|
|
@ -26,7 +27,7 @@ public function create(): Response
|
||||||
/**
|
/**
|
||||||
* Handle an incoming registration request.
|
* Handle an incoming registration request.
|
||||||
*
|
*
|
||||||
* @throws \Illuminate\Validation\ValidationException
|
* @throws ValidationException
|
||||||
*/
|
*/
|
||||||
public function store(Request $request): RedirectResponse
|
public function store(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Auth\Events\Verified;
|
use Illuminate\Auth\Events\Verified;
|
||||||
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
|
||||||
|
|
@ -19,7 +20,7 @@ public function __invoke(EmailVerificationRequest $request): RedirectResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->user()->markEmailAsVerified()) {
|
if ($request->user()->markEmailAsVerified()) {
|
||||||
/** @var \Illuminate\Contracts\Auth\MustVerifyEmail $user */
|
/** @var MustVerifyEmail $user */
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|
||||||
event(new Verified($user));
|
event(new Verified($user));
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Milestone;
|
use App\Models\Milestone;
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class MilestoneController extends Controller
|
class MilestoneController extends Controller
|
||||||
{
|
{
|
||||||
|
|
@ -31,4 +31,4 @@ public function index(): JsonResponse
|
||||||
|
|
||||||
return response()->json($milestones);
|
return response()->json($milestones);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Pricing\AssetPrice;
|
use App\Models\Pricing\AssetPrice;
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
|
@ -12,9 +13,9 @@ class PricingController extends Controller
|
||||||
public function current(): JsonResponse
|
public function current(): JsonResponse
|
||||||
{
|
{
|
||||||
// Get the first/default user (since no auth)
|
// Get the first/default user (since no auth)
|
||||||
$user = \App\Models\User::first();
|
$user = User::first();
|
||||||
$assetId = $user ? $user->asset_id : null;
|
$assetId = $user ? $user->asset_id : null;
|
||||||
|
|
||||||
$price = AssetPrice::current($assetId);
|
$price = AssetPrice::current($assetId);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|
@ -30,13 +31,17 @@ public function update(Request $request)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Get the first/default user (since no auth)
|
// Get the first/default user (since no auth)
|
||||||
$user = \App\Models\User::first();
|
$user = User::first();
|
||||||
|
|
||||||
if (!$user || !$user->asset_id) {
|
if (! $user || ! $user->asset_id) {
|
||||||
return back()->withErrors(['asset' => 'Please set an asset first.']);
|
return back()->withErrors(['asset' => 'Please set an asset first.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$assetPrice = AssetPrice::updatePrice($user->asset_id, $validated['date'], $validated['price']);
|
AssetPrice::updatePrice($user->asset_id, $validated['date'], $validated['price']);
|
||||||
|
|
||||||
|
if (! $user->price_tracking_enabled) {
|
||||||
|
$user->update(['price_tracking_enabled' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
return back()->with('success', 'Asset price updated successfully!');
|
return back()->with('success', 'Asset price updated successfully!');
|
||||||
}
|
}
|
||||||
|
|
@ -44,9 +49,9 @@ public function update(Request $request)
|
||||||
public function history(Request $request): JsonResponse
|
public function history(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
// Get the first/default user (since no auth)
|
// Get the first/default user (since no auth)
|
||||||
$user = \App\Models\User::first();
|
$user = User::first();
|
||||||
$assetId = $user ? $user->asset_id : null;
|
$assetId = $user ? $user->asset_id : null;
|
||||||
|
|
||||||
$limit = $request->get('limit', 30);
|
$limit = $request->get('limit', 30);
|
||||||
$history = AssetPrice::history($assetId, $limit);
|
$history = AssetPrice::history($assetId, $limit);
|
||||||
|
|
||||||
|
|
@ -56,9 +61,9 @@ public function history(Request $request): JsonResponse
|
||||||
public function forDate(Request $request, string $date): JsonResponse
|
public function forDate(Request $request, string $date): JsonResponse
|
||||||
{
|
{
|
||||||
// Get the first/default user (since no auth)
|
// Get the first/default user (since no auth)
|
||||||
$user = \App\Models\User::first();
|
$user = User::first();
|
||||||
$assetId = $user ? $user->asset_id : null;
|
$assetId = $user ? $user->asset_id : null;
|
||||||
|
|
||||||
$price = AssetPrice::forDate($date, $assetId);
|
$price = AssetPrice::forDate($date, $assetId);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
use App\Models\Transactions\Purchase;
|
use App\Models\Transactions\Purchase;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Redirect;
|
|
||||||
|
|
||||||
class PurchaseController extends Controller
|
class PurchaseController extends Controller
|
||||||
{
|
{
|
||||||
|
|
@ -30,7 +29,7 @@ public function store(Request $request)
|
||||||
$calculatedTotal = $validated['shares'] * $validated['price_per_share'];
|
$calculatedTotal = $validated['shares'] * $validated['price_per_share'];
|
||||||
if (abs($calculatedTotal - $validated['total_cost']) > 0.01) {
|
if (abs($calculatedTotal - $validated['total_cost']) > 0.01) {
|
||||||
return back()->withErrors([
|
return back()->withErrors([
|
||||||
'total_cost' => 'Total cost does not match shares × price per share.'
|
'total_cost' => 'Total cost does not match shares × price per share.',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ class HandleAppearance
|
||||||
/**
|
/**
|
||||||
* Handle an incoming request.
|
* Handle an incoming request.
|
||||||
*
|
*
|
||||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
* @param Closure(Request): (Response) $next
|
||||||
*/
|
*/
|
||||||
public function handle(Request $request, Closure $next): Response
|
public function handle(Request $request, Closure $next): Response
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
namespace App\Http\Requests\Auth;
|
namespace App\Http\Requests\Auth;
|
||||||
|
|
||||||
use Illuminate\Auth\Events\Lockout;
|
use Illuminate\Auth\Events\Lockout;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
|
@ -22,7 +23,7 @@ public function authorize(): bool
|
||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get the validation rules that apply to the request.
|
||||||
*
|
*
|
||||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
|
|
@ -35,7 +36,7 @@ public function rules(): array
|
||||||
/**
|
/**
|
||||||
* Attempt to authenticate the request's credentials.
|
* Attempt to authenticate the request's credentials.
|
||||||
*
|
*
|
||||||
* @throws \Illuminate\Validation\ValidationException
|
* @throws ValidationException
|
||||||
*/
|
*/
|
||||||
public function authenticate(): void
|
public function authenticate(): void
|
||||||
{
|
{
|
||||||
|
|
@ -55,7 +56,7 @@ public function authenticate(): void
|
||||||
/**
|
/**
|
||||||
* Ensure the login request is not rate limited.
|
* Ensure the login request is not rate limited.
|
||||||
*
|
*
|
||||||
* @throws \Illuminate\Validation\ValidationException
|
* @throws ValidationException
|
||||||
*/
|
*/
|
||||||
public function ensureIsNotRateLimited(): void
|
public function ensureIsNotRateLimited(): void
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
* @method static where(string $string, string $value)
|
* @method static where(string $string, string $value)
|
||||||
* @method static find(int $id)
|
* @method static find(int $id)
|
||||||
* @method static orderBy(string $string)
|
* @method static orderBy(string $string)
|
||||||
|
*
|
||||||
* @property int $id
|
* @property int $id
|
||||||
* @property string $symbol
|
* @property string $symbol
|
||||||
* @property string|null $full_name
|
* @property string|null $full_name
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Models\Pricing;
|
namespace App\Models\Pricing;
|
||||||
|
|
||||||
|
use App\Models\Asset;
|
||||||
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;
|
||||||
|
|
@ -13,6 +14,7 @@
|
||||||
* @method static where(string $string, string $string1, string $date)
|
* @method static where(string $string, string $string1, string $date)
|
||||||
* @method static updateOrCreate(string[] $array, float[] $array1)
|
* @method static updateOrCreate(string[] $array, float[] $array1)
|
||||||
* @method static orderBy(string $string, string $string1)
|
* @method static orderBy(string $string, string $string1)
|
||||||
|
*
|
||||||
* @property Carbon $date
|
* @property Carbon $date
|
||||||
* @property float $price
|
* @property float $price
|
||||||
*/
|
*/
|
||||||
|
|
@ -33,13 +35,13 @@ class AssetPrice extends Model
|
||||||
|
|
||||||
public function asset(): BelongsTo
|
public function asset(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(\App\Models\Asset::class);
|
return $this->belongsTo(Asset::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function current(int $assetId = null): ?float
|
public static function current(?int $assetId = null): ?float
|
||||||
{
|
{
|
||||||
$query = static::latest('date');
|
$query = static::latest('date');
|
||||||
|
|
||||||
if ($assetId) {
|
if ($assetId) {
|
||||||
$query->where('asset_id', $assetId);
|
$query->where('asset_id', $assetId);
|
||||||
}
|
}
|
||||||
|
|
@ -49,11 +51,11 @@ public static function current(int $assetId = null): ?float
|
||||||
return $latestPrice ? $latestPrice->price : null;
|
return $latestPrice ? $latestPrice->price : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function forDate(string $date, int $assetId = null): ?float
|
public static function forDate(string $date, ?int $assetId = null): ?float
|
||||||
{
|
{
|
||||||
$query = static::where('date', '<=', $date)
|
$query = static::where('date', '<=', $date)
|
||||||
->orderBy('date', 'desc');
|
->orderBy('date', 'desc');
|
||||||
|
|
||||||
if ($assetId) {
|
if ($assetId) {
|
||||||
$query->where('asset_id', $assetId);
|
$query->where('asset_id', $assetId);
|
||||||
}
|
}
|
||||||
|
|
@ -71,10 +73,10 @@ public static function updatePrice(int $assetId, string $date, float $price): se
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function history(int $assetId = null, int $limit = 30): Collection
|
public static function history(?int $assetId = null, int $limit = 30): Collection
|
||||||
{
|
{
|
||||||
$query = static::orderBy('date', 'desc')->limit($limit);
|
$query = static::orderBy('date', 'desc')->limit($limit);
|
||||||
|
|
||||||
if ($assetId) {
|
if ($assetId) {
|
||||||
$query->where('asset_id', $assetId);
|
$query->where('asset_id', $assetId);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ public static function averageCostPerShare(): float
|
||||||
{
|
{
|
||||||
$totalShares = static::totalShares();
|
$totalShares = static::totalShares();
|
||||||
$totalCost = static::totalInvestment();
|
$totalCost = static::totalInvestment();
|
||||||
|
|
||||||
return $totalShares > 0 ? $totalCost / $totalShares : 0;
|
return $totalShares > 0 ? $totalCost / $totalShares : 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,19 @@
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use App\Models\Transactions\Purchase;
|
||||||
|
use Database\Factories\UserFactory;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
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
|
* @property int|null $asset_id
|
||||||
*/
|
*/
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasFactory, Notifiable;
|
use HasFactory, Notifiable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -26,6 +28,7 @@ class User extends Authenticatable
|
||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
'asset_id',
|
'asset_id',
|
||||||
|
'price_tracking_enabled',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -43,6 +46,7 @@ protected function casts(): array
|
||||||
return [
|
return [
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
|
'price_tracking_enabled' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,19 +57,16 @@ public function asset(): BelongsTo
|
||||||
|
|
||||||
public function hasCompletedOnboarding(): bool
|
public function hasCompletedOnboarding(): bool
|
||||||
{
|
{
|
||||||
// Check if user has asset, purchases, and milestones
|
return $this->hasPurchases() && $this->hasMilestones();
|
||||||
return $this->asset_id !== null
|
|
||||||
&& $this->hasPurchases()
|
|
||||||
&& $this->hasMilestones();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function hasPurchases(): bool
|
public function hasPurchases(): bool
|
||||||
{
|
{
|
||||||
return \App\Models\Transactions\Purchase::totalShares() > 0;
|
return Purchase::totalShares() > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function hasMilestones(): bool
|
public function hasMilestones(): bool
|
||||||
{
|
{
|
||||||
return \App\Models\Milestone::count() > 0;
|
return Milestone::count() > 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Providers\AppServiceProvider;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
App\Providers\AppServiceProvider::class,
|
AppServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
@ -62,7 +64,7 @@
|
||||||
'providers' => [
|
'providers' => [
|
||||||
'users' => [
|
'users' => [
|
||||||
'driver' => 'eloquent',
|
'driver' => 'eloquent',
|
||||||
'model' => env('AUTH_MODEL', App\Models\User::class),
|
'model' => env('AUTH_MODEL', User::class),
|
||||||
],
|
],
|
||||||
|
|
||||||
// 'users' => [
|
// 'users' => [
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,13 @@
|
||||||
|
|
||||||
namespace Database\Factories;
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
|
* @extends Factory<User>
|
||||||
*/
|
*/
|
||||||
class UserFactory extends Factory
|
class UserFactory extends Factory
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ public function up(): void
|
||||||
$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->foreignId('asset_id')->nullable()->constrained()->onDelete('set null');
|
||||||
|
$table->boolean('price_tracking_enabled')->default(false);
|
||||||
$table->rememberToken();
|
$table->rememberToken();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
FROM docker.io/library/php:8.2-fpm
|
FROM docker.io/library/php:8.3-fpm
|
||||||
|
|
||||||
# Install system dependencies
|
# Install system dependencies
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
|
|
@ -9,39 +9,21 @@ RUN apt-get update && apt-get install -y \
|
||||||
libxml2-dev \
|
libxml2-dev \
|
||||||
zip \
|
zip \
|
||||||
unzip \
|
unzip \
|
||||||
nodejs \
|
|
||||||
npm \
|
|
||||||
default-mysql-client \
|
default-mysql-client \
|
||||||
&& docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd
|
&& docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd
|
||||||
|
|
||||||
|
# Install Node.js 20.x via nodesource
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||||
|
&& apt-get install -y nodejs
|
||||||
|
|
||||||
# Install Composer
|
# Install Composer
|
||||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
# Set working directory
|
# Set working directory
|
||||||
WORKDIR /var/www/html
|
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 and set up container start script
|
||||||
COPY docker/dev/podman/container-start.sh /usr/local/bin/container-start.sh
|
COPY docker/dev/container-start.sh /usr/local/bin/container-start.sh
|
||||||
RUN chmod +x /usr/local/bin/container-start.sh
|
RUN chmod +x /usr/local/bin/container-start.sh
|
||||||
|
|
||||||
EXPOSE 8000 5173
|
EXPOSE 8000 5173
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
# Create .env file if it doesn't exist
|
# Create .env file if it doesn't exist
|
||||||
if [ ! -f /var/www/html/.env ]; then
|
if [ ! -f /var/www/html/.env ]; then
|
||||||
|
|
@ -6,15 +7,19 @@ if [ ! -f /var/www/html/.env ]; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Fix database name to match compose file
|
# Fix database name to match compose file
|
||||||
sed -i 's/DB_DATABASE=incr$/DB_DATABASE=incr_dev/' /var/www/html/.env
|
sed -i 's|^DB_DATABASE=.*|DB_DATABASE=incr_dev|' /var/www/html/.env
|
||||||
|
|
||||||
# Generate app key if not set or empty
|
# Generate app key if not set or empty
|
||||||
if ! grep -q "APP_KEY=base64:" /var/www/html/.env; then
|
if ! grep -q "APP_KEY=base64:" /var/www/html/.env; then
|
||||||
# Generate a new key and set it directly
|
# Generate a new key and set it directly
|
||||||
NEW_KEY=$(php -r "echo 'base64:' . base64_encode(random_bytes(32));")
|
NEW_KEY=$(php -r "echo 'base64:' . base64_encode(random_bytes(32));")
|
||||||
sed -i "s/APP_KEY=/APP_KEY=$NEW_KEY/" /var/www/html/.env
|
sed -i "s|^APP_KEY=.*|APP_KEY=$NEW_KEY|" /var/www/html/.env
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Install dependencies if needed
|
||||||
|
[ ! -f vendor/autoload.php ] && composer install --no-interaction
|
||||||
|
[ ! -d node_modules/.bin ] && npm install
|
||||||
|
|
||||||
# Run migrations
|
# Run migrations
|
||||||
php artisan migrate --force
|
php artisan migrate --force
|
||||||
|
|
||||||
|
|
@ -1,28 +1,16 @@
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
context: ../../..
|
context: ../..
|
||||||
dockerfile: docker/dev/podman/Dockerfile
|
dockerfile: docker/dev/Dockerfile
|
||||||
container_name: incr-dev-app
|
container_name: incr-dev-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
working_dir: /var/www/html
|
working_dir: /var/www/html
|
||||||
environment:
|
environment:
|
||||||
- APP_ENV=local
|
|
||||||
- APP_DEBUG=true
|
|
||||||
- APP_KEY=base64:YOUR_APP_KEY_HERE
|
|
||||||
- DB_CONNECTION=mysql
|
|
||||||
- DB_HOST=db
|
- DB_HOST=db
|
||||||
- DB_PORT=3306
|
|
||||||
- DB_DATABASE=incr_dev
|
|
||||||
- DB_USERNAME=incr_user
|
|
||||||
- DB_PASSWORD=incr_password
|
|
||||||
- VITE_PORT=5173
|
|
||||||
volumes:
|
volumes:
|
||||||
- ../../../:/var/www/html:Z
|
- ../../:/var/www/html:Z
|
||||||
- /var/www/html/node_modules
|
- app_node_modules:/var/www/html/node_modules
|
||||||
- /var/www/html/vendor
|
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
- "5173:5173"
|
- "5173:5173"
|
||||||
|
|
@ -43,6 +31,7 @@ services:
|
||||||
- MYSQL_ROOT_PASSWORD=root_password
|
- MYSQL_ROOT_PASSWORD=root_password
|
||||||
volumes:
|
volumes:
|
||||||
- db_data:/var/lib/mysql
|
- db_data:/var/lib/mysql
|
||||||
|
- ./mysql-init:/docker-entrypoint-initdb.d:ro
|
||||||
ports:
|
ports:
|
||||||
- "3307:3306"
|
- "3307:3306"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
@ -54,19 +43,12 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- incr-dev-network
|
- 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:
|
networks:
|
||||||
incr-dev-network:
|
incr-dev-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db_data:
|
db_data:
|
||||||
driver: local
|
driver: local
|
||||||
|
app_node_modules:
|
||||||
|
driver: local
|
||||||
2
docker/dev/mysql-init/create-testing-db.sql
Normal file
2
docker/dev/mysql-init/create-testing-db.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
CREATE DATABASE IF NOT EXISTS `testing` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
GRANT ALL PRIVILEGES ON `testing`.* TO 'incr_user'@'%';
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
# Podman aliases for Laravel Sail compatibility
|
# Podman aliases for Laravel Sail compatibility
|
||||||
# Source this file to use Sail commands with Podman
|
# Source this file to use Sail commands with Podman
|
||||||
# Usage: source docker/dev/podman/podman-sail-alias.sh
|
# Usage: source docker/dev/podman-sail-alias.sh
|
||||||
|
|
||||||
# Create docker alias pointing to podman
|
# Create docker alias pointing to podman
|
||||||
alias docker='podman'
|
alias docker='podman'
|
||||||
|
|
@ -12,10 +12,10 @@ alias docker-compose='podman-compose'
|
||||||
|
|
||||||
# Sail wrapper function that uses podman-compose
|
# Sail wrapper function that uses podman-compose
|
||||||
sail() {
|
sail() {
|
||||||
if [[ -f docker/dev/podman/docker-compose.yml ]]; then
|
if [[ -f docker/dev/docker-compose.yml ]]; then
|
||||||
podman-compose -f docker/dev/podman/docker-compose.yml "$@"
|
podman-compose -f docker/dev/docker-compose.yml "$@"
|
||||||
else
|
else
|
||||||
echo "❌ Podman compose file not found at docker/dev/podman/docker-compose.yml"
|
echo "❌ Podman compose file not found at docker/dev/docker-compose.yml"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
@ -22,7 +22,7 @@ fi
|
||||||
|
|
||||||
# Start services
|
# Start services
|
||||||
echo "🔧 Starting services..."
|
echo "🔧 Starting services..."
|
||||||
podman-compose -f docker/dev/podman/docker-compose.yml up -d
|
podman-compose -f docker/dev/docker-compose.yml up -d
|
||||||
|
|
||||||
# Wait for database to be ready
|
# Wait for database to be ready
|
||||||
echo "⏳ Waiting for database to be ready..."
|
echo "⏳ Waiting for database to be ready..."
|
||||||
|
|
@ -48,5 +48,5 @@ echo "🌐 Application: http://localhost:8000"
|
||||||
echo "🔥 Vite dev server: http://localhost:5173"
|
echo "🔥 Vite dev server: http://localhost:5173"
|
||||||
echo "💾 Database: localhost:3307"
|
echo "💾 Database: localhost:3307"
|
||||||
echo ""
|
echo ""
|
||||||
echo "To stop: podman-compose -f docker/dev/podman/docker-compose.yml down"
|
echo "To stop: podman-compose -f docker/dev/docker-compose.yml down"
|
||||||
echo "To view logs: podman-compose -f docker/dev/podman/docker-compose.yml logs -f"
|
echo "To view logs: podman-compose -f docker/dev/docker-compose.yml logs -f"
|
||||||
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "site",
|
"name": "html",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
|
|
|
||||||
20
phpunit.xml
20
phpunit.xml
|
|
@ -18,15 +18,15 @@
|
||||||
</include>
|
</include>
|
||||||
</source>
|
</source>
|
||||||
<php>
|
<php>
|
||||||
<env name="APP_ENV" value="testing"/>
|
<env name="APP_ENV" value="testing" force="true"/>
|
||||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file" force="true"/>
|
||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4" force="true"/>
|
||||||
<env name="CACHE_STORE" value="array"/>
|
<env name="CACHE_STORE" value="array" force="true"/>
|
||||||
<env name="DB_DATABASE" value="testing"/>
|
<env name="DB_DATABASE" value="testing" force="true"/>
|
||||||
<env name="MAIL_MAILER" value="array"/>
|
<env name="MAIL_MAILER" value="array" force="true"/>
|
||||||
<env name="PULSE_ENABLED" value="false"/>
|
<env name="PULSE_ENABLED" value="false" force="true"/>
|
||||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
|
||||||
<env name="SESSION_DRIVER" value="array"/>
|
<env name="SESSION_DRIVER" value="array" force="true"/>
|
||||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
<env name="TELESCOPE_ENABLED" value="false" force="true"/>
|
||||||
</php>
|
</php>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ interface StatsBoxProps {
|
||||||
onAddPurchase?: () => void;
|
onAddPurchase?: () => void;
|
||||||
onAddMilestone?: () => void;
|
onAddMilestone?: () => void;
|
||||||
onUpdatePrice?: () => void;
|
onUpdatePrice?: () => void;
|
||||||
|
assetSymbol?: string;
|
||||||
|
priceTrackingEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StatsBox({
|
export default function StatsBox({
|
||||||
|
|
@ -36,7 +38,9 @@ export default function StatsBox({
|
||||||
className,
|
className,
|
||||||
onAddPurchase,
|
onAddPurchase,
|
||||||
onAddMilestone,
|
onAddMilestone,
|
||||||
onUpdatePrice
|
onUpdatePrice,
|
||||||
|
assetSymbol,
|
||||||
|
priceTrackingEnabled = false,
|
||||||
}: StatsBoxProps) {
|
}: StatsBoxProps) {
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
|
||||||
|
|
@ -76,9 +80,9 @@ export default function StatsBox({
|
||||||
<ComponentTitle>Stats</ComponentTitle>
|
<ComponentTitle>Stats</ComponentTitle>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 relative">
|
<div className="flex items-center space-x-2 relative">
|
||||||
{stats.currentPrice && (
|
{priceTrackingEnabled && stats.currentPrice && (
|
||||||
<div className="text-red-500 text-sm font-mono tracking-wider">
|
<div className="text-red-500 text-sm font-mono tracking-wider">
|
||||||
VWCE: {formatCurrencyDetailed(stats.currentPrice)}
|
{assetSymbol ?? 'PRICE'}: {formatCurrencyDetailed(stats.currentPrice)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -117,7 +121,7 @@ export default function StatsBox({
|
||||||
ADD MILESTONE
|
ADD MILESTONE
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onUpdatePrice && (
|
{priceTrackingEnabled && onUpdatePrice && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onUpdatePrice();
|
onUpdatePrice();
|
||||||
|
|
@ -154,8 +158,8 @@ export default function StatsBox({
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left text-red-500 text-xs py-2">DESCRIPTION</th>
|
<th className="text-left text-red-500 text-xs py-2">DESCRIPTION</th>
|
||||||
<th className="text-right text-red-500 text-xs py-2">SHARES</th>
|
<th className="text-right text-red-500 text-xs py-2">SHARES</th>
|
||||||
<th className="text-right text-red-500 text-xs py-2 pr-4">SWR 3%</th>
|
{priceTrackingEnabled && <th className="text-right text-red-500 text-xs py-2 pr-4">SWR 3%</th>}
|
||||||
<th className="text-right text-red-500 text-xs py-2">SWR 4%</th>
|
{priceTrackingEnabled && <th className="text-right text-red-500 text-xs py-2">SWR 4%</th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -165,12 +169,16 @@ export default function StatsBox({
|
||||||
<td className="text-right py-1 pr-4">
|
<td className="text-right py-1 pr-4">
|
||||||
{Math.floor(stats.totalShares).toLocaleString()}
|
{Math.floor(stats.totalShares).toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-right py-1 pr-4">
|
{priceTrackingEnabled && (
|
||||||
{stats.currentPrice ? formatCurrency(stats.totalShares * stats.currentPrice * 0.03) : 'N/A'}
|
<td className="text-right py-1 pr-4">
|
||||||
</td>
|
{stats.currentPrice ? formatCurrency(stats.totalShares * stats.currentPrice * 0.03) : 'N/A'}
|
||||||
<td className="text-right py-1">
|
</td>
|
||||||
{stats.currentPrice ? formatCurrency(stats.totalShares * stats.currentPrice * 0.04) : 'N/A'}
|
)}
|
||||||
</td>
|
{priceTrackingEnabled && (
|
||||||
|
<td className="text-right py-1">
|
||||||
|
{stats.currentPrice ? formatCurrency(stats.totalShares * stats.currentPrice * 0.04) : 'N/A'}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{/* Render milestones after current */}
|
{/* Render milestones after current */}
|
||||||
|
|
@ -195,12 +203,16 @@ export default function StatsBox({
|
||||||
<td className="text-right py-1 pr-4">
|
<td className="text-right py-1 pr-4">
|
||||||
{Math.floor(milestone.target).toLocaleString()}
|
{Math.floor(milestone.target).toLocaleString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-right py-1 pr-4">
|
{priceTrackingEnabled && (
|
||||||
{stats.currentPrice ? formatCurrency(swr3) : 'N/A'}
|
<td className="text-right py-1 pr-4">
|
||||||
</td>
|
{stats.currentPrice ? formatCurrency(swr3) : 'N/A'}
|
||||||
<td className="text-right py-1">
|
</td>
|
||||||
{stats.currentPrice ? formatCurrency(swr4) : 'N/A'}
|
)}
|
||||||
</td>
|
{priceTrackingEnabled && (
|
||||||
|
<td className="text-right py-1">
|
||||||
|
{stats.currentPrice ? formatCurrency(swr4) : 'N/A'}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,85 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import AssetSetupForm from '@/components/Assets/AssetSetupForm';
|
import AssetSetupForm from '@/components/Assets/AssetSetupForm';
|
||||||
import AddPurchaseForm from '@/components/Transactions/AddPurchaseForm';
|
import AddPurchaseForm from '@/components/Transactions/AddPurchaseForm';
|
||||||
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
|
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
|
||||||
import UpdatePriceForm from '@/components/Pricing/UpdatePriceForm';
|
import UpdatePriceForm from '@/components/Pricing/UpdatePriceForm';
|
||||||
|
|
||||||
|
type TrackerType = 'simple' | 'asset';
|
||||||
|
|
||||||
|
function TrackerTypeSelector({ onSelect }: { onSelect: (type: TrackerType) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<p className="text-red-400 font-mono text-sm uppercase tracking-wider">
|
||||||
|
[SELECT] What do you want to track?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<button
|
||||||
|
onClick={() => onSelect('simple')}
|
||||||
|
className="text-left border-2 border-red-500/50 bg-black p-6 hover:bg-red-950/30 hover:border-red-400 hover:shadow-[0_0_20px_rgba(239,68,68,0.3)] transition-all"
|
||||||
|
>
|
||||||
|
<span className="block text-red-400 font-mono text-lg font-bold uppercase tracking-wider mb-3">
|
||||||
|
[01] Simple counter
|
||||||
|
</span>
|
||||||
|
<p className="text-red-400/60 font-mono text-xs">
|
||||||
|
Track anything you accumulate — no price tracking, no asset setup.
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onSelect('asset')}
|
||||||
|
className="text-left border-2 border-red-500/50 bg-black p-6 hover:bg-red-950/30 hover:border-red-400 hover:shadow-[0_0_20px_rgba(239,68,68,0.3)] transition-all"
|
||||||
|
>
|
||||||
|
<span className="block text-red-400 font-mono text-lg font-bold uppercase tracking-wider mb-3">
|
||||||
|
[02] Asset tracker
|
||||||
|
</span>
|
||||||
|
<p className="text-red-400/60 font-mono text-xs">
|
||||||
|
Track holdings with price tracking and P&L.
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PriceTrackingStep({ onEnable, onSkip }: { onEnable: () => void; onSkip?: () => void }) {
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer group">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={e => setEnabled(e.target.checked)}
|
||||||
|
className="w-4 h-4 accent-red-500"
|
||||||
|
/>
|
||||||
|
<span className="text-red-400 font-mono text-sm uppercase tracking-wider group-hover:text-red-300">
|
||||||
|
Enable price tracking (optional)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-red-400/60 font-mono text-xs">
|
||||||
|
Track the current market price of your asset to see portfolio value and P&L. You can enable this later in settings.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{enabled && (
|
||||||
|
<div className="border border-red-500/30 p-4">
|
||||||
|
<UpdatePriceForm onSuccess={onEnable} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!enabled && (
|
||||||
|
<button
|
||||||
|
onClick={onSkip}
|
||||||
|
className="w-full py-2 font-mono text-xs uppercase tracking-wider border border-red-500/50 text-red-400 hover:bg-red-950/30 hover:text-red-300 transition-colors"
|
||||||
|
>
|
||||||
|
Skip and finish
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface OnboardingStep {
|
interface OnboardingStep {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -12,121 +88,81 @@ interface OnboardingStep {
|
||||||
required: boolean;
|
required: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ASSET_STEPS: 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 goals', completed: false, required: true },
|
||||||
|
{ id: 'price', title: 'CURRENT PRICE', description: 'Set current asset price (optional)', completed: false, required: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SIMPLE_STEPS: OnboardingStep[] = [
|
||||||
|
{ id: 'purchases', title: 'STARTING AMOUNT', description: 'Enter your starting amount', completed: false, required: true },
|
||||||
|
{ id: 'milestones', title: 'SET MILESTONES', description: 'Define your goals', completed: false, required: true },
|
||||||
|
];
|
||||||
|
|
||||||
interface OnboardingFlowProps {
|
interface OnboardingFlowProps {
|
||||||
onComplete?: () => void;
|
onComplete?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
||||||
|
const [trackerType, setTrackerType] = useState<TrackerType | null>(null);
|
||||||
const [currentStep, setCurrentStep] = useState(0);
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
const [steps, setSteps] = useState<OnboardingStep[]>([
|
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
|
const checkOnboardingStatus = useCallback(async (currentSteps: OnboardingStep[]) => {
|
||||||
useEffect(() => {
|
|
||||||
checkOnboardingStatus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const checkOnboardingStatus = async () => {
|
|
||||||
try {
|
try {
|
||||||
// Check asset
|
const [purchaseData, milestonesData, assetData, priceData] = await Promise.all([
|
||||||
const assetResponse = await fetch('/assets/current');
|
fetch('/purchases/summary').then(r => r.json()),
|
||||||
const assetData = await assetResponse.json();
|
fetch('/milestones').then(r => r.json()),
|
||||||
const hasAsset = !!assetData.asset;
|
fetch('/assets/current').then(r => r.json()),
|
||||||
|
fetch('/pricing/current').then(r => r.json()),
|
||||||
|
]);
|
||||||
|
|
||||||
// Check purchases
|
|
||||||
const purchaseResponse = await fetch('/purchases/summary');
|
|
||||||
const purchaseData = await purchaseResponse.json();
|
|
||||||
const hasPurchases = purchaseData.total_shares > 0;
|
const hasPurchases = purchaseData.total_shares > 0;
|
||||||
|
|
||||||
// Check milestones
|
|
||||||
const milestonesResponse = await fetch('/milestones');
|
|
||||||
const milestonesData = await milestonesResponse.json();
|
|
||||||
const hasMilestones = milestonesData.length > 0;
|
const hasMilestones = milestonesData.length > 0;
|
||||||
|
const hasAsset = !!assetData.asset;
|
||||||
// Check current price
|
|
||||||
const priceResponse = await fetch('/pricing/current');
|
|
||||||
const priceData = await priceResponse.json();
|
|
||||||
const hasPrice = !!priceData.current_price;
|
const hasPrice = !!priceData.current_price;
|
||||||
|
|
||||||
setSteps(prev => prev.map(step => ({
|
const freshSteps = currentSteps.map(step => ({
|
||||||
...step,
|
...step,
|
||||||
completed:
|
completed:
|
||||||
(step.id === 'asset' && hasAsset) ||
|
(step.id === 'asset' && hasAsset) ||
|
||||||
(step.id === 'purchases' && hasPurchases) ||
|
(step.id === 'purchases' && hasPurchases) ||
|
||||||
(step.id === 'milestones' && hasMilestones) ||
|
(step.id === 'milestones' && hasMilestones) ||
|
||||||
(step.id === 'price' && hasPrice)
|
(step.id === 'price' && hasPrice),
|
||||||
})));
|
}));
|
||||||
|
|
||||||
// Find first incomplete required step
|
setSteps(freshSteps);
|
||||||
const firstIncompleteStep = steps.findIndex(step =>
|
|
||||||
step.required && !step.completed
|
const firstIncompleteRequired = freshSteps.findIndex(s => s.required && !s.completed);
|
||||||
);
|
|
||||||
|
if (firstIncompleteRequired !== -1) {
|
||||||
if (firstIncompleteStep !== -1) {
|
setCurrentStep(firstIncompleteRequired);
|
||||||
setCurrentStep(firstIncompleteStep);
|
} else if (onComplete) {
|
||||||
} else {
|
onComplete();
|
||||||
// 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) {
|
} catch (error) {
|
||||||
console.error('Failed to check onboarding status:', error);
|
console.error('Failed to check onboarding status:', error);
|
||||||
}
|
}
|
||||||
};
|
}, [onComplete]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (trackerType === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialSteps = trackerType === 'simple' ? SIMPLE_STEPS : ASSET_STEPS;
|
||||||
|
setSteps(initialSteps);
|
||||||
|
setCurrentStep(0);
|
||||||
|
checkOnboardingStatus(initialSteps);
|
||||||
|
}, [trackerType, checkOnboardingStatus]);
|
||||||
|
|
||||||
const handleStepComplete = async () => {
|
const handleStepComplete = async () => {
|
||||||
// Mark current step as completed
|
const updatedSteps = steps.map((step, index) =>
|
||||||
setSteps(prev => prev.map((step, index) =>
|
|
||||||
index === currentStep ? { ...step, completed: true } : step
|
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
|
|
||||||
);
|
);
|
||||||
|
setSteps(updatedSteps);
|
||||||
if (nextIncompleteStep !== -1) {
|
await checkOnboardingStatus(updatedSteps);
|
||||||
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) => {
|
const handleStepSelect = (stepIndex: number) => {
|
||||||
|
|
@ -135,32 +171,19 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
||||||
|
|
||||||
const renderStepContent = () => {
|
const renderStepContent = () => {
|
||||||
const step = steps[currentStep];
|
const step = steps[currentStep];
|
||||||
|
if (!step) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
switch (step.id) {
|
switch (step.id) {
|
||||||
case 'asset':
|
case 'asset':
|
||||||
return (
|
return <AssetSetupForm onSuccess={handleStepComplete} />;
|
||||||
<AssetSetupForm
|
|
||||||
onSuccess={handleStepComplete}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'purchases':
|
case 'purchases':
|
||||||
return (
|
return <AddPurchaseForm onSuccess={handleStepComplete} />;
|
||||||
<AddPurchaseForm
|
|
||||||
onSuccess={handleStepComplete}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'milestones':
|
case 'milestones':
|
||||||
return (
|
return <AddMilestoneForm onSuccess={handleStepComplete} />;
|
||||||
<AddMilestoneForm
|
|
||||||
onSuccess={handleStepComplete}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'price':
|
case 'price':
|
||||||
return (
|
return <PriceTrackingStep onEnable={handleStepComplete} onSkip={onComplete ?? (() => {})} />;
|
||||||
<UpdatePriceForm
|
|
||||||
onSuccess={handleStepComplete}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -169,66 +192,69 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-black flex items-center justify-center p-4">
|
<div className="min-h-screen bg-black flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-4xl">
|
<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">
|
<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">
|
<div className="mb-8">
|
||||||
<h1 className="text-red-400 font-mono text-2xl font-bold uppercase tracking-wider mb-2">
|
<h1 className="text-red-400 font-mono text-2xl font-bold uppercase tracking-wider mb-2">
|
||||||
[SYSTEM] ONBOARDING SEQUENCE
|
[SYSTEM] ONBOARDING SEQUENCE
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-red-400/60 font-mono text-sm">
|
<p className="text-red-400/60 font-mono text-sm">
|
||||||
Initialize your asset tracking system
|
{trackerType === null ? 'Choose how you want to track' : 'Set up your tracker'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress indicator */}
|
{trackerType === null ? (
|
||||||
<div className="mb-8">
|
<div className="border border-red-500/30 bg-black/50 p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<TrackerTypeSelector onSelect={setTrackerType} />
|
||||||
{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>
|
||||||
|
) : (
|
||||||
<div className="text-center">
|
<>
|
||||||
<p className="text-red-400 font-mono text-sm">
|
<div className="mb-8">
|
||||||
{steps[currentStep].description}
|
<div className="flex items-center justify-between mb-4">
|
||||||
</p>
|
{steps.map((step, index) => (
|
||||||
<p className="text-red-400/60 font-mono text-xs mt-1">
|
<button
|
||||||
STEP {currentStep + 1}/{steps.length}
|
key={step.id}
|
||||||
</p>
|
onClick={() => handleStepSelect(index)}
|
||||||
</div>
|
className={`flex-1 px-4 py-2 font-mono text-xs uppercase tracking-wider border border-red-500/50 transition-all ${
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
{/* Step content */}
|
<div className="text-center">
|
||||||
<div className="border border-red-500/30 bg-black/50 p-6">
|
<p className="text-red-400 font-mono text-sm">
|
||||||
{renderStepContent()}
|
{steps[currentStep]?.description}
|
||||||
</div>
|
</p>
|
||||||
|
<p className="text-red-400/60 font-mono text-xs mt-1">
|
||||||
|
STEP {currentStep + 1}/{steps.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Status footer */}
|
<div className="border border-red-500/30 bg-black/50 p-6">
|
||||||
<div className="mt-6 pt-4 border-t border-red-500/30">
|
{renderStepContent()}
|
||||||
<div className="flex justify-between items-center">
|
</div>
|
||||||
<p className="text-red-400/60 font-mono text-xs">
|
|
||||||
[STATUS] {steps.filter(s => s.completed).length}/{steps.length} STEPS COMPLETE
|
<div className="mt-6 pt-4 border-t border-red-500/30">
|
||||||
</p>
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-red-400/60 font-mono text-xs">
|
<p className="text-red-400/60 font-mono text-xs">
|
||||||
{steps.filter(s => s.required && !s.completed).length} REQUIRED REMAINING
|
[STATUS] {steps.filter(s => s.completed).length}/{steps.length} STEPS COMPLETE
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<p className="text-red-400/60 font-mono text-xs">
|
||||||
</div>
|
{steps.filter(s => s.required && !s.completed).length} REQUIRED REMAINING
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ export default function Dashboard() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
||||||
const [currentAsset, setCurrentAsset] = useState<any>(null);
|
const [currentAsset, setCurrentAsset] = useState<any>(null);
|
||||||
|
const [priceTrackingEnabled, setPriceTrackingEnabled] = useState(false);
|
||||||
|
|
||||||
// Fetch purchase summary, current price, milestones, and check onboarding
|
// Fetch purchase summary, current price, milestones, and check onboarding
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -72,6 +73,7 @@ export default function Dashboard() {
|
||||||
if (assetResponse.ok) {
|
if (assetResponse.ok) {
|
||||||
const assetData = await assetResponse.json();
|
const assetData = await assetResponse.json();
|
||||||
setCurrentAsset(assetData.asset);
|
setCurrentAsset(assetData.asset);
|
||||||
|
setPriceTrackingEnabled(assetData.price_tracking_enabled ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if onboarding is needed after all data is loaded
|
// Check if onboarding is needed after all data is loaded
|
||||||
|
|
@ -104,7 +106,7 @@ export default function Dashboard() {
|
||||||
const hasMilestones = milestonesData.length > 0;
|
const hasMilestones = milestonesData.length > 0;
|
||||||
|
|
||||||
// User needs onboarding if any required step is missing
|
// User needs onboarding if any required step is missing
|
||||||
const needsOnboarding = !hasAsset || !hasPurchases || !hasMilestones;
|
const needsOnboarding = !hasPurchases || !hasMilestones;
|
||||||
setNeedsOnboarding(needsOnboarding);
|
setNeedsOnboarding(needsOnboarding);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to check onboarding status:', error);
|
console.error('Failed to check onboarding status:', error);
|
||||||
|
|
@ -238,6 +240,7 @@ export default function Dashboard() {
|
||||||
if (assetResponse.ok) {
|
if (assetResponse.ok) {
|
||||||
const assetData = await assetResponse.json();
|
const assetData = await assetResponse.json();
|
||||||
setCurrentAsset(assetData.asset);
|
setCurrentAsset(assetData.asset);
|
||||||
|
setPriceTrackingEnabled(assetData.price_tracking_enabled ?? false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -253,7 +256,7 @@ export default function Dashboard() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head title="VWCE Tracker" />
|
<Head title="incr" />
|
||||||
|
|
||||||
{/* Stacked Layout */}
|
{/* Stacked Layout */}
|
||||||
<div className="min-h-screen bg-black">
|
<div className="min-h-screen bg-black">
|
||||||
|
|
@ -286,6 +289,8 @@ export default function Dashboard() {
|
||||||
onAddPurchase={() => setActiveForm('purchase')}
|
onAddPurchase={() => setActiveForm('purchase')}
|
||||||
onAddMilestone={() => setActiveForm('milestone')}
|
onAddMilestone={() => setActiveForm('milestone')}
|
||||||
onUpdatePrice={() => setActiveForm('price')}
|
onUpdatePrice={() => setActiveForm('price')}
|
||||||
|
assetSymbol={currentAsset?.symbol}
|
||||||
|
priceTrackingEnabled={priceTrackingEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\AssetController;
|
use App\Http\Controllers\AssetController;
|
||||||
use App\Http\Controllers\Transactions\PurchaseController;
|
|
||||||
use App\Http\Controllers\Pricing\PricingController;
|
|
||||||
use App\Http\Controllers\Milestones\MilestoneController;
|
use App\Http\Controllers\Milestones\MilestoneController;
|
||||||
|
use App\Http\Controllers\Pricing\PricingController;
|
||||||
|
use App\Http\Controllers\Transactions\PurchaseController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
|
|
||||||
148
shell.nix
Normal file
148
shell.nix
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
{ pkgs ? import <nixpkgs> {} }:
|
||||||
|
|
||||||
|
pkgs.mkShell {
|
||||||
|
buildInputs = with pkgs; [
|
||||||
|
# PHP and tools
|
||||||
|
php83
|
||||||
|
php83Packages.composer
|
||||||
|
|
||||||
|
# Node.js (needed for Tailwind CSS / Vite)
|
||||||
|
nodejs_22
|
||||||
|
|
||||||
|
# Container tools
|
||||||
|
podman
|
||||||
|
podman-compose
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
git
|
||||||
|
curl
|
||||||
|
gnumake
|
||||||
|
];
|
||||||
|
|
||||||
|
shellHook = ''
|
||||||
|
export USER_ID=$(id -u)
|
||||||
|
export GROUP_ID=$(id -g)
|
||||||
|
export PODMAN_USERNS=keep-id
|
||||||
|
|
||||||
|
# Compose file location
|
||||||
|
COMPOSE_FILE="$PWD/docker/dev/docker-compose.yml"
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# ALIASES
|
||||||
|
# ===================
|
||||||
|
alias pc='podman-compose -f $COMPOSE_FILE'
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# DEV COMMANDS
|
||||||
|
# ===================
|
||||||
|
dev-up() {
|
||||||
|
echo "Starting services..."
|
||||||
|
PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE up -d "$@"
|
||||||
|
echo ""
|
||||||
|
podman-compose -f $COMPOSE_FILE ps
|
||||||
|
echo ""
|
||||||
|
echo "App available at: http://localhost:8000"
|
||||||
|
}
|
||||||
|
|
||||||
|
dev-down() {
|
||||||
|
if [[ "$1" == "-v" ]]; then
|
||||||
|
echo "Stopping services and removing volumes..."
|
||||||
|
podman-compose -f $COMPOSE_FILE down -v
|
||||||
|
else
|
||||||
|
echo "Stopping services..."
|
||||||
|
podman-compose -f $COMPOSE_FILE down
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
dev-restart() {
|
||||||
|
echo "Restarting services..."
|
||||||
|
podman-compose -f $COMPOSE_FILE restart "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
dev-rebuild() {
|
||||||
|
echo "Rebuilding services (down -v + up --build)..."
|
||||||
|
podman-compose -f $COMPOSE_FILE down -v
|
||||||
|
PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE up -d --build "$@"
|
||||||
|
echo ""
|
||||||
|
podman-compose -f $COMPOSE_FILE ps
|
||||||
|
echo ""
|
||||||
|
echo "App available at: http://localhost:8000"
|
||||||
|
}
|
||||||
|
|
||||||
|
dev-logs() {
|
||||||
|
podman-compose -f $COMPOSE_FILE logs -f app "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
dev-logs-db() {
|
||||||
|
podman-compose -f $COMPOSE_FILE logs -f db "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
dev-shell() {
|
||||||
|
podman-compose -f $COMPOSE_FILE exec app bash
|
||||||
|
}
|
||||||
|
|
||||||
|
dev-artisan() {
|
||||||
|
podman-compose -f $COMPOSE_FILE exec app php artisan "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
dev-composer() {
|
||||||
|
podman-compose -f $COMPOSE_FILE exec app composer "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# BUILD COMMANDS
|
||||||
|
# ===================
|
||||||
|
base-build() {
|
||||||
|
local image="forge.lvl0.xyz/lvl0/incr:latest"
|
||||||
|
|
||||||
|
if ! podman login --get-login forge.lvl0.xyz &>/dev/null; then
|
||||||
|
echo "Not logged in to forge.lvl0.xyz"
|
||||||
|
podman login forge.lvl0.xyz || return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Building image: $image"
|
||||||
|
if ! podman build -t "$image" -f Dockerfile .; then
|
||||||
|
echo "Build failed!"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Pushing to registry..."
|
||||||
|
if ! podman push "$image"; then
|
||||||
|
echo "Push failed!"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done! Image pushed: $image"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# WELCOME MESSAGE
|
||||||
|
# ===================
|
||||||
|
echo ""
|
||||||
|
echo "================================================="
|
||||||
|
echo " incr Dev Environment "
|
||||||
|
echo "================================================="
|
||||||
|
echo ""
|
||||||
|
echo " Podman: $(podman --version | cut -d' ' -f3)"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " dev-up [services] Start all or specific services"
|
||||||
|
echo " dev-down [-v] Stop services (-v removes volumes)"
|
||||||
|
echo " dev-rebuild Fresh start (down -v + up)"
|
||||||
|
echo " dev-restart Restart services"
|
||||||
|
echo " dev-logs Tail app logs"
|
||||||
|
echo " dev-logs-db Tail database logs"
|
||||||
|
echo " dev-shell Shell into app container"
|
||||||
|
echo " dev-artisan <cmd> Run artisan command"
|
||||||
|
echo " dev-composer <cmd> Run composer command"
|
||||||
|
echo " base-build Build and push image"
|
||||||
|
echo ""
|
||||||
|
echo "Services:"
|
||||||
|
echo " app Laravel http://localhost:8000"
|
||||||
|
echo " vite HMR http://localhost:5173"
|
||||||
|
echo " db MySQL localhost:3307"
|
||||||
|
echo ""
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature\Auth;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class AuthenticationTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
public function test_login_screen_can_be_rendered()
|
|
||||||
{
|
|
||||||
$response = $this->get('/login');
|
|
||||||
|
|
||||||
$response->assertStatus(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_users_can_authenticate_using_the_login_screen()
|
|
||||||
{
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$response = $this->post('/login', [
|
|
||||||
'email' => $user->email,
|
|
||||||
'password' => 'password',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertAuthenticated();
|
|
||||||
$response->assertRedirect(route('dashboard', absolute: false));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_users_can_not_authenticate_with_invalid_password()
|
|
||||||
{
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$this->post('/login', [
|
|
||||||
'email' => $user->email,
|
|
||||||
'password' => 'wrong-password',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertGuest();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_users_can_logout()
|
|
||||||
{
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)->post('/logout');
|
|
||||||
|
|
||||||
$this->assertGuest();
|
|
||||||
$response->assertRedirect('/');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature\Auth;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Auth\Events\Verified;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Support\Facades\Event;
|
|
||||||
use Illuminate\Support\Facades\URL;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class EmailVerificationTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
public function test_email_verification_screen_can_be_rendered()
|
|
||||||
{
|
|
||||||
$user = User::factory()->unverified()->create();
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)->get('/verify-email');
|
|
||||||
|
|
||||||
$response->assertStatus(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_email_can_be_verified()
|
|
||||||
{
|
|
||||||
$user = User::factory()->unverified()->create();
|
|
||||||
|
|
||||||
Event::fake();
|
|
||||||
|
|
||||||
$verificationUrl = URL::temporarySignedRoute(
|
|
||||||
'verification.verify',
|
|
||||||
now()->addMinutes(60),
|
|
||||||
['id' => $user->id, 'hash' => sha1($user->email)]
|
|
||||||
);
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)->get($verificationUrl);
|
|
||||||
|
|
||||||
Event::assertDispatched(Verified::class);
|
|
||||||
$this->assertTrue($user->fresh()->hasVerifiedEmail());
|
|
||||||
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_email_is_not_verified_with_invalid_hash()
|
|
||||||
{
|
|
||||||
$user = User::factory()->unverified()->create();
|
|
||||||
|
|
||||||
$verificationUrl = URL::temporarySignedRoute(
|
|
||||||
'verification.verify',
|
|
||||||
now()->addMinutes(60),
|
|
||||||
['id' => $user->id, 'hash' => sha1('wrong-email')]
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->actingAs($user)->get($verificationUrl);
|
|
||||||
|
|
||||||
$this->assertFalse($user->fresh()->hasVerifiedEmail());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature\Auth;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class PasswordConfirmationTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
public function test_confirm_password_screen_can_be_rendered()
|
|
||||||
{
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)->get('/confirm-password');
|
|
||||||
|
|
||||||
$response->assertStatus(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_password_can_be_confirmed()
|
|
||||||
{
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)->post('/confirm-password', [
|
|
||||||
'password' => 'password',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertRedirect();
|
|
||||||
$response->assertSessionHasNoErrors();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_password_is_not_confirmed_with_invalid_password()
|
|
||||||
{
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)->post('/confirm-password', [
|
|
||||||
'password' => 'wrong-password',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertSessionHasErrors();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature\Auth;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Auth\Notifications\ResetPassword;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Support\Facades\Notification;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class PasswordResetTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
public function test_reset_password_link_screen_can_be_rendered()
|
|
||||||
{
|
|
||||||
$response = $this->get('/forgot-password');
|
|
||||||
|
|
||||||
$response->assertStatus(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_reset_password_link_can_be_requested()
|
|
||||||
{
|
|
||||||
Notification::fake();
|
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$this->post('/forgot-password', ['email' => $user->email]);
|
|
||||||
|
|
||||||
Notification::assertSentTo($user, ResetPassword::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_reset_password_screen_can_be_rendered()
|
|
||||||
{
|
|
||||||
Notification::fake();
|
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$this->post('/forgot-password', ['email' => $user->email]);
|
|
||||||
|
|
||||||
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
|
|
||||||
$response = $this->get('/reset-password/'.$notification->token);
|
|
||||||
|
|
||||||
$response->assertStatus(200);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_password_can_be_reset_with_valid_token()
|
|
||||||
{
|
|
||||||
Notification::fake();
|
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$this->post('/forgot-password', ['email' => $user->email]);
|
|
||||||
|
|
||||||
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
|
|
||||||
$response = $this->post('/reset-password', [
|
|
||||||
'token' => $notification->token,
|
|
||||||
'email' => $user->email,
|
|
||||||
'password' => 'password',
|
|
||||||
'password_confirmation' => 'password',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertSessionHasNoErrors()
|
|
||||||
->assertRedirect(route('login'));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature\Auth;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class RegistrationTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
public function test_registration_screen_can_be_rendered()
|
|
||||||
{
|
|
||||||
$response = $this->get('/register');
|
|
||||||
|
|
||||||
$response->assertStatus(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_new_users_can_register()
|
|
||||||
{
|
|
||||||
$response = $this->post('/register', [
|
|
||||||
'name' => 'Test User',
|
|
||||||
'email' => 'test@example.com',
|
|
||||||
'password' => 'password',
|
|
||||||
'password_confirmation' => 'password',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertAuthenticated();
|
|
||||||
$response->assertRedirect(route('dashboard', absolute: false));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class DashboardTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
public function test_guests_are_redirected_to_the_login_page()
|
|
||||||
{
|
|
||||||
$this->get('/dashboard')->assertRedirect('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_authenticated_users_can_visit_the_dashboard()
|
|
||||||
{
|
|
||||||
$this->actingAs($user = User::factory()->create());
|
|
||||||
|
|
||||||
$this->get('/dashboard')->assertOk();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -14,12 +14,12 @@ public function test_can_create_milestone(): void
|
||||||
{
|
{
|
||||||
$milestone = Milestone::create([
|
$milestone = Milestone::create([
|
||||||
'target' => 1500,
|
'target' => 1500,
|
||||||
'description' => 'First milestone'
|
'description' => 'First milestone',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertDatabaseHas('milestones', [
|
$this->assertDatabaseHas('milestones', [
|
||||||
'target' => 1500,
|
'target' => 1500,
|
||||||
'description' => 'First milestone'
|
'description' => 'First milestone',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertEquals(1500, $milestone->target);
|
$this->assertEquals(1500, $milestone->target);
|
||||||
|
|
@ -38,7 +38,7 @@ public function test_can_fetch_milestones_via_api(): void
|
||||||
$response->assertJsonCount(2);
|
$response->assertJsonCount(2);
|
||||||
$response->assertJson([
|
$response->assertJson([
|
||||||
['target' => 1500, 'description' => 'First milestone'],
|
['target' => 1500, 'description' => 'First milestone'],
|
||||||
['target' => 3000, 'description' => 'Second milestone']
|
['target' => 3000, 'description' => 'Second milestone'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature\Settings;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class PasswordUpdateTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
public function test_password_can_be_updated()
|
|
||||||
{
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$response = $this
|
|
||||||
->actingAs($user)
|
|
||||||
->from('/settings/password')
|
|
||||||
->put('/settings/password', [
|
|
||||||
'current_password' => 'password',
|
|
||||||
'password' => 'new-password',
|
|
||||||
'password_confirmation' => 'new-password',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertSessionHasNoErrors()
|
|
||||||
->assertRedirect('/settings/password');
|
|
||||||
|
|
||||||
$this->assertTrue(Hash::check('new-password', $user->refresh()->password));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_correct_password_must_be_provided_to_update_password()
|
|
||||||
{
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$response = $this
|
|
||||||
->actingAs($user)
|
|
||||||
->from('/settings/password')
|
|
||||||
->put('/settings/password', [
|
|
||||||
'current_password' => 'wrong-password',
|
|
||||||
'password' => 'new-password',
|
|
||||||
'password_confirmation' => 'new-password',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertSessionHasErrors('current_password')
|
|
||||||
->assertRedirect('/settings/password');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Feature\Settings;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class ProfileUpdateTest extends TestCase
|
|
||||||
{
|
|
||||||
use RefreshDatabase;
|
|
||||||
|
|
||||||
public function test_profile_page_is_displayed()
|
|
||||||
{
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$response = $this
|
|
||||||
->actingAs($user)
|
|
||||||
->get('/settings/profile');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_profile_information_can_be_updated()
|
|
||||||
{
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$response = $this
|
|
||||||
->actingAs($user)
|
|
||||||
->patch('/settings/profile', [
|
|
||||||
'name' => 'Test User',
|
|
||||||
'email' => 'test@example.com',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertSessionHasNoErrors()
|
|
||||||
->assertRedirect('/settings/profile');
|
|
||||||
|
|
||||||
$user->refresh();
|
|
||||||
|
|
||||||
$this->assertSame('Test User', $user->name);
|
|
||||||
$this->assertSame('test@example.com', $user->email);
|
|
||||||
$this->assertNull($user->email_verified_at);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged()
|
|
||||||
{
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$response = $this
|
|
||||||
->actingAs($user)
|
|
||||||
->patch('/settings/profile', [
|
|
||||||
'name' => 'Test User',
|
|
||||||
'email' => $user->email,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertSessionHasNoErrors()
|
|
||||||
->assertRedirect('/settings/profile');
|
|
||||||
|
|
||||||
$this->assertNotNull($user->refresh()->email_verified_at);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_user_can_delete_their_account()
|
|
||||||
{
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$response = $this
|
|
||||||
->actingAs($user)
|
|
||||||
->delete('/settings/profile', [
|
|
||||||
'password' => 'password',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertSessionHasNoErrors()
|
|
||||||
->assertRedirect('/');
|
|
||||||
|
|
||||||
$this->assertGuest();
|
|
||||||
$this->assertNull($user->fresh());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function test_correct_password_must_be_provided_to_delete_account()
|
|
||||||
{
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$response = $this
|
|
||||||
->actingAs($user)
|
|
||||||
->from('/settings/profile')
|
|
||||||
->delete('/settings/profile', [
|
|
||||||
'password' => 'wrong-password',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertSessionHasErrors('password')
|
|
||||||
->assertRedirect('/settings/profile');
|
|
||||||
|
|
||||||
$this->assertNotNull($user->fresh());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -5,6 +5,18 @@ import { resolve } from 'node:path';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
hmr: {
|
||||||
|
host: 'localhost',
|
||||||
|
clientPort: 5173,
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
usePolling: true,
|
||||||
|
ignored: ['**/storage/framework/views/**'],
|
||||||
|
},
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
laravel({
|
laravel({
|
||||||
input: ['resources/css/app.css', 'resources/js/app.tsx'],
|
input: ['resources/css/app.css', 'resources/js/app.tsx'],
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue