Add pricing-page waitlist (name + email signup)

Replaces the disabled "Coming soon" buttons on the pricing page with a
waitlist band so visitors can be notified when alerts launch — separate
from registered users.

- waitlist_subscribers table (name, email unique, source, referrer)
- WaitlistService::subscribe — normalises email, idempotent
- Public POST /api/waitlist (throttle:10,1), thin controller + form request
- Read-only Filament resource with streamed CSV export
- Vue: useWaitlist composable + WaitlistForm, rendered below the grid
  while any tier is still "coming soon"; sends source + document.referrer

Announcement send mechanism deferred to a later task.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ovidiu U
2026-06-12 10:27:25 +01:00
parent e7d19488fd
commit 61adc133aa
15 changed files with 505 additions and 1 deletions

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Filament\Resources;
use App\Filament\NavigationGroup;
use App\Filament\Resources\WaitlistSubscriberResource\Pages\ListWaitlistSubscribers;
use App\Models\WaitlistSubscriber;
use Filament\Resources\Resource;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class WaitlistSubscriberResource extends Resource
{
protected static ?string $model = WaitlistSubscriber::class;
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Users;
protected static ?string $navigationLabel = 'Waitlist';
protected static ?int $navigationSort = 3;
public static function canCreate(): bool
{
return false;
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->searchable(),
TextColumn::make('email')
->searchable()
->copyable(),
TextColumn::make('source')
->badge()
->placeholder('—')
->toggleable(),
TextColumn::make('referrer')
->limit(40)
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('created_at')
->label('Joined')
->dateTime('d M Y H:i')
->sortable(),
])
->defaultSort('created_at', 'desc')
->recordActions([])
->filters([]);
}
public static function getPages(): array
{
return [
'index' => ListWaitlistSubscribers::route('/'),
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Filament\Resources\WaitlistSubscriberResource\Pages;
use App\Filament\Resources\WaitlistSubscriberResource;
use App\Models\WaitlistSubscriber;
use Filament\Actions\Action;
use Filament\Resources\Pages\ListRecords;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ListWaitlistSubscribers extends ListRecords
{
protected static string $resource = WaitlistSubscriberResource::class;
protected function getHeaderActions(): array
{
return [
Action::make('export')
->label('Export CSV')
->icon('heroicon-o-arrow-down-tray')
->action(fn (): StreamedResponse => response()->streamDownload(function (): void {
$handle = fopen('php://output', 'wb');
fputcsv($handle, ['name', 'email', 'source', 'referrer', 'joined_at']);
WaitlistSubscriber::query()
->orderBy('created_at')
->each(function (WaitlistSubscriber $subscriber) use ($handle): void {
fputcsv($handle, [
$subscriber->name,
$subscriber->email,
$subscriber->source,
$subscriber->referrer,
$subscriber->created_at?->toDateTimeString(),
]);
});
fclose($handle);
}, 'waitlist.csv', ['Content-Type' => 'text/csv'])),
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\StoreWaitlistRequest;
use App\Services\WaitlistService;
use Illuminate\Http\JsonResponse;
class WaitlistController extends Controller
{
public function __construct(private readonly WaitlistService $waitlist) {}
public function store(StoreWaitlistRequest $request): JsonResponse
{
$this->waitlist->subscribe(
name: $request->string('name')->toString(),
email: $request->string('email')->toString(),
source: $request->filled('source') ? $request->string('source')->toString() : null,
referrer: $request->filled('referrer') ? $request->string('referrer')->toString() : null,
);
return response()->json([
'message' => "You're on the list — we'll email you when alerts go live.",
], 201);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest;
class StoreWaitlistRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'],
'source' => ['nullable', 'string', 'max:64'],
'referrer' => ['nullable', 'string', 'max:2048'],
];
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Database\Factories\WaitlistSubscriberFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['name', 'email', 'source', 'referrer'])]
class WaitlistSubscriber extends Model
{
/** @use HasFactory<WaitlistSubscriberFactory> */
use HasFactory;
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Services;
use App\Models\WaitlistSubscriber;
final class WaitlistService
{
/**
* Add someone to the feature-launch waitlist.
*
* Idempotent: re-subscribing an existing email is a no-op that returns the
* original subscriber unchanged original meta (source, referrer) is kept,
* never a duplicate row or an error.
*/
public function subscribe(
string $name,
string $email,
?string $source = null,
?string $referrer = null,
): WaitlistSubscriber {
$email = strtolower(trim($email));
return WaitlistSubscriber::firstOrCreate(
['email' => $email],
['name' => $name, 'source' => $source, 'referrer' => $referrer],
);
}
}