Add design spec for pricing-page waitlist
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>
This commit is contained in:
197
docs/superpowers/specs/2026-06-12-pricing-waitlist-design.md
Normal file
197
docs/superpowers/specs/2026-06-12-pricing-waitlist-design.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# 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 `<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/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 `<WaitlistForm />` below grid)
|
||||||
Reference in New Issue
Block a user