Compare commits
37 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
367abb9a69 | |
|
|
841cec6037 | |
|
|
d9647a543a | |
|
|
88594b9281 | |
|
|
5fff2afa08 | |
|
|
c88318e951 | |
|
|
e6cab5eef5 | |
|
|
08a75ddcca | |
|
|
1db8aa4643 | |
|
|
e84929d746 | |
|
|
6e2ea65757 | |
|
|
a454d7ec25 | |
|
|
70d6afec53 | |
|
|
9ac46a37e0 | |
|
|
3eaa46fa27 | |
|
|
fb5c09dcd8 | |
|
|
eb6d3ce091 | |
|
|
7abc86218c | |
|
|
c99812ae70 | |
|
|
10821e6679 | |
|
|
4a9f74d1b1 | |
|
|
8f1f8dd405 | |
|
|
f94110c83a | |
|
|
9f5dedc8b9 | |
|
|
d5d3c1ef1b | |
|
|
43d615869c | |
|
|
214ad684a8 | |
|
|
67347c36ed | |
|
|
35d91a3b13 | |
|
|
bbbe8e31bf | |
|
|
08f5d1f947 | |
|
|
2f6ee33d4d | |
|
|
dbe9b8956f | |
|
|
f36ddb4f36 | |
|
|
9cb063e515 | |
|
|
73bb25ae8f | |
|
|
54833fcdc0 |
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(bun run format)",
|
||||
"Bash(bun run lint)"
|
||||
]
|
||||
},
|
||||
"enabledMcpjsonServers": [
|
||||
"laravel-boost"
|
||||
],
|
||||
"enableAllProjectMcpServers": true
|
||||
}
|
||||
|
|
@ -47,14 +47,15 @@ REDIS_HOST=127.0.0.1
|
|||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
MAIL_FROM_ADDRESS="hi@whisper.money"
|
||||
MAIL_FROM_NAME="Whisper Money"
|
||||
|
||||
# Resend Email Service (set MAIL_MAILER=resend to use in production)
|
||||
RESEND_API_KEY=
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
|
|
|
|||
|
|
@ -146,6 +146,9 @@ jobs:
|
|||
- name: Lint Frontend
|
||||
run: bun run lint
|
||||
|
||||
- name: Frontend Tests
|
||||
run: bun run test
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [tests, linter]
|
||||
|
|
|
|||
|
|
@ -28,3 +28,4 @@ yarn-error.log
|
|||
/.zed
|
||||
.php-cs-fixer.cache
|
||||
/traefik/certs/*.pem
|
||||
.claude/settings.local.json
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"git": {
|
||||
"commitMessage": "chore: release v${version}",
|
||||
"tagName": "v${version}"
|
||||
},
|
||||
"npm": {
|
||||
"publish": false
|
||||
},
|
||||
"plugins": {
|
||||
"@release-it/conventional-changelog": {
|
||||
"preset": "angular",
|
||||
"infile": "CHANGELOG.md"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.1.0] - 2025-12-28
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release with end-to-end encrypted finance tracking
|
||||
- Account management and bank sync via GoCardless
|
||||
- Transaction categorization and labeling
|
||||
- Net worth and account balance charts with multiple view modes
|
||||
- PWA support for mobile installation
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DeleteUserCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'user:delete {email : The email address of the user to delete}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Delete a user and all their associated data';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$email = $this->argument('email');
|
||||
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
|
||||
if (! $user) {
|
||||
$this->error("User with email '{$email}' not found.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Check for active subscriptions
|
||||
if ($user->subscribed('default')) {
|
||||
$this->error('Cannot delete user with an active subscription. Please cancel the subscription first.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $this->confirm("Are you sure you want to delete user '{$user->name}' ({$user->email}) and all their data?")) {
|
||||
$this->info('Deletion cancelled.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($user) {
|
||||
// Delete account balances through accounts
|
||||
foreach ($user->accounts as $account) {
|
||||
$account->balances()->delete();
|
||||
}
|
||||
|
||||
// Delete all related data
|
||||
$user->encryptedMessage()->delete();
|
||||
$user->transactions()->delete();
|
||||
$user->accounts()->delete();
|
||||
$user->categories()->delete();
|
||||
$user->automationRules()->delete();
|
||||
$user->labels()->delete();
|
||||
$user->mailLogs()->delete();
|
||||
|
||||
// Delete user's banks
|
||||
DB::table('banks')->where('user_id', $user->id)->delete();
|
||||
|
||||
// Delete Cashier subscription data if exists
|
||||
if ($user->subscriptions()->exists()) {
|
||||
$user->subscriptions()->delete();
|
||||
}
|
||||
|
||||
// Delete the user
|
||||
$user->delete();
|
||||
});
|
||||
|
||||
$this->info("User '{$email}' and all associated data have been deleted successfully.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mail\Drip\FeedbackEmail;
|
||||
use App\Mail\Drip\ImportHelpEmail;
|
||||
use App\Mail\Drip\OnboardingReminderEmail;
|
||||
use App\Mail\Drip\PromoCodeEmail;
|
||||
use App\Mail\Drip\WelcomeEmail;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class SendTestEmails extends Command
|
||||
{
|
||||
protected $signature = 'email:test {email : The email address to send all test emails to}';
|
||||
|
||||
protected $description = 'Send all application emails to a specific email address for testing';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$email = $this->argument('email');
|
||||
|
||||
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$this->error("Invalid email address: {$email}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Sending all test emails to: {$email}");
|
||||
$this->newLine();
|
||||
|
||||
$user = User::firstOrCreate(
|
||||
['email' => $email],
|
||||
[
|
||||
'name' => 'Test User',
|
||||
'password' => bcrypt('password'),
|
||||
]
|
||||
);
|
||||
|
||||
$emails = [
|
||||
'Welcome Email' => new WelcomeEmail($user),
|
||||
'Feedback Email' => new FeedbackEmail($user),
|
||||
'Import Help Email' => new ImportHelpEmail($user),
|
||||
'Onboarding Reminder Email' => new OnboardingReminderEmail($user),
|
||||
'Promo Code Email' => new PromoCodeEmail($user),
|
||||
];
|
||||
|
||||
foreach ($emails as $name => $mailable) {
|
||||
try {
|
||||
Mail::to($email)->send($mailable);
|
||||
$this->info("✓ Sent: {$name}");
|
||||
} catch (\Exception $e) {
|
||||
$this->error("✗ Failed to send {$name}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info('All emails sent successfully!');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mail\UserLeadInvitation;
|
||||
use App\Models\UserLead;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class SendUserLeadInvitations extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'leads:send-invitations {--force : Skip confirmation prompt}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Send invitation emails to all UserLeads with FOUNDER promo code';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$leads = UserLead::query()->get();
|
||||
|
||||
if ($leads->isEmpty()) {
|
||||
$this->info('No user leads found in the database.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$leads->count()} user lead(s).");
|
||||
|
||||
if (! $this->option('force')) {
|
||||
if (! $this->confirm("Do you want to send invitation emails to {$leads->count()} lead(s)?", true)) {
|
||||
$this->info('Cancelled.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info('Sending invitation emails...');
|
||||
|
||||
$progressBar = $this->output->createProgressBar($leads->count());
|
||||
$progressBar->start();
|
||||
|
||||
$sent = 0;
|
||||
foreach ($leads as $lead) {
|
||||
Mail::to($lead->email)->send(new UserLeadInvitation($lead));
|
||||
$sent++;
|
||||
$progressBar->advance();
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
$this->newLine();
|
||||
|
||||
$this->info("Successfully queued {$sent} invitation email(s)!");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\DripEmailType;
|
||||
use App\Jobs\Drip\SendFeedbackEmailJob;
|
||||
use App\Jobs\Drip\SendImportHelpEmailJob;
|
||||
use App\Jobs\Drip\SendOnboardingReminderEmailJob;
|
||||
use App\Jobs\Drip\SendPromoCodeEmailJob;
|
||||
use App\Jobs\Drip\SendWelcomeEmailJob;
|
||||
use App\Models\User;
|
||||
use App\Models\UserMailLog;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class TestScheduledDripEmailCommand extends Command
|
||||
{
|
||||
protected $signature = 'email:test-scheduled-drip {email} {--force : Delete existing mail logs for this user}';
|
||||
|
||||
protected $description = 'Test the scheduled drip email campaign for a specific user';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$email = $this->argument('email');
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
|
||||
if (! $user) {
|
||||
$this->error("User with email '{$email}' not found.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($this->option('force')) {
|
||||
$deleted = UserMailLog::query()
|
||||
->where('user_id', $user->id)
|
||||
->whereIn('email_type', [
|
||||
DripEmailType::Welcome,
|
||||
DripEmailType::OnboardingReminder,
|
||||
DripEmailType::PromoCode,
|
||||
DripEmailType::ImportHelp,
|
||||
DripEmailType::Feedback,
|
||||
])
|
||||
->delete();
|
||||
|
||||
if ($deleted > 0) {
|
||||
$this->info("Deleted {$deleted} mail log(s) for user '{$email}'.");
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Scheduling drip emails for user '{$email}'...");
|
||||
|
||||
SendWelcomeEmailJob::dispatch($user)->delay(now()->addMinutes(30));
|
||||
SendOnboardingReminderEmailJob::dispatch($user)->delay(now()->addDay());
|
||||
SendPromoCodeEmailJob::dispatch($user)->delay(now()->addDay());
|
||||
SendImportHelpEmailJob::dispatch($user)->delay(now()->addDay());
|
||||
SendFeedbackEmailJob::dispatch($user)->delay(now()->addDays(5));
|
||||
|
||||
$this->info('Drip emails have been scheduled successfully!');
|
||||
$this->newLine();
|
||||
$this->table(
|
||||
['Email Type', 'Scheduled For'],
|
||||
[
|
||||
['Welcome', now()->addMinutes(30)->format('Y-m-d H:i:s')],
|
||||
['Onboarding Reminder', now()->addDay()->format('Y-m-d H:i:s')],
|
||||
['Promo Code', now()->addDay()->format('Y-m-d H:i:s')],
|
||||
['Import Help', now()->addDay()->format('Y-m-d H:i:s')],
|
||||
['Feedback', now()->addDays(5)->format('Y-m-d H:i:s')],
|
||||
]
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum DripEmailType: string
|
||||
{
|
||||
case Welcome = 'welcome';
|
||||
case OnboardingReminder = 'onboarding_reminder';
|
||||
case PromoCode = 'promo_code';
|
||||
case ImportHelp = 'import_help';
|
||||
case Feedback = 'feedback';
|
||||
}
|
||||
|
|
@ -44,6 +44,7 @@ class HandleInertiaRequests extends Middleware
|
|||
...parent::share($request),
|
||||
'name' => config('app.name'),
|
||||
'appUrl' => config('app.url'),
|
||||
'version' => json_decode(file_get_contents(base_path('package.json')))->version ?? '0.0.0',
|
||||
'quote' => ['message' => trim($message), 'author' => trim($author)],
|
||||
'auth' => [
|
||||
'user' => $user,
|
||||
|
|
@ -53,7 +54,7 @@ class HandleInertiaRequests extends Middleware
|
|||
'pricing' => [
|
||||
'plans' => config('subscriptions.plans', []),
|
||||
'defaultPlan' => config('subscriptions.default_plan', 'monthly'),
|
||||
'bestValuePlan' => config('subscriptions.best_value', null),
|
||||
'bestValuePlan' => config('subscriptions.best_value_plan', null),
|
||||
'promo' => config('subscriptions.promo', []),
|
||||
],
|
||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs\Drip;
|
||||
|
||||
use App\Enums\DripEmailType;
|
||||
use App\Mail\Drip\FeedbackEmail;
|
||||
use App\Models\User;
|
||||
use App\Models\UserMailLog;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class SendFeedbackEmailJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public User $user)
|
||||
{
|
||||
$this->onQueue('emails');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
if ($this->user->hasReceivedEmail(DripEmailType::Feedback)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->user->hasProPlan()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Mail::to($this->user)->send(new FeedbackEmail($this->user));
|
||||
|
||||
UserMailLog::create([
|
||||
'user_id' => $this->user->id,
|
||||
'email_type' => DripEmailType::Feedback,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs\Drip;
|
||||
|
||||
use App\Enums\DripEmailType;
|
||||
use App\Mail\Drip\ImportHelpEmail;
|
||||
use App\Models\User;
|
||||
use App\Models\UserMailLog;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class SendImportHelpEmailJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public User $user)
|
||||
{
|
||||
$this->onQueue('emails');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
if ($this->user->hasReceivedEmail(DripEmailType::ImportHelp)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->user->isOnboarded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->user->transactions()->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->user->hasProPlan()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Mail::to($this->user)->send(new ImportHelpEmail($this->user));
|
||||
|
||||
UserMailLog::create([
|
||||
'user_id' => $this->user->id,
|
||||
'email_type' => DripEmailType::ImportHelp,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs\Drip;
|
||||
|
||||
use App\Enums\DripEmailType;
|
||||
use App\Mail\Drip\OnboardingReminderEmail;
|
||||
use App\Models\User;
|
||||
use App\Models\UserMailLog;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class SendOnboardingReminderEmailJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public User $user)
|
||||
{
|
||||
$this->onQueue('emails');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
if ($this->user->hasReceivedEmail(DripEmailType::OnboardingReminder)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->user->isOnboarded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Mail::to($this->user)->send(new OnboardingReminderEmail($this->user));
|
||||
|
||||
UserMailLog::create([
|
||||
'user_id' => $this->user->id,
|
||||
'email_type' => DripEmailType::OnboardingReminder,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs\Drip;
|
||||
|
||||
use App\Enums\DripEmailType;
|
||||
use App\Mail\Drip\PromoCodeEmail;
|
||||
use App\Models\User;
|
||||
use App\Models\UserMailLog;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class SendPromoCodeEmailJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public User $user)
|
||||
{
|
||||
$this->onQueue('emails');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
if ($this->user->hasReceivedEmail(DripEmailType::PromoCode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->user->isOnboarded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->user->transactions()->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->user->hasProPlan()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Mail::to($this->user)->send(new PromoCodeEmail($this->user));
|
||||
|
||||
UserMailLog::create([
|
||||
'user_id' => $this->user->id,
|
||||
'email_type' => DripEmailType::PromoCode,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs\Drip;
|
||||
|
||||
use App\Enums\DripEmailType;
|
||||
use App\Mail\Drip\WelcomeEmail;
|
||||
use App\Models\User;
|
||||
use App\Models\UserMailLog;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class SendWelcomeEmailJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public User $user)
|
||||
{
|
||||
$this->onQueue('emails');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
if ($this->user->hasReceivedEmail(DripEmailType::Welcome)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Mail::to($this->user)->send(new WelcomeEmail($this->user));
|
||||
|
||||
UserMailLog::create([
|
||||
'user_id' => $this->user->id,
|
||||
'email_type' => DripEmailType::Welcome,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Jobs\Drip\SendFeedbackEmailJob;
|
||||
use App\Jobs\Drip\SendImportHelpEmailJob;
|
||||
use App\Jobs\Drip\SendOnboardingReminderEmailJob;
|
||||
use App\Jobs\Drip\SendPromoCodeEmailJob;
|
||||
use App\Jobs\Drip\SendWelcomeEmailJob;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
|
||||
class ScheduleDripEmailsListener
|
||||
{
|
||||
public function handle(Registered $event): void
|
||||
{
|
||||
$user = $event->user;
|
||||
|
||||
SendWelcomeEmailJob::dispatch($user)->delay(now()->addMinutes(30));
|
||||
SendOnboardingReminderEmailJob::dispatch($user)->delay(now()->addDay());
|
||||
SendPromoCodeEmailJob::dispatch($user)->delay(now()->addDay());
|
||||
SendImportHelpEmailJob::dispatch($user)->delay(now()->addDay());
|
||||
SendFeedbackEmailJob::dispatch($user)->delay(now()->addDays(5));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail\Drip;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\Middleware\RateLimited;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class FeedbackEmail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public $tries = 5;
|
||||
|
||||
/**
|
||||
* The number of seconds to wait before retrying the job.
|
||||
*
|
||||
* @var array<int, int>
|
||||
*/
|
||||
public $backoff = [2, 5, 10, 30];
|
||||
|
||||
public function __construct(public User $user)
|
||||
{
|
||||
$this->onQueue('emails');
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: "How's Your Experience So Far?",
|
||||
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'mail.drip.feedback',
|
||||
with: [
|
||||
'userName' => $this->user->name,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the middleware the job should pass through.
|
||||
*
|
||||
* @return array<int, object>
|
||||
*/
|
||||
public function middleware(): array
|
||||
{
|
||||
return [(new RateLimited('emails'))->releaseAfter(1)];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail\Drip;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\Middleware\RateLimited;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ImportHelpEmail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public $tries = 5;
|
||||
|
||||
/**
|
||||
* The number of seconds to wait before retrying the job.
|
||||
*
|
||||
* @var array<int, int>
|
||||
*/
|
||||
public $backoff = [2, 5, 10, 30];
|
||||
|
||||
public function __construct(public User $user)
|
||||
{
|
||||
$this->onQueue('emails');
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: "Let's Import Your Transactions",
|
||||
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'mail.drip.import-help',
|
||||
with: [
|
||||
'userName' => $this->user->name,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the middleware the job should pass through.
|
||||
*
|
||||
* @return array<int, object>
|
||||
*/
|
||||
public function middleware(): array
|
||||
{
|
||||
return [(new RateLimited('emails'))->releaseAfter(1)];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail\Drip;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\Middleware\RateLimited;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class OnboardingReminderEmail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public $tries = 5;
|
||||
|
||||
/**
|
||||
* The number of seconds to wait before retrying the job.
|
||||
*
|
||||
* @var array<int, int>
|
||||
*/
|
||||
public $backoff = [2, 5, 10, 30];
|
||||
|
||||
public function __construct(public User $user)
|
||||
{
|
||||
$this->onQueue('emails');
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Need Help Getting Started?',
|
||||
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'mail.drip.onboarding-reminder',
|
||||
with: [
|
||||
'userName' => $this->user->name,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the middleware the job should pass through.
|
||||
*
|
||||
* @return array<int, object>
|
||||
*/
|
||||
public function middleware(): array
|
||||
{
|
||||
return [(new RateLimited('emails'))->releaseAfter(1)];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail\Drip;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\Middleware\RateLimited;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class PromoCodeEmail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public $tries = 5;
|
||||
|
||||
/**
|
||||
* The number of seconds to wait before retrying the job.
|
||||
*
|
||||
* @var array<int, int>
|
||||
*/
|
||||
public $backoff = [2, 5, 10, 30];
|
||||
|
||||
public function __construct(public User $user)
|
||||
{
|
||||
$this->onQueue('emails');
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Your Founder Discount - First Month for $1',
|
||||
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'mail.drip.promo-code',
|
||||
with: [
|
||||
'userName' => $this->user->name,
|
||||
'promoCode' => 'FOUNDER',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the middleware the job should pass through.
|
||||
*
|
||||
* @return array<int, object>
|
||||
*/
|
||||
public function middleware(): array
|
||||
{
|
||||
return [(new RateLimited('emails'))->releaseAfter(1)];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail\Drip;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\Middleware\RateLimited;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class WelcomeEmail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public $tries = 5;
|
||||
|
||||
/**
|
||||
* The number of seconds to wait before retrying the job.
|
||||
*
|
||||
* @var array<int, int>
|
||||
*/
|
||||
public $backoff = [2, 5, 10, 30];
|
||||
|
||||
public function __construct(public User $user)
|
||||
{
|
||||
$this->onQueue('emails');
|
||||
}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Welcome to Whisper Money - Your Privacy-First Finance App',
|
||||
)->from(config('mail.from.address', 'hello@example.com'), 'Victor');
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'mail.drip.welcome',
|
||||
with: [
|
||||
'userName' => $this->user->name,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the middleware the job should pass through.
|
||||
*
|
||||
* @return array<int, object>
|
||||
*/
|
||||
public function middleware(): array
|
||||
{
|
||||
return [(new RateLimited('emails'))->releaseAfter(1)];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\UserLead;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\Middleware\RateLimited;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class UserLeadInvitation extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* The number of times the job may be attempted.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
public $tries = 5;
|
||||
|
||||
/**
|
||||
* The number of seconds to wait before retrying the job.
|
||||
*
|
||||
* @var array<int, int>
|
||||
*/
|
||||
public $backoff = [2, 5, 10, 30];
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct(public UserLead $lead)
|
||||
{
|
||||
$this->onQueue('emails');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Your early access to Whisper Money is ready',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'mail.user-lead-invitation',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*
|
||||
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the middleware the job should pass through.
|
||||
*
|
||||
* @return array<int, object>
|
||||
*/
|
||||
public function middleware(): array
|
||||
{
|
||||
return [(new RateLimited('emails'))->releaseAfter(1)];
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use App\Enums\DripEmailType;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
|
@ -92,6 +93,16 @@ class User extends Authenticatable
|
|||
return $this->hasMany(Label::class);
|
||||
}
|
||||
|
||||
public function mailLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserMailLog::class);
|
||||
}
|
||||
|
||||
public function hasReceivedEmail(DripEmailType $type): bool
|
||||
{
|
||||
return $this->mailLogs()->where('email_type', $type)->exists();
|
||||
}
|
||||
|
||||
public function hasProPlan(): bool
|
||||
{
|
||||
if (! config('subscriptions.enabled')) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\DripEmailType;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class UserMailLog extends Model
|
||||
{
|
||||
use HasFactory, HasUuids;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'email_type',
|
||||
'sent_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_type' => DripEmailType::class,
|
||||
'sent_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@
|
|||
namespace App\Providers;
|
||||
|
||||
use App\Http\Responses\RegisterResponse;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
|
||||
|
||||
|
|
@ -21,6 +23,8 @@ class AppServiceProvider extends ServiceProvider
|
|||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
RateLimiter::for('emails', function (object $job): Limit {
|
||||
return Limit::perSecond(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,13 @@ services:
|
|||
- ping
|
||||
retries: 3
|
||||
timeout: 5s
|
||||
mailhog:
|
||||
image: 'mailhog/mailhog:latest'
|
||||
ports:
|
||||
- '${FORWARD_MAILHOG_PORT:-1025}:1025'
|
||||
- '${FORWARD_MAILHOG_DASHBOARD_PORT:-8025}:8025'
|
||||
networks:
|
||||
- sail
|
||||
networks:
|
||||
sail:
|
||||
driver: bridge
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@
|
|||
"laravel/fortify": "^1.30",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"laravel/wayfinder": "^0.1.9"
|
||||
"laravel/wayfinder": "^0.1.9",
|
||||
"resend/resend-php": "^1.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "6b2387225b885940346beca54c271107",
|
||||
"content-hash": "40779ce6890f5022e5e7c7c569bd328c",
|
||||
"packages": [
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
|
|
@ -3927,6 +3927,63 @@
|
|||
},
|
||||
"time": "2025-09-04T20:59:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "resend/resend-php",
|
||||
"version": "v1.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/resend/resend-php.git",
|
||||
"reference": "c1ce4ee5dc8ca90a5e6a2dc6d772a06abbe7c9d3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/resend/resend-php/zipball/c1ce4ee5dc8ca90a5e6a2dc6d772a06abbe7c9d3",
|
||||
"reference": "c1ce4ee5dc8ca90a5e6a2dc6d772a06abbe7c9d3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"guzzlehttp/guzzle": "^7.5",
|
||||
"php": "^8.1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.13",
|
||||
"mockery/mockery": "^1.6",
|
||||
"pestphp/pest": "^2.0|^3.0|^4.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/Resend.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Resend\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Resend and contributors",
|
||||
"homepage": "https://github.com/resend/resend-php/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Resend PHP library.",
|
||||
"homepage": "https://resend.com/",
|
||||
"keywords": [
|
||||
"api",
|
||||
"client",
|
||||
"php",
|
||||
"resend",
|
||||
"sdk"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/resend/resend-php/issues",
|
||||
"source": "https://github.com/resend/resend-php/tree/v1.1.0"
|
||||
},
|
||||
"time": "2025-11-26T08:41:40+00:00"
|
||||
},
|
||||
{
|
||||
"name": "stripe/stripe-php",
|
||||
"version": "v17.6.0",
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ return [
|
|||
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
||||
'queue' => env('DB_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
|
||||
'after_commit' => false,
|
||||
'after_commit' => true,
|
||||
],
|
||||
|
||||
'beanstalkd' => [
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ return [
|
|||
|
|
||||
*/
|
||||
|
||||
'best_value_plan' => null,
|
||||
'best_value_plan' => 'yearly',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\DripEmailType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\UserMailLog>
|
||||
*/
|
||||
class UserMailLogFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => User::factory(),
|
||||
'email_type' => fake()->randomElement(DripEmailType::cases()),
|
||||
'sent_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
public function welcome(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'email_type' => DripEmailType::Welcome,
|
||||
]);
|
||||
}
|
||||
|
||||
public function onboardingReminder(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'email_type' => DripEmailType::OnboardingReminder,
|
||||
]);
|
||||
}
|
||||
|
||||
public function promoCode(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'email_type' => DripEmailType::PromoCode,
|
||||
]);
|
||||
}
|
||||
|
||||
public function importHelp(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'email_type' => DripEmailType::ImportHelp,
|
||||
]);
|
||||
}
|
||||
|
||||
public function feedback(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'email_type' => DripEmailType::Feedback,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('user_mail_logs', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('email_type', 50);
|
||||
$table->timestamp('sent_at');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'email_type']);
|
||||
$table->index('email_type');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user_mail_logs');
|
||||
}
|
||||
};
|
||||
|
|
@ -54,11 +54,22 @@ autorestart=true
|
|||
stderr_logfile=/var/log/worker-inertia-ssr.log
|
||||
stdout_logfile=/var/log/worker-inertia-ssr.log
|
||||
|
||||
# [program:horizon]
|
||||
# process_name=%(program_name)s
|
||||
# command=php /app/artisan horizon
|
||||
# autostart=true
|
||||
# autorestart=true
|
||||
# redirect_stderr=true
|
||||
# stdout_logfile=/var/log/horizon.log
|
||||
# stopwaitsecs=3600
|
||||
[program:queue-worker-emails]
|
||||
process_name=%(program_name)s
|
||||
command=php /app/artisan queue:work database --queue=emails --sleep=1 --tries=5 --max-time=3600
|
||||
autostart=true
|
||||
autorestart=true
|
||||
redirect_stderr=true
|
||||
stdout_logfile=/var/log/queue-worker-emails.log
|
||||
stopwaitsecs=3600
|
||||
numprocs=1
|
||||
|
||||
[program:queue-worker]
|
||||
process_name=%(program_name)s_%(process_num)02d
|
||||
command=php /app/artisan queue:work database --queue=default --sleep=3 --tries=3 --max-time=3600
|
||||
autostart=true
|
||||
autorestart=true
|
||||
redirect_stderr=true
|
||||
stdout_logfile=/var/log/queue-worker.log
|
||||
stopwaitsecs=3600
|
||||
numprocs=2
|
||||
|
|
|
|||
14
package.json
|
|
@ -1,5 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "whisper-money",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
@ -9,11 +11,18 @@
|
|||
"format": "prettier --write resources/",
|
||||
"format:check": "prettier --check resources/",
|
||||
"lint": "eslint . --fix",
|
||||
"release": "release-it",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"types": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@laravel/vite-plugin-wayfinder": "^0.1.3",
|
||||
"@release-it/conventional-changelog": "^10.0.4",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/json-logic-js": "^2.0.8",
|
||||
"@types/node": "^22.13.5",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
|
|
@ -21,11 +30,14 @@
|
|||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-react": "^7.37.3",
|
||||
"eslint-plugin-react-hooks": "^7.0.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"playwright": "^1.57.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-organize-imports": "^4.1.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"typescript-eslint": "^8.23.0"
|
||||
"release-it": "^19.2.2",
|
||||
"typescript-eslint": "^8.23.0",
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^2.2.0",
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
|
@ -210,4 +210,12 @@
|
|||
.bg-linear-to-l {
|
||||
background: linear-gradient(to left, var(--tw-gradient-stops));
|
||||
}
|
||||
|
||||
/* Charts */
|
||||
.recharts-responsive-container:focus-visible,
|
||||
svg:focus,
|
||||
g:focus,
|
||||
path:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import {
|
||||
ChartViewToggle,
|
||||
MoMChart,
|
||||
MoMPercentChart,
|
||||
} from '@/components/charts';
|
||||
import { PercentageTrendIndicator } from '@/components/dashboard/percentage-trend-indicator';
|
||||
import { EncryptedText } from '@/components/encrypted-text';
|
||||
import { AmountDisplay } from '@/components/ui/amount-display';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
|
@ -13,9 +19,13 @@ import {
|
|||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from '@/components/ui/chart';
|
||||
import {
|
||||
convertSingleAccountData,
|
||||
useChartViews,
|
||||
} from '@/hooks/use-chart-views';
|
||||
import { Account } from '@/types/account';
|
||||
import { format, subMonths } from 'date-fns';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Bar, BarChart, XAxis } from 'recharts';
|
||||
|
||||
interface BalanceDataPoint {
|
||||
|
|
@ -145,6 +155,23 @@ export function AccountBalanceChart({
|
|||
};
|
||||
}, [balanceData]);
|
||||
|
||||
// Convert data for useChartViews hook
|
||||
const { data: hookData, accounts: hookAccounts } = useMemo(() => {
|
||||
return convertSingleAccountData(
|
||||
chartData,
|
||||
account.id,
|
||||
account.type,
|
||||
account.currency_code,
|
||||
);
|
||||
}, [chartData, account.id, account.type, account.currency_code]);
|
||||
|
||||
const chartViews = useChartViews({
|
||||
data: hookData,
|
||||
accounts: hookAccounts,
|
||||
initialView: 'stacked',
|
||||
hasStackedView: true,
|
||||
});
|
||||
|
||||
const chartConfig: ChartConfig = {
|
||||
value: {
|
||||
label: (
|
||||
|
|
@ -162,6 +189,17 @@ export function AccountBalanceChart({
|
|||
return formatCurrency(value, account.currency_code);
|
||||
};
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const minBarWidth = 50;
|
||||
const minChartWidth = chartData.length * minBarWidth;
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollLeft =
|
||||
scrollContainerRef.current.scrollWidth;
|
||||
}
|
||||
}, [chartData]);
|
||||
|
||||
if (initialLoading || isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
|
|
@ -194,11 +232,23 @@ export function AccountBalanceChart({
|
|||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="group">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-row items-start justify-between">
|
||||
<div className="flex flex-col gap-1 sm:gap-2">
|
||||
<CardTitle>Balance evolution</CardTitle>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBalanceClick}
|
||||
className="-ml-3 cursor-pointer rounded-md px-2 py-1 text-left text-4xl font-semibold tabular-nums transition-colors hover:bg-muted"
|
||||
>
|
||||
<AmountDisplay
|
||||
amountInCents={currentBalance}
|
||||
currencyCode={account.currency_code}
|
||||
minimumFractionDigits={0}
|
||||
maximumFractionDigits={0}
|
||||
/>
|
||||
</button>
|
||||
<CardDescription className="flex flex-col gap-1 text-sm">
|
||||
<PercentageTrendIndicator
|
||||
trend={monthlyTrend?.percentage ?? null}
|
||||
|
|
@ -216,48 +266,67 @@ export function AccountBalanceChart({
|
|||
/>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBalanceClick}
|
||||
className="-mr-2 cursor-pointer rounded-md px-2 py-1 text-4xl font-semibold tabular-nums transition-colors hover:bg-muted"
|
||||
>
|
||||
{formatCurrency(
|
||||
currentBalance,
|
||||
account.currency_code,
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<ChartViewToggle
|
||||
value={chartViews.currentView}
|
||||
onValueChange={chartViews.setCurrentView}
|
||||
availableViews={chartViews.availableViews}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-[300px] w-full"
|
||||
>
|
||||
<BarChart accessibilityLayer data={chartData}>
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={formatXAxisLabel}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
hideLabel
|
||||
valueFormatter={valueFormatter}
|
||||
<CardContent className="relative">
|
||||
{chartViews.currentView === 'stacked' && (
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="h-[300px] w-full overflow-x-auto"
|
||||
>
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-full w-full"
|
||||
style={{ minWidth: `${minChartWidth}px` }}
|
||||
>
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={chartData.slice(1)}
|
||||
>
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={formatXAxisLabel}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="value"
|
||||
fill="var(--color-chart-2)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
hideLabel
|
||||
valueFormatter={valueFormatter}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="value"
|
||||
fill="var(--color-chart-2)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)}
|
||||
{chartViews.currentView === 'mom' && (
|
||||
<MoMChart
|
||||
data={chartViews.deltaSeries}
|
||||
currencyCode={account.currency_code}
|
||||
xAxisFormatter={formatXAxisLabel}
|
||||
className="h-[300px] w-full"
|
||||
/>
|
||||
)}
|
||||
{chartViews.currentView === 'mom_percent' && (
|
||||
<MoMPercentChart
|
||||
data={chartViews.momPercentSeries}
|
||||
xAxisFormatter={formatXAxisLabel}
|
||||
className="h-[300px] w-full"
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ export function CreateAccountDialog({ onSuccess }: { onSuccess?: () => void }) {
|
|||
</TooltipProvider>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogContent hasKeyboard className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Account</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ export function EditAccountDialog({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogContent hasKeyboard className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Account</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ export function UpdateBalanceDialog({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogContent hasKeyboard className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update balance</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export function CreateCategoryDialog({
|
|||
<DialogTrigger asChild>
|
||||
<Button>Create Category</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogContent hasKeyboard className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Category</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export function EditCategoryDialog({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogContent hasKeyboard className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Category</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { ChartViewType } from '@/hooks/use-chart-views';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { BarChart3, Percent, TrendingUp } from 'lucide-react';
|
||||
|
||||
interface ChartViewToggleProps {
|
||||
value: ChartViewType;
|
||||
onValueChange: (value: ChartViewType) => void;
|
||||
availableViews: ChartViewType[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const viewConfig: Record<
|
||||
ChartViewType,
|
||||
{ icon: React.ElementType; label: string; tooltip: string }
|
||||
> = {
|
||||
stacked: {
|
||||
icon: BarChart3,
|
||||
label: 'Aggregate',
|
||||
tooltip: 'Monthly balance',
|
||||
},
|
||||
mom: { icon: TrendingUp, label: 'MoM', tooltip: 'Month over month change' },
|
||||
mom_percent: {
|
||||
icon: Percent,
|
||||
label: 'MoM%',
|
||||
tooltip: 'Month over month change (%)',
|
||||
},
|
||||
};
|
||||
|
||||
export function ChartViewToggle({
|
||||
value,
|
||||
onValueChange,
|
||||
availableViews,
|
||||
className,
|
||||
}: ChartViewToggleProps) {
|
||||
return (
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={value}
|
||||
onValueChange={(v) => {
|
||||
if (v) onValueChange(v as ChartViewType);
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn('', className)}
|
||||
>
|
||||
{availableViews.map((view) => {
|
||||
const config = viewConfig[view];
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<Tooltip key={view}>
|
||||
<TooltipTrigger asChild>
|
||||
<ToggleGroupItem
|
||||
value={view}
|
||||
aria-label={config.label}
|
||||
className="px-2"
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
</ToggleGroupItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{config.tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</ToggleGroup>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { ChartViewToggle } from './chart-view-toggle';
|
||||
export { MoMChart } from './mom-chart';
|
||||
export { MoMPercentChart } from './mom-percent-chart';
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import { AmountDisplay } from '@/components/ui/amount-display';
|
||||
import { ChartConfig, ChartContainer } from '@/components/ui/chart';
|
||||
import { ChangeDataPoint } from '@/lib/chart-calculations';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
Cell,
|
||||
ReferenceLine,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
interface MoMChartProps {
|
||||
data: ChangeDataPoint[];
|
||||
currencyCode: string;
|
||||
xAxisFormatter?: (value: string) => string;
|
||||
className?: string;
|
||||
minBarWidth?: number;
|
||||
}
|
||||
|
||||
const chartConfig: ChartConfig = {
|
||||
change: {
|
||||
label: 'Change',
|
||||
color: 'var(--color-chart-1)',
|
||||
},
|
||||
};
|
||||
|
||||
interface TooltipPayloadItem {
|
||||
payload?: {
|
||||
month?: string;
|
||||
value?: number;
|
||||
change?: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface CustomTooltipProps {
|
||||
active?: boolean;
|
||||
payload?: TooltipPayloadItem[];
|
||||
currencyCode: string;
|
||||
}
|
||||
|
||||
function CustomTooltip({ active, payload, currencyCode }: CustomTooltipProps) {
|
||||
if (!active || !payload?.length) return null;
|
||||
|
||||
const data = payload[0].payload;
|
||||
if (!data) return null;
|
||||
|
||||
const change = data.change;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
|
||||
<div className="font-medium">{data.month}</div>
|
||||
<div className="mt-1 flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">Change</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono font-medium tabular-nums',
|
||||
change !== null && change > 0 && 'text-emerald-600',
|
||||
change !== null && change < 0 && 'text-red-600',
|
||||
)}
|
||||
>
|
||||
<AmountDisplay
|
||||
amountInCents={change ?? 0}
|
||||
currencyCode={currencyCode}
|
||||
minimumFractionDigits={0}
|
||||
maximumFractionDigits={0}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MoMChart({
|
||||
data,
|
||||
currencyCode,
|
||||
xAxisFormatter,
|
||||
className,
|
||||
minBarWidth = 50,
|
||||
}: MoMChartProps) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const minChartWidth = data.length * minBarWidth;
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollLeft =
|
||||
scrollContainerRef.current.scrollWidth;
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const chartData = data.map((point) => ({
|
||||
...point,
|
||||
displayValue: point.change,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={cn('overflow-x-auto', className)}
|
||||
>
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-full w-full"
|
||||
style={{ minWidth: `${minChartWidth}px` }}
|
||||
>
|
||||
<BarChart accessibilityLayer data={chartData}>
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={xAxisFormatter}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
}).format(value / 100);
|
||||
}}
|
||||
width={50}
|
||||
/>
|
||||
<ReferenceLine
|
||||
y={0}
|
||||
stroke="var(--color-border)"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<Tooltip
|
||||
content={<CustomTooltip currencyCode={currencyCode} />}
|
||||
cursor={{ fill: 'var(--color-muted)', opacity: 0.3 }}
|
||||
/>
|
||||
<Bar dataKey="displayValue" radius={[4, 4, 0, 0]}>
|
||||
{chartData.map((entry, index) => {
|
||||
const value = entry.displayValue;
|
||||
let fill = 'var(--color-muted)';
|
||||
if (value !== null) {
|
||||
fill =
|
||||
value >= 0
|
||||
? 'var(--color-chart-2)'
|
||||
: 'var(--color-chart-5)';
|
||||
}
|
||||
return <Cell key={`cell-${index}`} fill={fill} />;
|
||||
})}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import { ChartConfig, ChartContainer } from '@/components/ui/chart';
|
||||
import { formatPercentValue, PercentDataPoint } from '@/lib/chart-calculations';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
Cell,
|
||||
ReferenceLine,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
interface MoMPercentChartProps {
|
||||
data: PercentDataPoint[];
|
||||
xAxisFormatter?: (value: string) => string;
|
||||
className?: string;
|
||||
minBarWidth?: number;
|
||||
}
|
||||
|
||||
const chartConfig: ChartConfig = {
|
||||
percent: {
|
||||
label: 'Change %',
|
||||
color: 'var(--color-chart-1)',
|
||||
},
|
||||
};
|
||||
|
||||
interface TooltipPayloadItem {
|
||||
payload?: {
|
||||
month?: string;
|
||||
value?: number;
|
||||
percent?: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface CustomTooltipProps {
|
||||
active?: boolean;
|
||||
payload?: TooltipPayloadItem[];
|
||||
}
|
||||
|
||||
function CustomTooltip({ active, payload }: CustomTooltipProps) {
|
||||
if (!active || !payload?.length) return null;
|
||||
|
||||
const data = payload[0].payload;
|
||||
if (!data) return null;
|
||||
|
||||
const percent = data.percent;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
|
||||
<div className="font-medium">{data.month}</div>
|
||||
<div className="mt-1 flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">Change</span>
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono font-medium tabular-nums',
|
||||
percent !== null && percent > 0 && 'text-emerald-600',
|
||||
percent !== null && percent < 0 && 'text-red-600',
|
||||
)}
|
||||
>
|
||||
{formatPercentValue(percent ?? null)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MoMPercentChart({
|
||||
data,
|
||||
xAxisFormatter,
|
||||
className,
|
||||
minBarWidth = 50,
|
||||
}: MoMPercentChartProps) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const minChartWidth = data.length * minBarWidth;
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollLeft =
|
||||
scrollContainerRef.current.scrollWidth;
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const chartData = data.map((point) => ({
|
||||
...point,
|
||||
displayValue: point.percent,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className={cn('overflow-x-auto', className)}
|
||||
>
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-full w-full"
|
||||
style={{ minWidth: `${minChartWidth}px` }}
|
||||
>
|
||||
<BarChart accessibilityLayer data={chartData}>
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={xAxisFormatter}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value: number) =>
|
||||
`${value.toFixed(0)}%`
|
||||
}
|
||||
width={50}
|
||||
/>
|
||||
<ReferenceLine
|
||||
y={0}
|
||||
stroke="var(--color-border)"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<Tooltip
|
||||
content={<CustomTooltip />}
|
||||
cursor={{ fill: 'var(--color-muted)', opacity: 0.3 }}
|
||||
/>
|
||||
<Bar dataKey="displayValue" radius={[4, 4, 0, 0]}>
|
||||
{chartData.map((entry, index) => {
|
||||
const value = entry.displayValue;
|
||||
let fill = 'var(--color-muted)';
|
||||
if (value !== null) {
|
||||
fill =
|
||||
value >= 0
|
||||
? 'var(--color-chart-2)'
|
||||
: 'var(--color-chart-5)';
|
||||
}
|
||||
return <Cell key={`cell-${index}`} fill={fill} />;
|
||||
})}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,8 @@
|
|||
import {
|
||||
ChartViewToggle,
|
||||
MoMChart,
|
||||
MoMPercentChart,
|
||||
} from '@/components/charts';
|
||||
import { EncryptedText } from '@/components/encrypted-text';
|
||||
import { AmountDisplay } from '@/components/ui/amount-display';
|
||||
import {
|
||||
|
|
@ -9,7 +14,9 @@ import {
|
|||
} from '@/components/ui/card';
|
||||
import { ChartConfig } from '@/components/ui/chart';
|
||||
import { StackedBarChart } from '@/components/ui/stacked-bar-chart';
|
||||
import { useChartViews } from '@/hooks/use-chart-views';
|
||||
import { NetWorthEvolutionData } from '@/hooks/use-dashboard-data';
|
||||
import { AccountInfo } from '@/lib/chart-calculations';
|
||||
import { useMemo } from 'react';
|
||||
import { PercentageTrendIndicator } from './percentage-trend-indicator';
|
||||
|
||||
|
|
@ -140,6 +147,7 @@ export function NetWorthChart({
|
|||
currencyTotals,
|
||||
accountCurrencies,
|
||||
primaryCurrency,
|
||||
accountsForHook,
|
||||
} = useMemo(() => {
|
||||
const accounts = data.accounts || {};
|
||||
const accountIds = Object.keys(accounts);
|
||||
|
|
@ -147,6 +155,7 @@ export function NetWorthChart({
|
|||
|
||||
const config: ChartConfig = {};
|
||||
const currencies: Record<string, string> = {};
|
||||
const hookAccounts: Record<string, AccountInfo> = {};
|
||||
|
||||
accountIds.forEach((id) => {
|
||||
const account = accounts[id];
|
||||
|
|
@ -156,6 +165,13 @@ export function NetWorthChart({
|
|||
if (account?.currency_code) {
|
||||
currencies[id] = account.currency_code;
|
||||
}
|
||||
if (account) {
|
||||
hookAccounts[id] = {
|
||||
id: account.id,
|
||||
type: account.type,
|
||||
currency_code: account.currency_code,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const totals: Record<string, number> = {};
|
||||
|
|
@ -192,9 +208,17 @@ export function NetWorthChart({
|
|||
currencyTotals: currencyTotalsList,
|
||||
accountCurrencies: currencies,
|
||||
primaryCurrency: primary,
|
||||
accountsForHook: hookAccounts,
|
||||
};
|
||||
}, [data]);
|
||||
|
||||
const chartViews = useChartViews({
|
||||
data: chartData,
|
||||
accounts: accountsForHook,
|
||||
initialView: 'stacked',
|
||||
hasStackedView: true,
|
||||
});
|
||||
|
||||
const valueFormatter = useMemo(() => {
|
||||
return (value: number, accountId?: string): React.ReactNode => {
|
||||
const currency =
|
||||
|
|
@ -244,12 +268,15 @@ export function NetWorthChart({
|
|||
}
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<Card className="group overflow-hidden">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex flex-row items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<CardTitle>Net Worth Evolution</CardTitle>
|
||||
<CardDescription className="flex flex-col gap-1 text-sm">
|
||||
<div className="text-foreground">
|
||||
<TotalDisplay totals={currencyTotals} />
|
||||
</div>
|
||||
<PercentageTrendIndicator
|
||||
trend={monthlyTrend?.percentage ?? null}
|
||||
label="this month"
|
||||
|
|
@ -267,23 +294,42 @@ export function NetWorthChart({
|
|||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
<TotalDisplay totals={currencyTotals} />
|
||||
</div>
|
||||
<ChartViewToggle
|
||||
value={chartViews.currentView}
|
||||
onValueChange={chartViews.setCurrentView}
|
||||
availableViews={chartViews.availableViews}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="min-w-0">
|
||||
<StackedBarChart
|
||||
data={chartData}
|
||||
dataKeys={dataKeys}
|
||||
config={chartConfig}
|
||||
xAxisKey="month"
|
||||
xAxisFormatter={formatXAxisLabel}
|
||||
valueFormatter={valueFormatter}
|
||||
accountCurrencies={accountCurrencies}
|
||||
className="h-[300px] w-full"
|
||||
showLegend={showLegend}
|
||||
/>
|
||||
<CardContent className="relative min-w-0">
|
||||
{chartViews.currentView === 'stacked' && (
|
||||
<StackedBarChart
|
||||
data={chartData.slice(1)}
|
||||
dataKeys={dataKeys}
|
||||
config={chartConfig}
|
||||
xAxisKey="month"
|
||||
xAxisFormatter={formatXAxisLabel}
|
||||
valueFormatter={valueFormatter}
|
||||
accountCurrencies={accountCurrencies}
|
||||
className="h-[300px] w-full"
|
||||
showLegend={showLegend}
|
||||
/>
|
||||
)}
|
||||
{chartViews.currentView === 'mom' && (
|
||||
<MoMChart
|
||||
data={chartViews.deltaSeries}
|
||||
currencyCode={primaryCurrency}
|
||||
xAxisFormatter={formatXAxisLabel}
|
||||
className="h-[300px] w-full"
|
||||
/>
|
||||
)}
|
||||
{chartViews.currentView === 'mom_percent' && (
|
||||
<MoMPercentChart
|
||||
data={chartViews.momPercentSeries}
|
||||
xAxisFormatter={formatXAxisLabel}
|
||||
className="h-[300px] w-full"
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export function CreateLabelDialog({ onSuccess }: { onSuccess?: () => void }) {
|
|||
<DialogTrigger asChild>
|
||||
<Button>Create Label</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogContent hasKeyboard className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Label</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export function EditLabelDialog({
|
|||
}: EditLabelDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogContent hasKeyboard className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Label</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
|
|||
|
|
@ -103,8 +103,8 @@ export function TransactionFilters({
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col items-center gap-3 sm:flex-row">
|
||||
<div className="flex w-full flex-row items-center gap-2 sm:w-auto">
|
||||
<div className="flex flex-col items-center gap-3 lg:flex-row">
|
||||
<div className="flex w-full flex-row items-center gap-2 lg:w-auto">
|
||||
<Input
|
||||
placeholder={
|
||||
isKeySet
|
||||
|
|
@ -119,7 +119,7 @@ export function TransactionFilters({
|
|||
})
|
||||
}
|
||||
disabled={!isKeySet}
|
||||
className="max-w-sm flex-1 sm:min-w-[200px] md:min-w-[300px]"
|
||||
className="max-w-sm flex-1 md:max-w-full"
|
||||
/>
|
||||
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
|
|
|
|||
|
|
@ -50,9 +50,11 @@ function DialogContent({
|
|||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
hasKeyboard = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
showCloseButton?: boolean,
|
||||
hasKeyboard?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
|
|
@ -60,7 +62,9 @@ function DialogContent({
|
|||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-1.5rem)] max-h-[95vh] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 overflow-y-auto sm:max-w-lg",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] z-50 grid w-full max-w-[calc(100%-1.5rem)] max-h-[95vh] translate-x-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 overflow-y-auto sm:max-w-lg",
|
||||
hasKeyboard && "top-[10%] sm:top-[50%] sm:translate-y-[-50%]",
|
||||
!hasKeyboard && "top-[50%] translate-y-[-50%]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ export default function UnlockMessageDialog({
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogContent hasKeyboard>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Unlock Encrypted Message</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ import { useMobileNavigation } from '@/hooks/use-mobile-navigation';
|
|||
import { clearKey } from '@/lib/key-storage';
|
||||
import { logout } from '@/routes';
|
||||
import accounts from '@/routes/accounts';
|
||||
import { type User } from '@/types';
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
import { type SharedData, type User } from '@/types';
|
||||
import { Link, router, usePage } from '@inertiajs/react';
|
||||
import { Eye, EyeOff, LogOut, Settings } from 'lucide-react';
|
||||
|
||||
interface UserMenuContentProps {
|
||||
|
|
@ -22,6 +22,7 @@ interface UserMenuContentProps {
|
|||
export function UserMenuContent({ user }: UserMenuContentProps) {
|
||||
const cleanup = useMobileNavigation();
|
||||
const { isPrivacyModeEnabled, togglePrivacyMode } = usePrivacyMode();
|
||||
const { version } = usePage<SharedData>().props;
|
||||
|
||||
const handleLogout = () => {
|
||||
clearKey();
|
||||
|
|
@ -86,6 +87,10 @@ export function UserMenuContent({ user }: UserMenuContentProps) {
|
|||
Log out
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 py-1.5 text-center text-xs text-muted-foreground">
|
||||
v{version}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
import {
|
||||
AccountInfo,
|
||||
ChangeDataPoint,
|
||||
computeDeltaSeries,
|
||||
computeMoMPercent,
|
||||
computeNetWorthSeries,
|
||||
MonthDataPoint,
|
||||
PercentDataPoint,
|
||||
} from '@/lib/chart-calculations';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
export type ChartViewType = 'stacked' | 'mom' | 'mom_percent';
|
||||
|
||||
export interface UseChartViewsOptions {
|
||||
/** Raw monthly data with account balances */
|
||||
data: Array<Record<string, string | number>>;
|
||||
/** Account information keyed by account ID */
|
||||
accounts: Record<string, AccountInfo>;
|
||||
/** Initial view type */
|
||||
initialView?: ChartViewType;
|
||||
/** Whether to show stacked view option (false for single account charts) */
|
||||
hasStackedView?: boolean;
|
||||
}
|
||||
|
||||
export interface UseChartViewsReturn {
|
||||
// View state
|
||||
currentView: ChartViewType;
|
||||
setCurrentView: (view: ChartViewType) => void;
|
||||
availableViews: ChartViewType[];
|
||||
|
||||
// Computed series
|
||||
netWorthSeries: MonthDataPoint[];
|
||||
deltaSeries: ChangeDataPoint[];
|
||||
momPercentSeries: PercentDataPoint[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing chart view state and computed series
|
||||
* Shared between net-worth-chart and account-balance-chart
|
||||
*/
|
||||
export function useChartViews({
|
||||
data,
|
||||
accounts,
|
||||
initialView = 'stacked',
|
||||
hasStackedView = true,
|
||||
}: UseChartViewsOptions): UseChartViewsReturn {
|
||||
// View state
|
||||
const [currentView, setCurrentViewState] = useState<ChartViewType>(
|
||||
hasStackedView
|
||||
? initialView
|
||||
: initialView === 'stacked'
|
||||
? 'mom'
|
||||
: initialView,
|
||||
);
|
||||
|
||||
// Available views based on chart type
|
||||
const availableViews = useMemo<ChartViewType[]>(() => {
|
||||
if (hasStackedView) {
|
||||
return ['stacked', 'mom', 'mom_percent'];
|
||||
}
|
||||
return ['mom', 'mom_percent'];
|
||||
}, [hasStackedView]);
|
||||
|
||||
// Set current view with validation
|
||||
const setCurrentView = useCallback(
|
||||
(view: ChartViewType) => {
|
||||
if (availableViews.includes(view)) {
|
||||
setCurrentViewState(view);
|
||||
}
|
||||
},
|
||||
[availableViews],
|
||||
);
|
||||
|
||||
// Compute all chart series in a single memoized block
|
||||
// The first month is needed for MoM calculations but shouldn't be displayed,
|
||||
// so we slice(1) to show only 12 bars
|
||||
const { netWorthSeries, deltaSeries, momPercentSeries } = useMemo(() => {
|
||||
const fullNetWorth = computeNetWorthSeries(data, accounts);
|
||||
const fullDelta = computeDeltaSeries(fullNetWorth);
|
||||
const fullMomPercent = computeMoMPercent(fullNetWorth);
|
||||
|
||||
return {
|
||||
netWorthSeries: fullNetWorth.slice(1),
|
||||
deltaSeries: fullDelta.slice(1),
|
||||
momPercentSeries: fullMomPercent.slice(1),
|
||||
};
|
||||
}, [data, accounts]);
|
||||
|
||||
return {
|
||||
// View state
|
||||
currentView,
|
||||
setCurrentView,
|
||||
availableViews,
|
||||
|
||||
// Computed series (with first month removed)
|
||||
netWorthSeries,
|
||||
deltaSeries,
|
||||
momPercentSeries,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert single-account balance data to the format expected by useChartViews
|
||||
* For use in account-balance-chart
|
||||
*/
|
||||
export function convertSingleAccountData(
|
||||
balanceData: Array<{ month: string; timestamp?: number; value: number }>,
|
||||
accountId: string,
|
||||
accountType: string,
|
||||
currencyCode: string,
|
||||
): {
|
||||
data: Array<Record<string, string | number>>;
|
||||
accounts: Record<string, AccountInfo>;
|
||||
} {
|
||||
const data = balanceData.map((point) => ({
|
||||
month: point.month,
|
||||
timestamp: point.timestamp ?? 0,
|
||||
[accountId]: point.value,
|
||||
}));
|
||||
|
||||
const accounts: Record<string, AccountInfo> = {
|
||||
[accountId]: {
|
||||
id: accountId,
|
||||
type: accountType as AccountInfo['type'],
|
||||
currency_code: currencyCode,
|
||||
},
|
||||
};
|
||||
|
||||
return { data, accounts };
|
||||
}
|
||||
|
|
@ -0,0 +1,320 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
AccountInfo,
|
||||
computeDeltaSeries,
|
||||
computeMoMPercent,
|
||||
computeNetWorthSeries,
|
||||
formatPercentValue,
|
||||
getAccountSign,
|
||||
isLiabilityType,
|
||||
MonthDataPoint,
|
||||
} from './chart-calculations';
|
||||
|
||||
describe('isLiabilityType', () => {
|
||||
it('returns true for credit_card', () => {
|
||||
expect(isLiabilityType('credit_card')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for loan', () => {
|
||||
expect(isLiabilityType('loan')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for checking', () => {
|
||||
expect(isLiabilityType('checking')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for savings', () => {
|
||||
expect(isLiabilityType('savings')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for investment', () => {
|
||||
expect(isLiabilityType('investment')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for retirement', () => {
|
||||
expect(isLiabilityType('retirement')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for others', () => {
|
||||
expect(isLiabilityType('others')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAccountSign', () => {
|
||||
it('returns -1 for liabilities', () => {
|
||||
expect(getAccountSign('credit_card')).toBe(-1);
|
||||
expect(getAccountSign('loan')).toBe(-1);
|
||||
});
|
||||
|
||||
it('returns 1 for assets', () => {
|
||||
expect(getAccountSign('checking')).toBe(1);
|
||||
expect(getAccountSign('savings')).toBe(1);
|
||||
expect(getAccountSign('investment')).toBe(1);
|
||||
expect(getAccountSign('retirement')).toBe(1);
|
||||
expect(getAccountSign('others')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeNetWorthSeries', () => {
|
||||
const createAccounts = (
|
||||
types: Record<string, 'checking' | 'savings' | 'credit_card' | 'loan'>,
|
||||
): Record<string, AccountInfo> => {
|
||||
const accounts: Record<string, AccountInfo> = {};
|
||||
for (const [id, type] of Object.entries(types)) {
|
||||
accounts[id] = { id, type, currency_code: 'EUR' };
|
||||
}
|
||||
return accounts;
|
||||
};
|
||||
|
||||
it('returns empty array for empty data', () => {
|
||||
const result = computeNetWorthSeries([], {});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('sums assets correctly', () => {
|
||||
const data = [
|
||||
{ month: '2025-01', acc1: 10000, acc2: 20000 },
|
||||
{ month: '2025-02', acc1: 15000, acc2: 25000 },
|
||||
];
|
||||
const accounts = createAccounts({ acc1: 'checking', acc2: 'savings' });
|
||||
|
||||
const result = computeNetWorthSeries(data, accounts);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({ month: '2025-01', value: 30000 });
|
||||
expect(result[1]).toEqual({ month: '2025-02', value: 40000 });
|
||||
});
|
||||
|
||||
it('subtracts liabilities from net worth', () => {
|
||||
const data = [
|
||||
{ month: '2025-01', checking: 50000, credit_card: 10000 },
|
||||
];
|
||||
const accounts = createAccounts({
|
||||
checking: 'checking',
|
||||
credit_card: 'credit_card',
|
||||
});
|
||||
|
||||
const result = computeNetWorthSeries(data, accounts);
|
||||
|
||||
expect(result[0].value).toBe(40000); // 50000 - 10000
|
||||
});
|
||||
|
||||
it('handles mixed assets and liabilities', () => {
|
||||
const data = [
|
||||
{
|
||||
month: '2025-01',
|
||||
savings: 100000,
|
||||
checking: 20000,
|
||||
credit_card: 5000,
|
||||
loan: 30000,
|
||||
},
|
||||
];
|
||||
const accounts = createAccounts({
|
||||
savings: 'savings',
|
||||
checking: 'checking',
|
||||
credit_card: 'credit_card',
|
||||
loan: 'loan',
|
||||
});
|
||||
|
||||
const result = computeNetWorthSeries(data, accounts);
|
||||
|
||||
// Net worth = 100000 + 20000 - 5000 - 30000 = 85000
|
||||
expect(result[0].value).toBe(85000);
|
||||
});
|
||||
|
||||
it('handles negative net worth (more liabilities than assets)', () => {
|
||||
const data = [{ month: '2025-01', checking: 10000, loan: 50000 }];
|
||||
const accounts = createAccounts({ checking: 'checking', loan: 'loan' });
|
||||
|
||||
const result = computeNetWorthSeries(data, accounts);
|
||||
|
||||
expect(result[0].value).toBe(-40000); // 10000 - 50000
|
||||
});
|
||||
|
||||
it('preserves timestamp if present', () => {
|
||||
const data = [{ month: '2025-01', timestamp: 1704067200, acc1: 10000 }];
|
||||
const accounts = createAccounts({ acc1: 'checking' });
|
||||
|
||||
const result = computeNetWorthSeries(data, accounts);
|
||||
|
||||
expect(result[0].timestamp).toBe(1704067200);
|
||||
});
|
||||
|
||||
it('skips non-numeric balance values', () => {
|
||||
const data = [{ month: '2025-01', acc1: 10000, acc2: 'invalid' }];
|
||||
const accounts = createAccounts({ acc1: 'checking', acc2: 'savings' });
|
||||
|
||||
const result = computeNetWorthSeries(data, accounts);
|
||||
|
||||
expect(result[0].value).toBe(10000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeDeltaSeries', () => {
|
||||
it('returns null change for first month', () => {
|
||||
const series: MonthDataPoint[] = [{ month: '2025-01', value: 10000 }];
|
||||
|
||||
const result = computeDeltaSeries(series);
|
||||
|
||||
expect(result[0].change).toBeNull();
|
||||
});
|
||||
|
||||
it('computes correct positive delta', () => {
|
||||
const series: MonthDataPoint[] = [
|
||||
{ month: '2025-01', value: 10000 },
|
||||
{ month: '2025-02', value: 15000 },
|
||||
];
|
||||
|
||||
const result = computeDeltaSeries(series);
|
||||
|
||||
expect(result[1].change).toBe(5000);
|
||||
});
|
||||
|
||||
it('computes correct negative delta', () => {
|
||||
const series: MonthDataPoint[] = [
|
||||
{ month: '2025-01', value: 15000 },
|
||||
{ month: '2025-02', value: 10000 },
|
||||
];
|
||||
|
||||
const result = computeDeltaSeries(series);
|
||||
|
||||
expect(result[1].change).toBe(-5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeMoMPercent', () => {
|
||||
it('returns null for first month', () => {
|
||||
const series: MonthDataPoint[] = [{ month: '2025-01', value: 10000 }];
|
||||
|
||||
const result = computeMoMPercent(series);
|
||||
|
||||
expect(result[0].percent).toBeNull();
|
||||
});
|
||||
|
||||
it('computes correct positive percentage', () => {
|
||||
const series: MonthDataPoint[] = [
|
||||
{ month: '2025-01', value: 10000 },
|
||||
{ month: '2025-02', value: 11000 },
|
||||
];
|
||||
|
||||
const result = computeMoMPercent(series);
|
||||
|
||||
expect(result[1].percent).toBe(10); // +10%
|
||||
});
|
||||
|
||||
it('computes correct negative percentage', () => {
|
||||
const series: MonthDataPoint[] = [
|
||||
{ month: '2025-01', value: 10000 },
|
||||
{ month: '2025-02', value: 9000 },
|
||||
];
|
||||
|
||||
const result = computeMoMPercent(series);
|
||||
|
||||
expect(result[1].percent).toBe(-10); // -10%
|
||||
});
|
||||
|
||||
it('returns null when previous value is zero (division by zero guard)', () => {
|
||||
const series: MonthDataPoint[] = [
|
||||
{ month: '2025-01', value: 0 },
|
||||
{ month: '2025-02', value: 10000 },
|
||||
];
|
||||
|
||||
const result = computeMoMPercent(series);
|
||||
|
||||
expect(result[1].percent).toBeNull();
|
||||
});
|
||||
|
||||
it('handles negative to positive transition correctly', () => {
|
||||
const series: MonthDataPoint[] = [
|
||||
{ month: '2025-01', value: -10000 },
|
||||
{ month: '2025-02', value: 5000 },
|
||||
];
|
||||
|
||||
const result = computeMoMPercent(series);
|
||||
|
||||
// Change of 15000 relative to absolute value of -10000 = 150%
|
||||
expect(result[1].percent).toBe(150);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases for slice(1) in useChartViews hook', () => {
|
||||
// The hook slices off the first month (needed for MoM calculation baseline)
|
||||
// These tests document expected behavior for edge cases
|
||||
|
||||
it('single month data results in empty series after slice', () => {
|
||||
const data = [{ month: '2025-01', acc1: 10000 }];
|
||||
const accounts: Record<string, AccountInfo> = {
|
||||
acc1: { id: 'acc1', type: 'checking', currency_code: 'EUR' },
|
||||
};
|
||||
|
||||
const netWorth = computeNetWorthSeries(data, accounts);
|
||||
const delta = computeDeltaSeries(netWorth);
|
||||
const momPercent = computeMoMPercent(netWorth);
|
||||
|
||||
// All series have 1 element before slicing
|
||||
expect(netWorth).toHaveLength(1);
|
||||
expect(delta).toHaveLength(1);
|
||||
expect(momPercent).toHaveLength(1);
|
||||
|
||||
// After slice(1), all become empty
|
||||
expect(netWorth.slice(1)).toHaveLength(0);
|
||||
expect(delta.slice(1)).toHaveLength(0);
|
||||
expect(momPercent.slice(1)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('two months data results in single data point after slice', () => {
|
||||
const data = [
|
||||
{ month: '2025-01', acc1: 10000 },
|
||||
{ month: '2025-02', acc1: 15000 },
|
||||
];
|
||||
const accounts: Record<string, AccountInfo> = {
|
||||
acc1: { id: 'acc1', type: 'checking', currency_code: 'EUR' },
|
||||
};
|
||||
|
||||
const netWorth = computeNetWorthSeries(data, accounts);
|
||||
const delta = computeDeltaSeries(netWorth);
|
||||
const momPercent = computeMoMPercent(netWorth);
|
||||
|
||||
// After slice(1), we have 1 displayable data point
|
||||
expect(netWorth.slice(1)).toHaveLength(1);
|
||||
expect(delta.slice(1)).toHaveLength(1);
|
||||
expect(momPercent.slice(1)).toHaveLength(1);
|
||||
|
||||
// And the values are computed correctly
|
||||
expect(netWorth.slice(1)[0].value).toBe(15000);
|
||||
expect(delta.slice(1)[0].change).toBe(5000);
|
||||
expect(momPercent.slice(1)[0].percent).toBe(50);
|
||||
});
|
||||
|
||||
it('empty data results in empty series', () => {
|
||||
const netWorth = computeNetWorthSeries([], {});
|
||||
const delta = computeDeltaSeries(netWorth);
|
||||
const momPercent = computeMoMPercent(netWorth);
|
||||
|
||||
expect(netWorth.slice(1)).toHaveLength(0);
|
||||
expect(delta.slice(1)).toHaveLength(0);
|
||||
expect(momPercent.slice(1)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPercentValue', () => {
|
||||
it('returns dash for null values', () => {
|
||||
expect(formatPercentValue(null)).toBe('—');
|
||||
});
|
||||
|
||||
it('adds plus sign for positive values', () => {
|
||||
expect(formatPercentValue(10.5)).toBe('+10.5%');
|
||||
});
|
||||
|
||||
it('does not add sign for negative values', () => {
|
||||
expect(formatPercentValue(-5.3)).toBe('-5.3%');
|
||||
});
|
||||
|
||||
it('formats zero without sign', () => {
|
||||
expect(formatPercentValue(0)).toBe('0.0%');
|
||||
});
|
||||
|
||||
it('respects custom decimal places', () => {
|
||||
expect(formatPercentValue(10.567, { decimals: 2 })).toBe('+10.57%');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
import { AccountType } from '@/types/account';
|
||||
|
||||
/**
|
||||
* Account types that reduce net worth (liabilities)
|
||||
*/
|
||||
export const LIABILITY_TYPES: AccountType[] = ['credit_card', 'loan'];
|
||||
|
||||
/**
|
||||
* Check if an account type is a liability
|
||||
*/
|
||||
export function isLiabilityType(type: AccountType): boolean {
|
||||
return LIABILITY_TYPES.includes(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sign multiplier for an account type
|
||||
* Assets are positive (+1), Liabilities are negative (-1)
|
||||
*/
|
||||
export function getAccountSign(type: AccountType): 1 | -1 {
|
||||
return isLiabilityType(type) ? -1 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data point representing net worth for a month
|
||||
*/
|
||||
export interface MonthDataPoint {
|
||||
month: string;
|
||||
timestamp?: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data point with percentage change
|
||||
*/
|
||||
export interface PercentDataPoint extends MonthDataPoint {
|
||||
percent: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data point with absolute change
|
||||
*/
|
||||
export interface ChangeDataPoint extends MonthDataPoint {
|
||||
change: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Account info for calculations
|
||||
*/
|
||||
export interface AccountInfo {
|
||||
id: string;
|
||||
type: AccountType;
|
||||
currency_code: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute net worth series from raw balance data
|
||||
* Handles sign correctly: assets add to NW, liabilities subtract
|
||||
*/
|
||||
export function computeNetWorthSeries(
|
||||
data: Array<Record<string, string | number>>,
|
||||
accounts: Record<string, AccountInfo>,
|
||||
): MonthDataPoint[] {
|
||||
if (!data || data.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const accountIds = Object.keys(accounts);
|
||||
|
||||
return data.map((point) => {
|
||||
const month = point.month as string;
|
||||
const timestamp = point.timestamp as number | undefined;
|
||||
|
||||
const totalNetWorth = accountIds.reduce((sum, id) => {
|
||||
const balance = point[id];
|
||||
if (typeof balance !== 'number') return sum;
|
||||
|
||||
const account = accounts[id];
|
||||
if (!account) return sum + balance;
|
||||
|
||||
const sign = getAccountSign(account.type);
|
||||
return sum + sign * balance;
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
month,
|
||||
timestamp,
|
||||
value: totalNetWorth,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute delta series (change from previous month)
|
||||
*/
|
||||
export function computeDeltaSeries(
|
||||
nwSeries: MonthDataPoint[],
|
||||
): ChangeDataPoint[] {
|
||||
return nwSeries.map((point, index) => {
|
||||
if (index === 0) {
|
||||
return { ...point, change: null };
|
||||
}
|
||||
|
||||
const previousValue = nwSeries[index - 1].value;
|
||||
return {
|
||||
...point,
|
||||
change: point.value - previousValue,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute month-over-month percentage change
|
||||
*/
|
||||
export function computeMoMPercent(
|
||||
nwSeries: MonthDataPoint[],
|
||||
): PercentDataPoint[] {
|
||||
return nwSeries.map((point, index) => {
|
||||
if (index === 0) {
|
||||
return { ...point, percent: null };
|
||||
}
|
||||
|
||||
const previousValue = nwSeries[index - 1].value;
|
||||
|
||||
// Guard against division by zero or undefined previous value
|
||||
if (previousValue === 0) {
|
||||
return { ...point, percent: null };
|
||||
}
|
||||
|
||||
const percentChange =
|
||||
((point.value - previousValue) / Math.abs(previousValue)) * 100;
|
||||
|
||||
return { ...point, percent: percentChange };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format percentage value for display
|
||||
*/
|
||||
export function formatPercentValue(
|
||||
value: number | null,
|
||||
options?: { decimals?: number },
|
||||
): string {
|
||||
if (value === null) return '—';
|
||||
|
||||
const decimals = options?.decimals ?? 1;
|
||||
const formatted = value.toFixed(decimals);
|
||||
const sign = value > 0 ? '+' : '';
|
||||
|
||||
return `${sign}${formatted}%`;
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export const LEAD_FUNNEL_EVENT_UUID = '9668a06c-dee9-47a8-9bee-eaaa2a3a5915';
|
||||
export const IMPORT_FUNNEL_EVENT_UUID = 'eea25dce-75ef-4959-b975-a7e9094b3ef5';
|
||||
export const ONBOARDING_FUNNEL_EVENT_UUID = '8a10c1da-080b-44cc-8dcf-78734b29106c';
|
||||
export const ONBOARDING_FUNNEL_EVENT_UUID =
|
||||
'8a10c1da-080b-44cc-8dcf-78734b29106c';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import InputError from '@/components/input-error';
|
||||
import Header from '@/components/partials/header';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
|
|
@ -11,15 +9,18 @@ import {
|
|||
import { LEAD_FUNNEL_EVENT_UUID } from '@/lib/constants';
|
||||
import { trackEvent } from '@/lib/track-event';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { store } from '@/routes/user-leads';
|
||||
import { type SharedData } from '@/types';
|
||||
import { Plan } from '@/types/pricing';
|
||||
import { Form, Head, Link, usePage } from '@inertiajs/react';
|
||||
import { Head, Link, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
BellIcon,
|
||||
BrainIcon,
|
||||
Building2Icon,
|
||||
CheckIcon,
|
||||
CodeIcon,
|
||||
EyeOffIcon,
|
||||
FileUpIcon,
|
||||
KeyIcon,
|
||||
LockIcon,
|
||||
PieChartIcon,
|
||||
ShieldCheckIcon,
|
||||
|
|
@ -40,8 +41,6 @@ function LandingPlanCard({
|
|||
plan,
|
||||
isDefault,
|
||||
isBestValue,
|
||||
promoEnabled,
|
||||
promoBadge,
|
||||
}: {
|
||||
plan: Plan;
|
||||
isDefault: boolean;
|
||||
|
|
@ -68,16 +67,16 @@ function LandingPlanCard({
|
|||
Best Value
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute -top-12 -right-12 h-32 w-32 rounded-full bg-gradient-to-br from-emerald-500/20 to-teal-500/20 blur-3xl" />
|
||||
<div
|
||||
className={cn(
|
||||
'absolute -top-12 -right-12 h-32 w-32 rounded-full bg-gradient-to-br from-emerald-500/20 to-teal-500/20 blur-3xl',
|
||||
isBestValue && 'from-blue-500/20 to-cyan-500/20',
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="relative flex flex-1 flex-col p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-xl font-semibold">{plan.name}</h3>
|
||||
{promoEnabled && isDefault && (
|
||||
<div className="inline-block rounded-lg bg-emerald-100 px-2 py-1 text-xs font-semibold text-emerald-700 uppercase dark:bg-emerald-900/30 dark:text-emerald-400">
|
||||
{promoBadge}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-baseline gap-2">
|
||||
|
|
@ -135,7 +134,6 @@ export default function Welcome({
|
|||
const { appUrl, subscriptionsEnabled, pricing } =
|
||||
usePage<SharedData>().props;
|
||||
const planEntries = Object.entries(pricing.plans);
|
||||
const emailInputRef = useRef<HTMLInputElement>(null);
|
||||
const visitTrackedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -240,7 +238,7 @@ export default function Welcome({
|
|||
<span className="inline-flex items-center gap-2 rounded-full border border-[#e3e3e0] px-2.5 py-1 text-[0.8rem] font-medium dark:border-[#3E3E3A]">
|
||||
<LockIcon className="size-3.5 opacity-75" />
|
||||
<span className="text-[#706f6c] dark:text-[#A1A09A]">
|
||||
Military Grade Encryption
|
||||
E2E Encryption
|
||||
</span>
|
||||
</span>
|
||||
<h1 className="font-heading max-w-[840px] bg-gradient-to-r from-[#1b1b18] to-[#1b1b18] bg-clip-text text-4xl leading-tight font-semibold text-transparent drop-shadow-2xl sm:text-5xl sm:leading-tight lg:text-6xl lg:leading-tight dark:from-[#EDEDEC] dark:to-[#A1A09A]">
|
||||
|
|
@ -254,46 +252,15 @@ export default function Welcome({
|
|||
while keeping your information completely
|
||||
secure.
|
||||
</p>
|
||||
<div className="flex w-full max-w-lg flex-col gap-4">
|
||||
<Form
|
||||
{...store.form()}
|
||||
className="flex flex-col justify-center gap-2 sm:flex-row sm:items-center"
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<>
|
||||
<Input
|
||||
ref={emailInputRef}
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
className="h-12 border-[#e3e3e0] bg-[#FDFDFC] dark:border-[#3E3E3A] dark:bg-[#0a0a0a]"
|
||||
autoComplete="email"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
className="text-shadow duration h-12 cursor-pointer bg-gradient-to-t from-zinc-700 to-zinc-900 px-6 text-white shadow-sm transition-all hover:from-zinc-800 hover:to-black hover:shadow-md disabled:cursor-default dark:bg-[#eeeeec] dark:from-zinc-200 dark:to-zinc-300 dark:text-[#1C1C1A] dark:hover:bg-white hover:dark:from-zinc-50 dark:hover:shadow-md"
|
||||
>
|
||||
{processing
|
||||
? 'Submitting...'
|
||||
: 'Get Early Access'}
|
||||
</Button>
|
||||
{errors.email && (
|
||||
<div className="w-full">
|
||||
<InputError
|
||||
message={
|
||||
errors.email
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
<div className="flex w-full max-w-xs flex-col gap-4">
|
||||
<Link href="/register">
|
||||
<Button className="text-shadow duration h-14 w-full cursor-pointer bg-gradient-to-t from-zinc-700 to-zinc-900 text-base text-white shadow-sm transition-all hover:from-zinc-800 hover:to-black hover:shadow-md dark:bg-[#eeeeec] dark:from-zinc-200 dark:to-zinc-300 dark:text-[#1C1C1A] dark:hover:bg-white hover:dark:from-zinc-50 dark:hover:shadow-md">
|
||||
Get Started
|
||||
</Button>
|
||||
</Link>
|
||||
<p className="text-xs text-[#706f6c] dark:text-[#A1A09A]">
|
||||
Your data is yours alone. Join our
|
||||
waitlist for early access.
|
||||
Your data is yours alone. Sign up to get
|
||||
started.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -365,6 +332,170 @@ export default function Welcome({
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<section className="px-4 py-12 sm:py-24 md:py-32">
|
||||
<div className="mx-auto flex max-w-7xl flex-col items-center gap-8 sm:gap-12">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<h2 className="max-w-[720px] text-3xl leading-tight font-semibold text-balance sm:text-5xl sm:leading-tight">
|
||||
How End-to-End Encryption Works
|
||||
</h2>
|
||||
<p className="text-md max-w-[640px] font-medium text-[#706f6c] sm:text-xl dark:text-[#A1A09A]">
|
||||
Your financial data is encrypted on your
|
||||
device before it ever reaches our servers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full gap-8 sm:grid-cols-3">
|
||||
<div className="flex flex-col items-center gap-4 rounded-2xl border border-[#e3e3e0] bg-[#FDFDFC] p-8 text-center dark:border-[#3E3E3A] dark:bg-[#161615]">
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-emerald-500/10">
|
||||
<KeyIcon className="size-8 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold">
|
||||
Your Private Key
|
||||
</h3>
|
||||
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
|
||||
A unique encryption key is generated on
|
||||
your device. Only you have access to
|
||||
it—we never see or store it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-4 rounded-2xl border border-[#e3e3e0] bg-[#FDFDFC] p-8 text-center dark:border-[#3E3E3A] dark:bg-[#161615]">
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-emerald-500/10">
|
||||
<LockIcon className="size-8 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold">
|
||||
Client-Side Encryption
|
||||
</h3>
|
||||
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
|
||||
Your transactions, accounts, and budgets
|
||||
are encrypted on your device before
|
||||
syncing to the cloud.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-4 rounded-2xl border border-[#e3e3e0] bg-[#FDFDFC] p-8 text-center dark:border-[#3E3E3A] dark:bg-[#161615]">
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-emerald-500/10">
|
||||
<ShieldCheckIcon className="size-8 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold">
|
||||
Zero-Knowledge Architecture
|
||||
</h3>
|
||||
<p className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
|
||||
We store encrypted data we can't read.
|
||||
Even if our servers were compromised,
|
||||
your data stays secure.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="px-4 py-12 sm:py-24 md:py-32">
|
||||
<div className="mx-auto flex max-w-7xl flex-col items-center gap-8 sm:gap-12">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<h2 className="max-w-[720px] text-3xl leading-tight font-semibold sm:text-5xl sm:leading-tight">
|
||||
Privacy by Design
|
||||
</h2>
|
||||
<p className="text-md max-w-[640px] font-medium text-[#706f6c] sm:text-xl dark:text-[#A1A09A]">
|
||||
No AI. No bank connections. Your privacy is
|
||||
our priority.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full gap-8 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-6 rounded-2xl border border-[#e3e3e0] bg-[#FDFDFC] p-8 dark:border-[#3E3E3A] dark:bg-[#161615]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-red-500/10">
|
||||
<BrainIcon className="size-6 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold">
|
||||
No AI Snooping
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[#706f6c] dark:text-[#A1A09A]">
|
||||
AI can't help you with your transactions
|
||||
because they're end-to-end encrypted.
|
||||
This is intentional—we believe your
|
||||
financial data should never be fed into
|
||||
AI systems that you don't control.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 rounded-2xl border border-[#e3e3e0] bg-[#FDFDFC] p-8 dark:border-[#3E3E3A] dark:bg-[#161615]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-full bg-red-500/10">
|
||||
<Building2Icon className="size-6 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold">
|
||||
No Bank Access Required
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[#706f6c] dark:text-[#A1A09A]">
|
||||
We don't need direct access to your bank
|
||||
accounts. No sharing credentials, no
|
||||
third-party integrations, no security
|
||||
risks. You stay in complete control of
|
||||
your financial data.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="px-4 py-12 sm:py-24 md:py-32">
|
||||
<div className="mx-auto flex max-w-7xl flex-col items-center gap-8 sm:gap-12">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<h2 className="max-w-[720px] text-3xl leading-tight font-semibold text-balance sm:text-5xl sm:leading-tight">
|
||||
Import Your Transactions in Seconds
|
||||
</h2>
|
||||
<p className="text-md max-w-[640px] font-medium text-[#706f6c] sm:text-xl dark:text-[#A1A09A]">
|
||||
Get started quickly with your existing
|
||||
financial data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full max-w-4xl flex-col items-center gap-8 rounded-2xl border border-[#e3e3e0] bg-[#FDFDFC] p-8 sm:p-12 dark:border-[#3E3E3A] dark:bg-[#161615]">
|
||||
<div className="flex size-20 items-center justify-center rounded-full bg-emerald-500/10">
|
||||
<FileUpIcon className="size-10 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 text-center">
|
||||
<h3 className="text-2xl font-semibold">
|
||||
Lightning-Fast CSV/XLS Import
|
||||
</h3>
|
||||
<p className="text-lg text-[#706f6c] dark:text-[#A1A09A]">
|
||||
Import a year's worth of transactions in
|
||||
under 10 seconds. Simply export a CSV or
|
||||
XLS file from your bank and drag it into
|
||||
Whisper Money. All data is encrypted
|
||||
locally before upload.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid w-full gap-4 sm:grid-cols-3">
|
||||
<div className="flex items-center gap-3 rounded-lg border border-[#e3e3e0] bg-background p-4 dark:border-[#3E3E3A]">
|
||||
<CheckIcon className="size-5 shrink-0 text-emerald-500" />
|
||||
<span className="text-sm font-medium">
|
||||
Export from any bank
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-lg border border-[#e3e3e0] bg-background p-4 dark:border-[#3E3E3A]">
|
||||
<CheckIcon className="size-5 shrink-0 text-emerald-500" />
|
||||
<span className="text-sm font-medium">
|
||||
Encrypted on your device
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-lg border border-[#e3e3e0] bg-background p-4 dark:border-[#3E3E3A]">
|
||||
<CheckIcon className="size-5 shrink-0 text-emerald-500" />
|
||||
<span className="text-sm font-medium">
|
||||
Import in seconds
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="px-4 py-12 sm:py-24 md:py-32 dark:border-[#3E3E3A]">
|
||||
<div className="mx-auto flex max-w-7xl flex-col items-center gap-6 sm:gap-20">
|
||||
<h2 className="max-w-[560px] text-center text-3xl leading-tight font-semibold sm:text-5xl sm:leading-tight">
|
||||
|
|
@ -519,7 +650,7 @@ export default function Welcome({
|
|||
</div>
|
||||
|
||||
{pricing.promo.enabled && (
|
||||
<p className="-mt-6 text-center text-sm text-[#706f6c] dark:text-[#A1A09A]">
|
||||
<p className="text-center text-sm text-[#706f6c] dark:text-[#A1A09A]">
|
||||
🎉 Get a founder discount •{' '}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export interface NavDivider {
|
|||
export interface SharedData {
|
||||
name: string;
|
||||
appUrl: string;
|
||||
version: string;
|
||||
quote: { message: string; author: string };
|
||||
auth: Auth;
|
||||
subscriptionsEnabled: boolean;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
<x-mail::message>
|
||||
# How's it going, {{ $userName }}?
|
||||
|
||||
Hi! It's Victor here, the founder of Whisper Money. You've been using the app for a few days now, and I'd love to hear how it's working for you.
|
||||
|
||||
## A Few Quick Questions
|
||||
|
||||
- Is the app meeting your expectations?
|
||||
- Is there anything confusing or frustrating?
|
||||
- What features would make Whisper Money more useful for you?
|
||||
|
||||
As a solo founder, your feedback directly shapes what I build next. I personally read every response and take your suggestions seriously. When you subscribe, you're not just supporting some big corporation - you're helping me continue building something I'm passionate about.
|
||||
|
||||
## Help Me Improve
|
||||
|
||||
Whether it's a bug you've found, a feature you'd love to see, or just general thoughts - I want to hear it all. This is my project, and I care deeply about making it work well for you.
|
||||
|
||||
Just reply to this email with your feedback. No surveys, no forms, just a direct conversation with me.
|
||||
|
||||
Thanks for being part of Whisper Money. It really means a lot.
|
||||
|
||||
Best,<br>
|
||||
Víctor F,<br>
|
||||
Founder of Whisper Money
|
||||
</x-mail::message>
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<x-mail::message>
|
||||
# Need help importing your transactions, {{ $userName }}?
|
||||
|
||||
Hi! It's Victor, the founder of Whisper Money. I noticed you've completed your setup but haven't imported any transactions yet. Let me help you get started!
|
||||
|
||||
## How to Import Your Transactions
|
||||
|
||||
**Step 1: Export from your bank**
|
||||
Log into your bank's website and look for "Export" or "Download transactions". Choose CSV format if available.
|
||||
|
||||
**Step 2: Upload to Whisper Money**
|
||||
Go to your dashboard and click "Import Transactions". Select your CSV file and I'll map the columns automatically.
|
||||
|
||||
**Step 3: Review and confirm**
|
||||
Check that everything looks correct and click "Import". Your transactions will be encrypted and stored securely.
|
||||
|
||||
## Prefer to Start Fresh?
|
||||
|
||||
You can also manually add transactions and account balances. Some users prefer to start tracking from today rather than importing history - that's totally fine! Do whatever works best for you.
|
||||
|
||||
<x-mail::button :url="config('app.url') . '/dashboard'">
|
||||
Go to Dashboard
|
||||
</x-mail::button>
|
||||
|
||||
If you're having trouble with the import or need help with your specific bank's format, just reply to this email. I personally handle support and I'm happy to help you figure it out.
|
||||
|
||||
Thanks for giving Whisper Money a try!
|
||||
|
||||
Best,<br>
|
||||
Víctor F,<br>
|
||||
Founder of Whisper Money
|
||||
</x-mail::message>
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<x-mail::message>
|
||||
# Hey {{ $userName }}, is everything okay?
|
||||
|
||||
Hi! It's Victor, the founder of Whisper Money. I noticed you signed up but haven't completed your setup yet. I wanted to check in personally and see if something went wrong or if you need any help.
|
||||
|
||||
I know setting up a new app can be overwhelming, and I want to make sure you have everything you need to get started.
|
||||
|
||||
## Common Questions
|
||||
|
||||
**Is the encryption confusing?**
|
||||
No worries! Your encryption key is automatically generated and stored securely on your device. You don't need to remember anything - I've made it as simple as possible.
|
||||
|
||||
**Not sure how to import transactions?**
|
||||
I support CSV imports from most banks. Just export your transactions and upload them - it takes less than a minute. If your bank format is different, just let me know and I can help.
|
||||
|
||||
**Something not working?**
|
||||
Just reply to this email and let me know what's happening. I personally read every response and will help you get started. This is my project, so I care about making sure it works for you.
|
||||
|
||||
<x-mail::button :url="config('app.url') . '/onboarding'">
|
||||
Continue Setup
|
||||
</x-mail::button>
|
||||
|
||||
Looking forward to hearing from you! And if you decide Whisper Money isn't for you, that's totally fine - but I'd love to know why so I can improve.
|
||||
|
||||
Best,<br>
|
||||
Víctor F,<br>
|
||||
Founder of Whisper Money
|
||||
</x-mail::message>
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<x-mail::message>
|
||||
# Welcome aboard, {{ $userName }}!
|
||||
|
||||
Hi! It's Victor, the founder of Whisper Money. I see you've already started importing your transactions - that's awesome! You're well on your way to taking control of your finances while keeping your data private.
|
||||
|
||||
## A Special Offer for You
|
||||
|
||||
As one of our early users, I want to offer you a special founder's discount. When you subscribe, you're not just getting a great app - you're directly supporting me as I continue building Whisper Money. Every subscription helps me keep the lights on and build features you actually want.
|
||||
|
||||
<x-mail::panel>
|
||||
Use code **{{ $promoCode }}** to get your first month for just **$1**
|
||||
</x-mail::panel>
|
||||
|
||||
This gives you full access to all Whisper Money features:
|
||||
|
||||
- Unlimited transaction imports
|
||||
- Automated categorization rules
|
||||
- Multiple account tracking
|
||||
- End-to-end encrypted storage
|
||||
|
||||
<x-mail::button :url="config('app.url') . '/subscribe'">
|
||||
Claim Your Discount
|
||||
</x-mail::button>
|
||||
|
||||
This code won't last forever, but more importantly, your support means the world to me. As a solo founder, every subscriber helps me continue building something I'm passionate about.
|
||||
|
||||
Thanks for being part of this journey with me!
|
||||
|
||||
Best,<br>
|
||||
Víctor F,<br>
|
||||
Founder of Whisper Money
|
||||
</x-mail::message>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<x-mail::message>
|
||||
# Welcome to Whisper Money, {{ $userName }}!
|
||||
|
||||
Hi! I'm Victor, the founder of Whisper Money. I wanted to personally welcome you and thank you for joining us.
|
||||
|
||||
When I started building Whisper Money, I was frustrated with finance apps that wanted to mine my data or use AI to analyze my spending. I just wanted a simple, private way to track my finances - and I figured others might too.
|
||||
|
||||
## Your Data is Truly Private
|
||||
|
||||
I built Whisper Money with **end-to-end encryption** because I believe your financial data should be yours alone:
|
||||
|
||||
- Your financial data is encrypted with a key that only you control
|
||||
- I cannot read your transactions, balances, or any personal information (and I don't want to!)
|
||||
- Your encryption key never leaves your device
|
||||
|
||||
## No AI, No Data Mining
|
||||
|
||||
I don't use AI to analyze your spending or sell insights about your habits. Your financial data stays between you and your spreadsheet-loving self.
|
||||
|
||||
What's more, even if I wanted to use it, I couldn't, since you're the only one who has the key to read the data.
|
||||
|
||||
<div style="height: 40px"></div>
|
||||
|
||||
If you have any questions or run into any issues, **just reply to this email**. I personally read every message and I'm here to help!
|
||||
|
||||
Thanks for giving Whisper Money a try. It means a lot to me.
|
||||
|
||||
Best,<br>
|
||||
Víctor F,<br>
|
||||
Founder of Whisper Money
|
||||
</x-mail::message>
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<x-mail::message>
|
||||
# Your early access to Whisper Money is ready
|
||||
|
||||
Hey there!
|
||||
|
||||
It's Victor, the founder of Whisper Money. You signed up a while back to hear about our privacy-first personal finance app, and I'm excited to tell you - we're live!
|
||||
|
||||
## What is Whisper Money?
|
||||
|
||||
I built Whisper Money because I was tired of giving my financial data to big companies who use it for who knows what. Whisper Money keeps your data **end-to-end encrypted** - only you can see your transactions, categories, and insights. Not me, not anyone.
|
||||
|
||||
It's personal finance, but actually private.
|
||||
|
||||
## Special Founder's Offer - Just for You
|
||||
|
||||
As someone who believed in us early, I want to offer you something special:
|
||||
|
||||
<x-mail::panel>
|
||||
Use code **FOUNDER** to get **$8 off** - your first month for just **$1**
|
||||
</x-mail::panel>
|
||||
|
||||
This gets you full access to everything:
|
||||
|
||||
- Unlimited transaction imports
|
||||
- Automated categorization rules
|
||||
- Multiple account tracking
|
||||
- End-to-end encrypted storage
|
||||
- Mobile app (iOS & Android)
|
||||
|
||||
<x-mail::button :url="config('app.url') . '/register'">
|
||||
Get Started for $1
|
||||
</x-mail::button>
|
||||
|
||||
## Built by one person, for real people
|
||||
|
||||
I'm a solo founder building this in public. Every subscription helps me keep the lights on and build features you actually want. No investors, no board meetings, just me trying to build something useful and private.
|
||||
|
||||
**Have feedback? Questions? Issues?** Just hit reply to this email. I read and respond to every message personally.
|
||||
|
||||
Thanks for being interested in what I'm building!
|
||||
|
||||
Best,<br>
|
||||
Víctor<br>
|
||||
Founder, Whisper Money
|
||||
|
||||
P.S. The FOUNDER code gives you $8 off, so your first month is just $1. After that, it's $9/month. Cancel anytime, no questions asked.
|
||||
</x-mail::message>
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
@props([
|
||||
'url',
|
||||
'color' => 'primary',
|
||||
'align' => 'center',
|
||||
])
|
||||
<table class="action" align="{{ $align }}" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td align="{{ $align }}">
|
||||
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td align="{{ $align }}">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ $url }}" class="button button-{{ $color }}" target="_blank" rel="noopener">{!! $slot !!}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<tr>
|
||||
<td>
|
||||
<table class="footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td class="content-cell" align="center">
|
||||
{{ Illuminate\Mail\Markdown::parse($slot) }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
@props(['url'])
|
||||
<tr>
|
||||
<td class="header">
|
||||
<a href="{{ $url }}" style="display: inline-block;">
|
||||
<img src="{{ $url }}/images/whisper_money_e.png" width="75" height="75" title="Whisper Money" alt="Whisper Money" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<title>{{ config('app.name') }}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="supported-color-schemes" content="light">
|
||||
<style>
|
||||
@media only screen and (max-width: 600px) {
|
||||
.inner-body {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.footer {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 500px) {
|
||||
.button {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{!! $head ?? '' !!}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<table class="wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table class="content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
{!! $header ?? '' !!}
|
||||
|
||||
<!-- Email Body -->
|
||||
<tr>
|
||||
<td class="body" width="100%" cellpadding="0" cellspacing="0" style="border: hidden !important;">
|
||||
<table class="inner-body" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<!-- Body content -->
|
||||
<tr>
|
||||
<td class="content-cell">
|
||||
{!! Illuminate\Mail\Markdown::parse($slot) !!}
|
||||
|
||||
{!! $subcopy ?? '' !!}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{!! $footer ?? '' !!}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<x-mail::layout>
|
||||
{{-- Header --}}
|
||||
<x-slot:header>
|
||||
<x-mail::header :url="config('app.url')">
|
||||
{{ config('app.name') }}
|
||||
</x-mail::header>
|
||||
</x-slot:header>
|
||||
|
||||
{{-- Body --}}
|
||||
{!! $slot !!}
|
||||
|
||||
{{-- Subcopy --}}
|
||||
@isset($subcopy)
|
||||
<x-slot:subcopy>
|
||||
<x-mail::subcopy>
|
||||
{!! $subcopy !!}
|
||||
</x-mail::subcopy>
|
||||
</x-slot:subcopy>
|
||||
@endisset
|
||||
|
||||
{{-- Footer --}}
|
||||
<x-slot:footer>
|
||||
<x-mail::footer>
|
||||
© {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }}
|
||||
</x-mail::footer>
|
||||
</x-slot:footer>
|
||||
</x-mail::layout>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<table class="panel" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td class="panel-content">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td class="panel-item">
|
||||
{{ Illuminate\Mail\Markdown::parse($slot) }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<table class="subcopy" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td>
|
||||
{{ Illuminate\Mail\Markdown::parse($slot) }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<div class="table">
|
||||
{{ Illuminate\Mail\Markdown::parse($slot) }}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,300 @@
|
|||
/* Base */
|
||||
|
||||
body,
|
||||
body *:not(html):not(style):not(br):not(tr):not(code) {
|
||||
box-sizing: border-box;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial,
|
||||
sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body {
|
||||
-webkit-text-size-adjust: none;
|
||||
background-color: #ffffff;
|
||||
color: #52525b;
|
||||
height: 100%;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
blockquote {
|
||||
line-height: 1.4;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #18181b;
|
||||
}
|
||||
|
||||
a img {
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
|
||||
h1 {
|
||||
color: #18181b;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-top: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-top: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
margin-top: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
line-height: 1.5em;
|
||||
margin-top: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
p.sub {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
|
||||
.wrapper {
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
-premailer-width: 100%;
|
||||
background-color: #fafafa;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
-premailer-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
|
||||
.header {
|
||||
padding: 25px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header a {
|
||||
color: #18181b;
|
||||
font-size: 19px;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Logo */
|
||||
|
||||
.logo {
|
||||
height: 75px;
|
||||
margin-top: 15px;
|
||||
margin-bottom: 10px;
|
||||
max-height: 75px;
|
||||
width: 75px;
|
||||
}
|
||||
|
||||
/* Body */
|
||||
|
||||
.body {
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
-premailer-width: 100%;
|
||||
background-color: #fafafa;
|
||||
border-bottom: 1px solid #fafafa;
|
||||
border-top: 1px solid #fafafa;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inner-body {
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
-premailer-width: 570px;
|
||||
background-color: #ffffff;
|
||||
border-color: #e4e4e7;
|
||||
border-radius: 4px;
|
||||
border-width: 1px;
|
||||
box-shadow:
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.1),
|
||||
0 1px 2px -1px rgba(0, 0, 0, 0.1);
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
width: 570px;
|
||||
}
|
||||
|
||||
.inner-body a {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Subcopy */
|
||||
|
||||
.subcopy {
|
||||
border-top: 1px solid #e4e4e7;
|
||||
margin-top: 25px;
|
||||
padding-top: 25px;
|
||||
}
|
||||
|
||||
.subcopy p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
|
||||
.footer {
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
-premailer-width: 570px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
width: 570px;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
color: #a1a1aa;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #a1a1aa;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
|
||||
.table table {
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
-premailer-width: 100%;
|
||||
margin: 30px auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table th {
|
||||
border-bottom: 1px solid #e4e4e7;
|
||||
margin: 0;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.table td {
|
||||
color: #52525b;
|
||||
font-size: 15px;
|
||||
line-height: 18px;
|
||||
margin: 0;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.content-cell {
|
||||
max-width: 100vw;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
|
||||
.action {
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
-premailer-width: 100%;
|
||||
margin: 30px auto;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
float: unset;
|
||||
}
|
||||
|
||||
.button {
|
||||
-webkit-text-size-adjust: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.button-blue,
|
||||
.button-primary {
|
||||
background-color: #18181b;
|
||||
border-bottom: 8px solid #18181b;
|
||||
border-left: 18px solid #18181b;
|
||||
border-right: 18px solid #18181b;
|
||||
border-top: 8px solid #18181b;
|
||||
}
|
||||
|
||||
.button-green,
|
||||
.button-success {
|
||||
background-color: #16a34a;
|
||||
border-bottom: 8px solid #16a34a;
|
||||
border-left: 18px solid #16a34a;
|
||||
border-right: 18px solid #16a34a;
|
||||
border-top: 8px solid #16a34a;
|
||||
}
|
||||
|
||||
.button-red,
|
||||
.button-error {
|
||||
background-color: #dc2626;
|
||||
border-bottom: 8px solid #dc2626;
|
||||
border-left: 18px solid #dc2626;
|
||||
border-right: 18px solid #dc2626;
|
||||
border-top: 8px solid #dc2626;
|
||||
}
|
||||
|
||||
/* Panels */
|
||||
|
||||
.panel {
|
||||
border-left: #18181b solid 4px;
|
||||
margin: 21px 0;
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
background-color: #fafafa;
|
||||
color: #52525b;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.panel-content p {
|
||||
color: #52525b;
|
||||
}
|
||||
|
||||
.panel-item {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.panel-item p:last-of-type {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
|
||||
.break-all {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{{ $slot }}: {{ $url }}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{{ $slot }}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{{ $slot }}: {{ $url }}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{!! strip_tags($header ?? '') !!}
|
||||
|
||||
{!! strip_tags($slot) !!}
|
||||
@isset($subcopy)
|
||||
|
||||
{!! strip_tags($subcopy) !!}
|
||||
@endisset
|
||||
|
||||
{!! strip_tags($footer ?? '') !!}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<x-mail::layout>
|
||||
{{-- Header --}}
|
||||
<x-slot:header>
|
||||
<x-mail::header :url="config('app.url')">
|
||||
{{ config('app.name') }}
|
||||
</x-mail::header>
|
||||
</x-slot:header>
|
||||
|
||||
{{-- Body --}}
|
||||
{{ $slot }}
|
||||
|
||||
{{-- Subcopy --}}
|
||||
@isset($subcopy)
|
||||
<x-slot:subcopy>
|
||||
<x-mail::subcopy>
|
||||
{{ $subcopy }}
|
||||
</x-mail::subcopy>
|
||||
</x-slot:subcopy>
|
||||
@endisset
|
||||
|
||||
{{-- Footer --}}
|
||||
<x-slot:footer>
|
||||
<x-mail::footer>
|
||||
© {{ date('Y') }} {{ config('app.name') }}. @lang('All rights reserved.')
|
||||
</x-mail::footer>
|
||||
</x-slot:footer>
|
||||
</x-mail::layout>
|
||||
|
|
@ -0,0 +1 @@
|
|||
{{ $slot }}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{{ $slot }}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{{ $slot }}
|
||||
|
|
@ -1,8 +1,5 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
Schedule::command('horizon:snapshot')->everyFiveMinutes();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
test('registration screen can be rendered', function () {
|
||||
$response = $this->get(route('register'));
|
||||
|
||||
|
|
@ -7,6 +9,8 @@ test('registration screen can be rendered', function () {
|
|||
});
|
||||
|
||||
test('new users can register', function () {
|
||||
Queue::fake();
|
||||
|
||||
$response = $this->post(route('register.store'), [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,142 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\AccountBalance;
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Category;
|
||||
use App\Models\EncryptedMessage;
|
||||
use App\Models\Label;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use App\Models\UserMailLog;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Cashier\Subscription;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('deletes user and all associated data when confirmed', function () {
|
||||
$user = User::factory()->onboarded()->create([
|
||||
'email' => 'test@example.com',
|
||||
'name' => 'Test User',
|
||||
]);
|
||||
|
||||
// Create associated data
|
||||
EncryptedMessage::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'encrypted_content' => 'test-content',
|
||||
'iv' => 'test-iv',
|
||||
]);
|
||||
Transaction::factory()->count(3)->create(['user_id' => $user->id]);
|
||||
$account = Account::factory()->create(['user_id' => $user->id]);
|
||||
AccountBalance::factory()->count(2)->create(['account_id' => $account->id]);
|
||||
Account::factory()->create(['user_id' => $user->id]);
|
||||
Category::factory()->count(2)->create(['user_id' => $user->id]);
|
||||
AutomationRule::factory()->count(1)->create(['user_id' => $user->id]);
|
||||
Label::factory()->count(2)->create(['user_id' => $user->id]);
|
||||
UserMailLog::factory()->count(1)->create(['user_id' => $user->id]);
|
||||
Bank::factory()->count(2)->create(['user_id' => $user->id]);
|
||||
|
||||
// Confirm deletion
|
||||
$this->artisan('user:delete', ['email' => 'test@example.com'])
|
||||
->expectsConfirmation("Are you sure you want to delete user 'Test User' (test@example.com) and all their data?", 'yes')
|
||||
->expectsOutput("User 'test@example.com' and all associated data have been deleted successfully.")
|
||||
->assertSuccessful();
|
||||
|
||||
// Verify user is deleted
|
||||
expect(User::query()->where('email', 'test@example.com')->exists())->toBeFalse();
|
||||
|
||||
// Verify all associated data is deleted
|
||||
expect(EncryptedMessage::query()->where('user_id', $user->id)->exists())->toBeFalse();
|
||||
expect(Transaction::query()->where('user_id', $user->id)->exists())->toBeFalse();
|
||||
expect(Account::query()->where('user_id', $user->id)->exists())->toBeFalse();
|
||||
expect(AccountBalance::query()->where('account_id', $account->id)->exists())->toBeFalse();
|
||||
expect(Category::query()->where('user_id', $user->id)->exists())->toBeFalse();
|
||||
expect(AutomationRule::query()->where('user_id', $user->id)->exists())->toBeFalse();
|
||||
expect(Label::query()->where('user_id', $user->id)->exists())->toBeFalse();
|
||||
expect(UserMailLog::query()->where('user_id', $user->id)->exists())->toBeFalse();
|
||||
expect(Bank::query()->where('user_id', $user->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('shows error when user not found', function () {
|
||||
$this->artisan('user:delete', ['email' => 'nonexistent@example.com'])
|
||||
->expectsOutput("User with email 'nonexistent@example.com' not found.")
|
||||
->assertFailed();
|
||||
});
|
||||
|
||||
test('cancels deletion when not confirmed', function () {
|
||||
$user = User::factory()->onboarded()->create([
|
||||
'email' => 'test@example.com',
|
||||
'name' => 'Test User',
|
||||
]);
|
||||
|
||||
$this->artisan('user:delete', ['email' => 'test@example.com'])
|
||||
->expectsConfirmation("Are you sure you want to delete user 'Test User' (test@example.com) and all their data?", 'no')
|
||||
->expectsOutput('Deletion cancelled.')
|
||||
->assertSuccessful();
|
||||
|
||||
// Verify user still exists
|
||||
expect(User::query()->where('email', 'test@example.com')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('deletes user without associated data', function () {
|
||||
$user = User::factory()->onboarded()->create([
|
||||
'email' => 'test@example.com',
|
||||
'name' => 'Test User',
|
||||
]);
|
||||
|
||||
$this->artisan('user:delete', ['email' => 'test@example.com'])
|
||||
->expectsConfirmation("Are you sure you want to delete user 'Test User' (test@example.com) and all their data?", 'yes')
|
||||
->expectsOutput("User 'test@example.com' and all associated data have been deleted successfully.")
|
||||
->assertSuccessful();
|
||||
|
||||
expect(User::query()->where('email', 'test@example.com')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('does not delete other users data', function () {
|
||||
$userToDelete = User::factory()->onboarded()->create([
|
||||
'email' => 'delete@example.com',
|
||||
]);
|
||||
$otherUser = User::factory()->onboarded()->create([
|
||||
'email' => 'keep@example.com',
|
||||
]);
|
||||
|
||||
// Create data for both users
|
||||
Account::factory()->create(['user_id' => $userToDelete->id]);
|
||||
Account::factory()->create(['user_id' => $otherUser->id]);
|
||||
|
||||
$this->artisan('user:delete', ['email' => 'delete@example.com'])
|
||||
->expectsConfirmation("Are you sure you want to delete user '{$userToDelete->name}' (delete@example.com) and all their data?", 'yes')
|
||||
->assertSuccessful();
|
||||
|
||||
// Verify only the target user is deleted
|
||||
expect(User::query()->where('email', 'delete@example.com')->exists())->toBeFalse();
|
||||
expect(User::query()->where('email', 'keep@example.com')->exists())->toBeTrue();
|
||||
|
||||
// Verify other user's data is intact
|
||||
expect(Account::query()->where('user_id', $otherUser->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('prevents deletion of user with active subscription', function () {
|
||||
$user = User::factory()->onboarded()->create([
|
||||
'email' => 'subscribed@example.com',
|
||||
'name' => 'Subscribed User',
|
||||
]);
|
||||
|
||||
// Create an active subscription
|
||||
Subscription::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_test123',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_test123',
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
$this->artisan('user:delete', ['email' => 'subscribed@example.com'])
|
||||
->expectsOutput('Cannot delete user with an active subscription. Please cancel the subscription first.')
|
||||
->assertFailed();
|
||||
|
||||
// Verify user still exists
|
||||
expect(User::query()->where('email', 'subscribed@example.com')->exists())->toBeTrue();
|
||||
});
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\DripEmailType;
|
||||
use App\Jobs\Drip\SendFeedbackEmailJob;
|
||||
use App\Mail\Drip\FeedbackEmail;
|
||||
use App\Models\User;
|
||||
use App\Models\UserMailLog;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
beforeEach(function () {
|
||||
Mail::fake();
|
||||
config(['subscriptions.enabled' => true]);
|
||||
});
|
||||
|
||||
test('feedback email is sent to non-subscribed users', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
SendFeedbackEmailJob::dispatchSync($user);
|
||||
|
||||
Mail::assertQueued(FeedbackEmail::class, function ($mail) use ($user) {
|
||||
return $mail->hasTo($user->email);
|
||||
});
|
||||
|
||||
$this->assertDatabaseHas('user_mail_logs', [
|
||||
'user_id' => $user->id,
|
||||
'email_type' => DripEmailType::Feedback->value,
|
||||
]);
|
||||
});
|
||||
|
||||
test('feedback email is not sent to subscribed users', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_test123',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_test123',
|
||||
]);
|
||||
|
||||
SendFeedbackEmailJob::dispatchSync($user);
|
||||
|
||||
Mail::assertNotQueued(FeedbackEmail::class);
|
||||
});
|
||||
|
||||
test('feedback email is not sent if already received', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
UserMailLog::factory()->for($user)->feedback()->create();
|
||||
|
||||
SendFeedbackEmailJob::dispatchSync($user);
|
||||
|
||||
Mail::assertNotQueued(FeedbackEmail::class);
|
||||
});
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\DripEmailType;
|
||||
use App\Jobs\Drip\SendImportHelpEmailJob;
|
||||
use App\Mail\Drip\ImportHelpEmail;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use App\Models\UserMailLog;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
beforeEach(function () {
|
||||
Mail::fake();
|
||||
config(['subscriptions.enabled' => true]);
|
||||
});
|
||||
|
||||
test('import help email is sent to onboarded users without transactions who are not subscribed', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
|
||||
SendImportHelpEmailJob::dispatchSync($user);
|
||||
|
||||
Mail::assertQueued(ImportHelpEmail::class, function ($mail) use ($user) {
|
||||
return $mail->hasTo($user->email);
|
||||
});
|
||||
|
||||
$this->assertDatabaseHas('user_mail_logs', [
|
||||
'user_id' => $user->id,
|
||||
'email_type' => DripEmailType::ImportHelp->value,
|
||||
]);
|
||||
});
|
||||
|
||||
test('import help email is not sent to non-onboarded users', function () {
|
||||
$user = User::factory()->notOnboarded()->create();
|
||||
|
||||
SendImportHelpEmailJob::dispatchSync($user);
|
||||
|
||||
Mail::assertNotQueued(ImportHelpEmail::class);
|
||||
});
|
||||
|
||||
test('import help email is not sent to users with transactions', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
Transaction::factory()->for($user)->create();
|
||||
|
||||
SendImportHelpEmailJob::dispatchSync($user);
|
||||
|
||||
Mail::assertNotQueued(ImportHelpEmail::class);
|
||||
});
|
||||
|
||||
test('import help email is not sent to subscribed users', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
|
||||
$user->subscriptions()->create([
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_test123',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_test123',
|
||||
]);
|
||||
|
||||
SendImportHelpEmailJob::dispatchSync($user);
|
||||
|
||||
Mail::assertNotQueued(ImportHelpEmail::class);
|
||||
});
|
||||
|
||||
test('import help email is not sent if already received', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
|
||||
UserMailLog::factory()->for($user)->importHelp()->create();
|
||||
|
||||
SendImportHelpEmailJob::dispatchSync($user);
|
||||
|
||||
Mail::assertNotQueued(ImportHelpEmail::class);
|
||||
});
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\DripEmailType;
|
||||
use App\Jobs\Drip\SendOnboardingReminderEmailJob;
|
||||
use App\Mail\Drip\OnboardingReminderEmail;
|
||||
use App\Models\User;
|
||||
use App\Models\UserMailLog;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
beforeEach(function () {
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
test('onboarding reminder email is sent to non-onboarded users', function () {
|
||||
$user = User::factory()->notOnboarded()->create();
|
||||
|
||||
SendOnboardingReminderEmailJob::dispatchSync($user);
|
||||
|
||||
Mail::assertQueued(OnboardingReminderEmail::class, function ($mail) use ($user) {
|
||||
return $mail->hasTo($user->email);
|
||||
});
|
||||
|
||||
$this->assertDatabaseHas('user_mail_logs', [
|
||||
'user_id' => $user->id,
|
||||
'email_type' => DripEmailType::OnboardingReminder->value,
|
||||
]);
|
||||
});
|
||||
|
||||
test('onboarding reminder email is not sent to onboarded users', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
|
||||
SendOnboardingReminderEmailJob::dispatchSync($user);
|
||||
|
||||
Mail::assertNotQueued(OnboardingReminderEmail::class);
|
||||
|
||||
$this->assertDatabaseMissing('user_mail_logs', [
|
||||
'user_id' => $user->id,
|
||||
'email_type' => DripEmailType::OnboardingReminder->value,
|
||||
]);
|
||||
});
|
||||
|
||||
test('onboarding reminder email is not sent if already received', function () {
|
||||
$user = User::factory()->notOnboarded()->create();
|
||||
|
||||
UserMailLog::factory()->for($user)->onboardingReminder()->create();
|
||||
|
||||
SendOnboardingReminderEmailJob::dispatchSync($user);
|
||||
|
||||
Mail::assertNotQueued(OnboardingReminderEmail::class);
|
||||
});
|
||||