diff --git a/app/Filament/Resources/WallOfLoveSubmissionResource.php b/app/Filament/Resources/WallOfLoveSubmissionResource.php new file mode 100644 index 00000000..db0254b3 --- /dev/null +++ b/app/Filament/Resources/WallOfLoveSubmissionResource.php @@ -0,0 +1,191 @@ +schema([ + Forms\Components\Section::make('Submission Details') + ->schema([ + Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255), + + Forms\Components\TextInput::make('company') + ->maxLength(255), + + Forms\Components\FileUpload::make('photo_path') + ->label('Photo') + ->image() + ->disk('public') + ->directory('wall-of-love-photos'), + + Forms\Components\TextInput::make('url') + ->label('Website/Social URL') + ->url() + ->maxLength(255), + + Forms\Components\Textarea::make('testimonial') + ->maxLength(1000) + ->rows(4), + ]), + + Forms\Components\Section::make('Review Information') + ->schema([ + Forms\Components\Select::make('user_id') + ->relationship('user', 'name') + ->required() + ->disabled(), + + Forms\Components\DateTimePicker::make('approved_at') + ->label('Approved At'), + + Forms\Components\Select::make('approved_by') + ->relationship('approvedBy', 'name') + ->label('Approved By'), + + Forms\Components\Placeholder::make('created_at') + ->label('Submitted At') + ->content(fn (WallOfLoveSubmission $record): ?string => $record->created_at?->diffForHumans()), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name') + ->searchable() + ->sortable(), + + Tables\Columns\TextColumn::make('company') + ->searchable() + ->toggleable(), + + Tables\Columns\TextColumn::make('user.name') + ->label('Submitted By') + ->searchable() + ->sortable(), + + Tables\Columns\ImageColumn::make('photo_path') + ->label('Photo') + ->disk('public') + ->height(40) + ->toggleable(), + + Tables\Columns\IconColumn::make('approved_at') + ->label('Status') + ->boolean() + ->trueIcon('heroicon-o-check-circle') + ->falseIcon('heroicon-o-clock') + ->trueColor('success') + ->falseColor('warning') + ->sortable(), + + Tables\Columns\TextColumn::make('approvedBy.name') + ->label('Approved By') + ->toggleable(), + + Tables\Columns\TextColumn::make('created_at') + ->label('Submitted') + ->dateTime() + ->sortable() + ->toggleable(), + ]) + ->filters([ + Tables\Filters\TernaryFilter::make('approved_at') + ->label('Status') + ->placeholder('All submissions') + ->trueLabel('Approved') + ->falseLabel('Pending') + ->queries( + true: fn (Builder $query) => $query->whereNotNull('approved_at'), + false: fn (Builder $query) => $query->whereNull('approved_at'), + ), + ]) + ->actions([ + Tables\Actions\Action::make('approve') + ->icon('heroicon-o-check') + ->color('success') + ->visible(fn (WallOfLoveSubmission $record) => $record->isPending()) + ->action(fn (WallOfLoveSubmission $record) => $record->update([ + 'approved_at' => now(), + 'approved_by' => auth()->id(), + ])) + ->requiresConfirmation() + ->modalHeading('Approve Submission') + ->modalDescription('Are you sure you want to approve this submission for the Wall of Love?'), + + Tables\Actions\Action::make('unapprove') + ->icon('heroicon-o-x-mark') + ->color('warning') + ->visible(fn (WallOfLoveSubmission $record) => $record->isApproved()) + ->action(fn (WallOfLoveSubmission $record) => $record->update([ + 'approved_at' => null, + 'approved_by' => null, + ])) + ->requiresConfirmation() + ->modalHeading('Unapprove Submission') + ->modalDescription('Are you sure you want to unapprove this submission?'), + + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\BulkAction::make('approve') + ->icon('heroicon-o-check') + ->color('success') + ->action(function ($records) { + $records->each(fn (WallOfLoveSubmission $record) => $record->update([ + 'approved_at' => now(), + 'approved_by' => auth()->id(), + ])); + }) + ->requiresConfirmation() + ->modalHeading('Approve Selected Submissions') + ->modalDescription('Are you sure you want to approve all selected submissions?'), + + Tables\Actions\DeleteBulkAction::make(), + ]), + ]) + ->defaultSort('created_at', 'desc'); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListWallOfLoveSubmissions::route('/'), + // 'create' => Pages\CreateWallOfLoveSubmission::route('/create'), + 'edit' => Pages\EditWallOfLoveSubmission::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/WallOfLoveSubmissionResource/Pages/EditWallOfLoveSubmission.php b/app/Filament/Resources/WallOfLoveSubmissionResource/Pages/EditWallOfLoveSubmission.php new file mode 100644 index 00000000..c0172f91 --- /dev/null +++ b/app/Filament/Resources/WallOfLoveSubmissionResource/Pages/EditWallOfLoveSubmission.php @@ -0,0 +1,19 @@ +user() + ->licenses() + ->where('created_at', '<', '2025-06-01') + ->exists(); + + if (! $hasEarlyAdopterLicense) { + abort(404); + } + + // Check if user already has a submission + $hasExistingSubmission = auth()->user()->wallOfLoveSubmissions()->exists(); + + if ($hasExistingSubmission) { + return redirect()->route('customer.licenses')->with('info', 'You have already submitted your story to the Wall of Love.'); + } + + return view('customer.wall-of-love.create'); + } +} diff --git a/app/Livewire/WallOfLoveBanner.php b/app/Livewire/WallOfLoveBanner.php new file mode 100644 index 00000000..3041eed1 --- /dev/null +++ b/app/Livewire/WallOfLoveBanner.php @@ -0,0 +1,33 @@ +put('wall_of_love_dismissed_'.auth()->id(), true, now()->addWeek()); + $this->dispatch('banner-dismissed'); + } + + public function shouldShowBanner(): bool + { + // Check if user has early adopter licenses (before June 1st, 2025) + $hasEarlyAdopterLicenses = auth()->user()->licenses()->where('created_at', '<', '2025-06-01')->exists(); + + // Check if user already submitted + $hasExistingSubmission = auth()->user()->wallOfLoveSubmissions()->exists(); + + // Check if banner was dismissed + $hasDismissedBanner = cache()->has('wall_of_love_dismissed_'.auth()->id()); + + return $hasEarlyAdopterLicenses && ! $hasExistingSubmission && ! $hasDismissedBanner; + } + + public function render() + { + return view('livewire.wall-of-love-banner'); + } +} diff --git a/app/Livewire/WallOfLoveSubmissionForm.php b/app/Livewire/WallOfLoveSubmissionForm.php new file mode 100644 index 00000000..56d502c3 --- /dev/null +++ b/app/Livewire/WallOfLoveSubmissionForm.php @@ -0,0 +1,62 @@ + 'required|string|max:255', + 'company' => 'nullable|string|max:255', + 'photo' => 'nullable|image|max:2048', // 2MB max + 'url' => 'nullable|url|max:255', + 'testimonial' => 'nullable|string|max:1000', + ]; + + public function mount() + { + // Pre-fill name if user has a name + $this->name = auth()->user()->name ?? ''; + } + + public function submit() + { + $this->validate(); + + $photoPath = null; + if ($this->photo) { + $photoPath = $this->photo->store('wall-of-love-photos', 'public'); + } + + WallOfLoveSubmission::create([ + 'user_id' => auth()->id(), + 'name' => $this->name, + 'company' => $this->company ?: null, + 'photo_path' => $photoPath, + 'url' => $this->url ?: null, + 'testimonial' => $this->testimonial ?: null, + ]); + + return redirect()->route('customer.licenses')->with('success', 'Thank you! Your submission has been received and is awaiting review.'); + } + + public function render() + { + return view('livewire.wall-of-love-submission-form'); + } +} diff --git a/app/Models/License.php b/app/Models/License.php index af601228..0442ad35 100644 --- a/app/Models/License.php +++ b/app/Models/License.php @@ -118,7 +118,7 @@ public function canCreateSubLicense(): bool public function isLegacy(): bool { - return !$this->subscription_item_id + return ! $this->subscription_item_id && $this->created_at->lt(Carbon::create(2025, 5, 8)); } diff --git a/app/Models/User.php b/app/Models/User.php index bb6cc3a1..e1fec6ab 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -12,7 +12,6 @@ use Illuminate\Support\Collection; use Laravel\Cashier\Billable; use Laravel\Sanctum\HasApiTokens; -use Stripe\Customer; class User extends Authenticatable implements FilamentUser { @@ -48,6 +47,14 @@ public function licenses(): HasMany return $this->hasMany(License::class); } + /** + * @return HasMany + */ + public function wallOfLoveSubmissions(): HasMany + { + return $this->hasMany(WallOfLoveSubmission::class); + } + public function getFirstNameAttribute(): ?string { if (empty($this->name)) { diff --git a/app/Models/WallOfLoveSubmission.php b/app/Models/WallOfLoveSubmission.php new file mode 100644 index 00000000..b5c17932 --- /dev/null +++ b/app/Models/WallOfLoveSubmission.php @@ -0,0 +1,47 @@ + 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function approvedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + public function isApproved(): bool + { + return $this->approved_at !== null; + } + + public function isPending(): bool + { + return $this->approved_at === null; + } +} diff --git a/database/migrations/2025_09_19_041645_create_wall_of_love_submissions_table.php b/database/migrations/2025_09_19_041645_create_wall_of_love_submissions_table.php new file mode 100644 index 00000000..61b5e851 --- /dev/null +++ b/database/migrations/2025_09_19_041645_create_wall_of_love_submissions_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('company')->nullable(); + $table->string('photo_path')->nullable(); + $table->string('url')->nullable(); + $table->text('testimonial')->nullable(); + $table->timestamp('approved_at')->nullable(); + $table->foreignId('approved_by')->nullable()->constrained('users'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('wall_of_love_submissions'); + } +}; diff --git a/resources/views/components/footer.blade.php b/resources/views/components/footer.blade.php index 32395ad7..a108fce6 100644 --- a/resources/views/components/footer.blade.php +++ b/resources/views/components/footer.blade.php @@ -223,6 +223,14 @@ class="inline-block px-px py-1.5 transition duration-300 will-change-transform h Partners +
  • + + Wall of Love + +
  • @feature(App\Features\ShowAuthButtons::class)
  • + {{-- Wall of Love Callout for Early Adopters --}} + + {{-- Content --}}
    @if($licenses->count() > 0) diff --git a/resources/views/customer/licenses/show.blade.php b/resources/views/customer/licenses/show.blade.php index ebc8258f..207a34e6 100644 --- a/resources/views/customer/licenses/show.blade.php +++ b/resources/views/customer/licenses/show.blade.php @@ -6,18 +6,18 @@
    diff --git a/resources/views/customer/wall-of-love/create.blade.php b/resources/views/customer/wall-of-love/create.blade.php new file mode 100644 index 00000000..d75fa7a8 --- /dev/null +++ b/resources/views/customer/wall-of-love/create.blade.php @@ -0,0 +1,78 @@ + +
    + {{-- Header --}} +
    +
    +
    + {{-- Breadcrumb --}} + + +
    +

    Join our Wall of Love! 💙

    +

    + As an early adopter, your story matters. Share your experience with NativePHP and inspire other developers in the community. +

    +
    +
    +
    +
    + + {{-- Content --}} +
    +
    +
    + {{-- Success Message --}} + @if(session()->has('success')) +
    +
    + + + +

    {{ session('success') }}

    +
    +
    + @endif + + {{-- Info about early adopter status --}} +
    +
    +
    +

    + You're an Early Adopter! +

    +

    + Thank you for supporting NativePHP from the beginning. As a reward, you can appear + permanently on our + Wall of Love. +

    +

    + Your submission will be reviewed by our team and, once approved, will appear + on the page. +

    +
    +
    +
    + + {{-- Submission Form --}} + +
    +
    +
    +
    +
    diff --git a/resources/views/livewire/wall-of-love-banner.blade.php b/resources/views/livewire/wall-of-love-banner.blade.php new file mode 100644 index 00000000..aaaabd02 --- /dev/null +++ b/resources/views/livewire/wall-of-love-banner.blade.php @@ -0,0 +1,32 @@ +
    + @if($this->shouldShowBanner()) +
    +
    +
    +
    + + + +
    +
    +

    + Join our Wall of Love! 💙 +

    +

    + As an early adopter who purchased a license before June 1st, 2025, we'd love to feature you on + our Wall of Love page. +

    +
    + + Submit Your Details + + +
    +
    +
    +
    +
    + @endif +
    diff --git a/resources/views/livewire/wall-of-love-submission-form.blade.php b/resources/views/livewire/wall-of-love-submission-form.blade.php new file mode 100644 index 00000000..a70d7ef0 --- /dev/null +++ b/resources/views/livewire/wall-of-love-submission-form.blade.php @@ -0,0 +1,58 @@ +
    + {{-- Name Field --}} +
    + + + @error('name')

    {{ $message }}

    @enderror +
    + + {{-- Company Field --}} +
    + + + @error('company')

    {{ $message }}

    @enderror +
    + + {{-- Photo Field --}} +
    + +
    + +
    + @error('photo')

    {{ $message }}

    @enderror +
    + + {{-- URL Field --}} +
    + + + @error('url')

    {{ $message }}

    @enderror +
    + + {{-- Testimonial Field --}} +
    + + + @error('testimonial')

    {{ $message }}

    @enderror +

    Share what you built, how NativePHP helped you, or what you love about the framework.

    +
    + +
    + + Cancel + + +
    +
    \ No newline at end of file diff --git a/resources/views/wall-of-love.blade.php b/resources/views/wall-of-love.blade.php new file mode 100644 index 00000000..22360aa0 --- /dev/null +++ b/resources/views/wall-of-love.blade.php @@ -0,0 +1,188 @@ + + {{-- Hero Section --}} +
    +
    + {{-- Blurred circle - Decorative --}} + + + {{-- Primary Heading --}} +

    +
    +
    + Thank +
    + +
    +
    +
    +
    +
    + You! +
    +
    +
    Early
    +
    Adopters
    +
    +
    +

    + +
    +
    +
    +
    +
    +
    +
    +
    + + {{-- Description --}} +

    + Every great story starts with a small circle of believers. You + stood with us at the beginning, and your support will always be + part of the NativePHP story. +

    +
    +
    + + {{-- List --}} + @php + // Get approved submissions + $approvedSubmissions = App\Models\WallOfLoveSubmission::whereNotNull('approved_at') + ->orderBy('approved_at', 'desc') + ->get(); + + // Convert approved submissions to the format expected by the component + $earlyAdopters = $approvedSubmissions->map(function ($submission) { + return [ + 'name' => $submission->name, + 'title' => $submission->company, + 'url' => $submission->url, + 'image' => $submission->photo_path + ? asset('storage/' . $submission->photo_path) + : 'https://i.pravatar.cc/300?img=' . rand(1, 70), + 'featured' => rand(0, 4) === 0, // Randomly feature about 20% of submissions + 'testimonial' => $submission->testimonial, + ]; + })->toArray(); + @endphp + + @if(count($earlyAdopters) > 0) +
    + @foreach ($earlyAdopters as $adopter) + + @endforeach +
    + @else +
    +
    +
    🚀
    +

    + Coming Soon! +

    +

    + Our early adopters will appear here soon. +

    +
    +
    + @endif +
    diff --git a/routes/web.php b/routes/web.php index 674f7fc5..9debe268 100644 --- a/routes/web.php +++ b/routes/web.php @@ -34,6 +34,7 @@ Route::view('/', 'welcome')->name('welcome'); Route::view('pricing', 'pricing')->name('pricing'); +Route::view('wall-of-love', 'wall-of-love')->name('wall-of-love'); Route::view('brand', 'brand')->name('brand'); Route::view('laracon-us-2025-giveaway', 'laracon-us-2025-giveaway')->name('laracon-us-2025-giveaway'); Route::view('privacy-policy', 'privacy-policy')->name('privacy-policy'); @@ -115,6 +116,9 @@ Route::get('licenses/{licenseKey}', [CustomerLicenseController::class, 'show'])->name('licenses.show'); Route::patch('licenses/{licenseKey}', [CustomerLicenseController::class, 'update'])->name('licenses.update'); + // Wall of Love submission + Route::get('wall-of-love/create', [App\Http\Controllers\WallOfLoveSubmissionController::class, 'create'])->name('wall-of-love.create'); + // Billing portal Route::get('billing-portal', function (Illuminate\Http\Request $request) { $user = $request->user();