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 @@
+
+ Or
+
+ create a new account
+
+
+ Manage your NativePHP Account. View your license(s) View and manage your support requests.
+ Check out our documentation for comprehensive guides and tutorials to help you get the most out of NativePHP.
+
+ Sign in to your account
+
+ Account
+
+ Not {{ auth()->user()->name }}? Logout.
+ License Management
+ Support Requests
+ Need more help?
+
+ Choose your platform to get started. +
+Build native iOS and Android apps
+ + + {{-- Desktop --}} + +Build native macOS, Windows and Linux apps
+ +No replies yet.
+ @endforelse ++ {{ $reply->user->name }} + @if($reply->is_from_user) + (You) + @elseif($reply->is_from_admin) + (Staff) + @endif +
+{{ $reply->message }}
++ Get help with NativePHP through our various support channels. +
++ Check out our documentation for comprehensive guides and tutorials to help you get the most out of NativePHP. +
+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 + +Report issues or contribute to NativePHP for Mobile
+ + + {{-- GitHub Desktop Issues --}} + +Report issues or contribute to NativePHP for Desktop
+ + + {{-- Discord Box --}} + +Join the community and get real-time help
+ + +