diff --git a/app/Filament/Resources/SupportTicketResource.php b/app/Filament/Resources/SupportTicketResource.php new file mode 100644 index 00000000..2b1840cd --- /dev/null +++ b/app/Filament/Resources/SupportTicketResource.php @@ -0,0 +1,157 @@ +columns(2) + ->schema([ + 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), + + 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([ + Actions\ViewAction::make(), + ]) + ->bulkActions([ + Actions\BulkActionGroup::make([ + 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..2c4d5ae4 --- /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..fa7cd8fb --- /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/AuthController.php b/app/Http/Controllers/Account/AuthController.php new file mode 100644 index 00000000..548c8bc4 --- /dev/null +++ b/app/Http/Controllers/Account/AuthController.php @@ -0,0 +1,69 @@ +logout(); + session()->regenerateToken(); + + return redirect()->route('account.login'); + } + + /** + * Process the login request. + * + * @TODO Implement additional brute-force protection with custom blocked IPs model. + * + * @return \Illuminate\Http\RedirectResponse + * + * @throws \Illuminate\Validation\ValidationException + */ + public function processLogin(LoginRequest $request) + { + $credentials = $request->only('email', 'password'); + $key = 'login-attempt:'.$request->ip(); + $attemptsPerHour = 5; + + if (\RateLimiter::tooManyAttempts($key, $attemptsPerHour)) { + $blockedUntil = Carbon::now() + ->addSeconds(\RateLimiter::availableIn($key)) + ->diffInMinutes(Carbon::now()); + + return back() + ->withInput($request->only(['email', 'remember'])) + ->withErrors([ + 'email' => 'Too many login attempts. Please try again in ' + .$blockedUntil.' minutes.', + ]); + } + + if (auth()->attempt($credentials, $request->boolean('remember'))) { + session()->regenerate(); + + \RateLimiter::clear($key); + + return redirect()->intended('/account'); + } + + \RateLimiter::increment($key, 3600); + + 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/Livewire/Customer/Support/Create.php b/app/Livewire/Customer/Support/Create.php new file mode 100644 index 00000000..19ee094b --- /dev/null +++ b/app/Livewire/Customer/Support/Create.php @@ -0,0 +1,214 @@ +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('customer.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.customer.support.create', [ + 'officialPlugins' => $officialPlugins, + ]); + } +} 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/Models/SupportTicket.php b/app/Models/SupportTicket.php new file mode 100644 index 00000000..9fe542cc --- /dev/null +++ b/app/Models/SupportTicket.php @@ -0,0 +1,61 @@ + Status::class, + 'metadata' => 'array', + ]; + } + + 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_'); + } + }); + } + + 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..beb363f7 --- /dev/null +++ b/app/Models/SupportTicket/Reply.php @@ -0,0 +1,51 @@ + 'array', + 'note' => 'boolean', + ]; + + public function isFromAdmin(): Attribute + { + return Attribute::get(fn () => $this->user->is_admin) + ->shouldCache(); + } + + public function isFromUser(): Attribute + { + return Attribute::get(fn () => $this->user_id === auth()->user()->id) + ->shouldCache(); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function supportTicket(): BelongsTo + { + return $this->belongsTo(SupportTicket::class); + } +} diff --git a/app/Notifications/SupportTicketReplied.php b/app/Notifications/SupportTicketReplied.php new file mode 100644 index 00000000..49236beb --- /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('customer.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 new file mode 100644 index 00000000..042b1c44 --- /dev/null +++ b/app/Policies/SupportTicketPolicy.php @@ -0,0 +1,85 @@ +user_id === $user->id + && $supportTicket->status !== Status::CLOSED; + } + + public function closeTicket(User $user, SupportTicket $supportTicket): bool + { + return $supportTicket->user_id === $user->id; + } + + /** + * Determine whether the user can view any models. + */ + public function viewAny(User $user): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can view the model. + */ + 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.'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return true; + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, SupportTicket $supportTicket): bool + { + return $user->id === $supportTicket->user_id; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, SupportTicket $supportTicket): bool + { + // Deletion not allowed. + return false; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, SupportTicket $supportTicket): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, SupportTicket $supportTicket): bool + { + return false; + } +} diff --git a/app/SupportTicket/Status.php b/app/SupportTicket/Status.php new file mode 100644 index 00000000..bdcdb3ed --- /dev/null +++ b/app/SupportTicket/Status.php @@ -0,0 +1,17 @@ +value); + } +} 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/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/factories/SupportTicketFactory.php b/database/factories/SupportTicketFactory.php new file mode 100644 index 00000000..cd2a0fa0 --- /dev/null +++ b/database/factories/SupportTicketFactory.php @@ -0,0 +1,52 @@ + '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/2025_04_28_135326_create_support_tickets_table.php b/database/migrations/2025_04_28_135326_create_support_tickets_table.php new file mode 100644 index 00000000..ca3bfcc2 --- /dev/null +++ b/database/migrations/2025_04_28_135326_create_support_tickets_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignIdFor(User::class); + $table->string('mask'); + $table->string('subject'); + $table->text('message'); + $table->string('status'); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('support_tickets'); + } +}; 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..a838b0f9 --- /dev/null +++ b/database/migrations/2025_04_28_160102_create_replies_table.php @@ -0,0 +1,29 @@ +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/migrations/2025_04_29_141211_update_users_table_add_admin_flag.php b/database/migrations/2025_04_29_141211_update_users_table_add_admin_flag.php new file mode 100644 index 00000000..2ffbc38c --- /dev/null +++ b/database/migrations/2025_04_29_141211_update_users_table_add_admin_flag.php @@ -0,0 +1,30 @@ +boolean('is_admin') + ->after('remember_token') + ->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('is_admin'); + }); + } +}; 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/database/seeders/SupportTicketSeeder.php b/database/seeders/SupportTicketSeeder.php new file mode 100644 index 00000000..6e34e53b --- /dev/null +++ b/database/seeders/SupportTicketSeeder.php @@ -0,0 +1,27 @@ +count(10) + ->has( + SupportTicket\Reply::factory() + ->state(['user_id' => 1]) + ->count(5) + ) + ->create([ + 'user_id' => 1, + 'status' => 'open', + ]); + } +} 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/account/auth/login.blade.php b/resources/views/account/auth/login.blade.php new file mode 100644 index 00000000..aa8ea0fc --- /dev/null +++ b/resources/views/account/auth/login.blade.php @@ -0,0 +1,88 @@ + +
+
+ +
+
+

+ Sign in to your account +

+

+ Or + + create a new account + +

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

{{ $message }}

+
+
+ @enderror +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+ + +
+ +
+ +
+
+
+
+
diff --git a/resources/views/account/index.blade.php b/resources/views/account/index.blade.php new file mode 100644 index 00000000..bbe18dfe --- /dev/null +++ b/resources/views/account/index.blade.php @@ -0,0 +1,52 @@ + + {{-- Support Grid Section --}} +
+ {{-- Header --}} +
+

Account

+

+ Manage your NativePHP Account.
+ Not {{ auth()->user()->name }}? Logout. +

+
+ + {{-- 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. +

+
+
+
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/footer.blade.php b/resources/views/components/footer.blade.php index b1e689e5..13b5a203 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 + +
  • + Support Tickets + + Showcase diff --git a/resources/views/components/navbar/mobile-menu.blade.php b/resources/views/components/navbar/mobile-menu.blade.php index b174d2ef..9fef9612 100644 --- a/resources/views/components/navbar/mobile-menu.blade.php +++ b/resources/views/components/navbar/mobile-menu.blade.php @@ -93,6 +93,7 @@ class="@md:grid-cols-3 grid grid-cols-2 text-xl" $isPartnersActive = request()->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..9b7436df --- /dev/null +++ b/resources/views/filament/resources/support-ticket-resource/widgets/ticket-replies.blade.php @@ -0,0 +1,71 @@ + + + {{-- Reply form at top --}} +
    +
    + + @error('newMessage') +

    {{ $message }}

    + @enderror +
    + + + Send Reply + Sending... + +
    +
    +
    + + {{-- 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 + @elseif ($isAdmin) + Staff + @endif +
    + + {{ $reply->created_at->diffForHumans() }} + +
    +
    {{ $reply->message }}
    +
    + @empty +

    No replies yet.

    + @endforelse +
    +
    +
    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 new file mode 100644 index 00000000..137c3e1a --- /dev/null +++ b/resources/views/support/index.blade.php @@ -0,0 +1,89 @@ + + {{-- Support Grid Section --}} +
    + {{-- Header --}} +
    +

    Support

    +

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

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

    Need more help?

    +

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

    +
    + + {{-- 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 --}} + +
    +
    diff --git a/routes/web.php b/routes/web.php index b1dcf071..5645724b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -223,9 +223,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'); @@ -259,7 +261,7 @@ 'page' => 'introduction', ]); } -})->name('docs')->where('page', '.*'); +})->name('docs.unversioned')->where('page', '.*'); Route::get('order/{checkoutSessionId}', OrderSuccess::class)->name('order.success'); @@ -342,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'); @@ -416,6 +424,9 @@ Route::get('cart/cancel', [CartController::class, 'cancel'])->name('cart.cancel'); }); +// 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 { Route::name('customer.developer.')->group(function (): void { diff --git a/tests/Feature/SupportTicketTest.php b/tests/Feature/SupportTicketTest.php new file mode 100644 index 00000000..6dea3ad6 --- /dev/null +++ b/tests/Feature/SupportTicketTest.php @@ -0,0 +1,918 @@ +get(route('customer.support.tickets.create')) + ->assertRedirect(); + } + + #[Test] + public function authenticated_users_can_access_create_ticket_page(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('customer.support.tickets.create')) + ->assertOk() + ->assertSeeLivewire(Create::class); + } + + #[Test] + public function wizard_starts_at_step_1(): void + { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(Create::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(Create::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(Create::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(Create::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(Create::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(Create::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(Create::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(Create::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(Create::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(Create::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(Create::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(Create::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(Create::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(Create::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(Create::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(Create::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(Create::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(Create::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(Create::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(Create::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(Create::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('customer.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(Create::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(Create::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(); + + Livewire::actingAs($user) + ->test(Index::class) + ->assertSee(route('customer.support.tickets.create')); + } + + #[Test] + public function back_button_returns_to_previous_step(): void + { + $user = User::factory()->create(); + + Livewire::actingAs($user) + ->test(Create::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(Create::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(Create::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(Create::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(Create::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]); + + 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, + '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]); + + Livewire::actingAs($user) + ->test(Show::class, ['supportTicket' => $ticket]) + ->set('replyMessage', 'I have more info.') + ->call('reply') + ->assertHasNoErrors(); + + 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, + ]); + + Livewire::actingAs($user) + ->test(Show::class, ['supportTicket' => $ticket]) + ->set('replyMessage', 'This should fail.') + ->call('reply') + ->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]); + + Livewire::actingAs($user) + ->test(Show::class, ['supportTicket' => $ticket]) + ->assertNotFound(); + } + + #[Test] + public function reply_message_is_required(): void + { + $user = User::factory()->create(); + $ticket = SupportTicket::factory()->create(['user_id' => $user->id]); + + Livewire::actingAs($user) + ->test(Show::class, ['supportTicket' => $ticket]) + ->set('replyMessage', '') + ->call('reply') + ->assertHasErrors('replyMessage'); + } + + #[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]); + + Livewire::actingAs($user) + ->test(Show::class, ['supportTicket' => $ticket]) + ->assertSee('Add a reply'); + } + + #[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, + ]); + + Livewire::actingAs($user) + ->test(Show::class, ['supportTicket' => $ticket]) + ->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, + ]); + + Livewire::actingAs($user) + ->test(Show::class, ['supportTicket' => $ticket]) + ->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', + ], + ]); + + Livewire::actingAs($user) + ->test(Show::class, ['supportTicket' => $ticket]) + ->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', + ], + ]); + + Livewire::actingAs($user) + ->test(Show::class, ['supportTicket' => $ticket]) + ->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.', + ]); + + 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); + } +}