From e7d19488fd8601c136142c5bf02b96f70972e883 Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Fri, 12 Jun 2026 09:53:19 +0100 Subject: [PATCH] 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) --- .../2026-06-12-pricing-waitlist-design.md | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-12-pricing-waitlist-design.md diff --git a/docs/superpowers/specs/2026-06-12-pricing-waitlist-design.md b/docs/superpowers/specs/2026-06-12-pricing-waitlist-design.md new file mode 100644 index 0000000..ea90a2d --- /dev/null +++ b/docs/superpowers/specs/2026-06-12-pricing-waitlist-design.md @@ -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 `` 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)