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,64 @@
<?php
use App\Models\WaitlistSubscriber;
it('stores a subscriber and returns 201', function () {
$response = $this->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');
});