# 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` ```php 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. - `final` class, constructor injection only (per `code-style.md`). - Returns the `WaitlistSubscriber` model (typed return, per `architecture.md`). ### Form Request — `Api/StoreWaitlistRequest` ```php 'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'max:255'], ``` `authorize()` returns `true` (public endpoint). ### Controller — `Api\WaitlistController@store` - Thin: validates via `StoreWaitlistRequest`, calls `WaitlistService::subscribe()`. - Returns `201`: ```json { "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`): ```php 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 /waitlist` via the configured `api` instance (`resources/js/axios.js`); never a bare axios/fetch call (per `frontend.md`). - `loading` (ref bool) - `error` (ref string|null) — surfaces validation/throttle errors - `success` (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?") + `name` input + `email` input + "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_SOON` logic — the Daily/Smart buttons stay as disabled "Coming soon". - Render `` 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/waitlist` with 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.php` - `app/Models/WaitlistSubscriber.php` - `database/factories/WaitlistSubscriberFactory.php` - `app/Services/WaitlistService.php` - `app/Http/Requests/Api/StoreWaitlistRequest.php` - `app/Http/Controllers/Api/WaitlistController.php` - `app/Filament/Resources/WaitlistSubscriberResource/...` (resource + list page) - `resources/js/composables/useWaitlist.js` - `resources/js/components/WaitlistForm.vue` - `tests/Feature/WaitlistTest.php` - `tests/Unit/Services/WaitlistServiceTest.php` **Modified:** - `routes/api.php` (add public throttled route) - `resources/js/components/PricingGrid.vue` (render `` below grid)