From 3db7116a9ed733429c3276b7f7128becfa5d8132 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 28 Mar 2026 12:39:50 +0000 Subject: [PATCH 1/3] Add notification email opt-in with branded email templates - Add receives_notification_emails column to users table - Add SuppressMailNotificationListener to respect user email preferences - Publish and customize mail templates with NativePHP branding (PNG logo) - Update all notifications to support database channel - Add settings UI for toggling notification emails - Remove TestNotification and SendTestEmail command - Add tests for suppression listener and settings Co-Authored-By: Claude Opus 4.6 --- app/Console/Commands/SendTestEmail.php | 23 -- .../SuppressMailNotificationListener.php | 22 ++ app/Livewire/Customer/Settings.php | 12 + app/Models/User.php | 1 + app/Notifications/BundleGranted.php | 4 +- app/Notifications/BundlePluginAdded.php | 4 +- app/Notifications/LegacyLicenseThankYou.php | 15 +- app/Notifications/LicenseExpiryWarning.php | 16 +- app/Notifications/LicenseKeyGenerated.php | 4 +- app/Notifications/PluginApproved.php | 4 +- app/Notifications/PluginGranted.php | 4 +- app/Notifications/PluginRejected.php | 4 +- .../PluginReviewChecksIncomplete.php | 4 +- app/Notifications/PluginSaleCompleted.php | 9 +- app/Notifications/PluginSubmitted.php | 4 +- app/Notifications/ProductGranted.php | 4 +- app/Notifications/SubscriptionCancelled.php | 13 +- app/Notifications/TestNotification.php | 26 -- app/Providers/EventServiceProvider.php | 5 + database/factories/UserFactory.php | 1 + ...ves_notification_emails_to_users_table.php | 25 ++ polyscope.json | 2 +- .../logo/nativephp-for-light-background.png | Bin 0 -> 13096 bytes .../livewire/customer/notifications.blade.php | 14 +- .../livewire/customer/settings.blade.php | 191 ++++++----- .../views/vendor/mail/html/button.blade.php | 24 ++ .../views/vendor/mail/html/footer.blade.php | 11 + .../views/vendor/mail/html/header.blade.php | 8 + .../views/vendor/mail/html/layout.blade.php | 58 ++++ .../views/vendor/mail/html/message.blade.php | 27 ++ .../views/vendor/mail/html/panel.blade.php | 14 + .../views/vendor/mail/html/subcopy.blade.php | 7 + .../views/vendor/mail/html/table.blade.php | 3 + .../views/vendor/mail/html/themes/default.css | 297 ++++++++++++++++++ .../views/vendor/mail/text/button.blade.php | 1 + .../views/vendor/mail/text/footer.blade.php | 1 + .../views/vendor/mail/text/header.blade.php | 1 + .../views/vendor/mail/text/layout.blade.php | 9 + .../views/vendor/mail/text/message.blade.php | 27 ++ .../views/vendor/mail/text/panel.blade.php | 1 + .../views/vendor/mail/text/subcopy.blade.php | 1 + .../views/vendor/mail/text/table.blade.php | 1 + .../vendor/notifications/email.blade.php | 58 ++++ .../Livewire/Customer/NotificationsTest.php | 13 +- .../Livewire/Customer/SettingsTest.php | 71 +++++ .../SuppressMailNotificationListenerTest.php | 74 +++++ 46 files changed, 967 insertions(+), 151 deletions(-) delete mode 100644 app/Console/Commands/SendTestEmail.php create mode 100644 app/Listeners/SuppressMailNotificationListener.php delete mode 100644 app/Notifications/TestNotification.php create mode 100644 database/migrations/2026_03_28_113048_add_receives_notification_emails_to_users_table.php create mode 100644 public/brand-assets/logo/nativephp-for-light-background.png create mode 100644 resources/views/vendor/mail/html/button.blade.php create mode 100644 resources/views/vendor/mail/html/footer.blade.php create mode 100644 resources/views/vendor/mail/html/header.blade.php create mode 100644 resources/views/vendor/mail/html/layout.blade.php create mode 100644 resources/views/vendor/mail/html/message.blade.php create mode 100644 resources/views/vendor/mail/html/panel.blade.php create mode 100644 resources/views/vendor/mail/html/subcopy.blade.php create mode 100644 resources/views/vendor/mail/html/table.blade.php create mode 100644 resources/views/vendor/mail/html/themes/default.css create mode 100644 resources/views/vendor/mail/text/button.blade.php create mode 100644 resources/views/vendor/mail/text/footer.blade.php create mode 100644 resources/views/vendor/mail/text/header.blade.php create mode 100644 resources/views/vendor/mail/text/layout.blade.php create mode 100644 resources/views/vendor/mail/text/message.blade.php create mode 100644 resources/views/vendor/mail/text/panel.blade.php create mode 100644 resources/views/vendor/mail/text/subcopy.blade.php create mode 100644 resources/views/vendor/mail/text/table.blade.php create mode 100644 resources/views/vendor/notifications/email.blade.php create mode 100644 tests/Feature/SuppressMailNotificationListenerTest.php 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..ef6cca1a1 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,17 @@ class Settings extends Component public string $deleteConfirmPassword = ''; + public bool $receivesNotificationEmails = true; + public function mount(): void { $this->name = auth()->user()->name ?? ''; + $this->receivesNotificationEmails = auth()->user()->receives_notification_emails; + } + + public function updatedReceivesNotificationEmails(bool $value): void + { + auth()->user()->update(['receives_notification_emails' => $value]); } public function updateName(): void diff --git a/app/Models/User.php b/app/Models/User.php index aaf3f3d6e..35ad1ca79 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -358,6 +358,7 @@ protected function casts(): array return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'receives_notification_emails' => '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/PluginApproved.php b/app/Notifications/PluginApproved.php index 65eec4d82..cde42e3df 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']; } /** @@ -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 Directory.", '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..522c58ec0 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']; } /** @@ -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 Directory.", '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..d27322964 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -25,6 +25,7 @@ public function definition(): array 'email_verified_at' => now(), 'password' => Hash::make('password'), 'remember_token' => Str::random(10), + 'receives_notification_emails' => 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/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 0000000000000000000000000000000000000000..890c3eaf0ccd49cfa305855bf74bb835809450e3 GIT binary patch literal 13096 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 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/settings.blade.php b/resources/views/livewire/customer/settings.blade.php index e0f9eeeb0..f9ed536af 100644 --- a/resources/views/livewire/customer/settings.blade.php +++ b/resources/views/livewire/customer/settings.blade.php @@ -4,100 +4,127 @@ 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 + +
+ +
+ + Save + +
- @if (session('name-updated')) + {{-- 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..a16bace0a --- /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') }} {{ config('app.name') }}. {{ __('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..80bce2112 --- /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') }} {{ config('app.name') }}. @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..beccd67b0 100644 --- a/tests/Feature/Livewire/Customer/SettingsTest.php +++ b/tests/Feature/Livewire/Customer/SettingsTest.php @@ -243,4 +243,75 @@ 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); + } } 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); + } +} From a7d81003347e9853cab4e0dcfcfe2ad512942d9b Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 28 Mar 2026 12:56:00 +0000 Subject: [PATCH 2/3] Add new plugin notification opt-in and rename Plugin Directory to Plugin Marketplace Co-Authored-By: Claude Opus 4.6 --- app/Livewire/Customer/Settings.php | 8 ++ app/Models/Plugin.php | 12 ++ app/Models/User.php | 1 + app/Notifications/NewPluginAvailable.php | 53 +++++++++ app/Notifications/PluginApproved.php | 6 +- app/Notifications/PluginRejected.php | 4 +- database/factories/UserFactory.php | 1 + ...ew_plugin_notifications_to_users_table.php | 25 ++++ .../views/customer/plugins/create.blade.php | 2 +- .../views/customer/plugins/index.blade.php | 2 +- .../customer/plugins/create.blade.php | 2 +- .../livewire/customer/plugins/index.blade.php | 2 +- .../livewire/customer/settings.blade.php | 8 +- .../Livewire/Customer/SettingsTest.php | 50 ++++++++ .../Notifications/NewPluginAvailableTest.php | 111 ++++++++++++++++++ 15 files changed, 277 insertions(+), 10 deletions(-) create mode 100644 app/Notifications/NewPluginAvailable.php create mode 100644 database/migrations/2026_03_28_124728_add_receives_new_plugin_notifications_to_users_table.php create mode 100644 tests/Feature/Notifications/NewPluginAvailableTest.php diff --git a/app/Livewire/Customer/Settings.php b/app/Livewire/Customer/Settings.php index ef6cca1a1..44bf05848 100644 --- a/app/Livewire/Customer/Settings.php +++ b/app/Livewire/Customer/Settings.php @@ -28,10 +28,13 @@ class Settings extends Component 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 @@ -39,6 +42,11 @@ 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 { $this->validate([ 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 35ad1ca79..51d06322e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -359,6 +359,7 @@ protected function casts(): array '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/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 cde42e3df..3161602b4 100644 --- a/app/Notifications/PluginApproved.php +++ b/app/Notifications/PluginApproved.php @@ -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!'); } @@ -48,7 +48,7 @@ public function toArray(object $notifiable): array { return [ 'title' => 'Your Plugin Has Been Approved!', - 'body' => "{$this->plugin->name} is now listed in the NativePHP Plugin Directory.", + '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/PluginRejected.php b/app/Notifications/PluginRejected.php index 522c58ec0..e743d8948 100644 --- a/app/Notifications/PluginRejected.php +++ b/app/Notifications/PluginRejected.php @@ -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')) @@ -50,7 +50,7 @@ public function toArray(object $notifiable): array { return [ 'title' => 'Plugin Submission Update', - 'body' => "Your plugin {$this->plugin->name} was not approved for the NativePHP Plugin Directory.", + '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/database/factories/UserFactory.php b/database/factories/UserFactory.php index d27322964..7787f90cd 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -26,6 +26,7 @@ public function definition(): array '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_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/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/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 f9ed536af..ad9c175aa 100644 --- a/resources/views/livewire/customer/settings.blade.php +++ b/resources/views/livewire/customer/settings.blade.php @@ -119,12 +119,18 @@ class="{{ $tab === 'notifications' ? 'border-b-2 border-zinc-800 dark:border-whi @endif @if ($tab === 'notifications') - + + + @endif diff --git a/tests/Feature/Livewire/Customer/SettingsTest.php b/tests/Feature/Livewire/Customer/SettingsTest.php index beccd67b0..a585a0022 100644 --- a/tests/Feature/Livewire/Customer/SettingsTest.php +++ b/tests/Feature/Livewire/Customer/SettingsTest.php @@ -314,4 +314,54 @@ public function test_user_can_enable_email_notifications(): void $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); + } +} From 51642c49291d754017a73420f3df44fc17c919ea Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 28 Mar 2026 13:17:07 +0000 Subject: [PATCH 3/3] Update email footer copyright to Bifrost Technology LLC Co-Authored-By: Claude Opus 4.6 --- resources/views/vendor/mail/html/message.blade.php | 2 +- resources/views/vendor/mail/text/message.blade.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/vendor/mail/html/message.blade.php b/resources/views/vendor/mail/html/message.blade.php index a16bace0a..27d6e0cac 100644 --- a/resources/views/vendor/mail/html/message.blade.php +++ b/resources/views/vendor/mail/html/message.blade.php @@ -21,7 +21,7 @@ {{-- Footer --}} -© {{ date('Y') }} {{ config('app.name') }}. {{ __('All rights reserved.') }} +© {{ date('Y') }} Bifrost Technology LLC. {{ __('All rights reserved.') }} diff --git a/resources/views/vendor/mail/text/message.blade.php b/resources/views/vendor/mail/text/message.blade.php index 80bce2112..40cfe79aa 100644 --- a/resources/views/vendor/mail/text/message.blade.php +++ b/resources/views/vendor/mail/text/message.blade.php @@ -21,7 +21,7 @@ {{-- Footer --}} - © {{ date('Y') }} {{ config('app.name') }}. @lang('All rights reserved.') + © {{ date('Y') }} Bifrost Technology LLC. @lang('All rights reserved.')