From afb9794db498b1e3eaccfcf2fa8210ec2f397c5e Mon Sep 17 00:00:00 2001 From: Pete Bishop <9081809+PeteBishwhip@users.noreply.github.com> Date: Mon, 28 Apr 2025 12:34:36 +0100 Subject: [PATCH 01/21] Add Support Page --- resources/views/support/index.blade.php | 3 +++ routes/web.php | 6 ++++++ 2 files changed, 9 insertions(+) create mode 100644 resources/views/support/index.blade.php diff --git a/resources/views/support/index.blade.php b/resources/views/support/index.blade.php new file mode 100644 index 00000000..fc170a76 --- /dev/null +++ b/resources/views/support/index.blade.php @@ -0,0 +1,3 @@ + + + diff --git a/routes/web.php b/routes/web.php index 0471a388..1f62a203 100644 --- a/routes/web.php +++ b/routes/web.php @@ -54,3 +54,9 @@ })->name('docs')->where('page', '.*'); Route::get('/order/{checkoutSessionId}', App\Livewire\OrderSuccess::class)->name('order.success'); + +Route::prefix('/support')->group(function () { + Route::get('/', function () { + return view('support.index'); + })->name('support.index'); +}); From 14d709e96e91fbf32f37eee775c5fa2ccdc397b6 Mon Sep 17 00:00:00 2001 From: Pete Bishop <9081809+PeteBishwhip@users.noreply.github.com> Date: Mon, 28 Apr 2025 12:42:35 +0100 Subject: [PATCH 02/21] Update support view --- resources/views/support/index.blade.php | 76 ++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/resources/views/support/index.blade.php b/resources/views/support/index.blade.php index fc170a76..8a3120f1 100644 --- a/resources/views/support/index.blade.php +++ b/resources/views/support/index.blade.php @@ -1,3 +1,77 @@ - + + {{-- Support Grid Section --}} +
+ {{-- Header --}} +
+

Support

+

+ Get help with NativePHP through our various support channels. +

+
+ {{-- Support Grid --}} + + + {{-- Additional Support Information --}} +
+

Need more help?

+

+ Check out our documentation for comprehensive guides and tutorials to help you get the most out of NativePHP. +

+
+
From db5a593820606a3bb9e7c074c4ef2f8c1d501695 Mon Sep 17 00:00:00 2001 From: Pete Bishop <9081809+PeteBishwhip@users.noreply.github.com> Date: Mon, 28 Apr 2025 13:06:10 +0100 Subject: [PATCH 03/21] Login for support --- resources/views/support/auth/login.blade.php | 84 ++++++++++++++++++++ resources/views/support/index.blade.php | 2 +- routes/web.php | 18 +++++ 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 resources/views/support/auth/login.blade.php diff --git a/resources/views/support/auth/login.blade.php b/resources/views/support/auth/login.blade.php new file mode 100644 index 00000000..ae81cccf --- /dev/null +++ b/resources/views/support/auth/login.blade.php @@ -0,0 +1,84 @@ + +
+
+ +
+
+

+ Sign in to your account +

+

+ Or + + create a new account + +

+
+
+ @csrf + +
+
+ + + @error('email') +

{{ $message }}

+ @enderror +
+
+ + + @error('password') +

{{ $message }}

+ @enderror +
+
+ +
+
+ + +
+ + +
+ +
+ +
+
+
+
+
diff --git a/resources/views/support/index.blade.php b/resources/views/support/index.blade.php index 8a3120f1..ec8d87ca 100644 --- a/resources/views/support/index.blade.php +++ b/resources/views/support/index.blade.php @@ -50,7 +50,7 @@ class="group flex w-full flex-col items-center rounded-xl bg-gray-100/80 p-8 tex {{-- Support Tickets Box --}} - name('support.index'); + + Route::prefix('/tickets')->group(function () { + Route::get('/', function () { + if (!Auth::check()) { + return redirect()->route('support.auth.login'); + } + + return view('support.tickets.index'); + })->name('support.tickets'); + + Route::get('/login', function () { + if (Auth::check()) { + return redirect()->route('support.tickets'); + } + return view('support.auth.login'); + })->name('support.auth.login'); + }); }); From 50d33deb95c4b12e8a47c51d5720289f0a05e03f Mon Sep 17 00:00:00 2001 From: Pete Bishop <9081809+PeteBishwhip@users.noreply.github.com> Date: Mon, 28 Apr 2025 14:14:46 +0100 Subject: [PATCH 04/21] Basic Account Functionality --- .../Controllers/Account/AuthController.php | 40 ++++++++++++++ app/Http/Requests/LoginRequest.php | 29 +++++++++++ app/Providers/AppServiceProvider.php | 17 +++++- app/Providers/RouteServiceProvider.php | 2 +- database/seeders/DatabaseSeeder.php | 12 ++--- .../{support => account}/auth/login.blade.php | 34 ++++++------ resources/views/account/index.blade.php | 52 +++++++++++++++++++ routes/web.php | 45 ++++++++++------ 8 files changed, 192 insertions(+), 39 deletions(-) create mode 100644 app/Http/Controllers/Account/AuthController.php create mode 100644 app/Http/Requests/LoginRequest.php rename resources/views/{support => account}/auth/login.blade.php (82%) create mode 100644 resources/views/account/index.blade.php diff --git a/app/Http/Controllers/Account/AuthController.php b/app/Http/Controllers/Account/AuthController.php new file mode 100644 index 00000000..5df75ee5 --- /dev/null +++ b/app/Http/Controllers/Account/AuthController.php @@ -0,0 +1,40 @@ +logout(); + session()->regenerateToken(); + + return redirect()->route('account.login'); + } + + public function processLogin(LoginRequest $request) + { + $credentials = $request->only('email', 'password'); + + if (auth()->attempt($credentials, $request->boolean('remember'))) { + session()->regenerate(); + + return redirect()->intended('/account'); + } + + return back() + ->withInput($request->only('email')) + ->withErrors([ + 'email' => 'The provided credentials do not match our records.', + ]); + } +} diff --git a/app/Http/Requests/LoginRequest.php b/app/Http/Requests/LoginRequest.php new file mode 100644 index 00000000..58c936e9 --- /dev/null +++ b/app/Http/Requests/LoginRequest.php @@ -0,0 +1,29 @@ +|string> + */ + public function rules(): array + { + return [ + 'email' => 'required|email', + 'password' => 'required|string', + ]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f58138ee..3ab7fbb8 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,9 @@ namespace App\Providers; use App\Support\GitHub; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; @@ -21,10 +24,11 @@ public function register(): void */ public function boot(): void { - $this->registerSharedViewVariables(); + $this->registerSharedViewVariables() + ->registerRateLimiters(); } - private function registerSharedViewVariables(): void + private function registerSharedViewVariables(): static { View::share('electronGitHubVersion', app()->environment('production') ? GitHub::electron()->latestVersion() @@ -34,5 +38,14 @@ private function registerSharedViewVariables(): void View::share('bskyLink', 'https://bsky.app/profile/nativephp.bsky.social'); View::share('openCollectiveLink', 'https://opencollective.com/nativephp'); View::share('githubLink', 'https://github.com/NativePHP'); + + return $this; + } + + private function registerRateLimiters() + { + RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by($request->input('email') . '|' . $request->ip()); + }); } } diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 1cf5f15c..07ca748c 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -17,7 +17,7 @@ class RouteServiceProvider extends ServiceProvider * * @var string */ - public const HOME = '/home'; + public const HOME = '/account'; /** * Define your route model bindings, pattern filters, and other route configuration. diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index a9f4519f..d67c9ba5 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -4,6 +4,7 @@ // use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\Hash; class DatabaseSeeder extends Seeder { @@ -12,11 +13,10 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // \App\Models\User::factory(10)->create(); - - // \App\Models\User::factory()->create([ - // 'name' => 'Test User', - // 'email' => 'test@example.com', - // ]); + \App\Models\User::factory()->create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => Hash::make('password'), + ]); } } diff --git a/resources/views/support/auth/login.blade.php b/resources/views/account/auth/login.blade.php similarity index 82% rename from resources/views/support/auth/login.blade.php rename to resources/views/account/auth/login.blade.php index ae81cccf..aa8ea0fc 100644 --- a/resources/views/support/auth/login.blade.php +++ b/resources/views/account/auth/login.blade.php @@ -14,40 +14,44 @@

-
+ @csrf + @error('email') +
+
+ + + +

{{ $message }}

+
+
+ @enderror
- @error('email') -

{{ $message }}

- @enderror
- @error('password') -

{{ $message }}

- @enderror
-
- +
+
- @forelse(auth()->user()->supportTickets as $ticket) + @forelse($supportTickets as $ticket) @@ -65,6 +64,10 @@ @endforelse
@@ -37,7 +36,7 @@
#{{ $ticket->mask }} @@ -51,7 +50,7 @@ - + View
+ +
+ {{ $supportTickets->links() }} +
{{-- Additional Support Information --}}
diff --git a/resources/views/support/tickets/show.blade.php b/resources/views/support/tickets/show.blade.php new file mode 100644 index 00000000..b5235d5d --- /dev/null +++ b/resources/views/support/tickets/show.blade.php @@ -0,0 +1,47 @@ + + + {{ $supportTicket->subject }} + + + +

+ {{ $supportTicket->subject }} +

+
+ +
+
+
+

Ticket Details

+

+ Ticket ID: #{{ $supportTicket->mask }}
+ Status: {{ $supportTicket->status }}
+ Created At: {{ $supportTicket->created_at->format('d M Y, H:i') }}
+ Updated At: {{ $supportTicket->updated_at->format('d M Y, H:i') }} +

+
+
+ + {{-- Ticket Messages --}} +
+
+

Messages

+ @foreach([] as $message) +
+

{{ $message->user->name }}:

+

{{ $message->content }}

+

{{ $message->created_at->format('d M Y, H:i') }}

+
+ @endforeach +
+
+ + {{-- Additional Support Information --}} +
+

Need more help?

+

+ Check out our documentation for comprehensive guides and tutorials to help you get the most out of NativePHP. +

+
+
+
diff --git a/routes/web.php b/routes/web.php index 38326123..dbafe198 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ group(function () { - Route::get('/', function () { - return view('support.tickets.index'); - })->name('support.tickets'); + Route::get('/', [TicketController::class, 'index'])->name('support.tickets'); - Route::get('/{ticketMask}', function ($ticketMask) { - return view('support.tickets.show', ['ticket' => $ticketMask]); - })->name('support.tickets.show'); + Route::get('/{supportTicket}', [TicketController::class, 'show']) + ->name('support.tickets.show'); }); }); From f750abdaed06b974fb7eaea686f8f673fbe81d82 Mon Sep 17 00:00:00 2001 From: Pete Bishop <9081809+PeteBishwhip@users.noreply.github.com> Date: Mon, 28 Apr 2025 17:19:28 +0100 Subject: [PATCH 08/21] More work on Support Ticket Single View --- app/Models/SupportTicket.php | 11 +++++- app/Models/SupportTicket/Reply.php | 37 +++++++++++++++++++ .../factories/SupportTicket/ReplyFactory.php | 28 ++++++++++++++ ...2025_04_28_160102_create_replies_table.php | 28 ++++++++++++++ database/seeders/SupportTicketSeeder.php | 5 +++ .../views/support/tickets/show.blade.php | 16 +++++--- 6 files changed, 119 insertions(+), 6 deletions(-) create mode 100644 app/Models/SupportTicket/Reply.php create mode 100644 database/factories/SupportTicket/ReplyFactory.php create mode 100644 database/migrations/2025_04_28_160102_create_replies_table.php diff --git a/app/Models/SupportTicket.php b/app/Models/SupportTicket.php index b0ad0663..91f08e63 100644 --- a/app/Models/SupportTicket.php +++ b/app/Models/SupportTicket.php @@ -2,9 +2,11 @@ namespace App\Models; +use App\Models\SupportTicket\Reply; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; class SupportTicket extends Model @@ -23,7 +25,8 @@ protected static function booted() { static::creating(function ($ticket) { if (is_null($ticket->mask)) { - + // @TODO Generate a unique mask for the ticket + $ticket->mask = uniqid('ticket_'); } }); } @@ -33,6 +36,12 @@ public function getRouteKeyName(): string return 'mask'; } + public function replies(): HasMany + { + return $this->hasMany(Reply::class) + ->orderBy('created_at', 'desc'); + } + public function user(): BelongsTo { return $this->belongsTo(User::class); diff --git a/app/Models/SupportTicket/Reply.php b/app/Models/SupportTicket/Reply.php new file mode 100644 index 00000000..02268e94 --- /dev/null +++ b/app/Models/SupportTicket/Reply.php @@ -0,0 +1,37 @@ + 'array', + 'note' => 'boolean', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function supportTicket(): BelongsTo + { + return $this->belongsTo(SupportTicket::class); + } +} diff --git a/database/factories/SupportTicket/ReplyFactory.php b/database/factories/SupportTicket/ReplyFactory.php new file mode 100644 index 00000000..3873abcc --- /dev/null +++ b/database/factories/SupportTicket/ReplyFactory.php @@ -0,0 +1,28 @@ + $this->faker->paragraphs(2, true), + 'attachments' => null, + 'note' => false, + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + + 'support_ticket_id' => SupportTicket::factory(), + 'user_id' => User::factory(), + ]; + } +} diff --git a/database/migrations/2025_04_28_160102_create_replies_table.php b/database/migrations/2025_04_28_160102_create_replies_table.php new file mode 100644 index 00000000..f12e6186 --- /dev/null +++ b/database/migrations/2025_04_28_160102_create_replies_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignIdFor(SupportTicket::class); + $table->foreignIdFor(User::class ); + $table->text('message'); + $table->json('attachments')->nullable(); + $table->boolean('note'); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('replies'); + } +}; diff --git a/database/seeders/SupportTicketSeeder.php b/database/seeders/SupportTicketSeeder.php index 73e8f9dd..34edb2c3 100644 --- a/database/seeders/SupportTicketSeeder.php +++ b/database/seeders/SupportTicketSeeder.php @@ -15,6 +15,11 @@ public function run(): void { SupportTicket::factory() ->count(10) + ->has( + SupportTicket\Reply::factory() + ->state(['user_id' => 1]) + ->count(5) + ) ->create([ 'user_id' => 1, 'status' => 'open', diff --git a/resources/views/support/tickets/show.blade.php b/resources/views/support/tickets/show.blade.php index b5235d5d..84b1ad10 100644 --- a/resources/views/support/tickets/show.blade.php +++ b/resources/views/support/tickets/show.blade.php @@ -26,11 +26,17 @@

Messages

- @foreach([] as $message) -
-

{{ $message->user->name }}:

-

{{ $message->content }}

-

{{ $message->created_at->format('d M Y, H:i') }}

+ @foreach($supportTicket->replies as $reply) +
+
+
+

{{ $reply->user->name }}

+

{{ $reply->message }}

+
+
+
+ {{ $reply->created_at->format('d M Y, H:i') }} +
@endforeach
From b0b098c00650c49050a5f5c998d7563bea142a72 Mon Sep 17 00:00:00 2001 From: Pete Bishop <9081809+PeteBishwhip@users.noreply.github.com> Date: Mon, 28 Apr 2025 17:39:07 +0100 Subject: [PATCH 09/21] More show ticket work --- app/Models/SupportTicket/Reply.php | 6 ++++ .../views/support/tickets/show.blade.php | 28 +++++++++++-------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/app/Models/SupportTicket/Reply.php b/app/Models/SupportTicket/Reply.php index 02268e94..c063cff7 100644 --- a/app/Models/SupportTicket/Reply.php +++ b/app/Models/SupportTicket/Reply.php @@ -4,6 +4,7 @@ use App\Models\SupportTicket; use App\Models\User; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -25,6 +26,11 @@ class Reply extends Model 'note' => 'boolean', ]; + public function isFromUser(): Attribute + { + return Attribute::get(fn () => $this->user_id === auth()->user()->id); + } + public function user(): BelongsTo { return $this->belongsTo(User::class); diff --git a/resources/views/support/tickets/show.blade.php b/resources/views/support/tickets/show.blade.php index 84b1ad10..d01827fb 100644 --- a/resources/views/support/tickets/show.blade.php +++ b/resources/views/support/tickets/show.blade.php @@ -1,18 +1,24 @@ - - {{ $supportTicket->subject }} - - - -

- {{ $supportTicket->subject }} -

-
-
-

Ticket Details

+
+

#{{ $supportTicket->mask }} » {{ $supportTicket->subject }}

+
+ + +
+

Ticket ID: #{{ $supportTicket->mask }}
Status: {{ $supportTicket->status }}
From 533a827ce0af9553afc8944dfecd3917d159223d Mon Sep 17 00:00:00 2001 From: Pete Bishop <9081809+PeteBishwhip@users.noreply.github.com> Date: Mon, 28 Apr 2025 17:55:16 +0100 Subject: [PATCH 10/21] Ticket statuses! --- app/Models/SupportTicket.php | 6 + app/SupportTicket/Status.php | 17 ++ lang/en/account.php | 13 ++ lang/en/auth.php | 20 ++ lang/en/pagination.php | 19 ++ lang/en/passwords.php | 22 ++ lang/en/validation.php | 191 ++++++++++++++++++ .../views/support/tickets/index.blade.php | 14 +- .../views/support/tickets/show.blade.php | 2 +- 9 files changed, 297 insertions(+), 7 deletions(-) create mode 100644 app/SupportTicket/Status.php create mode 100644 lang/en/account.php create mode 100644 lang/en/auth.php create mode 100644 lang/en/pagination.php create mode 100644 lang/en/passwords.php create mode 100644 lang/en/validation.php diff --git a/app/Models/SupportTicket.php b/app/Models/SupportTicket.php index 91f08e63..a2f1458e 100644 --- a/app/Models/SupportTicket.php +++ b/app/Models/SupportTicket.php @@ -3,6 +3,8 @@ namespace App\Models; use App\Models\SupportTicket\Reply; +use App\SupportTicket\Status; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -21,6 +23,10 @@ class SupportTicket extends Model 'status', ]; + protected $casts = [ + 'status' => Status::class, + ]; + protected static function booted() { static::creating(function ($ticket) { diff --git a/app/SupportTicket/Status.php b/app/SupportTicket/Status.php new file mode 100644 index 00000000..7d9297b1 --- /dev/null +++ b/app/SupportTicket/Status.php @@ -0,0 +1,17 @@ +value); + } +} diff --git a/lang/en/account.php b/lang/en/account.php new file mode 100644 index 00000000..889ffad0 --- /dev/null +++ b/lang/en/account.php @@ -0,0 +1,13 @@ + [ + 'status' => [ + 'open' => 'Open', + 'in_progress' => 'In Progress', + 'on_hold' => 'On Hold', + 'responded' => 'Responded', + 'closed' => 'Closed', + ], + ], +]; diff --git a/lang/en/auth.php b/lang/en/auth.php new file mode 100644 index 00000000..6598e2c0 --- /dev/null +++ b/lang/en/auth.php @@ -0,0 +1,20 @@ + 'These credentials do not match our records.', + 'password' => 'The provided password is incorrect.', + 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', + +]; diff --git a/lang/en/pagination.php b/lang/en/pagination.php new file mode 100644 index 00000000..d4814118 --- /dev/null +++ b/lang/en/pagination.php @@ -0,0 +1,19 @@ + '« Previous', + 'next' => 'Next »', + +]; diff --git a/lang/en/passwords.php b/lang/en/passwords.php new file mode 100644 index 00000000..f1223bd7 --- /dev/null +++ b/lang/en/passwords.php @@ -0,0 +1,22 @@ + 'Your password has been reset.', + 'sent' => 'We have emailed your password reset link.', + 'throttled' => 'Please wait before retrying.', + 'token' => 'This password reset token is invalid.', + 'user' => "We can't find a user with that email address.", + +]; diff --git a/lang/en/validation.php b/lang/en/validation.php new file mode 100644 index 00000000..8dbe37f1 --- /dev/null +++ b/lang/en/validation.php @@ -0,0 +1,191 @@ + 'The :attribute field must be accepted.', + 'accepted_if' => 'The :attribute field must be accepted when :other is :value.', + 'active_url' => 'The :attribute field must be a valid URL.', + 'after' => 'The :attribute field must be a date after :date.', + 'after_or_equal' => 'The :attribute field must be a date after or equal to :date.', + 'alpha' => 'The :attribute field must only contain letters.', + 'alpha_dash' => 'The :attribute field must only contain letters, numbers, dashes, and underscores.', + 'alpha_num' => 'The :attribute field must only contain letters and numbers.', + 'array' => 'The :attribute field must be an array.', + 'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.', + 'before' => 'The :attribute field must be a date before :date.', + 'before_or_equal' => 'The :attribute field must be a date before or equal to :date.', + 'between' => [ + 'array' => 'The :attribute field must have between :min and :max items.', + 'file' => 'The :attribute field must be between :min and :max kilobytes.', + 'numeric' => 'The :attribute field must be between :min and :max.', + 'string' => 'The :attribute field must be between :min and :max characters.', + ], + 'boolean' => 'The :attribute field must be true or false.', + 'can' => 'The :attribute field contains an unauthorized value.', + 'confirmed' => 'The :attribute field confirmation does not match.', + 'current_password' => 'The password is incorrect.', + 'date' => 'The :attribute field must be a valid date.', + 'date_equals' => 'The :attribute field must be a date equal to :date.', + 'date_format' => 'The :attribute field must match the format :format.', + 'decimal' => 'The :attribute field must have :decimal decimal places.', + 'declined' => 'The :attribute field must be declined.', + 'declined_if' => 'The :attribute field must be declined when :other is :value.', + 'different' => 'The :attribute field and :other must be different.', + 'digits' => 'The :attribute field must be :digits digits.', + 'digits_between' => 'The :attribute field must be between :min and :max digits.', + 'dimensions' => 'The :attribute field has invalid image dimensions.', + 'distinct' => 'The :attribute field has a duplicate value.', + 'doesnt_end_with' => 'The :attribute field must not end with one of the following: :values.', + 'doesnt_start_with' => 'The :attribute field must not start with one of the following: :values.', + 'email' => 'The :attribute field must be a valid email address.', + 'ends_with' => 'The :attribute field must end with one of the following: :values.', + 'enum' => 'The selected :attribute is invalid.', + 'exists' => 'The selected :attribute is invalid.', + 'extensions' => 'The :attribute field must have one of the following extensions: :values.', + 'file' => 'The :attribute field must be a file.', + 'filled' => 'The :attribute field must have a value.', + 'gt' => [ + 'array' => 'The :attribute field must have more than :value items.', + 'file' => 'The :attribute field must be greater than :value kilobytes.', + 'numeric' => 'The :attribute field must be greater than :value.', + 'string' => 'The :attribute field must be greater than :value characters.', + ], + 'gte' => [ + 'array' => 'The :attribute field must have :value items or more.', + 'file' => 'The :attribute field must be greater than or equal to :value kilobytes.', + 'numeric' => 'The :attribute field must be greater than or equal to :value.', + 'string' => 'The :attribute field must be greater than or equal to :value characters.', + ], + 'hex_color' => 'The :attribute field must be a valid hexadecimal color.', + 'image' => 'The :attribute field must be an image.', + 'in' => 'The selected :attribute is invalid.', + 'in_array' => 'The :attribute field must exist in :other.', + 'integer' => 'The :attribute field must be an integer.', + 'ip' => 'The :attribute field must be a valid IP address.', + 'ipv4' => 'The :attribute field must be a valid IPv4 address.', + 'ipv6' => 'The :attribute field must be a valid IPv6 address.', + 'json' => 'The :attribute field must be a valid JSON string.', + 'lowercase' => 'The :attribute field must be lowercase.', + 'lt' => [ + 'array' => 'The :attribute field must have less than :value items.', + 'file' => 'The :attribute field must be less than :value kilobytes.', + 'numeric' => 'The :attribute field must be less than :value.', + 'string' => 'The :attribute field must be less than :value characters.', + ], + 'lte' => [ + 'array' => 'The :attribute field must not have more than :value items.', + 'file' => 'The :attribute field must be less than or equal to :value kilobytes.', + 'numeric' => 'The :attribute field must be less than or equal to :value.', + 'string' => 'The :attribute field must be less than or equal to :value characters.', + ], + 'mac_address' => 'The :attribute field must be a valid MAC address.', + 'max' => [ + 'array' => 'The :attribute field must not have more than :max items.', + 'file' => 'The :attribute field must not be greater than :max kilobytes.', + 'numeric' => 'The :attribute field must not be greater than :max.', + 'string' => 'The :attribute field must not be greater than :max characters.', + ], + 'max_digits' => 'The :attribute field must not have more than :max digits.', + 'mimes' => 'The :attribute field must be a file of type: :values.', + 'mimetypes' => 'The :attribute field must be a file of type: :values.', + 'min' => [ + 'array' => 'The :attribute field must have at least :min items.', + 'file' => 'The :attribute field must be at least :min kilobytes.', + 'numeric' => 'The :attribute field must be at least :min.', + 'string' => 'The :attribute field must be at least :min characters.', + ], + 'min_digits' => 'The :attribute field must have at least :min digits.', + 'missing' => 'The :attribute field must be missing.', + 'missing_if' => 'The :attribute field must be missing when :other is :value.', + 'missing_unless' => 'The :attribute field must be missing unless :other is :value.', + 'missing_with' => 'The :attribute field must be missing when :values is present.', + 'missing_with_all' => 'The :attribute field must be missing when :values are present.', + 'multiple_of' => 'The :attribute field must be a multiple of :value.', + 'not_in' => 'The selected :attribute is invalid.', + 'not_regex' => 'The :attribute field format is invalid.', + 'numeric' => 'The :attribute field must be a number.', + 'password' => [ + 'letters' => 'The :attribute field must contain at least one letter.', + 'mixed' => 'The :attribute field must contain at least one uppercase and one lowercase letter.', + 'numbers' => 'The :attribute field must contain at least one number.', + 'symbols' => 'The :attribute field must contain at least one symbol.', + 'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.', + ], + 'present' => 'The :attribute field must be present.', + 'present_if' => 'The :attribute field must be present when :other is :value.', + 'present_unless' => 'The :attribute field must be present unless :other is :value.', + 'present_with' => 'The :attribute field must be present when :values is present.', + 'present_with_all' => 'The :attribute field must be present when :values are present.', + 'prohibited' => 'The :attribute field is prohibited.', + 'prohibited_if' => 'The :attribute field is prohibited when :other is :value.', + 'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.', + 'prohibits' => 'The :attribute field prohibits :other from being present.', + 'regex' => 'The :attribute field format is invalid.', + 'required' => 'The :attribute field is required.', + 'required_array_keys' => 'The :attribute field must contain entries for: :values.', + 'required_if' => 'The :attribute field is required when :other is :value.', + 'required_if_accepted' => 'The :attribute field is required when :other is accepted.', + 'required_unless' => 'The :attribute field is required unless :other is in :values.', + 'required_with' => 'The :attribute field is required when :values is present.', + 'required_with_all' => 'The :attribute field is required when :values are present.', + 'required_without' => 'The :attribute field is required when :values is not present.', + 'required_without_all' => 'The :attribute field is required when none of :values are present.', + 'same' => 'The :attribute field must match :other.', + 'size' => [ + 'array' => 'The :attribute field must contain :size items.', + 'file' => 'The :attribute field must be :size kilobytes.', + 'numeric' => 'The :attribute field must be :size.', + 'string' => 'The :attribute field must be :size characters.', + ], + 'starts_with' => 'The :attribute field must start with one of the following: :values.', + 'string' => 'The :attribute field must be a string.', + 'timezone' => 'The :attribute field must be a valid timezone.', + 'unique' => 'The :attribute has already been taken.', + 'uploaded' => 'The :attribute failed to upload.', + 'uppercase' => 'The :attribute field must be uppercase.', + 'url' => 'The :attribute field must be a valid URL.', + 'ulid' => 'The :attribute field must be a valid ULID.', + 'uuid' => 'The :attribute field must be a valid UUID.', + + /* + |-------------------------------------------------------------------------- + | Custom Validation Language Lines + |-------------------------------------------------------------------------- + | + | Here you may specify custom validation messages for attributes using the + | convention "attribute.rule" to name the lines. This makes it quick to + | specify a specific custom language line for a given attribute rule. + | + */ + + 'custom' => [ + 'attribute-name' => [ + 'rule-name' => 'custom-message', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Custom Validation Attributes + |-------------------------------------------------------------------------- + | + | The following language lines are used to swap our attribute placeholder + | with something more reader friendly such as "E-Mail Address" instead + | of "email". This simply helps us make our message more expressive. + | + */ + + 'attributes' => [], + +]; diff --git a/resources/views/support/tickets/index.blade.php b/resources/views/support/tickets/index.blade.php index 7a04f54b..6e2578b1 100644 --- a/resources/views/support/tickets/index.blade.php +++ b/resources/views/support/tickets/index.blade.php @@ -17,8 +17,8 @@ Submit a new request

-
- +
+
@@ -46,7 +46,7 @@ - {{ $ticket->status }} + {{ $ticket->status->translated() }} @@ -65,9 +65,11 @@
-
- {{ $supportTickets->links() }} -
+ @if ($supportTickets->hasPages()) +
+ {{ $supportTickets->links() }} +
+ @endif
{{-- Additional Support Information --}}
diff --git a/resources/views/support/tickets/show.blade.php b/resources/views/support/tickets/show.blade.php index d01827fb..69d3d0ab 100644 --- a/resources/views/support/tickets/show.blade.php +++ b/resources/views/support/tickets/show.blade.php @@ -21,7 +21,7 @@

Ticket ID: #{{ $supportTicket->mask }}
- Status: {{ $supportTicket->status }}
+ Status: {{ $supportTicket->status->translated() }}
Created At: {{ $supportTicket->created_at->format('d M Y, H:i') }}
Updated At: {{ $supportTicket->updated_at->format('d M Y, H:i') }}

From 1085a28949de77b522585f4f1b4b4e7671e7199c Mon Sep 17 00:00:00 2001 From: Pete Bishop <9081809+PeteBishwhip@users.noreply.github.com> Date: Tue, 29 Apr 2025 11:02:00 +0100 Subject: [PATCH 11/21] Add back to tickets button --- resources/views/support/tickets/show.blade.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/resources/views/support/tickets/show.blade.php b/resources/views/support/tickets/show.blade.php index 69d3d0ab..c65a984a 100644 --- a/resources/views/support/tickets/show.blade.php +++ b/resources/views/support/tickets/show.blade.php @@ -5,6 +5,12 @@

#{{ $supportTicket->mask }} » {{ $supportTicket->subject }}

+ + + + + Back to Tickets + + +

#{{ $supportTicket->mask }} » {{ $supportTicket->subject }}

-
- - - - - Back to Tickets - - - -

Ticket ID: #{{ $supportTicket->mask }}
@@ -62,4 +63,29 @@

+ + {{-- Mobile Footer - Visible only on Mobile --}} +
+ + + + + Back + + + +
+ + {{-- Add padding at the bottom to prevent content from being hidden behind the mobile footer --}} +
From 1f6f22afaa897010790fb1ef3f34ead9e12b40cb Mon Sep 17 00:00:00 2001 From: Pete Bishop <9081809+PeteBishwhip@users.noreply.github.com> Date: Tue, 29 Apr 2025 12:13:30 +0100 Subject: [PATCH 13/21] Add close ticket functionality --- .../Account/Support/TicketController.php | 14 +++++++ app/Policies/SupportTicketPolicy.php | 5 +++ .../views/support/tickets/show.blade.php | 40 ++++++++++++------- routes/web.php | 3 ++ 4 files changed, 47 insertions(+), 15 deletions(-) diff --git a/app/Http/Controllers/Account/Support/TicketController.php b/app/Http/Controllers/Account/Support/TicketController.php index eb8f858f..3a911dbf 100644 --- a/app/Http/Controllers/Account/Support/TicketController.php +++ b/app/Http/Controllers/Account/Support/TicketController.php @@ -4,12 +4,26 @@ use App\Http\Controllers\Controller; use App\Models\SupportTicket; +use App\SupportTicket\Status; use Illuminate\Http\Request; class TicketController extends Controller { public static string $paginationLimit = '10'; + public function closeTicket(SupportTicket $supportTicket) + { + $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() { $supportTickets = SupportTicket::whereUserId(auth()->user()->id) diff --git a/app/Policies/SupportTicketPolicy.php b/app/Policies/SupportTicketPolicy.php index 5069c809..848c1e49 100644 --- a/app/Policies/SupportTicketPolicy.php +++ b/app/Policies/SupportTicketPolicy.php @@ -8,6 +8,11 @@ class SupportTicketPolicy { + public function closeTicket(User $user, SupportTicket $supportTicket): bool + { + return $supportTicket->user_id == $user->id; + } + /** * Determine whether the user can view any models. */ diff --git a/resources/views/support/tickets/show.blade.php b/resources/views/support/tickets/show.blade.php index ccb1c111..5fad8a5e 100644 --- a/resources/views/support/tickets/show.blade.php +++ b/resources/views/support/tickets/show.blade.php @@ -1,24 +1,29 @@ {{-- Desktop Buttons - Hidden on Mobile --}} -
diff --git a/resources/views/support/tickets/show.blade.php b/resources/views/support/tickets/show.blade.php index 0ad70e6b..f028f42f 100644 --- a/resources/views/support/tickets/show.blade.php +++ b/resources/views/support/tickets/show.blade.php @@ -1,4 +1,5 @@ +
{{-- Desktop Buttons - Hidden on Mobile --}} +
+
+ + {{-- Add Alpine.js x-cloak style to hide elements with x-cloak before Alpine initializes --}} + +
From 1d12e1c22e86544bd03ae49365be91966ce0ec83 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Mon, 23 Mar 2026 22:14:54 +0000 Subject: [PATCH 19/21] Add support ticket system with admin panel, notifications, and chat UI Implements the full support ticket workflow: multi-step creation wizard, product-specific bug report forms, admin Filament resource with chat-style replies widget, email notifications for both user and admin replies, and user-facing ticket view with reply capability. Co-Authored-By: Claude Opus 4.6 --- .../Resources/SupportTicketResource.php | 157 +++ .../Pages/ListSupportTickets.php | 11 + .../Pages/ViewSupportTicket.php | 41 + .../RepliesRelationManager.php | 83 ++ .../Widgets/TicketRepliesWidget.php | 45 + .../Account/Support/TicketController.php | 34 +- app/Livewire/CreateSupportTicket.php | 210 ++++ app/Models/SupportTicket.php | 14 +- app/Models/SupportTicket/Reply.php | 1 + app/Notifications/SupportTicketReplied.php | 40 + app/Notifications/SupportTicketSubmitted.php | 41 + .../SupportTicketUserReplied.php | 45 + app/Policies/SupportTicketPolicy.php | 13 +- database/factories/SupportTicketFactory.php | 27 +- ..._and_metadata_to_support_tickets_table.php | 27 + resources/views/components/footer.blade.php | 8 + .../components/navbar/mobile-menu.blade.php | 24 + .../views/components/navigation-bar.blade.php | 19 + resources/views/docs/chooser.blade.php | 32 + .../widgets/ticket-replies.blade.php | 73 ++ .../livewire/create-support-ticket.blade.php | 452 +++++++++ resources/views/support/index.blade.php | 52 +- .../views/support/tickets/index.blade.php | 2 +- .../views/support/tickets/show.blade.php | 238 ++--- routes/web.php | 35 +- tests/Feature/SupportTicketTest.php | 894 ++++++++++++++++++ 26 files changed, 2464 insertions(+), 154 deletions(-) create mode 100644 app/Filament/Resources/SupportTicketResource.php create mode 100644 app/Filament/Resources/SupportTicketResource/Pages/ListSupportTickets.php create mode 100644 app/Filament/Resources/SupportTicketResource/Pages/ViewSupportTicket.php create mode 100644 app/Filament/Resources/SupportTicketResource/RelationManagers/RepliesRelationManager.php create mode 100644 app/Filament/Resources/SupportTicketResource/Widgets/TicketRepliesWidget.php create mode 100644 app/Livewire/CreateSupportTicket.php create mode 100644 app/Notifications/SupportTicketReplied.php create mode 100644 app/Notifications/SupportTicketSubmitted.php create mode 100644 app/Notifications/SupportTicketUserReplied.php create mode 100644 database/migrations/2026_03_20_160554_add_product_and_metadata_to_support_tickets_table.php create mode 100644 resources/views/docs/chooser.blade.php create mode 100644 resources/views/filament/resources/support-ticket-resource/widgets/ticket-replies.blade.php create mode 100644 resources/views/livewire/create-support-ticket.blade.php create mode 100644 tests/Feature/SupportTicketTest.php diff --git a/app/Filament/Resources/SupportTicketResource.php b/app/Filament/Resources/SupportTicketResource.php new file mode 100644 index 00000000..10222a44 --- /dev/null +++ b/app/Filament/Resources/SupportTicketResource.php @@ -0,0 +1,157 @@ +schema([ + Infolists\Components\Grid::make(2) + ->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), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('mask') + ->label('ID') + ->searchable() + ->sortable(), + + Tables\Columns\TextColumn::make('subject') + ->searchable() + ->sortable() + ->limit(50), + + Tables\Columns\TextColumn::make('user.email') + ->label('User') + ->searchable() + ->sortable(), + + Tables\Columns\TextColumn::make('product') + ->sortable(), + + Tables\Columns\TextColumn::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', + }) + ->sortable(), + + Tables\Columns\TextColumn::make('created_at') + ->label('Created') + ->dateTime() + ->sortable(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('status') + ->options(collect(Status::cases())->mapWithKeys(fn (Status $s) => [$s->value => $s->name])), + Tables\Filters\SelectFilter::make('product') + ->options([ + 'mobile' => 'Mobile', + 'desktop' => 'Desktop', + 'bifrost' => 'Bifrost', + 'nativephp.com' => 'NativePHP.com', + ]), + ]) + ->actions([ + Tables\Actions\ViewAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]) + ->defaultSort('created_at', 'desc'); + } + + public static function getRelations(): array + { + return []; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListSupportTickets::route('/'), + 'view' => Pages\ViewSupportTicket::route('/{record}'), + ]; + } +} diff --git a/app/Filament/Resources/SupportTicketResource/Pages/ListSupportTickets.php b/app/Filament/Resources/SupportTicketResource/Pages/ListSupportTickets.php new file mode 100644 index 00000000..4c356473 --- /dev/null +++ b/app/Filament/Resources/SupportTicketResource/Pages/ListSupportTickets.php @@ -0,0 +1,11 @@ +label('Update Status') + ->icon('heroicon-o-arrow-path') + ->form([ + \Filament\Forms\Components\Select::make('status') + ->label('Status') + ->options(collect(Status::cases())->mapWithKeys(fn (Status $s) => [$s->value => ucwords(str_replace('_', ' ', $s->value))])) + ->required(), + ]) + ->fillForm(fn () => ['status' => $this->record->status->value]) + ->action(function (array $data): void { + $this->record->update(['status' => $data['status']]); + $this->refreshFormData(['status']); + }), + ]; + } + + protected function getFooterWidgets(): array + { + return [ + TicketRepliesWidget::class, + ]; + } +} diff --git a/app/Filament/Resources/SupportTicketResource/RelationManagers/RepliesRelationManager.php b/app/Filament/Resources/SupportTicketResource/RelationManagers/RepliesRelationManager.php new file mode 100644 index 00000000..d416b9ea --- /dev/null +++ b/app/Filament/Resources/SupportTicketResource/RelationManagers/RepliesRelationManager.php @@ -0,0 +1,83 @@ +schema([ + Forms\Components\Textarea::make('message') + ->required() + ->maxLength(5000) + ->columnSpanFull(), + Forms\Components\Toggle::make('note') + ->label('Internal note (not visible to user)') + ->default(false), + ]); + } + + public function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('user.name') + ->label('Author') + ->sortable(), + + Tables\Columns\TextColumn::make('message') + ->limit(80) + ->tooltip(fn ($record) => $record->message), + + Tables\Columns\IconColumn::make('note') + ->label('Note') + ->boolean(), + + Tables\Columns\TextColumn::make('created_at') + ->label('Date') + ->dateTime() + ->sortable(), + ]) + ->defaultSort('created_at', 'desc') + ->headerActions([ + Tables\Actions\CreateAction::make() + ->label('Add Reply') + ->mutateFormDataUsing(function (array $data): array { + $data['user_id'] = auth()->id(); + + return $data; + }) + ->after(function (Reply $record): void { + if ($record->note) { + return; + } + + $ticket = $this->getOwnerRecord(); + $ticket->user->notify(new SupportTicketReplied($ticket, $record)); + }), + ]) + ->actions([ + // + ]); + } +} diff --git a/app/Filament/Resources/SupportTicketResource/Widgets/TicketRepliesWidget.php b/app/Filament/Resources/SupportTicketResource/Widgets/TicketRepliesWidget.php new file mode 100644 index 00000000..98a27c50 --- /dev/null +++ b/app/Filament/Resources/SupportTicketResource/Widgets/TicketRepliesWidget.php @@ -0,0 +1,45 @@ +validate([ + 'newMessage' => ['required', 'string', 'max:5000'], + ]); + + $reply = $this->record->replies()->create([ + 'user_id' => auth()->id(), + 'message' => $this->newMessage, + 'note' => $this->isNote, + ]); + + if (! $this->isNote) { + $this->record->user->notify(new SupportTicketReplied($this->record, $reply)); + } + + $this->newMessage = ''; + $this->isNote = false; + } +} diff --git a/app/Http/Controllers/Account/Support/TicketController.php b/app/Http/Controllers/Account/Support/TicketController.php index 3a911dbf..34b710a7 100644 --- a/app/Http/Controllers/Account/Support/TicketController.php +++ b/app/Http/Controllers/Account/Support/TicketController.php @@ -4,14 +4,40 @@ use App\Http\Controllers\Controller; use App\Models\SupportTicket; +use App\Notifications\SupportTicketUserReplied; use App\SupportTicket\Status; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Notification; +use Illuminate\View\View; class TicketController extends Controller { public static string $paginationLimit = '10'; - public function closeTicket(SupportTicket $supportTicket) + public function reply(Request $request, SupportTicket $supportTicket): RedirectResponse + { + $this->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); @@ -24,7 +50,7 @@ public function closeTicket(SupportTicket $supportTicket) ->with('success', __('account.support_ticket.close_ticket.success')); } - public function index() + public function index(): View { $supportTickets = SupportTicket::whereUserId(auth()->user()->id) ->orderBy('status', 'desc') @@ -34,11 +60,11 @@ public function index() return view('support.tickets.index', compact('supportTickets')); } - public function show(SupportTicket $supportTicket) + public function show(SupportTicket $supportTicket): View { $this->authorize('view', $supportTicket); - $supportTicket->load('user'); + $supportTicket->load(['user', 'replies.user']); return view('support.tickets.show', compact('supportTicket')); } diff --git a/app/Livewire/CreateSupportTicket.php b/app/Livewire/CreateSupportTicket.php new file mode 100644 index 00000000..017cd684 --- /dev/null +++ b/app/Livewire/CreateSupportTicket.php @@ -0,0 +1,210 @@ +mobileAreaType = ''; + $this->mobileArea = ''; + $this->issueType = ''; + $this->tryingToDo = ''; + $this->whatHappened = ''; + $this->reproductionSteps = ''; + $this->environment = ''; + $this->subject = ''; + $this->message = ''; + $this->resetValidation(); + } + + public function updatedMobileAreaType(): void + { + $this->mobileArea = ''; + } + + public function getShowMobileAreaProperty(): bool + { + return $this->selectedProduct === 'mobile'; + } + + public function getShowBugReportFieldsProperty(): bool + { + return in_array($this->selectedProduct, ['mobile', 'desktop']); + } + + public function getShowIssueTypeProperty(): bool + { + return in_array($this->selectedProduct, ['bifrost', 'nativephp.com']); + } + + public function nextStep(): void + { + if ($this->currentStep === 1) { + $this->validateStep1(); + } + + if ($this->currentStep === 2) { + $this->validateStep2(); + } + + $this->currentStep++; + } + + public function previousStep(): void + { + $this->currentStep--; + } + + public function submit(): void + { + $this->validateStep1(); + $this->validateStep2(); + + $metadata = null; + + if ($this->showMobileArea || $this->showBugReportFields) { + $metadata = array_filter([ + 'mobile_area_type' => $this->showMobileArea ? $this->mobileAreaType : null, + 'mobile_area' => $this->showMobileArea && $this->mobileAreaType === 'plugin' ? $this->mobileArea : null, + 'trying_to_do' => $this->showBugReportFields ? $this->tryingToDo : null, + 'what_happened' => $this->showBugReportFields ? $this->whatHappened : null, + 'reproduction_steps' => $this->showBugReportFields ? $this->reproductionSteps : null, + 'environment' => $this->showBugReportFields ? $this->environment : null, + ]); + } + + $subject = $this->subject; + $message = $this->message; + + if ($this->showBugReportFields) { + $subject = Str::limit($this->tryingToDo, 252); + $message = "**What I was trying to do:**\n{$this->tryingToDo}\n\n" + ."**What happened instead:**\n{$this->whatHappened}\n\n" + ."**Steps to reproduce:**\n{$this->reproductionSteps}\n\n" + ."**Environment:**\n{$this->environment}"; + } + + $ticket = SupportTicket::create([ + 'user_id' => auth()->id(), + 'subject' => $subject, + 'message' => $message, + 'status' => Status::OPEN, + 'product' => $this->selectedProduct, + 'issue_type' => $this->showIssueType ? $this->issueType : null, + 'metadata' => $metadata ?: null, + ]); + + Notification::route('mail', 'support@nativephp.com') + ->notify(new SupportTicketSubmitted($ticket)); + + $this->redirect(route('support.tickets.show', $ticket), navigate: false); + } + + protected function validateStep1(): void + { + $this->validate([ + 'selectedProduct' => ['required', 'string', 'in:mobile,desktop,bifrost,nativephp.com'], + ], [ + 'selectedProduct.required' => 'Please select a product.', + 'selectedProduct.in' => 'Please select a valid product.', + ]); + } + + protected function validateStep2(): void + { + $rules = []; + $messages = []; + + if (! $this->showBugReportFields) { + $rules['subject'] = ['required', 'string', 'max:255']; + $rules['message'] = ['required', 'string', 'max:5000']; + + $messages['subject.required'] = 'Please enter a subject for your ticket.'; + $messages['subject.max'] = 'The subject must not exceed 255 characters.'; + $messages['message.required'] = 'Please enter a message for your ticket.'; + $messages['message.max'] = 'The message must not exceed 5000 characters.'; + } + + if ($this->showMobileArea) { + $rules['mobileAreaType'] = ['required', 'string', 'in:core,plugin']; + + if ($this->mobileAreaType === 'plugin') { + $rules['mobileArea'] = ['required', 'string', 'max:255']; + } + + $messages['mobileAreaType.required'] = 'Please select what the issue is related to.'; + $messages['mobileArea.required'] = 'Please select a plugin or tool.'; + } + + if ($this->showBugReportFields) { + $rules['tryingToDo'] = ['required', 'string', 'max:5000']; + $rules['whatHappened'] = ['required', 'string', 'max:5000']; + $rules['reproductionSteps'] = ['required', 'string', 'max:5000']; + $rules['environment'] = ['required', 'string', 'max:1000']; + + $messages['tryingToDo.required'] = 'Please describe what you were trying to do.'; + $messages['whatHappened.required'] = 'Please describe what happened instead.'; + $messages['reproductionSteps.required'] = 'Please provide steps to reproduce the issue.'; + $messages['environment.required'] = 'Please describe your environment.'; + } + + if ($this->showIssueType) { + $rules['issueType'] = ['required', 'string', 'in:account_query,bug,feature_request,other']; + + $messages['issueType.required'] = 'Please select an issue type.'; + } + + $this->validate($rules, $messages); + } + + public function render() + { + $officialPlugins = collect(); + + if ($this->selectedProduct === 'mobile') { + $officialPlugins = Plugin::where('is_official', true) + ->orderBy('name') + ->pluck('name', 'id'); + } + + return view('livewire.create-support-ticket', [ + 'officialPlugins' => $officialPlugins, + ])->layout('components.layout', ['title' => 'Submit a Request - NativePHP']); + } +} diff --git a/app/Models/SupportTicket.php b/app/Models/SupportTicket.php index a2f1458e..9fe542cc 100644 --- a/app/Models/SupportTicket.php +++ b/app/Models/SupportTicket.php @@ -4,7 +4,6 @@ use App\Models\SupportTicket\Reply; use App\SupportTicket\Status; -use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -21,11 +20,18 @@ class SupportTicket extends Model 'subject', 'message', 'status', + 'product', + 'issue_type', + 'metadata', ]; - protected $casts = [ - 'status' => Status::class, - ]; + protected function casts(): array + { + return [ + 'status' => Status::class, + 'metadata' => 'array', + ]; + } protected static function booted() { diff --git a/app/Models/SupportTicket/Reply.php b/app/Models/SupportTicket/Reply.php index ae82f430..beb363f7 100644 --- a/app/Models/SupportTicket/Reply.php +++ b/app/Models/SupportTicket/Reply.php @@ -16,6 +16,7 @@ class Reply extends Model protected $fillable = [ 'support_ticket_id', + 'user_id', 'message', 'attachments', 'note', diff --git a/app/Notifications/SupportTicketReplied.php b/app/Notifications/SupportTicketReplied.php new file mode 100644 index 00000000..1472c73d --- /dev/null +++ b/app/Notifications/SupportTicketReplied.php @@ -0,0 +1,40 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject('Update on your support request: '.$this->ticket->subject) + ->greeting("Hi {$notifiable->first_name},") + ->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)); + } +} diff --git a/app/Notifications/SupportTicketSubmitted.php b/app/Notifications/SupportTicketSubmitted.php new file mode 100644 index 00000000..e1e72a36 --- /dev/null +++ b/app/Notifications/SupportTicketSubmitted.php @@ -0,0 +1,41 @@ +ticket->loadMissing('user'); + + return (new MailMessage) + ->subject('New Support Ticket: '.$ticket->subject) + ->replyTo($ticket->user->email, $ticket->user->name) + ->greeting('New support ticket received!') + ->line("**Product:** {$ticket->product}") + ->line('**Issue Type:** '.($ticket->issue_type ?? 'N/A')) + ->line("**Subject:** {$ticket->subject}") + ->line('**Message:**') + ->line(Str::limit($ticket->message, 500)) + ->action('View Ticket', SupportTicketResource::getUrl('view', ['record' => $ticket])); + } +} diff --git a/app/Notifications/SupportTicketUserReplied.php b/app/Notifications/SupportTicketUserReplied.php new file mode 100644 index 00000000..0f389d3f --- /dev/null +++ b/app/Notifications/SupportTicketUserReplied.php @@ -0,0 +1,45 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $ticket = $this->ticket->loadMissing('user'); + + return (new MailMessage) + ->subject('New Reply on Support Ticket: '.$ticket->subject) + ->replyTo($ticket->user->email, $ticket->user->name) + ->greeting('New reply on a support ticket!') + ->line("**From:** {$this->reply->user->name}") + ->line("**Subject:** {$ticket->subject}") + ->line('**Reply:**') + ->line(Str::limit($this->reply->message, 500)) + ->action('View Ticket', SupportTicketResource::getUrl('view', ['record' => $ticket])); + } +} diff --git a/app/Policies/SupportTicketPolicy.php b/app/Policies/SupportTicketPolicy.php index ae53e3c4..042b1c44 100644 --- a/app/Policies/SupportTicketPolicy.php +++ b/app/Policies/SupportTicketPolicy.php @@ -4,10 +4,17 @@ use App\Models\SupportTicket; use App\Models\User; +use App\SupportTicket\Status; use Illuminate\Auth\Access\Response; class SupportTicketPolicy { + public function reply(User $user, SupportTicket $supportTicket): bool + { + return $supportTicket->user_id === $user->id + && $supportTicket->status !== Status::CLOSED; + } + public function closeTicket(User $user, SupportTicket $supportTicket): bool { return $supportTicket->user_id === $user->id; @@ -18,7 +25,7 @@ public function closeTicket(User $user, SupportTicket $supportTicket): bool */ public function viewAny(User $user): bool { - return false; + return $user->isAdmin(); } /** @@ -26,6 +33,10 @@ public function viewAny(User $user): bool */ public function view(User $user, SupportTicket $supportTicket): Response { + if ($user->isAdmin()) { + return Response::allow(); + } + return $user->id === $supportTicket->user_id ? Response::allow() : Response::denyAsNotFound('Ticket not found.'); diff --git a/database/factories/SupportTicketFactory.php b/database/factories/SupportTicketFactory.php index ad153a9f..cd2a0fa0 100644 --- a/database/factories/SupportTicketFactory.php +++ b/database/factories/SupportTicketFactory.php @@ -14,14 +14,39 @@ class SupportTicketFactory extends Factory public function definition(): array { return [ - 'mask' => 'NATIVE-' . $this->faker->numberBetween(1000, 9999), + 'mask' => 'NATIVE-'.$this->faker->numberBetween(1000, 9999), 'subject' => $this->faker->sentence(), 'message' => $this->faker->paragraph(), 'status' => 'open', + 'product' => $this->faker->randomElement(['mobile', 'desktop', 'bifrost', 'nativephp.com']), + 'issue_type' => null, + 'metadata' => null, 'created_at' => Carbon::now(), 'updated_at' => Carbon::now(), 'user_id' => User::factory(), ]; } + + public function forMobile(): static + { + return $this->state(fn () => [ + 'product' => 'mobile', + 'metadata' => [ + 'mobile_area' => 'camera', + 'trying_to_do' => $this->faker->sentence(), + 'what_happened' => $this->faker->sentence(), + 'reproduction_steps' => $this->faker->paragraph(), + 'environment' => 'iOS 17, iPhone 15', + ], + ]); + } + + public function forBifrost(): static + { + return $this->state(fn () => [ + 'product' => 'bifrost', + 'issue_type' => $this->faker->randomElement(['account_query', 'bug', 'feature_request', 'other']), + ]); + } } diff --git a/database/migrations/2026_03_20_160554_add_product_and_metadata_to_support_tickets_table.php b/database/migrations/2026_03_20_160554_add_product_and_metadata_to_support_tickets_table.php new file mode 100644 index 00000000..745cf9a2 --- /dev/null +++ b/database/migrations/2026_03_20_160554_add_product_and_metadata_to_support_tickets_table.php @@ -0,0 +1,27 @@ +string('product')->nullable()->after('status'); + $table->string('issue_type')->nullable()->after('product'); + $table->json('metadata')->nullable()->after('issue_type'); + }); + } + + public function down(): void + { + Schema::table('support_tickets', function (Blueprint $table) { + $table->dropColumn(['product', 'issue_type', 'metadata']); + }); + } +}; diff --git a/resources/views/components/footer.blade.php b/resources/views/components/footer.blade.php index 0128ffc1..b8b70d49 100644 --- a/resources/views/components/footer.blade.php +++ b/resources/views/components/footer.blade.php @@ -250,6 +250,14 @@ class="inline-block px-px py-1.5 transition duration-300 will-change-transform h +
  • + + Support + +
  • routeIs('partners*'); $isServicesActive = request()->routeIs('build-my-app'); $isCourseActive = request()->routeIs('course'); + $isSupportActive = request()->routeIs('support.*'); $isSponsorActive = request()->routeIs('sponsoring*'); $isLoginActive = request()->routeIs('customer.login*'); @endphp @@ -271,6 +272,29 @@ class="size-4 shrink-0"
  • + {{-- Support Link --}} +
    + $isSupportActive, + 'opacity-50 hover:translate-x-1 hover:opacity-100' => ! $isSupportActive, + ]) + aria-current="{{ $isSupportActive ? 'page' : 'false' }}" + > + @if ($isSupportActive) + +
    + {{-- Login/Dashboard --}} @feature(App\Features\ShowAuthButtons::class)
    diff --git a/resources/views/components/navigation-bar.blade.php b/resources/views/components/navigation-bar.blade.php index 65f18de9..70edb616 100644 --- a/resources/views/components/navigation-bar.blade.php +++ b/resources/views/components/navigation-bar.blade.php @@ -184,6 +184,25 @@ class="size-[3px] rotate-45 rounded-xs bg-gray-400 transition duration-200 dark: + {{-- Decorative circle --}} + + + {{-- Link --}} + request()->routeIs('support.*'), + 'opacity-60 hover:opacity-100' => ! request()->routeIs('support.*'), + ]) + aria-current="{{ request()->routeIs('support.*') ? 'page' : 'false' }}" + > + Support + + {{-- Login/Logout --}} @feature(App\Features\ShowAuthButtons::class) {{-- Decorative circle --}} diff --git a/resources/views/docs/chooser.blade.php b/resources/views/docs/chooser.blade.php new file mode 100644 index 00000000..f5cbf46a --- /dev/null +++ b/resources/views/docs/chooser.blade.php @@ -0,0 +1,32 @@ + +
    +
    +

    Documentation

    +

    + Choose your platform to get started. +

    +
    + + +
    +
    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 new file mode 100644 index 00000000..3d21e7b6 --- /dev/null +++ b/resources/views/filament/resources/support-ticket-resource/widgets/ticket-replies.blade.php @@ -0,0 +1,73 @@ + + + {{-- Reply form at top --}} +
    +
    + + @error('newMessage') +

    {{ $message }}

    + @enderror +
    + + +
    +
    +
    + + {{-- Messages list (reverse chronological) --}} +
    + @forelse ($record->replies()->with('user')->orderBy('created_at', 'desc')->get() as $reply) + @php + $isAdmin = $reply->user?->isAdmin(); + $isNote = $reply->note; + @endphp +
    +
    +
    + + {{ $reply->user?->name ?? 'Unknown' }} + + @if ($isNote) + Note + @elseif ($isAdmin) + Staff + @endif +
    + + {{ $reply->created_at->diffForHumans() }} + +
    +
    {{ $reply->message }}
    +
    + @empty +

    No replies yet.

    + @endforelse +
    +
    +
    diff --git a/resources/views/livewire/create-support-ticket.blade.php b/resources/views/livewire/create-support-ticket.blade.php new file mode 100644 index 00000000..08735d9d --- /dev/null +++ b/resources/views/livewire/create-support-ticket.blade.php @@ -0,0 +1,452 @@ +
    + {{-- Header --}} +
    +

    Submit a Request

    +

    + Tell us about your issue and we'll get back to you as soon as possible. +

    +
    + + {{-- Step Indicator --}} +
    + @for ($i = 1; $i <= 3; $i++) +
    +
    $currentStep >= $i, + 'bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400' => $currentStep < $i, + ]) + > + @if ($currentStep > $i) + + + + @else + {{ $i }} + @endif +
    + @if ($i < 3) +
    $currentStep > $i, + 'bg-gray-200 dark:bg-gray-700' => $currentStep <= $i, + ]) + >
    + @endif +
    + @endfor +
    + +
    +
    + {{-- Step 1: Product Selection --}} + @if ($currentStep === 1) +
    +

    Which product is this about?

    +

    Select the product related to your request.

    + + @error('selectedProduct') +

    {{ $message }}

    + @enderror + +
    + @foreach ([ + 'mobile' => ['label' => 'Mobile', 'desc' => 'iOS & Android apps'], + 'desktop' => ['label' => 'Desktop', 'desc' => 'macOS, Windows & Linux apps'], + {{-- 'bifrost' => ['label' => 'Bifrost', 'desc' => 'Build & deployment service'], --}} + 'nativephp.com' => ['label' => 'nativephp.com', 'desc' => 'Website, account & billing'], + ] as $value => $product) + + @endforeach +
    +
    + @endif + + {{-- Step 2: Context Questions --}} + @if ($currentStep === 2) +
    + {{-- Mobile Area --}} + @if ($this->showMobileArea) +
    +

    What is the issue related to?

    +

    Is this about NativePHP for Mobile itself, or a specific plugin/tool?

    + +
    + @foreach ([ + 'core' => ['label' => 'NativePHP Mobile', 'desc' => 'The core framework'], + 'plugin' => ['label' => 'Plugin / Tool', 'desc' => 'A specific plugin or tool'], + ] as $value => $option) + + @endforeach +
    + + @error('mobileAreaType') +

    {{ $message }}

    + @enderror + + @if ($mobileAreaType === 'plugin') +
    + + + @error('mobileArea') +

    {{ $message }}

    + @enderror +
    + @endif +
    + @endif + + {{-- Bug Report Fields --}} + @if ($this->showBugReportFields) +
    +

    Bug report details

    +

    Help us understand and reproduce the issue.

    + +
    +
    + + + @error('tryingToDo') +

    {{ $message }}

    + @enderror +
    + +
    + + + @error('whatHappened') +

    {{ $message }}

    + @enderror +
    + +
    + + + @error('reproductionSteps') +

    {{ $message }}

    + @enderror +
    + +
    + +

    + Run php artisan native:debug in your project and paste the output here. +

    + + @error('environment') +

    {{ $message }}

    + @enderror +
    +
    +
    + @endif + + {{-- Issue Type --}} + @if ($this->showIssueType) +
    +

    Issue type

    +

    What kind of issue are you experiencing?

    + +
    + @foreach ([ + 'account_query' => 'Account query', + 'bug' => 'Bug', + 'feature_request' => 'Feature request', + 'other' => 'Other', + ] as $value => $label) + + @endforeach +
    + + @error('issueType') +

    {{ $message }}

    + @enderror +
    + @endif + + {{-- Subject + Message (only for non-bug-report flows) --}} + @if (! $this->showBugReportFields) +
    +

    Describe your issue

    +

    Provide a summary and any additional details.

    + +
    +
    + + + @error('subject') +

    {{ $message }}

    + @enderror +
    + +
    + + + @error('message') +

    {{ $message }}

    + @enderror +
    +
    +
    + @endif +
    + @endif + + {{-- Step 3: Review & Submit --}} + @if ($currentStep === 3) +
    +

    Review your request

    +

    Please review the details below before submitting.

    + +
    +
    +
    Product
    +
    + {{ ['mobile' => 'Mobile', 'desktop' => 'Desktop', 'bifrost' => 'Bifrost', 'nativephp.com' => 'nativephp.com'][$selectedProduct] ?? $selectedProduct }} +
    +
    + + @if ($mobileAreaType) +
    +
    Area
    +
    + {{ $mobileAreaType === 'core' ? 'NativePHP Mobile (core)' : $mobileArea }} +
    +
    + @endif + + @if ($issueType) +
    +
    Issue type
    +
    + {{ ['account_query' => 'Account query', 'bug' => 'Bug', 'feature_request' => 'Feature request', 'other' => 'Other'][$issueType] ?? $issueType }} +
    +
    + @endif + + @if (! $this->showBugReportFields) +
    +
    Subject
    +
    {{ $subject }}
    +
    + +
    +
    Message
    +
    {{ $message }}
    +
    + @endif + + @if ($tryingToDo) +
    +
    What you were trying to do
    +
    {{ $tryingToDo }}
    +
    + @endif + + @if ($whatHappened) +
    +
    What happened instead
    +
    {{ $whatHappened }}
    +
    + @endif + + @if ($reproductionSteps) +
    +
    Steps to reproduce
    +
    {{ $reproductionSteps }}
    +
    + @endif + + @if ($environment) +
    +
    Environment
    +
    {{ $environment }}
    +
    + @endif +
    +
    + @endif + + {{-- Navigation --}} +
    + @if ($currentStep === 1) + + + + + Back to Tickets + + @else + + @endif + + @if ($currentStep < 3) + + @else + + @endif +
    +
    +
    + + {{-- Additional Support Information --}} +
    +

    Need more help?

    +

    + Check out our documentation for comprehensive guides and tutorials to help you get the most out of NativePHP. +

    +
    +
    diff --git a/resources/views/support/index.blade.php b/resources/views/support/index.blade.php index 1018e1b1..86af2035 100644 --- a/resources/views/support/index.blade.php +++ b/resources/views/support/index.blade.php @@ -17,20 +17,58 @@

    + {{-- Priority Support (Max plan only) --}} + @auth + @if (auth()->user()->hasMaxAccess()) +
    +
    +
    + + + +
    +
    +

    Priority Support

    +

    As a Max plan subscriber, you have access to priority support. Submit a ticket and our team will get back to you as quickly as possible.

    + + + + + Submit a Ticket + +
    +
    +
    + @endif + @endauth + {{-- Support Grid --}} -
    +
    + + {{-- GitHub Mobile Issues --}} + +
    + +
    +

    GitHub Issues for Mobile

    +

    Report issues or contribute to NativePHP for Mobile

    +
    - {{-- GitHub Box --}} - + aria-label="Report desktop issues on GitHub">
    - +
    -

    GitHub

    -

    Report issues or contribute to the project

    +

    GitHub Issues for Desktop

    +

    Report issues or contribute to NativePHP for Desktop

    {{-- Discord Box --}} diff --git a/resources/views/support/tickets/index.blade.php b/resources/views/support/tickets/index.blade.php index 9e8ad4bf..82327348 100644 --- a/resources/views/support/tickets/index.blade.php +++ b/resources/views/support/tickets/index.blade.php @@ -10,7 +10,7 @@ {{-- Support ticket table --}}
    - + diff --git a/resources/views/support/tickets/show.blade.php b/resources/views/support/tickets/show.blade.php index f028f42f..af311059 100644 --- a/resources/views/support/tickets/show.blade.php +++ b/resources/views/support/tickets/show.blade.php @@ -1,5 +1,5 @@ - -
    + +
    {{-- Desktop Buttons - Hidden on Mobile --}} + {{-- Submission Details (collapsible) --}} +
    + +
    +
    +
    +
    +
    Product
    +
    {{ ucfirst($supportTicket->product) }}
    +
    + @if($supportTicket->issue_type) +
    +
    Issue Type
    +
    {{ str_replace('_', ' ', ucfirst($supportTicket->issue_type)) }}
    +
    + @endif +
    + + @if(! in_array($supportTicket->product, ['mobile', 'desktop'])) +
    +
    Original Message
    +
    {{ $supportTicket->message }}
    +
    + @endif + + @if($supportTicket->metadata) +
    +
    Additional Details
    +
    +
    + @foreach($supportTicket->metadata as $key => $value) +
    +
    {{ str_replace('_', ' ', ucfirst($key)) }}
    +
    {{ $value }}
    +
    + @endforeach +
    +
    +
    + @endif +
    +
    +
    + {{-- Ticket Messages --}}

    Messages

    - @foreach($supportTicket->replies as $reply) + + {{-- Inline Reply Form --}} + @if($supportTicket->status !== \App\SupportTicket\Status::CLOSED) +
    +
    + @csrf + + + @error('message') +

    {{ $message }}

    + @enderror +
    + +
    +
    +
    + @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
    @csrf
    - - {{-- Reply Modal --}} - - - {{-- 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 --}} -
    -
    + +
    @error('newMessage') -

    {{ $message }}

    +

    {{ $message }}

    @enderror -
    -
    {{-- Messages list (reverse chronological) --}} -
    +
    @forelse ($record->replies()->with('user')->orderBy('created_at', 'desc')->get() as $reply) @php $isAdmin = $reply->user?->isAdmin(); $isNote = $reply->note; + + if ($isNote) { + $bgColor = '#fefce8'; + $borderColor = '#facc15'; + } elseif ($isAdmin) { + $bgColor = '#ede9fe'; + $borderColor = '#c4b5fd'; + } else { + $bgColor = '#f3f4f6'; + $borderColor = '#d1d5db'; + } @endphp -
    -
    -
    - +
    +
    +
    + {{ $reply->user?->name ?? 'Unknown' }} @if ($isNote) - Note + Note @elseif ($isAdmin) - Staff + Staff @endif
    - + {{ $reply->created_at->diffForHumans() }}
    -
    {{ $reply->message }}
    +
    {{ $reply->message }}
    @empty -

    No replies yet.

    +

    No replies yet.

    @endforelse
    diff --git a/resources/views/livewire/create-support-ticket.blade.php b/resources/views/livewire/create-support-ticket.blade.php deleted file mode 100644 index 08735d9d..00000000 --- a/resources/views/livewire/create-support-ticket.blade.php +++ /dev/null @@ -1,452 +0,0 @@ -
    - {{-- Header --}} -
    -

    Submit a Request

    -

    - Tell us about your issue and we'll get back to you as soon as possible. -

    -
    - - {{-- Step Indicator --}} -
    - @for ($i = 1; $i <= 3; $i++) -
    -
    $currentStep >= $i, - 'bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400' => $currentStep < $i, - ]) - > - @if ($currentStep > $i) - - - - @else - {{ $i }} - @endif -
    - @if ($i < 3) -
    $currentStep > $i, - 'bg-gray-200 dark:bg-gray-700' => $currentStep <= $i, - ]) - >
    - @endif -
    - @endfor -
    - -
    -
    - {{-- Step 1: Product Selection --}} - @if ($currentStep === 1) -
    -

    Which product is this about?

    -

    Select the product related to your request.

    - - @error('selectedProduct') -

    {{ $message }}

    - @enderror - -
    - @foreach ([ - 'mobile' => ['label' => 'Mobile', 'desc' => 'iOS & Android apps'], - 'desktop' => ['label' => 'Desktop', 'desc' => 'macOS, Windows & Linux apps'], - {{-- 'bifrost' => ['label' => 'Bifrost', 'desc' => 'Build & deployment service'], --}} - 'nativephp.com' => ['label' => 'nativephp.com', 'desc' => 'Website, account & billing'], - ] as $value => $product) - - @endforeach -
    -
    - @endif - - {{-- Step 2: Context Questions --}} - @if ($currentStep === 2) -
    - {{-- Mobile Area --}} - @if ($this->showMobileArea) -
    -

    What is the issue related to?

    -

    Is this about NativePHP for Mobile itself, or a specific plugin/tool?

    - -
    - @foreach ([ - 'core' => ['label' => 'NativePHP Mobile', 'desc' => 'The core framework'], - 'plugin' => ['label' => 'Plugin / Tool', 'desc' => 'A specific plugin or tool'], - ] as $value => $option) - - @endforeach -
    - - @error('mobileAreaType') -

    {{ $message }}

    - @enderror - - @if ($mobileAreaType === 'plugin') -
    - - - @error('mobileArea') -

    {{ $message }}

    - @enderror -
    - @endif -
    - @endif - - {{-- Bug Report Fields --}} - @if ($this->showBugReportFields) -
    -

    Bug report details

    -

    Help us understand and reproduce the issue.

    - -
    -
    - - - @error('tryingToDo') -

    {{ $message }}

    - @enderror -
    - -
    - - - @error('whatHappened') -

    {{ $message }}

    - @enderror -
    - -
    - - - @error('reproductionSteps') -

    {{ $message }}

    - @enderror -
    - -
    - -

    - Run php artisan native:debug in your project and paste the output here. -

    - - @error('environment') -

    {{ $message }}

    - @enderror -
    -
    -
    - @endif - - {{-- Issue Type --}} - @if ($this->showIssueType) -
    -

    Issue type

    -

    What kind of issue are you experiencing?

    - -
    - @foreach ([ - 'account_query' => 'Account query', - 'bug' => 'Bug', - 'feature_request' => 'Feature request', - 'other' => 'Other', - ] as $value => $label) - - @endforeach -
    - - @error('issueType') -

    {{ $message }}

    - @enderror -
    - @endif - - {{-- Subject + Message (only for non-bug-report flows) --}} - @if (! $this->showBugReportFields) -
    -

    Describe your issue

    -

    Provide a summary and any additional details.

    - -
    -
    - - - @error('subject') -

    {{ $message }}

    - @enderror -
    - -
    - - - @error('message') -

    {{ $message }}

    - @enderror -
    -
    -
    - @endif -
    - @endif - - {{-- Step 3: Review & Submit --}} - @if ($currentStep === 3) -
    -

    Review your request

    -

    Please review the details below before submitting.

    - -
    -
    -
    Product
    -
    - {{ ['mobile' => 'Mobile', 'desktop' => 'Desktop', 'bifrost' => 'Bifrost', 'nativephp.com' => 'nativephp.com'][$selectedProduct] ?? $selectedProduct }} -
    -
    - - @if ($mobileAreaType) -
    -
    Area
    -
    - {{ $mobileAreaType === 'core' ? 'NativePHP Mobile (core)' : $mobileArea }} -
    -
    - @endif - - @if ($issueType) -
    -
    Issue type
    -
    - {{ ['account_query' => 'Account query', 'bug' => 'Bug', 'feature_request' => 'Feature request', 'other' => 'Other'][$issueType] ?? $issueType }} -
    -
    - @endif - - @if (! $this->showBugReportFields) -
    -
    Subject
    -
    {{ $subject }}
    -
    - -
    -
    Message
    -
    {{ $message }}
    -
    - @endif - - @if ($tryingToDo) -
    -
    What you were trying to do
    -
    {{ $tryingToDo }}
    -
    - @endif - - @if ($whatHappened) -
    -
    What happened instead
    -
    {{ $whatHappened }}
    -
    - @endif - - @if ($reproductionSteps) -
    -
    Steps to reproduce
    -
    {{ $reproductionSteps }}
    -
    - @endif - - @if ($environment) -
    -
    Environment
    -
    {{ $environment }}
    -
    - @endif -
    -
    - @endif - - {{-- Navigation --}} -
    - @if ($currentStep === 1) - - - - - Back to Tickets - - @else - - @endif - - @if ($currentStep < 3) - - @else - - @endif -
    -
    -
    - - {{-- Additional Support Information --}} -
    -

    Need more help?

    -

    - Check out our documentation for comprehensive guides and tutorials to help you get the most out of NativePHP. -

    -
    -
    diff --git a/resources/views/livewire/customer/support/create.blade.php b/resources/views/livewire/customer/support/create.blade.php new file mode 100644 index 00000000..184f796b --- /dev/null +++ b/resources/views/livewire/customer/support/create.blade.php @@ -0,0 +1,438 @@ +{{-- Step Indicator --}} +
    +
    +
    + @for ($i = 1; $i <= 3; $i++) +
    +
    $currentStep >= $i, + 'bg-gray-200 text-gray-500 dark:bg-gray-700 dark:text-gray-400' => $currentStep < $i, + ]) + > + @if ($currentStep > $i) + + + + @else + {{ $i }} + @endif +
    + @if ($i < 3) +
    $currentStep > $i, + 'bg-gray-200 dark:bg-gray-700' => $currentStep <= $i, + ]) + >
    + @endif +
    + @endfor +
    + +
    +
    + {{-- Step 1: Product Selection --}} + @if ($currentStep === 1) +
    +

    Which product is this about?

    +

    Select the product related to your request.

    + + @error('selectedProduct') +

    {{ $message }}

    + @enderror + +
    + @foreach ([ + 'mobile' => ['label' => 'Mobile', 'desc' => 'iOS & Android apps'], + 'desktop' => ['label' => 'Desktop', 'desc' => 'macOS, Windows & Linux apps'], + {{-- 'bifrost' => ['label' => 'Bifrost', 'desc' => 'Build & deployment service'], --}} + 'nativephp.com' => ['label' => 'nativephp.com', 'desc' => 'Website, account & billing'], + ] as $value => $product) + + @endforeach +
    +
    + @endif + + {{-- Step 2: Context Questions --}} + @if ($currentStep === 2) +
    + {{-- Mobile Area --}} + @if ($this->showMobileArea) +
    +

    What is the issue related to?

    +

    Is this about NativePHP for Mobile itself, or a specific plugin/tool?

    + +
    + @foreach ([ + 'core' => ['label' => 'NativePHP Mobile', 'desc' => 'The core framework'], + 'plugin' => ['label' => 'Plugin / Tool', 'desc' => 'A specific plugin or tool'], + ] as $value => $option) + + @endforeach +
    + + @error('mobileAreaType') +

    {{ $message }}

    + @enderror + + @if ($mobileAreaType === 'plugin') +
    + + + @error('mobileArea') +

    {{ $message }}

    + @enderror +
    + @endif +
    + @endif + + {{-- Bug Report Fields --}} + @if ($this->showBugReportFields) +
    +

    Bug report details

    +

    Help us understand and reproduce the issue.

    + +
    +
    + + + @error('tryingToDo') +

    {{ $message }}

    + @enderror +
    + +
    + + + @error('whatHappened') +

    {{ $message }}

    + @enderror +
    + +
    + + + @error('reproductionSteps') +

    {{ $message }}

    + @enderror +
    + +
    + +

    + Run php artisan native:debug in your project and paste the output here. +

    + + @error('environment') +

    {{ $message }}

    + @enderror +
    +
    +
    + @endif + + {{-- Issue Type --}} + @if ($this->showIssueType) +
    +

    Issue type

    +

    What kind of issue are you experiencing?

    + +
    + @foreach ([ + 'account_query' => 'Account query', + 'bug' => 'Bug', + 'feature_request' => 'Feature request', + 'other' => 'Other', + ] as $value => $label) + + @endforeach +
    + + @error('issueType') +

    {{ $message }}

    + @enderror +
    + @endif + + {{-- Subject + Message (only for non-bug-report flows) --}} + @if (! $this->showBugReportFields) +
    +

    Describe your issue

    +

    Provide a summary and any additional details.

    + +
    +
    + + + @error('subject') +

    {{ $message }}

    + @enderror +
    + +
    + + + @error('message') +

    {{ $message }}

    + @enderror +
    +
    +
    + @endif +
    + @endif + + {{-- Step 3: Review & Submit --}} + @if ($currentStep === 3) +
    +

    Review your request

    +

    Please review the details below before submitting.

    + +
    +
    +
    Product
    +
    + {{ ['mobile' => 'Mobile', 'desktop' => 'Desktop', 'bifrost' => 'Bifrost', 'nativephp.com' => 'nativephp.com'][$selectedProduct] ?? $selectedProduct }} +
    +
    + + @if ($mobileAreaType) +
    +
    Area
    +
    + {{ $mobileAreaType === 'core' ? 'NativePHP Mobile (core)' : $mobileArea }} +
    +
    + @endif + + @if ($issueType) +
    +
    Issue type
    +
    + {{ ['account_query' => 'Account query', 'bug' => 'Bug', 'feature_request' => 'Feature request', 'other' => 'Other'][$issueType] ?? $issueType }} +
    +
    + @endif + + @if (! $this->showBugReportFields) +
    +
    Subject
    +
    {{ $subject }}
    +
    + +
    +
    Message
    +
    {{ $message }}
    +
    + @endif + + @if ($tryingToDo) +
    +
    What you were trying to do
    +
    {{ $tryingToDo }}
    +
    + @endif + + @if ($whatHappened) +
    +
    What happened instead
    +
    {{ $whatHappened }}
    +
    + @endif + + @if ($reproductionSteps) +
    +
    Steps to reproduce
    +
    {{ $reproductionSteps }}
    +
    + @endif + + @if ($environment) +
    +
    Environment
    +
    {{ $environment }}
    +
    + @endif +
    +
    + @endif + + {{-- Navigation --}} +
    + @if ($currentStep === 1) + + + + + Back to Tickets + + @else + + @endif + + @if ($currentStep < 3) + + @else + + @endif +
    +
    +
    +
    +
    diff --git a/resources/views/livewire/customer/support/index.blade.php b/resources/views/livewire/customer/support/index.blade.php new file mode 100644 index 00000000..501cac90 --- /dev/null +++ b/resources/views/livewire/customer/support/index.blade.php @@ -0,0 +1,62 @@ +
    +
    +
    + Support Tickets + Manage your support tickets +
    + Submit a new request +
    + + @if(session()->has('success')) + + {{ session('success') }} + + @endif + + @if($this->supportTickets->count() > 0) + + + Ticket ID + Subject + Status + + + + + @foreach($this->supportTickets as $ticket) + + + + #{{ $ticket->mask }} + + + + {{ $ticket->subject }} + + + + + + + View + + + @endforeach + + + + @if($this->supportTickets->hasPages()) +
    + {{ $this->supportTickets->links() }} +
    + @endif + @else + + Submit a new request + + @endif +
    diff --git a/resources/views/livewire/customer/support/show.blade.php b/resources/views/livewire/customer/support/show.blade.php new file mode 100644 index 00000000..d8f82c1c --- /dev/null +++ b/resources/views/livewire/customer/support/show.blade.php @@ -0,0 +1,139 @@ +
    +
    + Back to Tickets + + @if($supportTicket->status !== \App\SupportTicket\Status::CLOSED) + + Close Ticket + + @endif +
    + + @if(session()->has('success')) + + {{ session('success') }} + + @endif + + {{-- Ticket Header --}} + +
    + #{{ $supportTicket->mask }} » {{ $supportTicket->subject }} +
    +
    + Ticket ID: #{{ $supportTicket->mask }} + Status: + Created: {{ $supportTicket->created_at->format('d M Y, H:i') }} + Updated: {{ $supportTicket->updated_at->format('d M Y, H:i') }} +
    +
    + + {{-- Submission Details (collapsible) --}} + + +
    +
    +
    +
    +
    Product
    +
    {{ ucfirst($supportTicket->product) }}
    +
    + @if($supportTicket->issue_type) +
    +
    Issue Type
    +
    {{ str_replace('_', ' ', ucfirst($supportTicket->issue_type)) }}
    +
    + @endif +
    + + @if(! in_array($supportTicket->product, ['mobile', 'desktop'])) +
    +
    Original Message
    +
    {{ $supportTicket->message }}
    +
    + @endif + + @if($supportTicket->metadata) +
    +
    Additional Details
    +
    +
    + @foreach($supportTicket->metadata as $key => $value) +
    +
    {{ str_replace('_', ' ', ucfirst($key)) }}
    +
    {{ $value }}
    +
    + @endforeach +
    +
    +
    + @endif +
    +
    +
    + + {{-- Messages --}} + + Messages + + {{-- Reply Form --}} + @if($supportTicket->status !== \App\SupportTicket\Status::CLOSED) +
    +
    + + + @error('replyMessage') +

    {{ $message }}

    + @enderror +
    + + Send Reply + +
    +
    +
    + @endif + + @foreach($supportTicket->replies->where('note', false) as $reply) +
    +
    +
    +

    + {{ $reply->user->name }} + @if($reply->is_from_user) + (You) + @elseif($reply->is_from_admin) + (Staff) + @endif +

    +

    {{ $reply->message }}

    +
    +
    +
    + {{ $reply->created_at->format('d M Y, H:i') }} +
    +
    + @endforeach +
    +
    diff --git a/resources/views/support/index.blade.php b/resources/views/support/index.blade.php index 86af2035..137c3e1a 100644 --- a/resources/views/support/index.blade.php +++ b/resources/views/support/index.blade.php @@ -30,7 +30,7 @@

    Priority Support

    As a Max plan subscriber, you have access to priority support. Submit a ticket and our team will get back to you as quickly as possible.

    - + diff --git a/resources/views/support/tickets/index.blade.php b/resources/views/support/tickets/index.blade.php deleted file mode 100644 index 82327348..00000000 --- a/resources/views/support/tickets/index.blade.php +++ /dev/null @@ -1,122 +0,0 @@ - -
    - {{-- Header --}} -
    -

    Support Tickets

    -

    - Manage your support tickets.
    -

    -
    - - {{-- Support ticket table --}} -
    -
    - - - - - - - - - - - - @forelse($supportTickets as $ticket) - - - - - - - @empty - - - - @endforelse - - - - -
    - @forelse($supportTickets as $ticket) -
    -
    - -
    - {{ $ticket->subject }} -
    - - -
    - Status: - - {{ $ticket->status->translated() }} - -
    - - -
    - Ticket ID: - #{{ $ticket->mask }} -
    - - - -
    -
    - @empty -
    - No tickets found. -
    - @endforelse -
    - - @if ($supportTickets->hasPages()) -
    - {{ $supportTickets->links() }} -
    - @endif -
    - {{-- Additional Support Information --}} -
    -

    Need more help?

    -

    - Check out our documentation for comprehensive guides and tutorials to help you get the most out of NativePHP. -

    -
    -
    - diff --git a/resources/views/support/tickets/show.blade.php b/resources/views/support/tickets/show.blade.php deleted file mode 100644 index af311059..00000000 --- a/resources/views/support/tickets/show.blade.php +++ /dev/null @@ -1,191 +0,0 @@ - -
    - {{-- Desktop Buttons - Hidden on Mobile --}} - -
    -
    -
    -
    -

    #{{ $supportTicket->mask }} » {{ $supportTicket->subject }}

    -
    -

    - Ticket ID: #{{ $supportTicket->mask }}
    - Status: {{ $supportTicket->status->translated() }}
    - Created At: {{ $supportTicket->created_at->format('d M Y, H:i') }}
    - Updated At: {{ $supportTicket->updated_at->format('d M Y, H:i') }} -

    -
    -
    - - {{-- Submission Details (collapsible) --}} -
    - -
    -
    -
    -
    -
    Product
    -
    {{ ucfirst($supportTicket->product) }}
    -
    - @if($supportTicket->issue_type) -
    -
    Issue Type
    -
    {{ str_replace('_', ' ', ucfirst($supportTicket->issue_type)) }}
    -
    - @endif -
    - - @if(! in_array($supportTicket->product, ['mobile', 'desktop'])) -
    -
    Original Message
    -
    {{ $supportTicket->message }}
    -
    - @endif - - @if($supportTicket->metadata) -
    -
    Additional Details
    -
    -
    - @foreach($supportTicket->metadata as $key => $value) -
    -
    {{ str_replace('_', ' ', ucfirst($key)) }}
    -
    {{ $value }}
    -
    - @endforeach -
    -
    -
    - @endif -
    -
    -
    - - {{-- Ticket Messages --}} -
    -
    -

    Messages

    - - {{-- Inline Reply Form --}} - @if($supportTicket->status !== \App\SupportTicket\Status::CLOSED) -
    -
    - @csrf - - - @error('message') -

    {{ $message }}

    - @enderror -
    - -
    -
    -
    - @endif - - @foreach($supportTicket->replies->where('note', false) as $reply) -
    -
    -
    -

    - {{ $reply->user->name }} - @if($reply->is_from_user) - (You) - @elseif($reply->is_from_admin) - (Staff) - @endif -

    -

    {{ $reply->message }}

    -
    -
    -
    - {{ $reply->created_at->format('d M Y, H:i') }} -
    -
    - @endforeach -
    -
    - - {{-- Additional Support Information --}} -
    -

    Need more help?

    -

    - Check out our documentation for comprehensive guides and tutorials to help you get the most out of NativePHP. -

    -
    -
    - - {{-- Mobile Footer - Visible only on Mobile --}} -
    - - - - - Back - - @if($supportTicket->status !== \App\SupportTicket\Status::CLOSED) - - @endif -
    - - {{-- Add padding at the bottom to prevent content from being hidden behind the mobile footer --}} -
    - - {{-- Close ticket form --}} -
    - @csrf -
    -
    -
    diff --git a/routes/web.php b/routes/web.php index 3d4984dd..5645724b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,7 +2,6 @@ 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; @@ -345,6 +344,12 @@ // Purchase history page Route::livewire('purchase-history', App\Livewire\Customer\PurchaseHistory\Index::class)->name('purchase-history.index'); + + // Support tickets + Route::livewire('support/tickets', App\Livewire\Customer\Support\Index::class)->name('support.tickets'); + Route::livewire('support/tickets/create', App\Livewire\Customer\Support\Create::class)->name('support.tickets.create'); + Route::livewire('support/tickets/{supportTicket}', App\Livewire\Customer\Support\Show::class)->name('support.tickets.show'); + Route::livewire('licenses/{licenseKey}', Show::class)->name('licenses.show'); Route::patch('licenses/{licenseKey}', [CustomerLicenseController::class, 'update'])->name('licenses.update'); Route::post('plugin-license-key/rotate', [CustomerLicenseController::class, 'rotatePluginLicenseKey'])->name('plugin-license-key.rotate'); @@ -419,31 +424,8 @@ 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'); - }); - }); +// Support (public hub page) +Route::get('support', fn () => view('support.index'))->name('support.index'); // Developer routes Route::middleware(['auth', EnsureFeaturesAreActive::using(ShowAuthButtons::class), EnsureFeaturesAreActive::using(ShowPlugins::class)])->prefix('dashboard/developer')->group(function (): void { diff --git a/tests/Feature/SupportTicketTest.php b/tests/Feature/SupportTicketTest.php index dda16d5c..6dea3ad6 100644 --- a/tests/Feature/SupportTicketTest.php +++ b/tests/Feature/SupportTicketTest.php @@ -2,7 +2,9 @@ namespace Tests\Feature; -use App\Livewire\CreateSupportTicket; +use App\Livewire\Customer\Support\Create; +use App\Livewire\Customer\Support\Index; +use App\Livewire\Customer\Support\Show; use App\Models\License; use App\Models\Plugin; use App\Models\SupportTicket; @@ -25,7 +27,7 @@ class SupportTicketTest extends TestCase #[Test] public function guests_cannot_access_create_ticket_page(): void { - $this->get(route('support.tickets.create')) + $this->get(route('customer.support.tickets.create')) ->assertRedirect(); } @@ -35,9 +37,9 @@ public function authenticated_users_can_access_create_ticket_page(): void $user = User::factory()->create(); $this->actingAs($user) - ->get(route('support.tickets.create')) + ->get(route('customer.support.tickets.create')) ->assertOk() - ->assertSeeLivewire(CreateSupportTicket::class); + ->assertSeeLivewire(Create::class); } #[Test] @@ -46,7 +48,7 @@ public function wizard_starts_at_step_1(): void $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->assertSet('currentStep', 1) ->assertSee('Which product is this about?'); } @@ -57,7 +59,7 @@ public function a_product_must_be_selected(): void $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->call('nextStep') ->assertHasErrors('selectedProduct') ->assertSet('currentStep', 1); @@ -69,7 +71,7 @@ public function product_must_be_a_valid_value(): void $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->set('selectedProduct', 'invalid') ->call('nextStep') ->assertHasErrors('selectedProduct') @@ -82,7 +84,7 @@ public function selecting_mobile_advances_to_step_2(): void $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->set('selectedProduct', 'mobile') ->call('nextStep') ->assertSet('currentStep', 2); @@ -94,7 +96,7 @@ public function mobile_selection_shows_area_type_and_bug_fields_on_step_2(): voi $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->set('selectedProduct', 'mobile') ->call('nextStep') ->assertSet('currentStep', 2) @@ -109,7 +111,7 @@ public function desktop_selection_shows_bug_fields_but_not_area_on_step_2(): voi $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->set('selectedProduct', 'desktop') ->call('nextStep') ->assertSet('currentStep', 2) @@ -124,7 +126,7 @@ public function bifrost_selection_shows_issue_type_on_step_2(): void $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->set('selectedProduct', 'bifrost') ->call('nextStep') ->assertSet('currentStep', 2) @@ -139,7 +141,7 @@ public function nativephp_com_selection_shows_issue_type_on_step_2(): void $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->set('selectedProduct', 'nativephp.com') ->call('nextStep') ->assertSet('currentStep', 2) @@ -154,7 +156,7 @@ public function bug_report_fields_are_required_when_shown(): void $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->set('selectedProduct', 'desktop') ->call('nextStep') ->assertSet('currentStep', 2) @@ -169,7 +171,7 @@ public function mobile_area_type_is_required_when_mobile_selected(): void $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->set('selectedProduct', 'mobile') ->call('nextStep') ->assertSet('currentStep', 2) @@ -188,7 +190,7 @@ public function mobile_area_is_required_when_plugin_type_selected(): void $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->set('selectedProduct', 'mobile') ->call('nextStep') ->assertSet('currentStep', 2) @@ -208,7 +210,7 @@ public function mobile_core_type_does_not_require_mobile_area(): void $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->set('selectedProduct', 'mobile') ->call('nextStep') ->assertSet('currentStep', 2) @@ -228,7 +230,7 @@ public function issue_type_is_required_when_bifrost_selected(): void $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->set('selectedProduct', 'bifrost') ->call('nextStep') ->assertSet('currentStep', 2) @@ -245,7 +247,7 @@ public function issue_type_must_be_valid_value(): void $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->set('selectedProduct', 'bifrost') ->call('nextStep') ->set('issueType', 'invalid_type') @@ -262,7 +264,7 @@ public function subject_is_required_on_step_2(): void $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->set('selectedProduct', 'bifrost') ->call('nextStep') ->set('issueType', 'bug') @@ -278,7 +280,7 @@ public function message_is_required_on_step_2(): void $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->set('selectedProduct', 'bifrost') ->call('nextStep') ->set('issueType', 'bug') @@ -294,7 +296,7 @@ public function subject_cannot_exceed_255_characters(): void $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->set('selectedProduct', 'bifrost') ->call('nextStep') ->set('issueType', 'bug') @@ -311,7 +313,7 @@ public function message_cannot_exceed_5000_characters(): void $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->set('selectedProduct', 'bifrost') ->call('nextStep') ->set('issueType', 'bug') @@ -328,7 +330,7 @@ public function full_desktop_submission_creates_ticket_with_bug_report_data(): v $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->set('selectedProduct', 'desktop') ->call('nextStep') ->set('tryingToDo', 'Build an app') @@ -360,7 +362,7 @@ public function bifrost_submission_stores_product_and_issue_type(): void $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->set('selectedProduct', 'bifrost') ->call('nextStep') ->set('issueType', 'feature_request') @@ -385,7 +387,7 @@ public function submission_redirects_to_show_page(): void $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->set('selectedProduct', 'nativephp.com') ->call('nextStep') ->set('issueType', 'other') @@ -399,7 +401,7 @@ public function submission_redirects_to_show_page(): void $ticket = SupportTicket::where('subject', 'Redirect test')->first(); $this->assertNotNull($ticket); - $this->assertNotEmpty(route('support.tickets.show', $ticket)); + $this->assertNotEmpty(route('customer.support.tickets.show', $ticket)); } #[Test] @@ -408,7 +410,7 @@ public function step_3_shows_full_summary_including_environment_and_reproduction $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->set('selectedProduct', 'desktop') ->call('nextStep') ->set('tryingToDo', 'Build an app') @@ -466,7 +468,7 @@ public function changing_product_resets_all_step_2_fields(): void $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->set('selectedProduct', 'mobile') ->call('nextStep') ->set('mobileAreaType', 'plugin') @@ -493,10 +495,9 @@ 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')); + Livewire::actingAs($user) + ->test(Index::class) + ->assertSee(route('customer.support.tickets.create')); } #[Test] @@ -505,7 +506,7 @@ public function back_button_returns_to_previous_step(): void $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->set('selectedProduct', 'desktop') ->call('nextStep') ->assertSet('currentStep', 2) @@ -525,7 +526,7 @@ public function plugin_type_shows_official_plugins_in_select(): void ]); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->set('selectedProduct', 'mobile') ->call('nextStep') ->set('mobileAreaType', 'plugin') @@ -539,7 +540,7 @@ public function mobile_plugin_submission_stores_area_in_metadata(): void $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->set('selectedProduct', 'mobile') ->call('nextStep') ->set('mobileAreaType', 'plugin') @@ -569,7 +570,7 @@ public function mobile_core_submission_stores_area_type_without_area(): void $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->set('selectedProduct', 'mobile') ->call('nextStep') ->set('mobileAreaType', 'core') @@ -599,7 +600,7 @@ public function submitting_a_ticket_sends_notification_to_support_email(): void $user = User::factory()->create(); Livewire::actingAs($user) - ->test(CreateSupportTicket::class) + ->test(Create::class) ->set('selectedProduct', 'bifrost') ->call('nextStep') ->set('issueType', 'bug') @@ -625,11 +626,11 @@ 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)); + Livewire::actingAs($user) + ->test(Show::class, ['supportTicket' => $ticket]) + ->set('replyMessage', 'This is my reply.') + ->call('reply') + ->assertHasNoErrors(); $this->assertDatabaseHas('replies', [ 'support_ticket_id' => $ticket->id, @@ -647,11 +648,11 @@ public function user_reply_sends_notification_to_support_email(): void $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(); + Livewire::actingAs($user) + ->test(Show::class, ['supportTicket' => $ticket]) + ->set('replyMessage', 'I have more info.') + ->call('reply') + ->assertHasNoErrors(); Notification::assertSentOnDemand( SupportTicketUserReplied::class, @@ -672,10 +673,10 @@ public function user_cannot_reply_to_a_closed_ticket(): void 'status' => Status::CLOSED, ]); - $this->actingAs($user) - ->post(route('support.tickets.reply', $ticket), [ - 'message' => 'This should fail.', - ]) + Livewire::actingAs($user) + ->test(Show::class, ['supportTicket' => $ticket]) + ->set('replyMessage', 'This should fail.') + ->call('reply') ->assertForbidden(); } @@ -686,11 +687,9 @@ public function user_cannot_reply_to_another_users_ticket(): void $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(); + Livewire::actingAs($user) + ->test(Show::class, ['supportTicket' => $ticket]) + ->assertNotFound(); } #[Test] @@ -699,11 +698,11 @@ 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'); + Livewire::actingAs($user) + ->test(Show::class, ['supportTicket' => $ticket]) + ->set('replyMessage', '') + ->call('reply') + ->assertHasErrors('replyMessage'); } #[Test] @@ -712,11 +711,9 @@ public function ticket_show_page_displays_inline_reply_form_for_open_ticket(): v $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)); + Livewire::actingAs($user) + ->test(Show::class, ['supportTicket' => $ticket]) + ->assertSee('Add a reply'); } #[Test] @@ -728,9 +725,8 @@ public function ticket_show_page_hides_reply_form_for_closed_ticket(): void 'status' => Status::CLOSED, ]); - $this->actingAs($user) - ->get(route('support.tickets.show', $ticket)) - ->assertOk() + Livewire::actingAs($user) + ->test(Show::class, ['supportTicket' => $ticket]) ->assertDontSee('Add a reply'); } @@ -755,9 +751,8 @@ public function ticket_show_page_hides_internal_notes_from_ticket_owner(): void 'note' => true, ]); - $this->actingAs($user) - ->get(route('support.tickets.show', $ticket)) - ->assertOk() + Livewire::actingAs($user) + ->test(Show::class, ['supportTicket' => $ticket]) ->assertSee('Visible staff reply') ->assertDontSee('Secret internal note'); } @@ -845,9 +840,8 @@ public function ticket_show_page_displays_submission_details_section(): void ], ]); - $this->actingAs($user) - ->get(route('support.tickets.show', $ticket)) - ->assertOk() + Livewire::actingAs($user) + ->test(Show::class, ['supportTicket' => $ticket]) ->assertSee('Submission Details') ->assertSee('Mobile') ->assertDontSee('Original submission message') @@ -868,9 +862,8 @@ public function ticket_show_page_hides_original_message_for_desktop_tickets(): v ], ]); - $this->actingAs($user) - ->get(route('support.tickets.show', $ticket)) - ->assertOk() + Livewire::actingAs($user) + ->test(Show::class, ['supportTicket' => $ticket]) ->assertDontSee('Original Message') ->assertSee('Run the app'); } @@ -885,10 +878,41 @@ public function ticket_show_page_shows_original_message_for_non_bug_report_ticke 'message' => 'I have a billing question.', ]); - $this->actingAs($user) - ->get(route('support.tickets.show', $ticket)) - ->assertOk() + Livewire::actingAs($user) + ->test(Show::class, ['supportTicket' => $ticket]) ->assertSee('Original Message') ->assertSee('I have a billing question.'); } + + #[Test] + public function user_can_close_their_ticket(): void + { + $user = User::factory()->create(); + $ticket = SupportTicket::factory()->create(['user_id' => $user->id]); + + Livewire::actingAs($user) + ->test(Show::class, ['supportTicket' => $ticket]) + ->call('closeTicket') + ->assertHasNoErrors(); + + $this->assertEquals(Status::CLOSED, $ticket->fresh()->status); + } + + #[Test] + public function guests_cannot_access_ticket_index(): void + { + $this->get(route('customer.support.tickets')) + ->assertRedirect(); + } + + #[Test] + public function authenticated_users_can_access_ticket_index(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('customer.support.tickets')) + ->assertOk() + ->assertSeeLivewire(Index::class); + } }