diff --git a/app/Console/Commands/SendTestEmail.php b/app/Console/Commands/SendTestEmail.php deleted file mode 100644 index 86300a867..000000000 --- a/app/Console/Commands/SendTestEmail.php +++ /dev/null @@ -1,23 +0,0 @@ -info('Sending test email via '.Mail::getDefaultDriver().'...'); - - Notification::route('mail', $this->argument('email')) - ->notify(new TestNotification); - } -} diff --git a/app/Listeners/SuppressMailNotificationListener.php b/app/Listeners/SuppressMailNotificationListener.php new file mode 100644 index 000000000..234eb0d14 --- /dev/null +++ b/app/Listeners/SuppressMailNotificationListener.php @@ -0,0 +1,22 @@ +channel !== 'mail') { + return true; + } + + if (! $event->notifiable instanceof User) { + return true; + } + + return $event->notifiable->receives_notification_emails; + } +} diff --git a/app/Livewire/Customer/Settings.php b/app/Livewire/Customer/Settings.php index e1fe5d978..44bf05848 100644 --- a/app/Livewire/Customer/Settings.php +++ b/app/Livewire/Customer/Settings.php @@ -6,12 +6,16 @@ use Illuminate\Support\Facades\Hash; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; +use Livewire\Attributes\Url; use Livewire\Component; #[Layout('components.layouts.dashboard')] #[Title('Settings')] class Settings extends Component { + #[Url] + public string $tab = 'account'; + public string $name = ''; public string $currentPassword = ''; @@ -22,9 +26,25 @@ class Settings extends Component public string $deleteConfirmPassword = ''; + public bool $receivesNotificationEmails = true; + + public bool $receivesNewPluginNotifications = true; + public function mount(): void { $this->name = auth()->user()->name ?? ''; + $this->receivesNotificationEmails = auth()->user()->receives_notification_emails; + $this->receivesNewPluginNotifications = auth()->user()->receives_new_plugin_notifications; + } + + public function updatedReceivesNotificationEmails(bool $value): void + { + auth()->user()->update(['receives_notification_emails' => $value]); + } + + public function updatedReceivesNewPluginNotifications(bool $value): void + { + auth()->user()->update(['receives_new_plugin_notifications' => $value]); } public function updateName(): void diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index d4121ece9..b504b3275 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -7,6 +7,7 @@ use App\Enums\PluginTier; use App\Enums\PluginType; use App\Enums\PriceTier; +use App\Notifications\NewPluginAvailable; use App\Notifications\PluginApproved; use App\Notifications\PluginRejected; use App\Services\PluginSyncService; @@ -20,6 +21,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Support\Facades\Notification; class Plugin extends Model { @@ -497,6 +499,7 @@ public function getRepositoryOwnerAndName(): ?array public function approve(int $approvedById): void { $previousStatus = $this->status; + $isFirstApproval = $this->approved_at === null; $this->update([ 'status' => PluginStatus::Approved, @@ -515,6 +518,15 @@ public function approve(int $approvedById): void $this->user->notify(new PluginApproved($this)); + if ($isFirstApproval) { + $recipients = User::query() + ->where('receives_new_plugin_notifications', true) + ->where('id', '!=', $this->user_id) + ->get(); + + Notification::send($recipients, new NewPluginAvailable($this)); + } + resolve(PluginSyncService::class)->sync($this); } diff --git a/app/Models/User.php b/app/Models/User.php index aaf3f3d6e..51d06322e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -358,6 +358,8 @@ protected function casts(): array return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'receives_notification_emails' => 'boolean', + 'receives_new_plugin_notifications' => 'boolean', 'mobile_repo_access_granted_at' => 'datetime', 'claude_plugins_repo_access_granted_at' => 'datetime', 'discord_role_granted_at' => 'datetime', diff --git a/app/Notifications/BundleGranted.php b/app/Notifications/BundleGranted.php index 67d2db434..a88b6dd39 100644 --- a/app/Notifications/BundleGranted.php +++ b/app/Notifications/BundleGranted.php @@ -27,7 +27,7 @@ public function __construct( */ public function via(object $notifiable): array { - return ['mail']; + return ['mail', 'database']; } public function toMail(object $notifiable): MailMessage @@ -53,6 +53,8 @@ public function toMail(object $notifiable): MailMessage public function toArray(object $notifiable): array { return [ + 'title' => "You've been granted the {$this->bundle->name} bundle!", + 'body' => "You now have access to {$this->grantedPlugins->count()} plugins in the {$this->bundle->name} bundle.", 'bundle_id' => $this->bundle->id, 'bundle_name' => $this->bundle->name, 'granted_plugin_ids' => $this->grantedPlugins->pluck('id')->toArray(), diff --git a/app/Notifications/BundlePluginAdded.php b/app/Notifications/BundlePluginAdded.php index a1f7dc8c7..cbc2d4140 100644 --- a/app/Notifications/BundlePluginAdded.php +++ b/app/Notifications/BundlePluginAdded.php @@ -23,7 +23,7 @@ public function __construct( */ public function via(object $notifiable): array { - return ['mail']; + return ['mail', 'database']; } public function toMail(object $notifiable): MailMessage @@ -48,6 +48,8 @@ public function toMail(object $notifiable): MailMessage public function toArray(object $notifiable): array { return [ + 'title' => "New plugin added to your {$this->bundle->name} bundle!", + 'body' => "{$this->plugin->name} has been added to your bundle — it's yours for free.", 'plugin_id' => $this->plugin->id, 'plugin_name' => $this->plugin->name, 'bundle_id' => $this->bundle->id, diff --git a/app/Notifications/LegacyLicenseThankYou.php b/app/Notifications/LegacyLicenseThankYou.php index f6cfa277a..7fdbee086 100644 --- a/app/Notifications/LegacyLicenseThankYou.php +++ b/app/Notifications/LegacyLicenseThankYou.php @@ -18,7 +18,20 @@ public function __construct( public function via($notifiable): array { - return ['mail']; + return ['mail', 'database']; + } + + /** + * @return array + */ + public function toArray($notifiable): array + { + return [ + 'title' => 'Thank You for Making NativePHP Mobile Free', + 'body' => 'NativePHP for Mobile is now free for everyone — thanks to early supporters like you.', + 'license_id' => $this->license->id, + 'license_key' => $this->license->key, + ]; } public function toMail($notifiable): MailMessage diff --git a/app/Notifications/LicenseExpiryWarning.php b/app/Notifications/LicenseExpiryWarning.php index 33016660d..b88c108f1 100644 --- a/app/Notifications/LicenseExpiryWarning.php +++ b/app/Notifications/LicenseExpiryWarning.php @@ -19,7 +19,7 @@ public function __construct( public function via($notifiable): array { - return ['mail']; + return ['mail', 'database']; } public function toMail($notifiable): MailMessage @@ -43,6 +43,20 @@ public function toMail($notifiable): MailMessage ->salutation("Best regards,\nThe NativePHP Team"); } + /** + * @return array + */ + public function toArray($notifiable): array + { + return [ + 'title' => $this->getSubject(), + 'body' => $this->getMainMessage(), + 'license_id' => $this->license->id, + 'license_key' => $this->license->key, + 'days_until_expiry' => $this->daysUntilExpiry, + ]; + } + private function getSubject(): string { return match ($this->daysUntilExpiry) { diff --git a/app/Notifications/LicenseKeyGenerated.php b/app/Notifications/LicenseKeyGenerated.php index a190e5202..585b59f32 100644 --- a/app/Notifications/LicenseKeyGenerated.php +++ b/app/Notifications/LicenseKeyGenerated.php @@ -25,7 +25,7 @@ public function __construct( */ public function via(object $notifiable): array { - return ['mail']; + return ['mail', 'database']; } public function toMail(object $notifiable): MailMessage @@ -58,6 +58,8 @@ public function toMail(object $notifiable): MailMessage public function toArray(object $notifiable): array { return [ + 'title' => 'Your NativePHP License Key', + 'body' => 'Your license key has been generated and is ready to use.', 'license_key' => $this->licenseKey, 'firstName' => $this->firstName, ]; diff --git a/app/Notifications/NewPluginAvailable.php b/app/Notifications/NewPluginAvailable.php new file mode 100644 index 000000000..421ed730d --- /dev/null +++ b/app/Notifications/NewPluginAvailable.php @@ -0,0 +1,53 @@ + + */ + public function via(object $notifiable): array + { + if (! $notifiable->receives_new_plugin_notifications) { + return []; + } + + return ['mail', 'database']; + } + + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject("New Plugin: {$this->plugin->name}") + ->greeting('A new plugin is available!') + ->line("**{$this->plugin->name}** has just been added to the NativePHP Plugin Marketplace.") + ->action('View Plugin', url('/plugins')) + ->line('You can manage your notification preferences in your account settings.'); + } + + /** + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'title' => "New Plugin: {$this->plugin->name}", + 'body' => "{$this->plugin->name} has just been added to the NativePHP Plugin Marketplace.", + 'plugin_id' => $this->plugin->id, + 'plugin_name' => $this->plugin->name, + ]; + } +} diff --git a/app/Notifications/PluginApproved.php b/app/Notifications/PluginApproved.php index 65eec4d82..3161602b4 100644 --- a/app/Notifications/PluginApproved.php +++ b/app/Notifications/PluginApproved.php @@ -23,7 +23,7 @@ public function __construct( */ public function via(object $notifiable): array { - return ['mail']; + return ['mail', 'database']; } /** @@ -34,8 +34,8 @@ public function toMail(object $notifiable): MailMessage return (new MailMessage) ->subject('Your Plugin Has Been Approved!') ->greeting('Great news!') - ->line("Your plugin **{$this->plugin->name}** has been approved and is now listed in the NativePHP Plugin Directory.") - ->action('View Plugin Directory', url('/plugins')) + ->line("Your plugin **{$this->plugin->name}** has been approved and is now listed in the NativePHP Plugin Marketplace.") + ->action('View Plugin Marketplace', url('/plugins')) ->line('Thank you for contributing to the NativePHP ecosystem!'); } @@ -47,6 +47,8 @@ public function toMail(object $notifiable): MailMessage public function toArray(object $notifiable): array { return [ + 'title' => 'Your Plugin Has Been Approved!', + 'body' => "{$this->plugin->name} is now listed in the NativePHP Plugin Marketplace.", 'plugin_id' => $this->plugin->id, 'plugin_name' => $this->plugin->name, ]; diff --git a/app/Notifications/PluginGranted.php b/app/Notifications/PluginGranted.php index c703327c7..5c7721a4b 100644 --- a/app/Notifications/PluginGranted.php +++ b/app/Notifications/PluginGranted.php @@ -21,7 +21,7 @@ public function __construct( */ public function via(object $notifiable): array { - return ['mail']; + return ['mail', 'database']; } public function toMail(object $notifiable): MailMessage @@ -46,6 +46,8 @@ public function toMail(object $notifiable): MailMessage public function toArray(object $notifiable): array { return [ + 'title' => "You've been granted access to {$this->plugin->name}!", + 'body' => "You now have access to the {$this->plugin->name} plugin.", 'plugin_id' => $this->plugin->id, 'plugin_name' => $this->plugin->name, ]; diff --git a/app/Notifications/PluginRejected.php b/app/Notifications/PluginRejected.php index 0bc4466c6..e743d8948 100644 --- a/app/Notifications/PluginRejected.php +++ b/app/Notifications/PluginRejected.php @@ -23,7 +23,7 @@ public function __construct( */ public function via(object $notifiable): array { - return ['mail']; + return ['mail', 'database']; } /** @@ -34,7 +34,7 @@ public function toMail(object $notifiable): MailMessage return (new MailMessage) ->subject('Plugin Submission Update') ->greeting('Hello,') - ->line("Unfortunately, your plugin **{$this->plugin->name}** was not approved for the NativePHP Plugin Directory.") + ->line("Unfortunately, your plugin **{$this->plugin->name}** was not approved for the NativePHP Plugin Marketplace.") ->line('**Reason:**') ->line($this->plugin->rejection_reason) ->action('View Your Plugins', route('customer.plugins.index')) @@ -49,6 +49,8 @@ public function toMail(object $notifiable): MailMessage public function toArray(object $notifiable): array { return [ + 'title' => 'Plugin Submission Update', + 'body' => "Your plugin {$this->plugin->name} was not approved for the NativePHP Plugin Marketplace.", 'plugin_id' => $this->plugin->id, 'plugin_name' => $this->plugin->name, 'rejection_reason' => $this->plugin->rejection_reason, diff --git a/app/Notifications/PluginReviewChecksIncomplete.php b/app/Notifications/PluginReviewChecksIncomplete.php index bf13e8732..f5135f9d2 100644 --- a/app/Notifications/PluginReviewChecksIncomplete.php +++ b/app/Notifications/PluginReviewChecksIncomplete.php @@ -69,7 +69,7 @@ public function __construct(public Plugin $plugin) {} */ public function via(object $notifiable): array { - return ['mail']; + return ['mail', 'database']; } public function toMail(object $notifiable): MailMessage @@ -113,6 +113,8 @@ public function toMail(object $notifiable): MailMessage public function toArray(object $notifiable): array { return [ + 'title' => "Action Required: {$this->plugin->name} — Review Checks", + 'body' => 'Some automated checks need your attention before we can approve your plugin.', 'plugin_id' => $this->plugin->id, 'plugin_name' => $this->plugin->name, ]; diff --git a/app/Notifications/PluginSaleCompleted.php b/app/Notifications/PluginSaleCompleted.php index df1ff2288..f9ef333f6 100644 --- a/app/Notifications/PluginSaleCompleted.php +++ b/app/Notifications/PluginSaleCompleted.php @@ -27,7 +27,7 @@ public function __construct( */ public function via(object $notifiable): array { - return ['mail']; + return ['mail', 'database']; } /** @@ -64,9 +64,14 @@ public function toMail(object $notifiable): MailMessage */ public function toArray(object $notifiable): array { + $totalPayout = $this->payouts->sum('developer_amount'); + $formattedTotal = number_format($totalPayout / 100, 2); + return [ + 'title' => "You've made a sale!", + 'body' => "A sale has been completed — total payout: \${$formattedTotal}.", 'payout_ids' => $this->payouts->pluck('id')->toArray(), - 'total_developer_amount' => $this->payouts->sum('developer_amount'), + 'total_developer_amount' => $totalPayout, ]; } } diff --git a/app/Notifications/PluginSubmitted.php b/app/Notifications/PluginSubmitted.php index 64757adaa..219045e4e 100644 --- a/app/Notifications/PluginSubmitted.php +++ b/app/Notifications/PluginSubmitted.php @@ -21,7 +21,7 @@ public function __construct( */ public function via(object $notifiable): array { - return ['mail']; + return ['mail', 'database']; } public function toMail(object $notifiable): MailMessage @@ -57,6 +57,8 @@ public function toMail(object $notifiable): MailMessage public function toArray(object $notifiable): array { return [ + 'title' => "Plugin Submitted: {$this->plugin->name}", + 'body' => 'Your plugin submission is now in our review queue.', 'plugin_id' => $this->plugin->id, 'plugin_name' => $this->plugin->name, ]; diff --git a/app/Notifications/ProductGranted.php b/app/Notifications/ProductGranted.php index f3019c9e5..d95d99608 100644 --- a/app/Notifications/ProductGranted.php +++ b/app/Notifications/ProductGranted.php @@ -21,7 +21,7 @@ public function __construct( */ public function via(object $notifiable): array { - return ['mail']; + return ['mail', 'database']; } public function toMail(object $notifiable): MailMessage @@ -40,6 +40,8 @@ public function toMail(object $notifiable): MailMessage public function toArray(object $notifiable): array { return [ + 'title' => "You've been granted access to {$this->product->name}!", + 'body' => "You now have access to {$this->product->name}.", 'product_id' => $this->product->id, 'product_name' => $this->product->name, ]; diff --git a/app/Notifications/SubscriptionCancelled.php b/app/Notifications/SubscriptionCancelled.php index cb0bb30d2..d643e8e2d 100644 --- a/app/Notifications/SubscriptionCancelled.php +++ b/app/Notifications/SubscriptionCancelled.php @@ -13,7 +13,18 @@ class SubscriptionCancelled extends Notification implements ShouldQueue public function via($notifiable): array { - return ['mail']; + return ['mail', 'database']; + } + + /** + * @return array + */ + public function toArray($notifiable): array + { + return [ + 'title' => 'Subscription Cancelled', + 'body' => 'Your NativePHP subscription has been cancelled.', + ]; } public function toMail($notifiable): MailMessage diff --git a/app/Notifications/TestNotification.php b/app/Notifications/TestNotification.php deleted file mode 100644 index 1bf2ba1ec..000000000 --- a/app/Notifications/TestNotification.php +++ /dev/null @@ -1,26 +0,0 @@ -subject('Test Notification') - ->greeting('It worked!') - ->line('This is a test mail notification.') - ->action('Go to Website', url(path: '/', secure: true)); - } -} diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 841246256..f6e8b1ccb 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -4,9 +4,11 @@ use App\Listeners\StripeWebhookHandledListener; use App\Listeners\StripeWebhookReceivedListener; +use App\Listeners\SuppressMailNotificationListener; use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Listeners\SendEmailVerificationNotification; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; +use Illuminate\Notifications\Events\NotificationSending; use Illuminate\Support\Facades\Event; use Laravel\Cashier\Events\WebhookHandled; use Laravel\Cashier\Events\WebhookReceived; @@ -28,6 +30,9 @@ class EventServiceProvider extends ServiceProvider WebhookHandled::class => [ StripeWebhookHandledListener::class, ], + NotificationSending::class => [ + SuppressMailNotificationListener::class, + ], ]; /** diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index a91ded67c..7787f90cd 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -25,6 +25,8 @@ public function definition(): array 'email_verified_at' => now(), 'password' => Hash::make('password'), 'remember_token' => Str::random(10), + 'receives_notification_emails' => true, + 'receives_new_plugin_notifications' => true, ]; } diff --git a/database/migrations/2026_03_28_113048_add_receives_notification_emails_to_users_table.php b/database/migrations/2026_03_28_113048_add_receives_notification_emails_to_users_table.php new file mode 100644 index 000000000..1d8a42d6e --- /dev/null +++ b/database/migrations/2026_03_28_113048_add_receives_notification_emails_to_users_table.php @@ -0,0 +1,25 @@ +boolean('receives_notification_emails')->default(true)->after('email_verified_at'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('receives_notification_emails'); + }); + } +}; diff --git a/database/migrations/2026_03_28_124728_add_receives_new_plugin_notifications_to_users_table.php b/database/migrations/2026_03_28_124728_add_receives_new_plugin_notifications_to_users_table.php new file mode 100644 index 000000000..18efb8ea4 --- /dev/null +++ b/database/migrations/2026_03_28_124728_add_receives_new_plugin_notifications_to_users_table.php @@ -0,0 +1,25 @@ +boolean('receives_new_plugin_notifications')->default(true)->after('receives_notification_emails'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('receives_new_plugin_notifications'); + }); + } +}; diff --git a/polyscope.json b/polyscope.json index 8fe67fd72..9edf7ec1d 100644 --- a/polyscope.json +++ b/polyscope.json @@ -13,6 +13,6 @@ ] }, "preview": { - "url": "http://{{folder}}.test" + "url": "https://{{folder}}.test" } } diff --git a/public/brand-assets/logo/nativephp-for-light-background.png b/public/brand-assets/logo/nativephp-for-light-background.png new file mode 100644 index 000000000..890c3eaf0 Binary files /dev/null and b/public/brand-assets/logo/nativephp-for-light-background.png differ diff --git a/resources/views/customer/plugins/create.blade.php b/resources/views/customer/plugins/create.blade.php index c0fece78b..a5d214218 100644 --- a/resources/views/customer/plugins/create.blade.php +++ b/resources/views/customer/plugins/create.blade.php @@ -24,7 +24,7 @@

Submit Your Plugin

- Add your plugin to the NativePHP Plugin Directory + Add your plugin to the NativePHP Plugin Marketplace

diff --git a/resources/views/customer/plugins/index.blade.php b/resources/views/customer/plugins/index.blade.php index d6dfc827a..52a72d16a 100644 --- a/resources/views/customer/plugins/index.blade.php +++ b/resources/views/customer/plugins/index.blade.php @@ -36,7 +36,7 @@

Submit Your Plugin

- Built a plugin? Submit it to the NativePHP Plugin Directory and share it with the community. + Built a plugin? Submit it to the NativePHP Plugin Marketplace and share it with the community.

Submit a Plugin diff --git a/resources/views/livewire/customer/notifications.blade.php b/resources/views/livewire/customer/notifications.blade.php index e803c2008..46b472d0b 100644 --- a/resources/views/livewire/customer/notifications.blade.php +++ b/resources/views/livewire/customer/notifications.blade.php @@ -5,11 +5,17 @@ Stay up to date with your account activity. - @if (auth()->user()->unreadNotifications->count() > 0) - - Mark all as read +
+ @if (auth()->user()->unreadNotifications->count() > 0) + + Mark all as read + + @endif + + + Settings - @endif +
@forelse ($this->notifications as $notification) diff --git a/resources/views/livewire/customer/plugins/create.blade.php b/resources/views/livewire/customer/plugins/create.blade.php index 6968c2ace..e476b072b 100644 --- a/resources/views/livewire/customer/plugins/create.blade.php +++ b/resources/views/livewire/customer/plugins/create.blade.php @@ -16,7 +16,7 @@ Submit Your Plugin - Add your plugin to the NativePHP Plugin Directory + Add your plugin to the NativePHP Plugin Marketplace
diff --git a/resources/views/livewire/customer/plugins/index.blade.php b/resources/views/livewire/customer/plugins/index.blade.php index 561ac73f6..d623c6a1e 100644 --- a/resources/views/livewire/customer/plugins/index.blade.php +++ b/resources/views/livewire/customer/plugins/index.blade.php @@ -15,7 +15,7 @@
Submit Your Plugin - Built a plugin? Submit it to the NativePHP Plugin Directory and share it with the community. + Built a plugin? Submit it to the NativePHP Plugin Marketplace and share it with the community. Submit a Plugin diff --git a/resources/views/livewire/customer/settings.blade.php b/resources/views/livewire/customer/settings.blade.php index e0f9eeeb0..ad9c175aa 100644 --- a/resources/views/livewire/customer/settings.blade.php +++ b/resources/views/livewire/customer/settings.blade.php @@ -4,100 +4,133 @@ Manage your account settings. - {{-- Update Name --}} - -
- Name - Update the name associated with your account. +
+ + +
+ + @if ($tab === 'account') + {{-- Update Name --}} + + + Name + Update the name associated with your account. + + @if (session('name-updated')) + + {{ session('name-updated') }} + + @endif + +
+ +
- @if (session('name-updated')) + Save + +
+ + {{-- Change Password --}} + + Password + Update your password to keep your account secure. + + @if (session('password-updated')) - {{ session('name-updated') }} + {{ session('password-updated') }} @endif -
- -
- - Save - -
+ @if (auth()->user()->github_id && ! auth()->user()->password) + + + Your account uses GitHub for authentication. To set a password, use the + password reset flow. + + + @else +
+
+ + + +
- {{-- Change Password --}} - - Password - Update your password to keep your account secure. + Update Password + + @endif +
- @if (session('password-updated')) - - {{ session('password-updated') }} - - @endif + {{-- Delete Account --}} + + Delete Account + Permanently delete your account and all associated data. - @if (auth()->user()->github_id && ! auth()->user()->password) - + - Your account uses GitHub for authentication. To set a password, use the - password reset flow. + This action is irreversible. All your licenses and data will be permanently removed. + @if (auth()->user()->subscription()?->active()) + Your active subscription will also be cancelled immediately. + @endif - @else -
-
- - - + + + Delete Account + + + + {{-- Delete Account Confirmation Modal --}} + + + Confirm Account Deletion + + + Please enter your password to confirm you want to permanently delete your account. + + +
+
- Update Password +
+ + Cancel + + Delete My Account +
- @endif - - - {{-- Delete Account --}} - - Delete Account - Permanently delete your account and all associated data. - - - - This action is irreversible. All your licenses and data will be permanently removed. - @if (auth()->user()->subscription()?->active()) - Your active subscription will also be cancelled immediately. - @endif - - - - - Delete Account - - - - {{-- Delete Account Confirmation Modal --}} - -
- Confirm Account Deletion - - - Please enter your password to confirm you want to permanently delete your account. - - -
- -
- -
- - Cancel - - Delete My Account -
-
-
+
+ @endif + + @if ($tab === 'notifications') + + + + + + @endif
diff --git a/resources/views/vendor/mail/html/button.blade.php b/resources/views/vendor/mail/html/button.blade.php new file mode 100644 index 000000000..050e969d2 --- /dev/null +++ b/resources/views/vendor/mail/html/button.blade.php @@ -0,0 +1,24 @@ +@props([ + 'url', + 'color' => 'primary', + 'align' => 'center', +]) + + + + + diff --git a/resources/views/vendor/mail/html/footer.blade.php b/resources/views/vendor/mail/html/footer.blade.php new file mode 100644 index 000000000..3ff41f89c --- /dev/null +++ b/resources/views/vendor/mail/html/footer.blade.php @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/resources/views/vendor/mail/html/header.blade.php b/resources/views/vendor/mail/html/header.blade.php new file mode 100644 index 000000000..df932926b --- /dev/null +++ b/resources/views/vendor/mail/html/header.blade.php @@ -0,0 +1,8 @@ +@props(['url']) + + +
+ + + + diff --git a/resources/views/vendor/mail/html/layout.blade.php b/resources/views/vendor/mail/html/layout.blade.php new file mode 100644 index 000000000..037efe3ea --- /dev/null +++ b/resources/views/vendor/mail/html/layout.blade.php @@ -0,0 +1,58 @@ + + + +{{ config('app.name') }} + + + + + +{!! $head ?? '' !!} + + + + + + + + + + diff --git a/resources/views/vendor/mail/html/message.blade.php b/resources/views/vendor/mail/html/message.blade.php new file mode 100644 index 000000000..27d6e0cac --- /dev/null +++ b/resources/views/vendor/mail/html/message.blade.php @@ -0,0 +1,27 @@ + +{{-- Header --}} + + +{{ config('app.name') }} + + + +{{-- Body --}} +{!! $slot !!} + +{{-- Subcopy --}} +@isset($subcopy) + + +{!! $subcopy !!} + + +@endisset + +{{-- Footer --}} + + +© {{ date('Y') }} Bifrost Technology LLC. {{ __('All rights reserved.') }} + + + diff --git a/resources/views/vendor/mail/html/panel.blade.php b/resources/views/vendor/mail/html/panel.blade.php new file mode 100644 index 000000000..2975a60a0 --- /dev/null +++ b/resources/views/vendor/mail/html/panel.blade.php @@ -0,0 +1,14 @@ + + + + + + diff --git a/resources/views/vendor/mail/html/subcopy.blade.php b/resources/views/vendor/mail/html/subcopy.blade.php new file mode 100644 index 000000000..790ce6c24 --- /dev/null +++ b/resources/views/vendor/mail/html/subcopy.blade.php @@ -0,0 +1,7 @@ + + + + + diff --git a/resources/views/vendor/mail/html/table.blade.php b/resources/views/vendor/mail/html/table.blade.php new file mode 100644 index 000000000..a5f3348b2 --- /dev/null +++ b/resources/views/vendor/mail/html/table.blade.php @@ -0,0 +1,3 @@ +
+{{ Illuminate\Mail\Markdown::parse($slot) }} +
diff --git a/resources/views/vendor/mail/html/themes/default.css b/resources/views/vendor/mail/html/themes/default.css new file mode 100644 index 000000000..d6ee6f0bd --- /dev/null +++ b/resources/views/vendor/mail/html/themes/default.css @@ -0,0 +1,297 @@ +/* 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: start; +} + +a { + color: #18181b; +} + +a img { + border: none; +} + +/* Typography */ + +h1 { + color: #18181b; + font-size: 18px; + font-weight: bold; + margin-top: 0; + text-align: start; +} + +h2 { + font-size: 16px; + font-weight: bold; + margin-top: 0; + text-align: start; +} + +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; +} diff --git a/resources/views/vendor/mail/text/button.blade.php b/resources/views/vendor/mail/text/button.blade.php new file mode 100644 index 000000000..97444ebdc --- /dev/null +++ b/resources/views/vendor/mail/text/button.blade.php @@ -0,0 +1 @@ +{{ $slot }}: {{ $url }} diff --git a/resources/views/vendor/mail/text/footer.blade.php b/resources/views/vendor/mail/text/footer.blade.php new file mode 100644 index 000000000..3338f620e --- /dev/null +++ b/resources/views/vendor/mail/text/footer.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/resources/views/vendor/mail/text/header.blade.php b/resources/views/vendor/mail/text/header.blade.php new file mode 100644 index 000000000..97444ebdc --- /dev/null +++ b/resources/views/vendor/mail/text/header.blade.php @@ -0,0 +1 @@ +{{ $slot }}: {{ $url }} diff --git a/resources/views/vendor/mail/text/layout.blade.php b/resources/views/vendor/mail/text/layout.blade.php new file mode 100644 index 000000000..ec58e83c9 --- /dev/null +++ b/resources/views/vendor/mail/text/layout.blade.php @@ -0,0 +1,9 @@ +{!! strip_tags($header ?? '') !!} + +{!! strip_tags($slot) !!} +@isset($subcopy) + +{!! strip_tags($subcopy) !!} +@endisset + +{!! strip_tags($footer ?? '') !!} diff --git a/resources/views/vendor/mail/text/message.blade.php b/resources/views/vendor/mail/text/message.blade.php new file mode 100644 index 000000000..40cfe79aa --- /dev/null +++ b/resources/views/vendor/mail/text/message.blade.php @@ -0,0 +1,27 @@ + + {{-- Header --}} + + + {{ config('app.name') }} + + + + {{-- Body --}} + {{ $slot }} + + {{-- Subcopy --}} + @isset($subcopy) + + + {{ $subcopy }} + + + @endisset + + {{-- Footer --}} + + + © {{ date('Y') }} Bifrost Technology LLC. @lang('All rights reserved.') + + + diff --git a/resources/views/vendor/mail/text/panel.blade.php b/resources/views/vendor/mail/text/panel.blade.php new file mode 100644 index 000000000..3338f620e --- /dev/null +++ b/resources/views/vendor/mail/text/panel.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/resources/views/vendor/mail/text/subcopy.blade.php b/resources/views/vendor/mail/text/subcopy.blade.php new file mode 100644 index 000000000..3338f620e --- /dev/null +++ b/resources/views/vendor/mail/text/subcopy.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/resources/views/vendor/mail/text/table.blade.php b/resources/views/vendor/mail/text/table.blade.php new file mode 100644 index 000000000..3338f620e --- /dev/null +++ b/resources/views/vendor/mail/text/table.blade.php @@ -0,0 +1 @@ +{{ $slot }} diff --git a/resources/views/vendor/notifications/email.blade.php b/resources/views/vendor/notifications/email.blade.php new file mode 100644 index 000000000..79c24083c --- /dev/null +++ b/resources/views/vendor/notifications/email.blade.php @@ -0,0 +1,58 @@ + +{{-- Greeting --}} +@if (! empty($greeting)) +# {{ $greeting }} +@else +@if ($level === 'error') +# @lang('Whoops!') +@else +# @lang('Hello!') +@endif +@endif + +{{-- Intro Lines --}} +@foreach ($introLines as $line) +{{ $line }} + +@endforeach + +{{-- Action Button --}} +@isset($actionText) + $level, + default => 'primary', + }; +?> + +{{ $actionText }} + +@endisset + +{{-- Outro Lines --}} +@foreach ($outroLines as $line) +{{ $line }} + +@endforeach + +{{-- Salutation --}} +@if (! empty($salutation)) +{{ $salutation }} +@else +@lang('Regards,')
+{{ config('app.name') }} +@endif + +{{-- Subcopy --}} +@isset($actionText) + +@lang( + "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\n". + 'into your web browser:', + [ + 'actionText' => $actionText, + ] +) [{{ $displayableActionUrl }}]({{ $actionUrl }}) + +@endisset +
diff --git a/tests/Feature/Livewire/Customer/NotificationsTest.php b/tests/Feature/Livewire/Customer/NotificationsTest.php index 1fe752fc8..99fe7f427 100644 --- a/tests/Feature/Livewire/Customer/NotificationsTest.php +++ b/tests/Feature/Livewire/Customer/NotificationsTest.php @@ -147,6 +147,17 @@ public function test_mark_all_as_read_button_shown_when_unread_exist(): void ->assertSee('Mark all as read'); } + // --- Settings link --- + + public function test_notifications_page_shows_link_to_notification_settings(): void + { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(Notifications::class) + ->assertSee('Settings'); + } + // --- Bell icon in layout --- public function test_bell_icon_shows_in_dashboard_layout(): void @@ -166,7 +177,7 @@ private function createNotification(User $user, array $data, ?Carbon $createdAt { return DatabaseNotification::create([ 'id' => Str::uuid()->toString(), - 'type' => 'App\\Notifications\\TestNotification', + 'type' => 'App\\Notifications\\PluginApproved', 'notifiable_type' => User::class, 'notifiable_id' => $user->id, 'data' => $data, diff --git a/tests/Feature/Livewire/Customer/SettingsTest.php b/tests/Feature/Livewire/Customer/SettingsTest.php index 15023e444..a585a0022 100644 --- a/tests/Feature/Livewire/Customer/SettingsTest.php +++ b/tests/Feature/Livewire/Customer/SettingsTest.php @@ -243,4 +243,125 @@ public function test_regular_user_does_not_see_github_hint(): void ->test(Settings::class) ->assertDontSee('Your account uses GitHub for authentication'); } + + // --- Tabs --- + + public function test_settings_page_has_account_and_notifications_tabs(): void + { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(Settings::class) + ->assertSee('Account') + ->assertSee('Notifications'); + } + + public function test_tab_defaults_to_account(): void + { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(Settings::class) + ->assertSet('tab', 'account'); + } + + // --- Email notification preference --- + + public function test_email_notification_toggle_is_shown(): void + { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(Settings::class) + ->set('tab', 'notifications') + ->assertSee('Email notifications') + ->assertSee('Receive email notifications about your account activity.'); + } + + public function test_email_notification_toggle_defaults_to_enabled(): void + { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(Settings::class) + ->assertSet('receivesNotificationEmails', true); + } + + public function test_user_can_disable_email_notifications(): void + { + $user = User::factory()->create(['receives_notification_emails' => true]); + + Livewire::actingAs($user) + ->test(Settings::class) + ->set('tab', 'notifications') + ->assertSet('receivesNotificationEmails', true) + ->set('receivesNotificationEmails', false) + ->assertSet('receivesNotificationEmails', false); + + $this->assertFalse($user->fresh()->receives_notification_emails); + } + + public function test_user_can_enable_email_notifications(): void + { + $user = User::factory()->create(['receives_notification_emails' => false]); + + Livewire::actingAs($user) + ->test(Settings::class) + ->set('tab', 'notifications') + ->assertSet('receivesNotificationEmails', false) + ->set('receivesNotificationEmails', true) + ->assertSet('receivesNotificationEmails', true); + + $this->assertTrue($user->fresh()->receives_notification_emails); + } + + // --- New plugin notification preference --- + + public function test_new_plugin_notification_toggle_is_shown(): void + { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(Settings::class) + ->set('tab', 'notifications') + ->assertSee('New plugin notifications') + ->assertSee('Get notified when new plugins are added to the directory.'); + } + + public function test_new_plugin_notification_toggle_defaults_to_enabled(): void + { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(Settings::class) + ->assertSet('receivesNewPluginNotifications', true); + } + + public function test_user_can_disable_new_plugin_notifications(): void + { + $user = User::factory()->create(['receives_new_plugin_notifications' => true]); + + Livewire::actingAs($user) + ->test(Settings::class) + ->set('tab', 'notifications') + ->assertSet('receivesNewPluginNotifications', true) + ->set('receivesNewPluginNotifications', false) + ->assertSet('receivesNewPluginNotifications', false); + + $this->assertFalse($user->fresh()->receives_new_plugin_notifications); + } + + public function test_user_can_enable_new_plugin_notifications(): void + { + $user = User::factory()->create(['receives_new_plugin_notifications' => false]); + + Livewire::actingAs($user) + ->test(Settings::class) + ->set('tab', 'notifications') + ->assertSet('receivesNewPluginNotifications', false) + ->set('receivesNewPluginNotifications', true) + ->assertSet('receivesNewPluginNotifications', true); + + $this->assertTrue($user->fresh()->receives_new_plugin_notifications); + } } diff --git a/tests/Feature/Notifications/NewPluginAvailableTest.php b/tests/Feature/Notifications/NewPluginAvailableTest.php new file mode 100644 index 000000000..7593b4eea --- /dev/null +++ b/tests/Feature/Notifications/NewPluginAvailableTest.php @@ -0,0 +1,111 @@ +mock(PluginSyncService::class, function ($mock): void { + $mock->shouldReceive('sync')->andReturn(true); + }); + } + + public function test_notification_is_sent_to_opted_in_users_on_first_approval(): void + { + Notification::fake(); + + $author = User::factory()->create(); + $optedIn = User::factory()->create(['receives_new_plugin_notifications' => true]); + $optedOut = User::factory()->create(['receives_new_plugin_notifications' => false]); + + $plugin = Plugin::factory()->pending()->for($author)->create(); + $admin = User::factory()->create(); + + $plugin->approve($admin->id); + + Notification::assertSentTo($optedIn, NewPluginAvailable::class); + Notification::assertNotSentTo($optedOut, NewPluginAvailable::class); + Notification::assertNotSentTo($author, NewPluginAvailable::class); + } + + public function test_notification_is_not_sent_on_re_approval(): void + { + Notification::fake(); + + $author = User::factory()->create(); + $optedIn = User::factory()->create(['receives_new_plugin_notifications' => true]); + + $plugin = Plugin::factory()->pending()->for($author)->create([ + 'approved_at' => now()->subDay(), + ]); + $admin = User::factory()->create(); + + $plugin->approve($admin->id); + + Notification::assertNotSentTo($optedIn, NewPluginAvailable::class); + } + + public function test_via_returns_empty_array_when_user_opted_out(): void + { + $user = User::factory()->create(['receives_new_plugin_notifications' => false]); + $plugin = Plugin::factory()->for($user)->create(); + + $notification = new NewPluginAvailable($plugin); + + $this->assertEmpty($notification->via($user)); + } + + public function test_via_returns_mail_and_database_when_user_opted_in(): void + { + $user = User::factory()->create(['receives_new_plugin_notifications' => true]); + $plugin = Plugin::factory()->for($user)->create(); + + $notification = new NewPluginAvailable($plugin); + + $this->assertEquals(['mail', 'database'], $notification->via($user)); + } + + public function test_mail_contains_plugin_name(): void + { + $user = User::factory()->create(); + $plugin = Plugin::factory()->for($user)->create(['name' => 'acme/awesome-plugin']); + + $notification = new NewPluginAvailable($plugin); + $mail = $notification->toMail($user); + + $this->assertStringContainsString('acme/awesome-plugin', $mail->subject); + } + + public function test_database_notification_contains_plugin_data(): void + { + $user = User::factory()->create(); + $plugin = Plugin::factory()->for($user)->create(['name' => 'acme/awesome-plugin']); + + $notification = new NewPluginAvailable($plugin); + $data = $notification->toArray($user); + + $this->assertEquals($plugin->id, $data['plugin_id']); + $this->assertEquals('acme/awesome-plugin', $data['plugin_name']); + $this->assertStringContainsString('acme/awesome-plugin', $data['title']); + } + + public function test_new_users_receive_new_plugin_notifications_by_default(): void + { + $user = User::factory()->create(); + + $this->assertTrue($user->receives_new_plugin_notifications); + } +} diff --git a/tests/Feature/SuppressMailNotificationListenerTest.php b/tests/Feature/SuppressMailNotificationListenerTest.php new file mode 100644 index 000000000..62716b402 --- /dev/null +++ b/tests/Feature/SuppressMailNotificationListenerTest.php @@ -0,0 +1,74 @@ +create(['receives_notification_emails' => true]); + + $event = new NotificationSending( + $user, + new PluginApproved( + plugin: Plugin::factory()->for($user)->create(), + ), + 'mail', + ); + + $listener = new SuppressMailNotificationListener; + + $this->assertTrue($listener->handle($event)); + } + + public function test_suppresses_mail_when_user_has_opted_out(): void + { + $user = User::factory()->create(['receives_notification_emails' => false]); + + $event = new NotificationSending( + $user, + new PluginApproved( + plugin: Plugin::factory()->for($user)->create(), + ), + 'mail', + ); + + $listener = new SuppressMailNotificationListener; + + $this->assertFalse($listener->handle($event)); + } + + public function test_allows_non_mail_channels_regardless_of_preference(): void + { + $user = User::factory()->create(['receives_notification_emails' => false]); + + $event = new NotificationSending( + $user, + new PluginApproved( + plugin: Plugin::factory()->for($user)->create(), + ), + 'database', + ); + + $listener = new SuppressMailNotificationListener; + + $this->assertTrue($listener->handle($event)); + } + + public function test_new_users_receive_notifications_by_default(): void + { + $user = User::factory()->create(); + + $this->assertTrue($user->receives_notification_emails); + } +}