- @foreach($supportTicket->replies as $reply)
+
+ {{-- Inline Reply Form --}}
+ @if($supportTicket->status !== \App\SupportTicket\Status::CLOSED)
+
+ @endif
+
+ @foreach($supportTicket->replies->where('note', false) as $reply)
@@ -89,22 +170,14 @@ class="inline-flex items-center px-4 py-2 text-sm font-medium text-violet-700 bg
Back
-
-
+ @if($supportTicket->status !== \App\SupportTicket\Status::CLOSED)
+
+ @endif
{{-- Add padding at the bottom to prevent content from being hidden behind the mobile footer --}}
@@ -114,106 +187,5 @@ class="flex-1 inline-flex items-center justify-center px-3 py-2 text-sm font-med
-
- {{-- Reply Modal --}}
-
-
- {{-- Modal backdrop --}}
-
-
- {{-- Modal positioning trick --}}
-
-
- {{-- Modal content --}}
-
-
-
-
-
-
- Reply to Ticket #{{ $supportTicket->mask }}
-
-
-
-
-
-
-
-
-
- {{-- Add Alpine.js x-cloak style to hide elements with x-cloak before Alpine initializes --}}
-
-
-
+
+
diff --git a/routes/web.php b/routes/web.php
index 7452bd54..68cd507b 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -2,6 +2,7 @@
use App\Features\ShowAuthButtons;
use App\Features\ShowPlugins;
+use App\Http\Controllers\Account\Support\TicketController;
use App\Http\Controllers\ApplinksController;
use App\Http\Controllers\Auth\CustomerAuthController;
use App\Http\Controllers\BundleController;
@@ -197,9 +198,11 @@
->where('page', '.*')
->name('docs.latest');
+// Docs platform chooser
+Route::view('docs', 'docs.chooser')->name('docs');
+
// Forward unversioned requests to the latest version
-Route::get('docs/{page?}', function ($page = null) {
- $page ??= 'introduction';
+Route::get('docs/{page}', function (string $page) {
$version = session('viewing_docs_version', '1');
$platform = session('viewing_docs_platform', 'mobile');
@@ -233,7 +236,7 @@
'page' => 'introduction',
]);
}
-})->name('docs')->where('page', '.*');
+})->name('docs.unversioned')->where('page', '.*');
Route::get('order/{checkoutSessionId}', App\Livewire\OrderSuccess::class)->name('order.success');
@@ -398,6 +401,32 @@
Route::get('cart/cancel', [CartController::class, 'cancel'])->name('cart.cancel');
});
+// Support
+Route::prefix('support')
+ ->middleware('auth:web')
+ ->group(function (): void {
+ Route::get('/', function () {
+ return view('support.index');
+ })
+ ->withoutMiddleware(['auth:web'])
+ ->name('support.index');
+
+ Route::prefix('tickets')
+ ->group(function (): void {
+ Route::get('/', [TicketController::class, 'index'])->name('support.tickets');
+ Route::get('/create', \App\Livewire\CreateSupportTicket::class)->name('support.tickets.create');
+
+ Route::get('/{supportTicket}', [TicketController::class, 'show'])
+ ->name('support.tickets.show');
+
+ Route::post('/{supportTicket}/reply', [TicketController::class, 'reply'])
+ ->name('support.tickets.reply');
+
+ Route::post('/{supportTicket}/close', [TicketController::class, 'closeTicket'])
+ ->name('support.tickets.close');
+ });
+ });
+
// Developer onboarding routes
Route::middleware(['auth', EnsureFeaturesAreActive::using(ShowAuthButtons::class), EnsureFeaturesAreActive::using(ShowPlugins::class)])->prefix('customer/developer')->name('customer.developer.')->group(function (): void {
Route::get('onboarding', [DeveloperOnboardingController::class, 'show'])->name('onboarding');
diff --git a/tests/Feature/SupportTicketTest.php b/tests/Feature/SupportTicketTest.php
new file mode 100644
index 00000000..dda16d5c
--- /dev/null
+++ b/tests/Feature/SupportTicketTest.php
@@ -0,0 +1,894 @@
+get(route('support.tickets.create'))
+ ->assertRedirect();
+ }
+
+ #[Test]
+ public function authenticated_users_can_access_create_ticket_page(): void
+ {
+ $user = User::factory()->create();
+
+ $this->actingAs($user)
+ ->get(route('support.tickets.create'))
+ ->assertOk()
+ ->assertSeeLivewire(CreateSupportTicket::class);
+ }
+
+ #[Test]
+ public function wizard_starts_at_step_1(): void
+ {
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->assertSet('currentStep', 1)
+ ->assertSee('Which product is this about?');
+ }
+
+ #[Test]
+ public function a_product_must_be_selected(): void
+ {
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->call('nextStep')
+ ->assertHasErrors('selectedProduct')
+ ->assertSet('currentStep', 1);
+ }
+
+ #[Test]
+ public function product_must_be_a_valid_value(): void
+ {
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->set('selectedProduct', 'invalid')
+ ->call('nextStep')
+ ->assertHasErrors('selectedProduct')
+ ->assertSet('currentStep', 1);
+ }
+
+ #[Test]
+ public function selecting_mobile_advances_to_step_2(): void
+ {
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->set('selectedProduct', 'mobile')
+ ->call('nextStep')
+ ->assertSet('currentStep', 2);
+ }
+
+ #[Test]
+ public function mobile_selection_shows_area_type_and_bug_fields_on_step_2(): void
+ {
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->set('selectedProduct', 'mobile')
+ ->call('nextStep')
+ ->assertSet('currentStep', 2)
+ ->assertSee('What is the issue related to?')
+ ->assertSee('Bug report details')
+ ->assertDontSee('Describe your issue');
+ }
+
+ #[Test]
+ public function desktop_selection_shows_bug_fields_but_not_area_on_step_2(): void
+ {
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->set('selectedProduct', 'desktop')
+ ->call('nextStep')
+ ->assertSet('currentStep', 2)
+ ->assertSee('Bug report details')
+ ->assertDontSee('Describe your issue')
+ ->assertDontSee('Which area?');
+ }
+
+ #[Test]
+ public function bifrost_selection_shows_issue_type_on_step_2(): void
+ {
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->set('selectedProduct', 'bifrost')
+ ->call('nextStep')
+ ->assertSet('currentStep', 2)
+ ->assertSee('Issue type')
+ ->assertSee('Describe your issue')
+ ->assertDontSee('Bug report details');
+ }
+
+ #[Test]
+ public function nativephp_com_selection_shows_issue_type_on_step_2(): void
+ {
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->set('selectedProduct', 'nativephp.com')
+ ->call('nextStep')
+ ->assertSet('currentStep', 2)
+ ->assertSee('Issue type')
+ ->assertSee('Describe your issue')
+ ->assertDontSee('Bug report details');
+ }
+
+ #[Test]
+ public function bug_report_fields_are_required_when_shown(): void
+ {
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->set('selectedProduct', 'desktop')
+ ->call('nextStep')
+ ->assertSet('currentStep', 2)
+ ->call('nextStep')
+ ->assertHasErrors(['tryingToDo', 'whatHappened', 'reproductionSteps', 'environment'])
+ ->assertSet('currentStep', 2);
+ }
+
+ #[Test]
+ public function mobile_area_type_is_required_when_mobile_selected(): void
+ {
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->set('selectedProduct', 'mobile')
+ ->call('nextStep')
+ ->assertSet('currentStep', 2)
+ ->set('tryingToDo', 'Test')
+ ->set('whatHappened', 'Test')
+ ->set('reproductionSteps', 'Test')
+ ->set('environment', 'Test')
+ ->call('nextStep')
+ ->assertHasErrors('mobileAreaType')
+ ->assertSet('currentStep', 2);
+ }
+
+ #[Test]
+ public function mobile_area_is_required_when_plugin_type_selected(): void
+ {
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->set('selectedProduct', 'mobile')
+ ->call('nextStep')
+ ->assertSet('currentStep', 2)
+ ->set('mobileAreaType', 'plugin')
+ ->set('tryingToDo', 'Test')
+ ->set('whatHappened', 'Test')
+ ->set('reproductionSteps', 'Test')
+ ->set('environment', 'Test')
+ ->call('nextStep')
+ ->assertHasErrors('mobileArea')
+ ->assertSet('currentStep', 2);
+ }
+
+ #[Test]
+ public function mobile_core_type_does_not_require_mobile_area(): void
+ {
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->set('selectedProduct', 'mobile')
+ ->call('nextStep')
+ ->assertSet('currentStep', 2)
+ ->set('mobileAreaType', 'core')
+ ->set('tryingToDo', 'Test')
+ ->set('whatHappened', 'Test')
+ ->set('reproductionSteps', 'Test')
+ ->set('environment', 'Test')
+ ->call('nextStep')
+ ->assertHasNoErrors('mobileArea')
+ ->assertSet('currentStep', 3);
+ }
+
+ #[Test]
+ public function issue_type_is_required_when_bifrost_selected(): void
+ {
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->set('selectedProduct', 'bifrost')
+ ->call('nextStep')
+ ->assertSet('currentStep', 2)
+ ->set('subject', 'Test subject')
+ ->set('message', 'Test message')
+ ->call('nextStep')
+ ->assertHasErrors('issueType')
+ ->assertSet('currentStep', 2);
+ }
+
+ #[Test]
+ public function issue_type_must_be_valid_value(): void
+ {
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->set('selectedProduct', 'bifrost')
+ ->call('nextStep')
+ ->set('issueType', 'invalid_type')
+ ->set('subject', 'Test subject')
+ ->set('message', 'Test message')
+ ->call('nextStep')
+ ->assertHasErrors('issueType')
+ ->assertSet('currentStep', 2);
+ }
+
+ #[Test]
+ public function subject_is_required_on_step_2(): void
+ {
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->set('selectedProduct', 'bifrost')
+ ->call('nextStep')
+ ->set('issueType', 'bug')
+ ->set('message', 'Some message')
+ ->call('nextStep')
+ ->assertHasErrors('subject')
+ ->assertSet('currentStep', 2);
+ }
+
+ #[Test]
+ public function message_is_required_on_step_2(): void
+ {
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->set('selectedProduct', 'bifrost')
+ ->call('nextStep')
+ ->set('issueType', 'bug')
+ ->set('subject', 'Some subject')
+ ->call('nextStep')
+ ->assertHasErrors('message')
+ ->assertSet('currentStep', 2);
+ }
+
+ #[Test]
+ public function subject_cannot_exceed_255_characters(): void
+ {
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->set('selectedProduct', 'bifrost')
+ ->call('nextStep')
+ ->set('issueType', 'bug')
+ ->set('subject', str_repeat('a', 256))
+ ->set('message', 'Some message')
+ ->call('nextStep')
+ ->assertHasErrors('subject')
+ ->assertSet('currentStep', 2);
+ }
+
+ #[Test]
+ public function message_cannot_exceed_5000_characters(): void
+ {
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->set('selectedProduct', 'bifrost')
+ ->call('nextStep')
+ ->set('issueType', 'bug')
+ ->set('subject', 'Some subject')
+ ->set('message', str_repeat('a', 5001))
+ ->call('nextStep')
+ ->assertHasErrors('message')
+ ->assertSet('currentStep', 2);
+ }
+
+ #[Test]
+ public function full_desktop_submission_creates_ticket_with_bug_report_data(): void
+ {
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->set('selectedProduct', 'desktop')
+ ->call('nextStep')
+ ->set('tryingToDo', 'Build an app')
+ ->set('whatHappened', 'It crashed')
+ ->set('reproductionSteps', '1. Open app 2. Click button')
+ ->set('environment', 'macOS 14, Electron 28')
+ ->call('nextStep')
+ ->assertSet('currentStep', 3)
+ ->call('submit')
+ ->assertRedirect();
+
+ $ticket = SupportTicket::where('user_id', $user->id)->first();
+
+ $this->assertNotNull($ticket);
+ $this->assertEquals('desktop', $ticket->product);
+ $this->assertEquals('Build an app', $ticket->subject);
+ $this->assertStringContainsString('Build an app', $ticket->message);
+ $this->assertStringContainsString('It crashed', $ticket->message);
+ $this->assertStringContainsString('1. Open app 2. Click button', $ticket->message);
+ $this->assertStringContainsString('macOS 14, Electron 28', $ticket->message);
+ $this->assertNull($ticket->issue_type);
+ $this->assertEquals('Build an app', $ticket->metadata['trying_to_do']);
+ $this->assertEquals('It crashed', $ticket->metadata['what_happened']);
+ }
+
+ #[Test]
+ public function bifrost_submission_stores_product_and_issue_type(): void
+ {
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->set('selectedProduct', 'bifrost')
+ ->call('nextStep')
+ ->set('issueType', 'feature_request')
+ ->set('subject', 'Feature request')
+ ->set('message', 'Please add this feature.')
+ ->call('nextStep')
+ ->assertSet('currentStep', 3)
+ ->call('submit')
+ ->assertRedirect();
+
+ $ticket = SupportTicket::where('subject', 'Feature request')->first();
+
+ $this->assertNotNull($ticket);
+ $this->assertEquals('bifrost', $ticket->product);
+ $this->assertEquals('feature_request', $ticket->issue_type);
+ $this->assertNull($ticket->metadata);
+ }
+
+ #[Test]
+ public function submission_redirects_to_show_page(): void
+ {
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->set('selectedProduct', 'nativephp.com')
+ ->call('nextStep')
+ ->set('issueType', 'other')
+ ->set('subject', 'Redirect test')
+ ->set('message', 'Testing redirect after creation.')
+ ->call('nextStep')
+ ->assertSet('currentStep', 3)
+ ->call('submit')
+ ->assertRedirect();
+
+ $ticket = SupportTicket::where('subject', 'Redirect test')->first();
+
+ $this->assertNotNull($ticket);
+ $this->assertNotEmpty(route('support.tickets.show', $ticket));
+ }
+
+ #[Test]
+ public function step_3_shows_full_summary_including_environment_and_reproduction_steps(): void
+ {
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->set('selectedProduct', 'desktop')
+ ->call('nextStep')
+ ->set('tryingToDo', 'Build an app')
+ ->set('whatHappened', 'It crashed')
+ ->set('reproductionSteps', '1. Open app 2. Click button')
+ ->set('environment', 'macOS 14, PHP 8.4')
+ ->call('nextStep')
+ ->assertSet('currentStep', 3)
+ ->assertSee('Review your request')
+ ->assertSee('Desktop')
+ ->assertSee('Build an app')
+ ->assertSee('It crashed')
+ ->assertSee('1. Open app 2. Click button')
+ ->assertSee('macOS 14, PHP 8.4');
+ }
+
+ #[Test]
+ public function support_page_shows_priority_support_for_max_plan_users(): void
+ {
+ $user = User::factory()->create();
+
+ License::factory()->max()->active()->create(['user_id' => $user->id]);
+
+ $this->actingAs($user)
+ ->get(route('support.index'))
+ ->assertOk()
+ ->assertSee('Priority Support')
+ ->assertSee('Submit a Ticket');
+ }
+
+ #[Test]
+ public function support_page_does_not_show_priority_support_for_non_max_users(): void
+ {
+ $user = User::factory()->create();
+
+ License::factory()->pro()->active()->create(['user_id' => $user->id]);
+
+ $this->actingAs($user)
+ ->get(route('support.index'))
+ ->assertOk()
+ ->assertDontSee('Priority Support');
+ }
+
+ #[Test]
+ public function support_page_does_not_show_priority_support_for_guests(): void
+ {
+ $this->get(route('support.index'))
+ ->assertOk()
+ ->assertDontSee('Priority Support');
+ }
+
+ #[Test]
+ public function changing_product_resets_all_step_2_fields(): void
+ {
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->set('selectedProduct', 'mobile')
+ ->call('nextStep')
+ ->set('mobileAreaType', 'plugin')
+ ->set('mobileArea', 'jump')
+ ->set('tryingToDo', 'Something')
+ ->set('whatHappened', 'Something else')
+ ->set('reproductionSteps', 'Steps here')
+ ->set('environment', 'macOS')
+ ->call('previousStep')
+ ->set('selectedProduct', 'nativephp.com')
+ ->assertSet('mobileAreaType', '')
+ ->assertSet('mobileArea', '')
+ ->assertSet('tryingToDo', '')
+ ->assertSet('whatHappened', '')
+ ->assertSet('reproductionSteps', '')
+ ->assertSet('environment', '')
+ ->assertSet('subject', '')
+ ->assertSet('message', '')
+ ->assertSet('issueType', '');
+ }
+
+ #[Test]
+ public function ticket_index_shows_create_button_link(): void
+ {
+ $user = User::factory()->create();
+
+ $this->actingAs($user)
+ ->get(route('support.tickets'))
+ ->assertOk()
+ ->assertSee(route('support.tickets.create'));
+ }
+
+ #[Test]
+ public function back_button_returns_to_previous_step(): void
+ {
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->set('selectedProduct', 'desktop')
+ ->call('nextStep')
+ ->assertSet('currentStep', 2)
+ ->call('previousStep')
+ ->assertSet('currentStep', 1);
+ }
+
+ #[Test]
+ public function plugin_type_shows_official_plugins_in_select(): void
+ {
+ $user = User::factory()->create();
+
+ Plugin::factory()->create([
+ 'name' => 'nativephp/mobile-camera',
+ 'is_official' => true,
+ 'user_id' => $user->id,
+ ]);
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->set('selectedProduct', 'mobile')
+ ->call('nextStep')
+ ->set('mobileAreaType', 'plugin')
+ ->assertSee('nativephp/mobile-camera')
+ ->assertSee('Jump');
+ }
+
+ #[Test]
+ public function mobile_plugin_submission_stores_area_in_metadata(): void
+ {
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->set('selectedProduct', 'mobile')
+ ->call('nextStep')
+ ->set('mobileAreaType', 'plugin')
+ ->set('mobileArea', 'jump')
+ ->set('tryingToDo', 'Navigate between screens')
+ ->set('whatHappened', 'App froze')
+ ->set('reproductionSteps', '1. Open app 2. Navigate')
+ ->set('environment', 'iOS 17, iPhone 15')
+ ->call('nextStep')
+ ->assertSet('currentStep', 3)
+ ->call('submit')
+ ->assertRedirect();
+
+ $ticket = SupportTicket::where('user_id', $user->id)->first();
+
+ $this->assertNotNull($ticket);
+ $this->assertEquals('mobile', $ticket->product);
+ $this->assertEquals('Navigate between screens', $ticket->subject);
+ $this->assertEquals('plugin', $ticket->metadata['mobile_area_type']);
+ $this->assertEquals('jump', $ticket->metadata['mobile_area']);
+ $this->assertEquals('Navigate between screens', $ticket->metadata['trying_to_do']);
+ }
+
+ #[Test]
+ public function mobile_core_submission_stores_area_type_without_area(): void
+ {
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->set('selectedProduct', 'mobile')
+ ->call('nextStep')
+ ->set('mobileAreaType', 'core')
+ ->set('tryingToDo', 'Build an app')
+ ->set('whatHappened', 'It crashed')
+ ->set('reproductionSteps', '1. Run build')
+ ->set('environment', 'iOS 17')
+ ->call('nextStep')
+ ->assertSet('currentStep', 3)
+ ->call('submit')
+ ->assertRedirect();
+
+ $ticket = SupportTicket::where('user_id', $user->id)->first();
+
+ $this->assertNotNull($ticket);
+ $this->assertEquals('mobile', $ticket->product);
+ $this->assertEquals('Build an app', $ticket->subject);
+ $this->assertEquals('core', $ticket->metadata['mobile_area_type']);
+ $this->assertArrayNotHasKey('mobile_area', $ticket->metadata);
+ }
+
+ #[Test]
+ public function submitting_a_ticket_sends_notification_to_support_email(): void
+ {
+ Notification::fake();
+
+ $user = User::factory()->create();
+
+ Livewire::actingAs($user)
+ ->test(CreateSupportTicket::class)
+ ->set('selectedProduct', 'bifrost')
+ ->call('nextStep')
+ ->set('issueType', 'bug')
+ ->set('subject', 'Notification test')
+ ->set('message', 'Testing notification dispatch.')
+ ->call('nextStep')
+ ->assertSet('currentStep', 3)
+ ->call('submit')
+ ->assertRedirect();
+
+ Notification::assertSentOnDemand(
+ SupportTicketSubmitted::class,
+ function (SupportTicketSubmitted $notification, array $channels, object $notifiable) {
+ return $notifiable->routes['mail'] === 'support@nativephp.com'
+ && $notification->ticket->subject === 'Notification test';
+ }
+ );
+ }
+
+ #[Test]
+ public function authenticated_user_can_reply_to_their_open_ticket(): void
+ {
+ $user = User::factory()->create();
+ $ticket = SupportTicket::factory()->create(['user_id' => $user->id]);
+
+ $this->actingAs($user)
+ ->post(route('support.tickets.reply', $ticket), [
+ 'message' => 'This is my reply.',
+ ])
+ ->assertRedirect(route('support.tickets.show', $ticket));
+
+ $this->assertDatabaseHas('replies', [
+ 'support_ticket_id' => $ticket->id,
+ 'user_id' => $user->id,
+ 'message' => 'This is my reply.',
+ 'note' => false,
+ ]);
+ }
+
+ #[Test]
+ public function user_reply_sends_notification_to_support_email(): void
+ {
+ Notification::fake();
+
+ $user = User::factory()->create();
+ $ticket = SupportTicket::factory()->create(['user_id' => $user->id]);
+
+ $this->actingAs($user)
+ ->post(route('support.tickets.reply', $ticket), [
+ 'message' => 'I have more info.',
+ ])
+ ->assertRedirect();
+
+ Notification::assertSentOnDemand(
+ SupportTicketUserReplied::class,
+ function (SupportTicketUserReplied $notification, array $channels, object $notifiable) use ($ticket) {
+ return $notifiable->routes['mail'] === 'support@nativephp.com'
+ && $notification->ticket->is($ticket)
+ && $notification->reply->message === 'I have more info.';
+ }
+ );
+ }
+
+ #[Test]
+ public function user_cannot_reply_to_a_closed_ticket(): void
+ {
+ $user = User::factory()->create();
+ $ticket = SupportTicket::factory()->create([
+ 'user_id' => $user->id,
+ 'status' => Status::CLOSED,
+ ]);
+
+ $this->actingAs($user)
+ ->post(route('support.tickets.reply', $ticket), [
+ 'message' => 'This should fail.',
+ ])
+ ->assertForbidden();
+ }
+
+ #[Test]
+ public function user_cannot_reply_to_another_users_ticket(): void
+ {
+ $user = User::factory()->create();
+ $otherUser = User::factory()->create();
+ $ticket = SupportTicket::factory()->create(['user_id' => $otherUser->id]);
+
+ $this->actingAs($user)
+ ->post(route('support.tickets.reply', $ticket), [
+ 'message' => 'This should fail.',
+ ])
+ ->assertForbidden();
+ }
+
+ #[Test]
+ public function reply_message_is_required(): void
+ {
+ $user = User::factory()->create();
+ $ticket = SupportTicket::factory()->create(['user_id' => $user->id]);
+
+ $this->actingAs($user)
+ ->post(route('support.tickets.reply', $ticket), [
+ 'message' => '',
+ ])
+ ->assertSessionHasErrors('message');
+ }
+
+ #[Test]
+ public function ticket_show_page_displays_inline_reply_form_for_open_ticket(): void
+ {
+ $user = User::factory()->create();
+ $ticket = SupportTicket::factory()->create(['user_id' => $user->id]);
+
+ $this->actingAs($user)
+ ->get(route('support.tickets.show', $ticket))
+ ->assertOk()
+ ->assertSee('Add a reply')
+ ->assertSee(route('support.tickets.reply', $ticket));
+ }
+
+ #[Test]
+ public function ticket_show_page_hides_reply_form_for_closed_ticket(): void
+ {
+ $user = User::factory()->create();
+ $ticket = SupportTicket::factory()->create([
+ 'user_id' => $user->id,
+ 'status' => Status::CLOSED,
+ ]);
+
+ $this->actingAs($user)
+ ->get(route('support.tickets.show', $ticket))
+ ->assertOk()
+ ->assertDontSee('Add a reply');
+ }
+
+ #[Test]
+ public function ticket_show_page_hides_internal_notes_from_ticket_owner(): void
+ {
+ $user = User::factory()->create();
+ $admin = User::factory()->create();
+ $ticket = SupportTicket::factory()->create(['user_id' => $user->id]);
+
+ Reply::factory()->create([
+ 'support_ticket_id' => $ticket->id,
+ 'user_id' => $admin->id,
+ 'message' => 'Visible staff reply',
+ 'note' => false,
+ ]);
+
+ Reply::factory()->create([
+ 'support_ticket_id' => $ticket->id,
+ 'user_id' => $admin->id,
+ 'message' => 'Secret internal note',
+ 'note' => true,
+ ]);
+
+ $this->actingAs($user)
+ ->get(route('support.tickets.show', $ticket))
+ ->assertOk()
+ ->assertSee('Visible staff reply')
+ ->assertDontSee('Secret internal note');
+ }
+
+ #[Test]
+ public function admin_reply_sends_notification_to_ticket_owner(): void
+ {
+ Notification::fake();
+
+ $user = User::factory()->create();
+ $admin = User::factory()->create();
+ $ticket = SupportTicket::factory()->create(['user_id' => $user->id]);
+
+ $reply = Reply::factory()->create([
+ 'support_ticket_id' => $ticket->id,
+ 'user_id' => $admin->id,
+ 'message' => 'We are looking into this.',
+ 'note' => false,
+ ]);
+
+ $ticket->user->notify(new SupportTicketReplied($ticket, $reply));
+
+ Notification::assertSentTo(
+ $user,
+ SupportTicketReplied::class,
+ function (SupportTicketReplied $notification) use ($ticket, $reply) {
+ return $notification->ticket->is($ticket)
+ && $notification->reply->is($reply);
+ }
+ );
+ }
+
+ #[Test]
+ public function internal_note_reply_does_not_send_notification_to_ticket_owner(): void
+ {
+ Notification::fake();
+
+ $user = User::factory()->create();
+ $admin = User::factory()->create();
+ $ticket = SupportTicket::factory()->create(['user_id' => $user->id]);
+
+ Reply::factory()->create([
+ 'support_ticket_id' => $ticket->id,
+ 'user_id' => $admin->id,
+ 'message' => 'Internal note only.',
+ 'note' => true,
+ ]);
+
+ // The RepliesRelationManager skips notification for notes,
+ // so we verify no notification was sent.
+ Notification::assertNotSentTo($user, SupportTicketReplied::class);
+ }
+
+ #[Test]
+ public function support_ticket_replied_notification_contains_correct_mail_content(): void
+ {
+ $user = User::factory()->create(['name' => 'Jane Doe']);
+ $ticket = SupportTicket::factory()->create([
+ 'user_id' => $user->id,
+ 'subject' => 'Login issue',
+ ]);
+ $reply = Reply::factory()->create([
+ 'support_ticket_id' => $ticket->id,
+ 'message' => 'We have fixed the login issue.',
+ ]);
+
+ $notification = new SupportTicketReplied($ticket, $reply);
+ $mail = $notification->toMail($user);
+
+ $this->assertStringContainsString('Login issue', $mail->subject);
+ $this->assertStringContainsString('Hi Jane', $mail->greeting);
+ }
+
+ #[Test]
+ public function ticket_show_page_displays_submission_details_section(): void
+ {
+ $user = User::factory()->create();
+ $ticket = SupportTicket::factory()->create([
+ 'user_id' => $user->id,
+ 'product' => 'mobile',
+ 'message' => 'Original submission message',
+ 'metadata' => [
+ 'trying_to_do' => 'Build an app',
+ 'what_happened' => 'It crashed',
+ ],
+ ]);
+
+ $this->actingAs($user)
+ ->get(route('support.tickets.show', $ticket))
+ ->assertOk()
+ ->assertSee('Submission Details')
+ ->assertSee('Mobile')
+ ->assertDontSee('Original submission message')
+ ->assertSee('Build an app')
+ ->assertSee('It crashed');
+ }
+
+ #[Test]
+ public function ticket_show_page_hides_original_message_for_desktop_tickets(): void
+ {
+ $user = User::factory()->create();
+ $ticket = SupportTicket::factory()->create([
+ 'user_id' => $user->id,
+ 'product' => 'desktop',
+ 'message' => 'Auto-generated bug report message',
+ 'metadata' => [
+ 'trying_to_do' => 'Run the app',
+ ],
+ ]);
+
+ $this->actingAs($user)
+ ->get(route('support.tickets.show', $ticket))
+ ->assertOk()
+ ->assertDontSee('Original Message')
+ ->assertSee('Run the app');
+ }
+
+ #[Test]
+ public function ticket_show_page_shows_original_message_for_non_bug_report_tickets(): void
+ {
+ $user = User::factory()->create();
+ $ticket = SupportTicket::factory()->create([
+ 'user_id' => $user->id,
+ 'product' => 'nativephp.com',
+ 'message' => 'I have a billing question.',
+ ]);
+
+ $this->actingAs($user)
+ ->get(route('support.tickets.show', $ticket))
+ ->assertOk()
+ ->assertSee('Original Message')
+ ->assertSee('I have a billing question.');
+ }
+}
From 0815e0acc4156d194c274300a2430a62dead2c12 Mon Sep 17 00:00:00 2001
From: Simon Hamp
Date: Mon, 23 Mar 2026 22:18:48 +0000
Subject: [PATCH 20/21] Apply Pint formatting fixes
Co-Authored-By: Claude Opus 4.6
---
app/Http/Controllers/Account/AuthController.php | 8 ++++----
app/SupportTicket/Status.php | 2 +-
.../2025_04_28_135326_create_support_tickets_table.php | 3 ++-
.../migrations/2025_04_28_160102_create_replies_table.php | 5 +++--
database/seeders/SupportTicketSeeder.php | 1 -
5 files changed, 10 insertions(+), 9 deletions(-)
diff --git a/app/Http/Controllers/Account/AuthController.php b/app/Http/Controllers/Account/AuthController.php
index 68054b36..548c8bc4 100644
--- a/app/Http/Controllers/Account/AuthController.php
+++ b/app/Http/Controllers/Account/AuthController.php
@@ -27,14 +27,14 @@ public function logout()
*
* @TODO Implement additional brute-force protection with custom blocked IPs model.
*
- * @param LoginRequest $request
- * @throws \Illuminate\Validation\ValidationException
* @return \Illuminate\Http\RedirectResponse
+ *
+ * @throws \Illuminate\Validation\ValidationException
*/
public function processLogin(LoginRequest $request)
{
$credentials = $request->only('email', 'password');
- $key = 'login-attempt:' . $request->ip();
+ $key = 'login-attempt:'.$request->ip();
$attemptsPerHour = 5;
if (\RateLimiter::tooManyAttempts($key, $attemptsPerHour)) {
@@ -46,7 +46,7 @@ public function processLogin(LoginRequest $request)
->withInput($request->only(['email', 'remember']))
->withErrors([
'email' => 'Too many login attempts. Please try again in '
- . $blockedUntil . ' minutes.',
+ .$blockedUntil.' minutes.',
]);
}
diff --git a/app/SupportTicket/Status.php b/app/SupportTicket/Status.php
index 7d9297b1..bdcdb3ed 100644
--- a/app/SupportTicket/Status.php
+++ b/app/SupportTicket/Status.php
@@ -12,6 +12,6 @@ enum Status: string
public function translated(): string
{
- return __('account.support_ticket.status.' . $this->value);
+ return __('account.support_ticket.status.'.$this->value);
}
}
diff --git a/database/migrations/2025_04_28_135326_create_support_tickets_table.php b/database/migrations/2025_04_28_135326_create_support_tickets_table.php
index bc647fb8..ca3bfcc2 100644
--- a/database/migrations/2025_04_28_135326_create_support_tickets_table.php
+++ b/database/migrations/2025_04_28_135326_create_support_tickets_table.php
@@ -5,7 +5,8 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::create('support_tickets', function (Blueprint $table) {
diff --git a/database/migrations/2025_04_28_160102_create_replies_table.php b/database/migrations/2025_04_28_160102_create_replies_table.php
index f12e6186..a838b0f9 100644
--- a/database/migrations/2025_04_28_160102_create_replies_table.php
+++ b/database/migrations/2025_04_28_160102_create_replies_table.php
@@ -6,13 +6,14 @@
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
-return new class extends Migration {
+return new class extends Migration
+{
public function up(): void
{
Schema::create('replies', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(SupportTicket::class);
- $table->foreignIdFor(User::class );
+ $table->foreignIdFor(User::class);
$table->text('message');
$table->json('attachments')->nullable();
$table->boolean('note');
diff --git a/database/seeders/SupportTicketSeeder.php b/database/seeders/SupportTicketSeeder.php
index 34edb2c3..6e34e53b 100644
--- a/database/seeders/SupportTicketSeeder.php
+++ b/database/seeders/SupportTicketSeeder.php
@@ -3,7 +3,6 @@
namespace Database\Seeders;
use App\Models\SupportTicket;
-use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class SupportTicketSeeder extends Seeder
From 2f8d4fc500714f0cce79990af45ec9f6c40edea9 Mon Sep 17 00:00:00 2001
From: Simon Hamp
Date: Tue, 24 Mar 2026 14:01:20 +0000
Subject: [PATCH 21/21] Move support tickets to customer dashboard with
Livewire components
Migrate support ticket views from public /support/* routes to
/dashboard/* using Livewire page components with Flux layout. Fix
Filament admin panel class references for this version and restyle
the admin ticket replies widget with inline styles.
Co-Authored-By: Claude Opus 4.6
---
.../Resources/SupportTicketResource.php | 110 ++---
.../RepliesRelationManager.php | 6 +-
.../Widgets/TicketRepliesWidget.php | 2 +-
.../Account/Support/TicketController.php | 71 ---
.../Support/Create.php} | 14 +-
app/Livewire/Customer/Support/Index.php | 33 ++
app/Livewire/Customer/Support/Show.php | 69 +++
app/Notifications/SupportTicketReplied.php | 2 +-
composer.lock | 44 +-
.../customer/status-badge.blade.php | 8 +-
.../components/layouts/dashboard.blade.php | 4 +
.../widgets/ticket-replies.blade.php | 66 ++-
.../livewire/create-support-ticket.blade.php | 452 ------------------
.../customer/support/create.blade.php | 438 +++++++++++++++++
.../livewire/customer/support/index.blade.php | 62 +++
.../livewire/customer/support/show.blade.php | 139 ++++++
resources/views/support/index.blade.php | 2 +-
.../views/support/tickets/index.blade.php | 122 -----
.../views/support/tickets/show.blade.php | 191 --------
routes/web.php | 34 +-
tests/Feature/SupportTicketTest.php | 186 +++----
21 files changed, 988 insertions(+), 1067 deletions(-)
delete mode 100644 app/Http/Controllers/Account/Support/TicketController.php
rename app/Livewire/{CreateSupportTicket.php => Customer/Support/Create.php} (94%)
create mode 100644 app/Livewire/Customer/Support/Index.php
create mode 100644 app/Livewire/Customer/Support/Show.php
delete mode 100644 resources/views/livewire/create-support-ticket.blade.php
create mode 100644 resources/views/livewire/customer/support/create.blade.php
create mode 100644 resources/views/livewire/customer/support/index.blade.php
create mode 100644 resources/views/livewire/customer/support/show.blade.php
delete mode 100644 resources/views/support/tickets/index.blade.php
delete mode 100644 resources/views/support/tickets/show.blade.php
diff --git a/app/Filament/Resources/SupportTicketResource.php b/app/Filament/Resources/SupportTicketResource.php
index 10222a44..2b1840cd 100644
--- a/app/Filament/Resources/SupportTicketResource.php
+++ b/app/Filament/Resources/SupportTicketResource.php
@@ -5,9 +5,11 @@
use App\Filament\Resources\SupportTicketResource\Pages;
use App\Models\SupportTicket;
use App\SupportTicket\Status;
+use Filament\Actions;
use Filament\Infolists;
-use Filament\Infolists\Infolist;
use Filament\Resources\Resource;
+use Filament\Schemas\Components\Section;
+use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
@@ -15,7 +17,7 @@ class SupportTicketResource extends Resource
{
protected static ?string $model = SupportTicket::class;
- protected static ?string $navigationIcon = 'heroicon-o-ticket';
+ protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-ticket';
protected static ?string $navigationLabel = 'Support Tickets';
@@ -26,59 +28,57 @@ public static function canCreate(): bool
return false;
}
- public static function infolist(Infolist $infolist): Infolist
+ public static function infolist(Schema $schema): Schema
{
- return $infolist
+ return $schema
+ ->columns(2)
->schema([
- Infolists\Components\Grid::make(2)
+ Section::make('Ticket Details')
->schema([
- Infolists\Components\Section::make('Ticket Details')
- ->schema([
- Infolists\Components\TextEntry::make('mask')
- ->label('Ticket ID'),
- Infolists\Components\TextEntry::make('status')
- ->badge()
- ->color(fn (Status $state): string => match ($state) {
- Status::OPEN => 'warning',
- Status::IN_PROGRESS => 'info',
- Status::ON_HOLD => 'gray',
- Status::RESPONDED => 'success',
- Status::CLOSED => 'danger',
- }),
- Infolists\Components\TextEntry::make('product')
- ->label('Product'),
- Infolists\Components\TextEntry::make('issue_type')
- ->label('Issue Type')
- ->placeholder('N/A'),
- Infolists\Components\TextEntry::make('user.email')
- ->label('User')
- ->url(fn (SupportTicket $record): string => UserResource::getUrl('edit', ['record' => $record->user_id])),
- Infolists\Components\TextEntry::make('created_at')
- ->label('Created')
- ->dateTime(),
- Infolists\Components\TextEntry::make('updated_at')
- ->label('Updated')
- ->dateTime(),
- ])
- ->columns(2)
- ->collapsible()
- ->persistCollapsed()
- ->columnSpan(1),
-
- Infolists\Components\Section::make('Context')
- ->schema([
- Infolists\Components\TextEntry::make('subject')
- ->label('Subject')
- ->columnSpanFull(),
- Infolists\Components\TextEntry::make('message')
- ->label('Message')
- ->markdown()
- ->columnSpanFull(),
- ])
- ->collapsible()
- ->persistCollapsed()
- ->columnSpan(1),
- ]),
+ Infolists\Components\TextEntry::make('mask')
+ ->label('Ticket ID'),
+ Infolists\Components\TextEntry::make('status')
+ ->badge()
+ ->color(fn (Status $state): string => match ($state) {
+ Status::OPEN => 'warning',
+ Status::IN_PROGRESS => 'info',
+ Status::ON_HOLD => 'gray',
+ Status::RESPONDED => 'success',
+ Status::CLOSED => 'danger',
+ }),
+ Infolists\Components\TextEntry::make('product')
+ ->label('Product'),
+ Infolists\Components\TextEntry::make('issue_type')
+ ->label('Issue Type')
+ ->placeholder('N/A'),
+ Infolists\Components\TextEntry::make('user.email')
+ ->label('User')
+ ->url(fn (SupportTicket $record): string => UserResource::getUrl('edit', ['record' => $record->user_id])),
+ Infolists\Components\TextEntry::make('created_at')
+ ->label('Created')
+ ->dateTime(),
+ Infolists\Components\TextEntry::make('updated_at')
+ ->label('Updated')
+ ->dateTime(),
+ ])
+ ->columns(2)
+ ->collapsible()
+ ->persistCollapsed()
+ ->columnSpan(1),
+
+ Section::make('Context')
+ ->schema([
+ Infolists\Components\TextEntry::make('subject')
+ ->label('Subject')
+ ->columnSpanFull(),
+ Infolists\Components\TextEntry::make('message')
+ ->label('Message')
+ ->markdown()
+ ->columnSpanFull(),
+ ])
+ ->collapsible()
+ ->persistCollapsed()
+ ->columnSpan(1),
]);
}
@@ -132,11 +132,11 @@ public static function table(Table $table): Table
]),
])
->actions([
- Tables\Actions\ViewAction::make(),
+ Actions\ViewAction::make(),
])
->bulkActions([
- Tables\Actions\BulkActionGroup::make([
- Tables\Actions\DeleteBulkAction::make(),
+ Actions\BulkActionGroup::make([
+ Actions\DeleteBulkAction::make(),
]),
])
->defaultSort('created_at', 'desc');
diff --git a/app/Filament/Resources/SupportTicketResource/RelationManagers/RepliesRelationManager.php b/app/Filament/Resources/SupportTicketResource/RelationManagers/RepliesRelationManager.php
index d416b9ea..2c4d5ae4 100644
--- a/app/Filament/Resources/SupportTicketResource/RelationManagers/RepliesRelationManager.php
+++ b/app/Filament/Resources/SupportTicketResource/RelationManagers/RepliesRelationManager.php
@@ -5,8 +5,8 @@
use App\Models\SupportTicket\Reply;
use App\Notifications\SupportTicketReplied;
use Filament\Forms;
-use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
+use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
@@ -23,9 +23,9 @@ public function isReadOnly(): bool
return false;
}
- public function form(Form $form): Form
+ public function form(Schema $schema): Schema
{
- return $form
+ return $schema
->schema([
Forms\Components\Textarea::make('message')
->required()
diff --git a/app/Filament/Resources/SupportTicketResource/Widgets/TicketRepliesWidget.php b/app/Filament/Resources/SupportTicketResource/Widgets/TicketRepliesWidget.php
index 98a27c50..fa7cd8fb 100644
--- a/app/Filament/Resources/SupportTicketResource/Widgets/TicketRepliesWidget.php
+++ b/app/Filament/Resources/SupportTicketResource/Widgets/TicketRepliesWidget.php
@@ -8,7 +8,7 @@
class TicketRepliesWidget extends Widget
{
- protected static string $view = 'filament.resources.support-ticket-resource.widgets.ticket-replies';
+ protected string $view = 'filament.resources.support-ticket-resource.widgets.ticket-replies';
public ?Model $record = null;
diff --git a/app/Http/Controllers/Account/Support/TicketController.php b/app/Http/Controllers/Account/Support/TicketController.php
deleted file mode 100644
index 34b710a7..00000000
--- a/app/Http/Controllers/Account/Support/TicketController.php
+++ /dev/null
@@ -1,71 +0,0 @@
-authorize('reply', $supportTicket);
-
- $request->validate([
- 'message' => ['required', 'string', 'max:5000'],
- ]);
-
- $reply = $supportTicket->replies()->create([
- 'user_id' => auth()->id(),
- 'message' => $request->input('message'),
- 'note' => false,
- ]);
-
- Notification::route('mail', 'support@nativephp.com')
- ->notify(new SupportTicketUserReplied($supportTicket, $reply));
-
- return redirect()
- ->route('support.tickets.show', $supportTicket)
- ->with('success', 'Your reply has been sent.');
- }
-
- public function closeTicket(SupportTicket $supportTicket): RedirectResponse
- {
- $this->authorize('closeTicket', $supportTicket);
-
- $supportTicket->update([
- 'status' => Status::CLOSED,
- ]);
-
- return redirect()
- ->route('support.tickets.show', $supportTicket)
- ->with('success', __('account.support_ticket.close_ticket.success'));
- }
-
- public function index(): View
- {
- $supportTickets = SupportTicket::whereUserId(auth()->user()->id)
- ->orderBy('status', 'desc')
- ->orderBy('created_at', 'desc')
- ->paginate(static::$paginationLimit);
-
- return view('support.tickets.index', compact('supportTickets'));
- }
-
- public function show(SupportTicket $supportTicket): View
- {
- $this->authorize('view', $supportTicket);
-
- $supportTicket->load(['user', 'replies.user']);
-
- return view('support.tickets.show', compact('supportTicket'));
- }
-}
diff --git a/app/Livewire/CreateSupportTicket.php b/app/Livewire/Customer/Support/Create.php
similarity index 94%
rename from app/Livewire/CreateSupportTicket.php
rename to app/Livewire/Customer/Support/Create.php
index 017cd684..19ee094b 100644
--- a/app/Livewire/CreateSupportTicket.php
+++ b/app/Livewire/Customer/Support/Create.php
@@ -1,6 +1,6 @@
notify(new SupportTicketSubmitted($ticket));
- $this->redirect(route('support.tickets.show', $ticket), navigate: false);
+ $this->redirect(route('customer.support.tickets.show', $ticket), navigate: false);
}
protected function validateStep1(): void
@@ -203,8 +207,8 @@ public function render()
->pluck('name', 'id');
}
- return view('livewire.create-support-ticket', [
+ return view('livewire.customer.support.create', [
'officialPlugins' => $officialPlugins,
- ])->layout('components.layout', ['title' => 'Submit a Request - NativePHP']);
+ ]);
}
}
diff --git a/app/Livewire/Customer/Support/Index.php b/app/Livewire/Customer/Support/Index.php
new file mode 100644
index 00000000..92970809
--- /dev/null
+++ b/app/Livewire/Customer/Support/Index.php
@@ -0,0 +1,33 @@
+id())
+ ->orderBy('status', 'desc')
+ ->orderBy('created_at', 'desc')
+ ->paginate(10);
+ }
+
+ public function render(): View
+ {
+ return view('livewire.customer.support.index');
+ }
+}
diff --git a/app/Livewire/Customer/Support/Show.php b/app/Livewire/Customer/Support/Show.php
new file mode 100644
index 00000000..895c813e
--- /dev/null
+++ b/app/Livewire/Customer/Support/Show.php
@@ -0,0 +1,69 @@
+authorize('view', $supportTicket);
+
+ $supportTicket->load(['user', 'replies.user']);
+
+ $this->supportTicket = $supportTicket;
+ }
+
+ public function reply(): void
+ {
+ $this->authorize('reply', $this->supportTicket);
+
+ $this->validate([
+ 'replyMessage' => ['required', 'string', 'max:5000'],
+ ]);
+
+ $reply = $this->supportTicket->replies()->create([
+ 'user_id' => auth()->id(),
+ 'message' => $this->replyMessage,
+ 'note' => false,
+ ]);
+
+ Notification::route('mail', 'support@nativephp.com')
+ ->notify(new SupportTicketUserReplied($this->supportTicket, $reply));
+
+ $this->replyMessage = '';
+ $this->supportTicket->load(['user', 'replies.user']);
+
+ session()->flash('success', 'Your reply has been sent.');
+ }
+
+ public function closeTicket(): void
+ {
+ $this->authorize('closeTicket', $this->supportTicket);
+
+ $this->supportTicket->update([
+ 'status' => Status::CLOSED,
+ ]);
+
+ session()->flash('success', __('account.support_ticket.close_ticket.success'));
+ }
+
+ public function render(): View
+ {
+ return view('livewire.customer.support.show');
+ }
+}
diff --git a/app/Notifications/SupportTicketReplied.php b/app/Notifications/SupportTicketReplied.php
index 1472c73d..49236beb 100644
--- a/app/Notifications/SupportTicketReplied.php
+++ b/app/Notifications/SupportTicketReplied.php
@@ -35,6 +35,6 @@ public function toMail(object $notifiable): MailMessage
->line('Your support ticket has received a new reply.')
->line('**'.e($this->ticket->subject).'**')
->line(Str::limit($this->reply->message, 500))
- ->action('View Ticket', route('support.tickets.show', $this->ticket));
+ ->action('View Ticket', route('customer.support.tickets.show', $this->ticket));
}
}
diff --git a/composer.lock b/composer.lock
index fdcee1a1..ff20ef83 100644
--- a/composer.lock
+++ b/composer.lock
@@ -133,16 +133,16 @@
},
{
"name": "aws/aws-sdk-php",
- "version": "3.373.7",
+ "version": "3.373.8",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
- "reference": "4402bd10f913e66b7271f44466be8d5ba6c9146e"
+ "reference": "18035d80bab38c962af8580307a787bf4e15cc47"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/4402bd10f913e66b7271f44466be8d5ba6c9146e",
- "reference": "4402bd10f913e66b7271f44466be8d5ba6c9146e",
+ "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/18035d80bab38c962af8580307a787bf4e15cc47",
+ "reference": "18035d80bab38c962af8580307a787bf4e15cc47",
"shasum": ""
},
"require": {
@@ -224,9 +224,9 @@
"support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues",
- "source": "https://github.com/aws/aws-sdk-php/tree/3.373.7"
+ "source": "https://github.com/aws/aws-sdk-php/tree/3.373.8"
},
- "time": "2026-03-20T18:14:19+00:00"
+ "time": "2026-03-23T18:08:22+00:00"
},
{
"name": "blade-ui-kit/blade-heroicons",
@@ -7150,16 +7150,16 @@
},
{
"name": "sentry/sentry",
- "version": "4.22.0",
+ "version": "4.23.0",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-php.git",
- "reference": "ce6ab95a7021f976a27b4628a4072e481c8acf60"
+ "reference": "121a674d5fffcdb8e414b75c1b76edba8e592b66"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/ce6ab95a7021f976a27b4628a4072e481c8acf60",
- "reference": "ce6ab95a7021f976a27b4628a4072e481c8acf60",
+ "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/121a674d5fffcdb8e414b75c1b76edba8e592b66",
+ "reference": "121a674d5fffcdb8e414b75c1b76edba8e592b66",
"shasum": ""
},
"require": {
@@ -7181,12 +7181,14 @@
"guzzlehttp/psr7": "^1.8.4|^2.1.1",
"monolog/monolog": "^1.6|^2.0|^3.0",
"nyholm/psr7": "^1.8",
+ "open-telemetry/api": "^1.0",
+ "open-telemetry/exporter-otlp": "^1.0",
+ "open-telemetry/sdk": "^1.0",
"phpbench/phpbench": "^1.0",
"phpstan/phpstan": "^1.3",
"phpunit/phpunit": "^8.5.52|^9.6.34",
"spiral/roadrunner-http": "^3.6",
- "spiral/roadrunner-worker": "^3.6",
- "vimeo/psalm": "^4.17"
+ "spiral/roadrunner-worker": "^3.6"
},
"suggest": {
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler."
@@ -7225,7 +7227,7 @@
],
"support": {
"issues": "https://github.com/getsentry/sentry-php/issues",
- "source": "https://github.com/getsentry/sentry-php/tree/4.22.0"
+ "source": "https://github.com/getsentry/sentry-php/tree/4.23.0"
},
"funding": [
{
@@ -7237,27 +7239,27 @@
"type": "custom"
}
],
- "time": "2026-03-16T13:03:46+00:00"
+ "time": "2026-03-23T13:15:52+00:00"
},
{
"name": "sentry/sentry-laravel",
- "version": "4.23.0",
+ "version": "4.24.0",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-laravel.git",
- "reference": "dcb44590017c613f62018e5e3163cb704b1da9a7"
+ "reference": "f823bd85e38e06cb4f1b7a82d48a2fc95320b31d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/dcb44590017c613f62018e5e3163cb704b1da9a7",
- "reference": "dcb44590017c613f62018e5e3163cb704b1da9a7",
+ "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/f823bd85e38e06cb4f1b7a82d48a2fc95320b31d",
+ "reference": "f823bd85e38e06cb4f1b7a82d48a2fc95320b31d",
"shasum": ""
},
"require": {
"illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0",
"nyholm/psr7": "^1.0",
"php": "^7.2 | ^8.0",
- "sentry/sentry": "^4.22.0",
+ "sentry/sentry": "^4.23.0",
"symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0 | ^8.0"
},
"require-dev": {
@@ -7316,7 +7318,7 @@
],
"support": {
"issues": "https://github.com/getsentry/sentry-laravel/issues",
- "source": "https://github.com/getsentry/sentry-laravel/tree/4.23.0"
+ "source": "https://github.com/getsentry/sentry-laravel/tree/4.24.0"
},
"funding": [
{
@@ -7328,7 +7330,7 @@
"type": "custom"
}
],
- "time": "2026-03-19T13:22:14+00:00"
+ "time": "2026-03-24T10:33:54+00:00"
},
{
"name": "simonhamp/the-og",
diff --git a/resources/views/components/customer/status-badge.blade.php b/resources/views/components/customer/status-badge.blade.php
index 785f4494..b1b64467 100644
--- a/resources/views/components/customer/status-badge.blade.php
+++ b/resources/views/components/customer/status-badge.blade.php
@@ -3,9 +3,11 @@
@php
$color = match($status) {
'Active', 'Approved', 'Licensed' => 'green',
- 'Expired', 'Pending', 'Pending Review' => 'yellow',
- 'Needs Renewal' => 'blue',
- 'Suspended', 'Rejected' => 'red',
+ 'Expired', 'Pending', 'Pending Review', 'Open' => 'yellow',
+ 'Needs Renewal', 'In Progress' => 'blue',
+ 'Suspended', 'Rejected', 'Closed' => 'red',
+ 'Responded' => 'green',
+ 'On Hold' => 'zinc',
default => 'zinc',
};
@endphp
diff --git a/resources/views/components/layouts/dashboard.blade.php b/resources/views/components/layouts/dashboard.blade.php
index 0fda420c..fc55470f 100644
--- a/resources/views/components/layouts/dashboard.blade.php
+++ b/resources/views/components/layouts/dashboard.blade.php
@@ -111,6 +111,10 @@ class="min-h-screen bg-white font-poppins antialiased dark:bg-zinc-900 dark:text
Purchase History
+
+ Support Tickets
+
+
Showcase
diff --git a/resources/views/filament/resources/support-ticket-resource/widgets/ticket-replies.blade.php b/resources/views/filament/resources/support-ticket-resource/widgets/ticket-replies.blade.php
index 3d21e7b6..9b7436df 100644
--- a/resources/views/filament/resources/support-ticket-resource/widgets/ticket-replies.blade.php
+++ b/resources/views/filament/resources/support-ticket-resource/widgets/ticket-replies.blade.php
@@ -1,72 +1,70 @@
{{-- Reply form at top --}}
-