diff --git a/app/Enums/PriceTier.php b/app/Enums/PriceTier.php
index 072f68f0..b982fb42 100644
--- a/app/Enums/PriceTier.php
+++ b/app/Enums/PriceTier.php
@@ -12,7 +12,7 @@ public function label(): string
{
return match ($this) {
self::Regular => 'Regular',
- self::Subscriber => 'Pro/Max Subscriber',
+ self::Subscriber => 'Subscriber',
self::Eap => 'Early Access',
};
}
@@ -21,7 +21,7 @@ public function description(): string
{
return match ($this) {
self::Regular => 'Standard pricing for all customers',
- self::Subscriber => 'Discounted pricing for Pro and Max license holders',
+ self::Subscriber => 'Discounted pricing for subscribers',
self::Eap => 'Special pricing for Early Access Program customers',
};
}
diff --git a/app/Filament/Resources/SupportTicketResource.php b/app/Filament/Resources/SupportTicketResource.php
index 2b1840cd..2a9e0539 100644
--- a/app/Filament/Resources/SupportTicketResource.php
+++ b/app/Filament/Resources/SupportTicketResource.php
@@ -51,8 +51,9 @@ public static function infolist(Schema $schema): Schema
Infolists\Components\TextEntry::make('issue_type')
->label('Issue Type')
->placeholder('N/A'),
- Infolists\Components\TextEntry::make('user.email')
+ Infolists\Components\TextEntry::make('user.name')
->label('User')
+ ->formatStateUsing(fn (SupportTicket $record): string => ($record->user->name ?? '').' ('.$record->user->email.')')
->url(fn (SupportTicket $record): string => UserResource::getUrl('edit', ['record' => $record->user_id])),
Infolists\Components\TextEntry::make('created_at')
->label('Created')
@@ -96,9 +97,10 @@ public static function table(Table $table): Table
->sortable()
->limit(50),
- Tables\Columns\TextColumn::make('user.email')
+ Tables\Columns\TextColumn::make('user.name')
->label('User')
- ->searchable()
+ ->formatStateUsing(fn (SupportTicket $record): string => ($record->user->name ?? '').' ('.$record->user->email.')')
+ ->searchable(query: fn ($query, string $search) => $query->whereHas('user', fn ($q) => $q->where('name', 'like', "%{$search}%")->orWhere('email', 'like', "%{$search}%")))
->sortable(),
Tables\Columns\TextColumn::make('product')
diff --git a/app/Filament/Resources/SupportTicketResource/Pages/ViewSupportTicket.php b/app/Filament/Resources/SupportTicketResource/Pages/ViewSupportTicket.php
index e4edfd12..a28f490a 100644
--- a/app/Filament/Resources/SupportTicketResource/Pages/ViewSupportTicket.php
+++ b/app/Filament/Resources/SupportTicketResource/Pages/ViewSupportTicket.php
@@ -8,6 +8,7 @@
use Filament\Actions;
use Filament\Forms\Components\Select;
use Filament\Resources\Pages\ViewRecord;
+use STS\FilamentImpersonate\Actions\Impersonate;
class ViewSupportTicket extends ViewRecord
{
@@ -16,6 +17,8 @@ class ViewSupportTicket extends ViewRecord
protected function getHeaderActions(): array
{
return [
+ Impersonate::make()->impersonateRecord(fn () => $this->getRecord()->user),
+
Actions\Action::make('updateStatus')
->label('Update Status')
->icon('heroicon-o-arrow-path')
diff --git a/app/Filament/Resources/SupportTicketResource/Widgets/TicketRepliesWidget.php b/app/Filament/Resources/SupportTicketResource/Widgets/TicketRepliesWidget.php
index fa7cd8fb..97836c7f 100644
--- a/app/Filament/Resources/SupportTicketResource/Widgets/TicketRepliesWidget.php
+++ b/app/Filament/Resources/SupportTicketResource/Widgets/TicketRepliesWidget.php
@@ -2,6 +2,7 @@
namespace App\Filament\Resources\SupportTicketResource\Widgets;
+use App\Models\SupportTicket\Reply;
use App\Notifications\SupportTicketReplied;
use Filament\Widgets\Widget;
use Illuminate\Database\Eloquent\Model;
@@ -42,4 +43,22 @@ public function sendReply(): void
$this->newMessage = '';
$this->isNote = false;
}
+
+ public function togglePin(int $replyId): void
+ {
+ $reply = Reply::where('support_ticket_id', $this->record->id)
+ ->where('id', $replyId)
+ ->where('note', true)
+ ->firstOrFail();
+
+ if ($reply->pinned) {
+ $reply->update(['pinned' => false]);
+ } else {
+ Reply::where('support_ticket_id', $this->record->id)
+ ->where('pinned', true)
+ ->update(['pinned' => false]);
+
+ $reply->update(['pinned' => true]);
+ }
+ }
}
diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php
index d95da484..981bdfad 100644
--- a/app/Filament/Resources/UserResource.php
+++ b/app/Filament/Resources/UserResource.php
@@ -12,6 +12,7 @@
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
+use STS\FilamentImpersonate\Actions\Impersonate;
class UserResource extends Resource
{
@@ -89,6 +90,7 @@ public static function table(Table $table): Table
//
])
->actions([
+ Impersonate::make(),
Actions\ActionGroup::make([
Actions\EditAction::make(),
Actions\Action::make('view_on_stripe')
diff --git a/app/Filament/Resources/UserResource/Pages/EditUser.php b/app/Filament/Resources/UserResource/Pages/EditUser.php
index 5774ad7c..5843618a 100644
--- a/app/Filament/Resources/UserResource/Pages/EditUser.php
+++ b/app/Filament/Resources/UserResource/Pages/EditUser.php
@@ -13,6 +13,7 @@
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Password;
+use STS\FilamentImpersonate\Actions\Impersonate;
class EditUser extends EditRecord
{
@@ -21,6 +22,8 @@ class EditUser extends EditRecord
protected function getHeaderActions(): array
{
return [
+ Impersonate::make()->record($this->getRecord()),
+
Actions\ActionGroup::make([
Actions\Action::make('createStripeCustomer')
->label('Create Stripe Customer')
diff --git a/app/Http/Controllers/UltraController.php b/app/Http/Controllers/UltraController.php
new file mode 100644
index 00000000..fc099492
--- /dev/null
+++ b/app/Http/Controllers/UltraController.php
@@ -0,0 +1,37 @@
+hasActiveUltraSubscription()) {
+ return to_route('pricing');
+ }
+
+ $subscription = $user->subscription();
+
+ $plugins = Plugin::query()
+ ->where('is_official', true)
+ ->where('is_active', true)
+ ->where('status', PluginStatus::Approved)
+ ->where('type', PluginType::Paid)
+ ->orderBy('name')
+ ->get();
+
+ return view('customer.ultra.index', [
+ 'subscription' => $subscription,
+ 'plugins' => $plugins,
+ ]);
+ }
+}
diff --git a/app/Livewire/Customer/Support/Show.php b/app/Livewire/Customer/Support/Show.php
index 4ada30a3..b9b13d35 100644
--- a/app/Livewire/Customer/Support/Show.php
+++ b/app/Livewire/Customer/Support/Show.php
@@ -7,6 +7,7 @@
use App\SupportTicket\Status;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Notification;
+use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
@@ -33,10 +34,22 @@ public function reply(): void
{
$this->authorize('reply', $this->supportTicket);
+ $key = 'support-reply:'.auth()->id();
+
+ if (RateLimiter::tooManyAttempts($key, 10)) {
+ $seconds = RateLimiter::availableIn($key);
+
+ $this->addError('replyMessage', "You're sending messages too quickly. Please wait {$seconds} seconds.");
+
+ return;
+ }
+
$this->validate([
'replyMessage' => ['required', 'string', 'max:5000'],
]);
+ RateLimiter::hit($key, 60);
+
$reply = $this->supportTicket->replies()->create([
'user_id' => auth()->id(),
'message' => $this->replyMessage,
@@ -48,8 +61,6 @@ public function reply(): void
$this->replyMessage = '';
$this->supportTicket->load(['user', 'replies.user']);
-
- session()->flash('success', 'Your reply has been sent.');
}
public function closeTicket(): void
@@ -60,7 +71,30 @@ public function closeTicket(): void
'status' => Status::CLOSED,
]);
- session()->flash('success', __('account.support_ticket.close_ticket.success'));
+ $this->supportTicket->replies()->create([
+ 'user_id' => null,
+ 'message' => auth()->user()->name.' closed this ticket.',
+ 'note' => false,
+ ]);
+
+ $this->supportTicket->load(['user', 'replies.user']);
+ }
+
+ public function reopenTicket(): void
+ {
+ $this->authorize('reopenTicket', $this->supportTicket);
+
+ $this->supportTicket->update([
+ 'status' => Status::OPEN,
+ ]);
+
+ $this->supportTicket->replies()->create([
+ 'user_id' => null,
+ 'message' => auth()->user()->name.' reopened this ticket.',
+ 'note' => false,
+ ]);
+
+ $this->supportTicket->load(['user', 'replies.user']);
}
public function render(): View
diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php
index ee1b5822..e1715dc3 100644
--- a/app/Models/Plugin.php
+++ b/app/Models/Plugin.php
@@ -64,6 +64,13 @@ public function routeParams(): array
protected static function booted(): void
{
+ static::saving(function (Plugin $plugin): void {
+ if ($plugin->isDirty('name') && $plugin->name && ! $plugin->isDirty('is_official')) {
+ $vendor = explode('/', $plugin->name)[0] ?? null;
+ $plugin->is_official = $vendor === 'nativephp';
+ }
+ });
+
static::created(function (Plugin $plugin): void {
$plugin->recordActivity(
PluginActivityType::Submitted,
@@ -185,16 +192,6 @@ public function getBestPriceForUser(?User $user): ?PluginPrice
->orderBy('amount', 'asc')
->first();
- // Ultra subscribers get official plugins for free
- if ($bestPrice && $user && $this->isOfficial() && $user->hasUltraAccess()) {
- $freePrice = $bestPrice->replicate();
- $freePrice->amount = 0;
- $freePrice->id = $bestPrice->id;
- $freePrice->exists = true;
-
- return $freePrice;
- }
-
return $bestPrice;
}
diff --git a/app/Models/SupportTicket.php b/app/Models/SupportTicket.php
index 9fe542cc..bec2aa36 100644
--- a/app/Models/SupportTicket.php
+++ b/app/Models/SupportTicket.php
@@ -8,6 +8,7 @@
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
class SupportTicket extends Model
@@ -54,6 +55,14 @@ public function replies(): HasMany
->orderBy('created_at', 'desc');
}
+ public function pinnedNote(): HasOne
+ {
+ return $this->hasOne(Reply::class)
+ ->where('note', true)
+ ->where('pinned', true)
+ ->latestOfMany();
+ }
+
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
diff --git a/app/Models/SupportTicket/Reply.php b/app/Models/SupportTicket/Reply.php
index beb363f7..99e9729a 100644
--- a/app/Models/SupportTicket/Reply.php
+++ b/app/Models/SupportTicket/Reply.php
@@ -20,11 +20,13 @@ class Reply extends Model
'message',
'attachments',
'note',
+ 'pinned',
];
protected $casts = [
'attachments' => 'array',
'note' => 'boolean',
+ 'pinned' => 'boolean',
];
public function isFromAdmin(): Attribute
diff --git a/app/Policies/SupportTicketPolicy.php b/app/Policies/SupportTicketPolicy.php
index 042b1c44..f954c10e 100644
--- a/app/Policies/SupportTicketPolicy.php
+++ b/app/Policies/SupportTicketPolicy.php
@@ -20,6 +20,12 @@ public function closeTicket(User $user, SupportTicket $supportTicket): bool
return $supportTicket->user_id === $user->id;
}
+ public function reopenTicket(User $user, SupportTicket $supportTicket): bool
+ {
+ return $supportTicket->user_id === $user->id
+ && $supportTicket->status === Status::CLOSED;
+ }
+
/**
* Determine whether the user can view any models.
*/
diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php
index 75aa22f0..0d2d26a4 100644
--- a/app/Providers/Filament/AdminPanelProvider.php
+++ b/app/Providers/Filament/AdminPanelProvider.php
@@ -10,6 +10,7 @@
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
+use Filament\View\PanelsRenderHook;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
@@ -33,6 +34,10 @@ public function panel(Panel $panel): Panel
])
->brandName('NativePHP Admin')
->favicon(asset('favicon.ico'))
+ ->renderHook(
+ PanelsRenderHook::HEAD_END,
+ fn (): string => '',
+ )
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
->pages([
diff --git a/composer.json b/composer.json
index aa8d99a9..073432bb 100644
--- a/composer.json
+++ b/composer.json
@@ -34,6 +34,7 @@
"simonhamp/the-og": "^0.7.0",
"spatie/laravel-menu": "^4.1",
"spatie/yaml-front-matter": "^2.0",
+ "stechstudio/filament-impersonate": "^5.1",
"symfony/http-client": "^7.2",
"symfony/mailgun-mailer": "^7.1",
"torchlight/torchlight-commonmark": "^0.6.0"
diff --git a/composer.lock b/composer.lock
index 02daeaaf..ad890c83 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "0cc77dd249810289ccbf001bbaec7aa5",
+ "content-hash": "8997aa561e2f9fd8f796a63500a97e5b",
"packages": [
{
"name": "artesaos/seotools",
@@ -7958,6 +7958,52 @@
],
"time": "2025-11-24T16:17:28+00:00"
},
+ {
+ "name": "stechstudio/filament-impersonate",
+ "version": "v5.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/stechstudio/filament-impersonate.git",
+ "reference": "c5d5b8b5150fad02db18c4479eafb9dc2f537ffc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/stechstudio/filament-impersonate/zipball/c5d5b8b5150fad02db18c4479eafb9dc2f537ffc",
+ "reference": "c5d5b8b5150fad02db18c4479eafb9dc2f537ffc",
+ "shasum": ""
+ },
+ "require": {
+ "filament/filament": "^4.0|^5.0"
+ },
+ "require-dev": {
+ "orchestra/testbench": "^10.0",
+ "pestphp/pest": "^3.0",
+ "pestphp/pest-plugin-laravel": "^3.0"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "STS\\FilamentImpersonate\\FilamentImpersonateServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "STS\\FilamentImpersonate\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "A Filament package to impersonate your users.",
+ "support": {
+ "issues": "https://github.com/stechstudio/filament-impersonate/issues",
+ "source": "https://github.com/stechstudio/filament-impersonate/tree/v5.1.0"
+ },
+ "time": "2026-02-22T15:57:37+00:00"
+ },
{
"name": "stripe/stripe-php",
"version": "v16.6.0",
diff --git a/database/migrations/2026_03_30_115347_make_replies_user_id_nullable.php b/database/migrations/2026_03_30_115347_make_replies_user_id_nullable.php
new file mode 100644
index 00000000..57b0775b
--- /dev/null
+++ b/database/migrations/2026_03_30_115347_make_replies_user_id_nullable.php
@@ -0,0 +1,28 @@
+unsignedBigInteger('user_id')->nullable()->change();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('replies', function (Blueprint $table) {
+ $table->unsignedBigInteger('user_id')->nullable(false)->change();
+ });
+ }
+};
diff --git a/database/migrations/2026_03_30_122442_add_pinned_to_replies_table.php b/database/migrations/2026_03_30_122442_add_pinned_to_replies_table.php
new file mode 100644
index 00000000..c1f6d256
--- /dev/null
+++ b/database/migrations/2026_03_30_122442_add_pinned_to_replies_table.php
@@ -0,0 +1,28 @@
+boolean('pinned')->default(false)->after('note');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('replies', function (Blueprint $table) {
+ $table->dropColumn('pinned');
+ });
+ }
+};
diff --git a/database/migrations/2026_03_30_123751_set_is_official_on_existing_nativephp_plugins.php b/database/migrations/2026_03_30_123751_set_is_official_on_existing_nativephp_plugins.php
new file mode 100644
index 00000000..4c3b3ac6
--- /dev/null
+++ b/database/migrations/2026_03_30_123751_set_is_official_on_existing_nativephp_plugins.php
@@ -0,0 +1,29 @@
+where('name', 'like', 'nativephp/%')
+ ->update(['is_official' => true]);
+
+ DB::table('plugins')
+ ->where('name', 'not like', 'nativephp/%')
+ ->update(['is_official' => false]);
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ // No rollback — is_official will be maintained by the model going forward
+ }
+};
diff --git a/package-lock.json b/package-lock.json
index 1ca1fd9b..3a761fe2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,5 +1,5 @@
{
- "name": "cyan-grizzly",
+ "name": "golden-poodle",
"lockfileVersion": 3,
"requires": true,
"packages": {
diff --git a/resources/views/article.blade.php b/resources/views/article.blade.php
index 7262e953..071f8798 100644
--- a/resources/views/article.blade.php
+++ b/resources/views/article.blade.php
@@ -102,10 +102,13 @@ class="prose min-w-0 max-w-none grow text-gray-600 dark:text-gray-400 dark:prose
Included with Ultra
@endif