diff --git a/app/Filament/Resources/WaitlistSubscriberResource.php b/app/Filament/Resources/WaitlistSubscriberResource.php new file mode 100644 index 0000000..caae775 --- /dev/null +++ b/app/Filament/Resources/WaitlistSubscriberResource.php @@ -0,0 +1,60 @@ +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('/'), + ]; + } +} diff --git a/app/Filament/Resources/WaitlistSubscriberResource/Pages/ListWaitlistSubscribers.php b/app/Filament/Resources/WaitlistSubscriberResource/Pages/ListWaitlistSubscribers.php new file mode 100644 index 0000000..002b031 --- /dev/null +++ b/app/Filament/Resources/WaitlistSubscriberResource/Pages/ListWaitlistSubscribers.php @@ -0,0 +1,42 @@ +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'])), + ]; + } +} diff --git a/app/Http/Controllers/Api/WaitlistController.php b/app/Http/Controllers/Api/WaitlistController.php new file mode 100644 index 0000000..060d92e --- /dev/null +++ b/app/Http/Controllers/Api/WaitlistController.php @@ -0,0 +1,27 @@ +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); + } +} diff --git a/app/Http/Requests/Api/StoreWaitlistRequest.php b/app/Http/Requests/Api/StoreWaitlistRequest.php new file mode 100644 index 0000000..de291bc --- /dev/null +++ b/app/Http/Requests/Api/StoreWaitlistRequest.php @@ -0,0 +1,26 @@ +> + */ + 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'], + ]; + } +} diff --git a/app/Models/WaitlistSubscriber.php b/app/Models/WaitlistSubscriber.php new file mode 100644 index 0000000..a60285b --- /dev/null +++ b/app/Models/WaitlistSubscriber.php @@ -0,0 +1,15 @@ + */ + use HasFactory; +} diff --git a/app/Services/WaitlistService.php b/app/Services/WaitlistService.php new file mode 100644 index 0000000..8ee9dd2 --- /dev/null +++ b/app/Services/WaitlistService.php @@ -0,0 +1,29 @@ + $email], + ['name' => $name, 'source' => $source, 'referrer' => $referrer], + ); + } +} diff --git a/database/factories/WaitlistSubscriberFactory.php b/database/factories/WaitlistSubscriberFactory.php new file mode 100644 index 0000000..b0bff83 --- /dev/null +++ b/database/factories/WaitlistSubscriberFactory.php @@ -0,0 +1,25 @@ + + */ +class WaitlistSubscriberFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + ]; + } +} diff --git a/database/migrations/2026_06_12_085857_create_waitlist_subscribers_table.php b/database/migrations/2026_06_12_085857_create_waitlist_subscribers_table.php new file mode 100644 index 0000000..7f2cb99 --- /dev/null +++ b/database/migrations/2026_06_12_085857_create_waitlist_subscribers_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->string('source', 64)->nullable()->comment('Where they joined from, e.g. pricing'); + $table->text('referrer')->nullable()->comment('document.referrer at signup'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('waitlist_subscribers'); + } +}; diff --git a/resources/js/components/PricingGrid.vue b/resources/js/components/PricingGrid.vue index 163e8a5..4bd081f 100644 --- a/resources/js/components/PricingGrid.vue +++ b/resources/js/components/PricingGrid.vue @@ -80,13 +80,16 @@ + + diff --git a/resources/js/composables/useWaitlist.js b/resources/js/composables/useWaitlist.js new file mode 100644 index 0000000..7a12333 --- /dev/null +++ b/resources/js/composables/useWaitlist.js @@ -0,0 +1,31 @@ +import { ref } from 'vue' +import api from '../axios.js' + +export function useWaitlist() { + const loading = ref(false) + const success = ref(false) + const error = ref(null) + + async function submit(name, email, source = null) { + loading.value = true + error.value = null + try { + await api.post('/waitlist', { + name, + email, + source, + referrer: document.referrer || null, + }) + success.value = true + } catch (e) { + const fieldErrors = e.response?.data?.errors + error.value = fieldErrors + ? Object.values(fieldErrors)[0][0] + : (e.response?.data?.message || 'Something went wrong — please try again.') + } finally { + loading.value = false + } + } + + return { loading, success, error, submit } +} diff --git a/routes/api.php b/routes/api.php index 8f2d629..5732ad9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Api\AuthController; use App\Http\Controllers\Api\StationController; use App\Http\Controllers\Api\StatsController; use App\Http\Controllers\Api\UserController; +use App\Http\Controllers\Api\WaitlistController; use App\Http\Middleware\VerifyApiKey; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Route; @@ -22,6 +23,9 @@ Route::get('/fuel-types', function () { Route::get('/stats/live', [StatsController::class, 'live']); +// Feature-launch waitlist signup (public, separate from registered users) +Route::post('/waitlist', [WaitlistController::class, 'store'])->middleware('throttle:10,1'); + // Protected endpoints (API key required) Route::middleware(['throttle:60,1', VerifyApiKey::class])->group(function (): void { Route::get('/stations', [StationController::class, 'index']); diff --git a/tests/Feature/Admin/WaitlistSubscriberResourceTest.php b/tests/Feature/Admin/WaitlistSubscriberResourceTest.php new file mode 100644 index 0000000..64b2e29 --- /dev/null +++ b/tests/Feature/Admin/WaitlistSubscriberResourceTest.php @@ -0,0 +1,31 @@ +actingAs(User::factory()->admin()->create()); +}); + +it('lists waitlist subscribers', function () { + $subscribers = WaitlistSubscriber::factory()->count(3)->create(); + + Livewire::test(ListWaitlistSubscribers::class) + ->assertOk() + ->assertCanSeeTableRecords($subscribers); +}); + +it('exposes a CSV export header action', function () { + Livewire::test(ListWaitlistSubscribers::class) + ->assertActionExists('export'); +}); + +it('exports a CSV download when the action runs against real subscribers', function () { + WaitlistSubscriber::factory()->count(2)->create(); + + Livewire::test(ListWaitlistSubscribers::class) + ->callAction('export') + ->assertFileDownloaded('waitlist.csv'); +}); diff --git a/tests/Feature/Api/WaitlistTest.php b/tests/Feature/Api/WaitlistTest.php new file mode 100644 index 0000000..ea249c1 --- /dev/null +++ b/tests/Feature/Api/WaitlistTest.php @@ -0,0 +1,64 @@ +postJson('/api/waitlist', [ + 'name' => 'Ada Lovelace', + 'email' => 'ada@example.com', + ]); + + $response->assertCreated() + ->assertJsonStructure(['message']); + + $this->assertDatabaseHas('waitlist_subscribers', [ + 'name' => 'Ada Lovelace', + 'email' => 'ada@example.com', + ]); +}); + +it('treats a duplicate email as success without creating a second row', function () { + WaitlistSubscriber::factory()->create(['email' => 'ada@example.com']); + + $this->postJson('/api/waitlist', [ + 'name' => 'Someone Else', + 'email' => 'ada@example.com', + ])->assertCreated(); + + expect(WaitlistSubscriber::where('email', 'ada@example.com')->count())->toBe(1); +}); + +it('stores the source and referrer sent with the request', function () { + $this->postJson('/api/waitlist', [ + 'name' => 'Ada Lovelace', + 'email' => 'ada@example.com', + 'source' => 'pricing', + 'referrer' => 'https://duckduckgo.com/', + ])->assertCreated(); + + $this->assertDatabaseHas('waitlist_subscribers', [ + 'email' => 'ada@example.com', + 'source' => 'pricing', + 'referrer' => 'https://duckduckgo.com/', + ]); +}); + +it('requires a name', function () { + $this->postJson('/api/waitlist', [ + 'email' => 'ada@example.com', + ])->assertStatus(422)->assertJsonValidationErrors('name'); +}); + +it('rejects an invalid email', function () { + $this->postJson('/api/waitlist', [ + 'name' => 'Ada Lovelace', + 'email' => 'not-an-email', + ])->assertStatus(422)->assertJsonValidationErrors('email'); +}); + +it('throttles the endpoint', function () { + $route = collect(app('router')->getRoutes()) + ->first(fn ($route) => $route->uri() === 'api/waitlist' && in_array('POST', $route->methods(), true)); + + expect($route?->gatherMiddleware())->toContain('throttle:10,1'); +}); diff --git a/tests/Unit/Services/WaitlistServiceTest.php b/tests/Unit/Services/WaitlistServiceTest.php new file mode 100644 index 0000000..ce7aee5 --- /dev/null +++ b/tests/Unit/Services/WaitlistServiceTest.php @@ -0,0 +1,47 @@ +subscribe('Ada Lovelace', ' Ada@Example.COM '); + + expect($subscriber->email)->toBe('ada@example.com') + ->and($subscriber->name)->toBe('Ada Lovelace'); + + $this->assertDatabaseHas('waitlist_subscribers', ['email' => 'ada@example.com']); +}); + +it('is idempotent — re-subscribing the same email keeps a single row and returns the original', function () { + $service = app(WaitlistService::class); + + $first = $service->subscribe('Ada', 'ada@example.com'); + $second = $service->subscribe('Different Name', 'ADA@example.com'); + + expect($second->id)->toBe($first->id) + ->and($second->name)->toBe('Ada'); + + expect(WaitlistSubscriber::where('email', 'ada@example.com')->count())->toBe(1); +}); + +it('stores the source and referrer on a new subscriber', function () { + $subscriber = app(WaitlistService::class)->subscribe( + 'Ada', + 'ada@example.com', + source: 'pricing', + referrer: 'https://duckduckgo.com/', + ); + + expect($subscriber->source)->toBe('pricing') + ->and($subscriber->referrer)->toBe('https://duckduckgo.com/'); +}); + +it('preserves the original meta when an existing email re-subscribes', function () { + $service = app(WaitlistService::class); + + $service->subscribe('Ada', 'ada@example.com', source: 'pricing', referrer: 'https://a.test'); + $second = $service->subscribe('Ada', 'ada@example.com', source: 'home', referrer: 'https://b.test'); + + expect($second->source)->toBe('pricing') + ->and($second->referrer)->toBe('https://a.test'); +});