Files
fuel-alert/docs/superpowers/specs/2026-06-12-pricing-waitlist-design.md
Ovidiu U e7d19488fd 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>
2026-06-12 09:53:19 +01:00

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.
  • final class, constructor injection only (per code-style.md).
  • Returns the WaitlistSubscriber model (typed return, per architecture.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, calls WaitlistService::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 /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)