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:
31
tests/Feature/Admin/WaitlistSubscriberResourceTest.php
Normal file
31
tests/Feature/Admin/WaitlistSubscriberResourceTest.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\WaitlistSubscriberResource\Pages\ListWaitlistSubscribers;
|
||||
use App\Models\User;
|
||||
use App\Models\WaitlistSubscriber;
|
||||
use Livewire\Livewire;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->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');
|
||||
});
|
||||
64
tests/Feature/Api/WaitlistTest.php
Normal file
64
tests/Feature/Api/WaitlistTest.php
Normal 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');
|
||||
});
|
||||
47
tests/Unit/Services/WaitlistServiceTest.php
Normal file
47
tests/Unit/Services/WaitlistServiceTest.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use App\Models\WaitlistSubscriber;
|
||||
use App\Services\WaitlistService;
|
||||
|
||||
it('normalises the email to trimmed lowercase before storing', function () {
|
||||
$subscriber = app(WaitlistService::class)->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');
|
||||
});
|
||||
Reference in New Issue
Block a user