Collect name + email from /pricing to notify when alerts launch, separate from registered users. Minimal table + public throttled endpoint + Filament read-only list with CSV export. Announcement send mechanism deferred to a later task. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7.1 KiB
Pricing-page waitlist — design
Date: 2026-06-12 Status: Approved, pending implementation plan
Problem
The /pricing page shows disabled "Coming soon" buttons on the Daily
(basic) and Smart (plus) cards, because their alerting features aren't
shipped yet (COMING_SOON = ['basic', 'plus'] in
resources/js/components/PricingGrid.vue). We want to capture interest instead
of showing a dead button: let a visitor leave their name + email to be
notified when alerts launch.
This list is separate from registered users — it's a marketing signup log, not an account.
Scope
In scope (now):
- Collect name + email from the pricing page.
- Store in a dedicated table.
- View + CSV export in the Filament admin panel (the "announce later" hook).
Out of scope (deliberately deferred to a later task):
- The announcement email itself — Mailable, queued bulk send, unsubscribe route/link. We collect now and build the send mechanism when we're actually ready to email the list.
- Tier segmentation — we do not record which tier (Daily vs Smart) the visitor was interested in. One flat list.
- IP / source / user-agent capture. Can be added later if abuse becomes a problem; not needed for v1.
Why not the offload-project/laravel-waitlist package
That package is built around referrals, queue positions, and invite flows — none of which this needs. It would add a dependency and migrations we'd work against. A single table + endpoint is less code and less maintenance for "collect name + email, announce later."
Architecture
Follows the existing shape: Vue SPA → public REST API → thin controller →
fat service → model (.claude/rules/architecture.md,
.claude/rules/frontend.md).
WaitlistForm.vue (below the pricing grid)
└── useWaitlist.js (composable, uses configured `api` axios instance)
└── POST /api/waitlist (public, throttle:10,1)
└── Api\WaitlistController@store
└── StoreWaitlistRequest (validation)
└── WaitlistService::subscribe()
└── WaitlistSubscriber (model)
Backend
Migration — create_waitlist_subscribers_table
| Column | Type | Notes |
|---|---|---|
id |
bigIncrements | |
name |
string | |
email |
string, unique | stored lowercased + trimmed |
created_at / updated_at |
timestamps |
No other columns, per the minimal-schema decision.
Model — WaitlistSubscriber
$fillable = ['name', 'email'].- A factory (
WaitlistSubscriberFactory) for tests.
Service — WaitlistService
public function subscribe(string $name, string $email): WaitlistSubscriber
- Normalises email:
trim+strtolower. firstOrCreate(['email' => $email], ['name' => $name])— idempotent. Re-submitting an existing email is a no-op success, never an error and never a duplicate row. Does not reveal whether the email was already present.finalclass, constructor injection only (percode-style.md).- Returns the
WaitlistSubscribermodel (typed return, perarchitecture.md).
Form Request — Api/StoreWaitlistRequest
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'],
authorize() returns true (public endpoint).
Controller — Api\WaitlistController@store
- Thin: validates via
StoreWaitlistRequest, callsWaitlistService::subscribe(). - Returns
201:{ "message": "You're on the list — we'll email you when alerts go live." } - Does not echo the model back (nothing sensitive to return; no API Resource needed).
Route — routes/api.php
Added to the public block (alongside /auth/register):
Route::post('/waitlist', [WaitlistController::class, 'store'])
->middleware('throttle:10,1');
Public (no API key, no Sanctum auth), but throttled to 10/min per IP. Uses the
same Sanctum-stateful XSRF path as /auth/register, so the SPA posts to it with
its existing axios credentials/XSRF config.
Frontend
Composable — resources/js/composables/useWaitlist.js
Exposes:
submit(name, email)—POST /waitlistvia the configuredapiinstance (resources/js/axios.js); never a bare axios/fetch call (perfrontend.md).loading(ref bool)error(ref string|null) — surfaces validation/throttle errorssuccess(ref bool)
Component — resources/js/components/WaitlistForm.vue
- A single shared "band" rendered once below the pricing grid.
- Heading (e.g. "Want in when alerts launch?") +
nameinput +emailinput + "Notify me" button, laid out inline on the band. - States: idle → loading (button disabled/spinner) → success (inline "You're on the list ✓", form hidden/replaced) or error (message shown, inputs retained).
- Tailwind styling consistent with the pricing cards
(
.claude/rules/code-style.md, Tailwind v4).
resources/js/components/PricingGrid.vue
- No change to the
COMING_SOONlogic — the Daily/Smart buttons stay as disabled "Coming soon". - Render
<WaitlistForm />once, below the.grid(still inside the section).
Admin — Filament WaitlistSubscriberResource
In app/Filament/Resources/ (matching siblings like UserResource).
- Table:
name,email, joined date (created_at). Searchable on name + email; default sort newest first. - CSV export: Filament table export action — this is the mechanism for pulling the list to announce later.
- Read-only: no create / edit / delete actions. It's a signup log; rows arrive only via the public endpoint.
Tests (Pest — .claude/rules/testing.md)
Feature (tests/Feature/, RefreshDatabase, factory-first):
POST /api/waitlistwith valid name + email →201, row exists.- Duplicate email →
201, still exactly one row (idempotent, no error). - Missing name →
422. - Invalid email →
422. - (Throttle middleware present on the route.)
Unit (tests/Unit/Services/WaitlistServiceTest.php):
subscribe()lowercases + trims the email before storing.subscribe()called twice with the same email creates one row and returns the existing subscriber.
Files touched
New:
database/migrations/xxxx_create_waitlist_subscribers_table.phpapp/Models/WaitlistSubscriber.phpdatabase/factories/WaitlistSubscriberFactory.phpapp/Services/WaitlistService.phpapp/Http/Requests/Api/StoreWaitlistRequest.phpapp/Http/Controllers/Api/WaitlistController.phpapp/Filament/Resources/WaitlistSubscriberResource/...(resource + list page)resources/js/composables/useWaitlist.jsresources/js/components/WaitlistForm.vuetests/Feature/WaitlistTest.phptests/Unit/Services/WaitlistServiceTest.php
Modified:
routes/api.php(add public throttled route)resources/js/components/PricingGrid.vue(render<WaitlistForm />below grid)