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 - {{-- Mobile partner card --}} -
- - Become a Partner + {{-- Mobile ad & partner card --}} +
+ +
diff --git a/resources/views/blog.blade.php b/resources/views/blog.blade.php index 94a2b4cf..4bc25240 100644 --- a/resources/views/blog.blade.php +++ b/resources/views/blog.blade.php @@ -113,10 +113,13 @@ class="flex grow flex-col gap-5"
- {{-- Mobile partner card --}} -
- - Become a Partner + {{-- Mobile ad & partner card --}} +
+ +
{{-- Pagination --}}
diff --git a/resources/views/cart/success.blade.php b/resources/views/cart/success.blade.php index 972975de..9dc35fdf 100644 --- a/resources/views/cart/success.blade.php +++ b/resources/views/cart/success.blade.php @@ -27,7 +27,7 @@
{{ $item->plugin->name }} - @if ($item->price_at_addition === 0 && $item->plugin->isOfficial()) + @if ($item->plugin->isOfficial() && auth()->user()?->hasUltraAccess())

Included with Ultra

@endif
diff --git a/resources/views/components/blog/ad-rotation.blade.php b/resources/views/components/blog/ad-rotation.blade.php new file mode 100644 index 00000000..3fb6bf15 --- /dev/null +++ b/resources/views/components/blog/ad-rotation.blade.php @@ -0,0 +1,162 @@ +@props(['ads' => ['mobile', 'devkit', 'ultra']]) + +@php + $adsJson = json_encode($ads); +@endphp + +
+ {{-- NativePHP Mobile Ad --}} + @if (in_array('mobile', $ads)) + + {{-- Logo --}} +
+ + NativePHP +
+ + {{-- Tagline --}} +
+ Bring your + Laravel + skills to + mobile apps. +
+ + {{-- Iphone --}} +
+ +
+ + {{-- Star 1 --}} + + {{-- Star 2 --}} + + {{-- Star 3 --}} + + {{-- White blur --}} +
+
+
+ {{-- Sky blur --}} +
+
+
+ {{-- Violet blur --}} +
+
+
+
+ @endif + + {{-- Plugin Dev Kit Ad --}} + @if (in_array('devkit', $ads)) + + {{-- Icon --}} +
+ +
+ + {{-- Title --}} +
+ Plugin Dev Kit +
+ + {{-- Tagline --}} +
+ Build native plugins with + Claude Code +
+ + {{-- CTA --}} +
+ Learn More +
+ + {{-- Decorative stars --}} + + + +
+ @endif + + {{-- Ultra Ad --}} + @if (in_array('ultra', $ads)) + + {{-- Icon --}} +
+ +
+ + {{-- Title --}} +
+ NativePHP Ultra +
+ + {{-- Tagline --}} +
+ All NativePHP plugins, teams & priority support from + ${{ config('subscriptions.plans.max.price_monthly') }}/mo +
+ + {{-- CTA --}} +
+ Learn More +
+ + {{-- Decorative stars --}} + + + +
+ @endif +
diff --git a/resources/views/components/blog/sidebar.blade.php b/resources/views/components/blog/sidebar.blade.php index 22c34e2e..9fc18be5 100644 --- a/resources/views/components/blog/sidebar.blade.php +++ b/resources/views/components/blog/sidebar.blade.php @@ -1,5 +1,4 @@ diff --git a/resources/views/pricing.blade.php b/resources/views/pricing.blade.php index b0cb706e..51bb572b 100644 --- a/resources/views/pricing.blade.php +++ b/resources/views/pricing.blade.php @@ -198,9 +198,9 @@ class="mx-auto flex w-full max-w-2xl flex-col items-center gap-4 pt-10"

- Ultra includes 10 team seats. If you need more, extra + Ultra includes {{ config('subscriptions.plans.max.included_seats') }} team seats. If you need more, extra seats can be purchased from your team settings page - at $5/mo per seat on monthly plans or $4/mo per seat + at ${{ config('subscriptions.plans.max.extra_seat_price_monthly') }}/mo per seat on monthly plans or ${{ config('subscriptions.plans.max.extra_seat_price_yearly') }}/mo per seat on annual plans. Extra seats are billed pro-rata to match your subscription cycle.

diff --git a/resources/views/sponsoring.blade.php b/resources/views/sponsoring.blade.php index a014c32a..01e68268 100644 --- a/resources/views/sponsoring.blade.php +++ b/resources/views/sponsoring.blade.php @@ -122,7 +122,7 @@ class="prose mt-2 max-w-none text-gray-600 will-change-transform dark:text-gray-