Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08afafe6bd | ||
|
|
685a84e159 | ||
|
|
4c0017cb91 | ||
|
|
347a71154b | ||
|
|
61adc133aa | ||
|
|
e7d19488fd |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -24,4 +24,5 @@ yarn-error.log
|
||||
/.zed
|
||||
/.tmp/
|
||||
/.worktrees/
|
||||
/.deploy-last-commit
|
||||
/ONSPD_Online_Latest_Centroids_*.csv
|
||||
|
||||
60
app/Filament/Resources/WaitlistSubscriberResource.php
Normal file
60
app/Filament/Resources/WaitlistSubscriberResource.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\NavigationGroup;
|
||||
use App\Filament\Resources\WaitlistSubscriberResource\Pages\ListWaitlistSubscribers;
|
||||
use App\Models\WaitlistSubscriber;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class WaitlistSubscriberResource extends Resource
|
||||
{
|
||||
protected static ?string $model = WaitlistSubscriber::class;
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Users;
|
||||
|
||||
protected static ?string $navigationLabel = 'Waitlist';
|
||||
|
||||
protected static ?int $navigationSort = 3;
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->searchable(),
|
||||
TextColumn::make('email')
|
||||
->searchable()
|
||||
->copyable(),
|
||||
TextColumn::make('source')
|
||||
->badge()
|
||||
->placeholder('—')
|
||||
->toggleable(),
|
||||
TextColumn::make('referrer')
|
||||
->limit(40)
|
||||
->placeholder('—')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('created_at')
|
||||
->label('Joined')
|
||||
->dateTime('d M Y H:i')
|
||||
->sortable(),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->recordActions([])
|
||||
->filters([]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListWaitlistSubscribers::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\WaitlistSubscriberResource\Pages;
|
||||
|
||||
use App\Filament\Resources\WaitlistSubscriberResource;
|
||||
use App\Models\WaitlistSubscriber;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class ListWaitlistSubscribers extends ListRecords
|
||||
{
|
||||
protected static string $resource = WaitlistSubscriberResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('export')
|
||||
->label('Export CSV')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->action(fn (): StreamedResponse => response()->streamDownload(function (): void {
|
||||
$handle = fopen('php://output', 'wb');
|
||||
|
||||
fputcsv($handle, ['name', 'email', 'source', 'referrer', 'joined_at']);
|
||||
|
||||
WaitlistSubscriber::query()
|
||||
->orderBy('created_at')
|
||||
->each(function (WaitlistSubscriber $subscriber) use ($handle): void {
|
||||
fputcsv($handle, [
|
||||
$subscriber->name,
|
||||
$subscriber->email,
|
||||
$subscriber->source,
|
||||
$subscriber->referrer,
|
||||
$subscriber->created_at?->toDateTimeString(),
|
||||
]);
|
||||
});
|
||||
|
||||
fclose($handle);
|
||||
}, 'waitlist.csv', ['Content-Type' => 'text/csv'])),
|
||||
];
|
||||
}
|
||||
}
|
||||
27
app/Http/Controllers/Api/WaitlistController.php
Normal file
27
app/Http/Controllers/Api/WaitlistController.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\StoreWaitlistRequest;
|
||||
use App\Services\WaitlistService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class WaitlistController extends Controller
|
||||
{
|
||||
public function __construct(private readonly WaitlistService $waitlist) {}
|
||||
|
||||
public function store(StoreWaitlistRequest $request): JsonResponse
|
||||
{
|
||||
$this->waitlist->subscribe(
|
||||
name: $request->string('name')->toString(),
|
||||
email: $request->string('email')->toString(),
|
||||
source: $request->filled('source') ? $request->string('source')->toString() : null,
|
||||
referrer: $request->filled('referrer') ? $request->string('referrer')->toString() : null,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => "You're on the list — we'll email you when alerts go live.",
|
||||
], 201);
|
||||
}
|
||||
}
|
||||
26
app/Http/Requests/Api/StoreWaitlistRequest.php
Normal file
26
app/Http/Requests/Api/StoreWaitlistRequest.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreWaitlistRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'source' => ['nullable', 'string', 'max:64'],
|
||||
'referrer' => ['nullable', 'string', 'max:2048'],
|
||||
];
|
||||
}
|
||||
}
|
||||
15
app/Models/WaitlistSubscriber.php
Normal file
15
app/Models/WaitlistSubscriber.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\WaitlistSubscriberFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['name', 'email', 'source', 'referrer'])]
|
||||
class WaitlistSubscriber extends Model
|
||||
{
|
||||
/** @use HasFactory<WaitlistSubscriberFactory> */
|
||||
use HasFactory;
|
||||
}
|
||||
29
app/Services/WaitlistService.php
Normal file
29
app/Services/WaitlistService.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\WaitlistSubscriber;
|
||||
|
||||
final class WaitlistService
|
||||
{
|
||||
/**
|
||||
* Add someone to the feature-launch waitlist.
|
||||
*
|
||||
* Idempotent: re-subscribing an existing email is a no-op that returns the
|
||||
* original subscriber unchanged — original meta (source, referrer) is kept,
|
||||
* never a duplicate row or an error.
|
||||
*/
|
||||
public function subscribe(
|
||||
string $name,
|
||||
string $email,
|
||||
?string $source = null,
|
||||
?string $referrer = null,
|
||||
): WaitlistSubscriber {
|
||||
$email = strtolower(trim($email));
|
||||
|
||||
return WaitlistSubscriber::firstOrCreate(
|
||||
['email' => $email],
|
||||
['name' => $name, 'source' => $source, 'referrer' => $referrer],
|
||||
);
|
||||
}
|
||||
}
|
||||
25
database/factories/WaitlistSubscriberFactory.php
Normal file
25
database/factories/WaitlistSubscriberFactory.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\WaitlistSubscriber;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<WaitlistSubscriber>
|
||||
*/
|
||||
class WaitlistSubscriberFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->name(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('waitlist_subscribers', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('email')->unique();
|
||||
$table->string('source', 64)->nullable()->comment('Where they joined from, e.g. pricing');
|
||||
$table->text('referrer')->nullable()->comment('document.referrer at signup');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('waitlist_subscribers');
|
||||
}
|
||||
};
|
||||
45
deploy.sh
45
deploy.sh
@@ -6,10 +6,11 @@
|
||||
# ./deploy.sh v0.1.3 # deploy a specific tag
|
||||
#
|
||||
# It puts the site in maintenance mode, updates the code, runs migrations and
|
||||
# cache rebuilds, restarts the queue, then brings the site back up. composer
|
||||
# install and npm build only run when their inputs actually changed, so most
|
||||
# deploys skip them. If any step fails the script aborts and the site stays in
|
||||
# maintenance mode on purpose — fix the issue, then re-run.
|
||||
# cache rebuilds, restarts the queue, then brings the site back up. The SPA is
|
||||
# always rebuilt; the heavier dependency installs (composer install, npm ci)
|
||||
# only run when their lockfiles changed or the installed dir is missing. If any
|
||||
# step fails the script aborts and the site stays in maintenance mode on
|
||||
# purpose — fix the issue, then re-run.
|
||||
#
|
||||
# See docs/ops/deployment.md for first-time setup and troubleshooting.
|
||||
set -euo pipefail
|
||||
@@ -19,8 +20,13 @@ cd "$(dirname "$0")"
|
||||
REF="${1:-main}"
|
||||
echo "==> Deploying ref: ${REF}"
|
||||
|
||||
# Remember the current commit so we can see what changed after checkout.
|
||||
BEFORE="$(git rev-parse HEAD)"
|
||||
# Baseline for change detection: the last *fully* deployed commit, recorded at
|
||||
# the end of a successful run (falls back to current HEAD the first time). Using
|
||||
# a persisted marker instead of the pre-checkout HEAD keeps the diff honest even
|
||||
# when the same ref is re-deployed or a previous run aborted partway — both of
|
||||
# which otherwise make BEFORE == AFTER and silently skip build/install steps.
|
||||
MARKER=".deploy-last-commit"
|
||||
BEFORE="$(cat "${MARKER}" 2>/dev/null || git rev-parse HEAD)"
|
||||
|
||||
echo "==> Maintenance mode on"
|
||||
php artisan down --retry=15
|
||||
@@ -36,23 +42,29 @@ fi
|
||||
AFTER="$(git rev-parse HEAD)"
|
||||
CHANGED="$(git diff --name-only "${BEFORE}" "${AFTER}" || true)"
|
||||
|
||||
# Reinstall PHP deps only if the lockfile moved.
|
||||
if grep -q '^composer\.lock$' <<<"${CHANGED}"; then
|
||||
echo "==> composer.lock changed — installing PHP deps"
|
||||
# Reinstall PHP deps only when the lockfile moved or vendor is missing.
|
||||
if grep -q '^composer\.lock$' <<<"${CHANGED}" || [ ! -d vendor ]; then
|
||||
echo "==> Installing PHP deps (composer.lock changed or vendor missing)"
|
||||
composer install --no-dev --optimize-autoloader
|
||||
else
|
||||
echo "==> composer.lock unchanged — skipping composer install"
|
||||
fi
|
||||
|
||||
# Rebuild the Vue SPA only if frontend sources or the JS lockfile moved.
|
||||
if grep -qE '^(package(-lock)?\.json|vite\.config\.|resources/(js|css)/)' <<<"${CHANGED}"; then
|
||||
echo "==> Frontend changed — rebuilding SPA"
|
||||
# Install JS deps only when the lockfile moved or node_modules is missing.
|
||||
if grep -qE '^package(-lock)?\.json$' <<<"${CHANGED}" || [ ! -d node_modules ]; then
|
||||
echo "==> Installing JS deps (lockfile changed or node_modules missing)"
|
||||
npm ci
|
||||
npm run build
|
||||
else
|
||||
echo "==> No frontend changes — skipping npm build"
|
||||
echo "==> JS deps unchanged — skipping npm ci"
|
||||
fi
|
||||
|
||||
# Always rebuild the SPA. The build is cheap (a few seconds), and gating it on a
|
||||
# git diff silently shipped stale assets whenever BEFORE == AFTER — re-deploying
|
||||
# the same ref, or re-running after an aborted deploy. Correctness over the few
|
||||
# seconds saved.
|
||||
echo "==> Building SPA"
|
||||
npm run build
|
||||
|
||||
echo "==> Running migrations"
|
||||
php artisan migrate --force
|
||||
|
||||
@@ -68,5 +80,10 @@ php artisan queue:restart
|
||||
echo "==> Maintenance mode off"
|
||||
php artisan up
|
||||
|
||||
# Record the just-deployed commit as the baseline for the next run. Only reached
|
||||
# on full success — `set -e` aborts earlier on any failure, so a broken deploy
|
||||
# never advances the baseline and the next run re-evaluates from the last good one.
|
||||
git rev-parse HEAD > "${MARKER}"
|
||||
|
||||
echo "==> Deploy complete"
|
||||
php artisan about
|
||||
|
||||
112
docs/superpowers/specs/2026-06-12-inline-fuel-pills-design.md
Normal file
112
docs/superpowers/specs/2026-06-12-inline-fuel-pills-design.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Inline Petrol/Diesel Quick Pills — Design
|
||||
|
||||
Date: 2026-06-12
|
||||
Status: Approved (brainstorm)
|
||||
Branch: `feature/inline-fuel-pills`
|
||||
|
||||
## Problem
|
||||
|
||||
After a station search, the only way to change fuel type is to open the
|
||||
"Filters" popover, which buries the two everyday fuels — Petrol (`e10`) and
|
||||
Diesel (`b7_standard`) — behind a button. Almost every user wants one of those
|
||||
two. We want them surfaced as one-tap pills directly in the results filter bar,
|
||||
while keeping the four long-tail fuels (Premium `e5`, Prem Diesel `b7_premium`,
|
||||
`b10`, `hvo`) reachable.
|
||||
|
||||
## Scope
|
||||
|
||||
- **Single file:** `resources/js/components/PostSearchFilters.vue`.
|
||||
- **No backend/API change** — server-side fuel filtering already exists via the
|
||||
`fuel_type` query param on `/api/stations`.
|
||||
- **`Home.vue` unchanged** — the pills reuse the existing `search` event
|
||||
contract the popover already emits.
|
||||
- **`HeroSearch.vue` unchanged** — placement is results-bar only (the hero keeps
|
||||
forwarding whatever `fuelType` is in the URL).
|
||||
|
||||
## Layout
|
||||
|
||||
The top row of the filter bar changes from a single right-aligned "Filters"
|
||||
button to:
|
||||
|
||||
```
|
||||
[ ⛽ Petrol ] [ Diesel ] ……………… [ ≡ Filters ▾ ]
|
||||
```
|
||||
|
||||
- Two fuel pills left-aligned; the Filters pill pushed right via `ml-auto`.
|
||||
- All three reuse the existing `pill !rounded-xl` class + `is-active` state, so
|
||||
they are visually identical (the user's explicit "same row, same style"
|
||||
requirement).
|
||||
- ⛽ (`lucide:fuel`) icon on Petrol only; Diesel is label-only (matches the
|
||||
user's mock).
|
||||
- The row stays `flex flex-wrap items-center` so it wraps gracefully on narrow
|
||||
screens.
|
||||
|
||||
## Fuel model
|
||||
|
||||
- **Primary fuels (pills):** `e10` → "Petrol", `b7_standard` → "Diesel".
|
||||
- **Long-tail fuels (popover "More fuels"):** every other entry in `FUEL_TYPES`
|
||||
— `e5`, `b7_premium`, `b10`, `hvo`. Derived by filtering `FUEL_TYPES` to
|
||||
exclude the two primary values, so the list stays driven by the shared
|
||||
`window.FUEL_TYPES` source rather than a second hard-coded list.
|
||||
|
||||
The two primary fuel values (`e10`, `b7_standard`) are defined once as a local
|
||||
constant in the component, with the short pill labels ("Petrol" / "Diesel").
|
||||
The canonical enum labels ("Petrol (E10)", "Diesel (B7)") are intentionally not
|
||||
used on the pills — the short forms are a UX shortening.
|
||||
|
||||
## Behaviour
|
||||
|
||||
- Tapping **Petrol** sets `fuelType = 'e10'`; tapping **Diesel** sets
|
||||
`fuelType = 'b7_standard'`. Either triggers an immediate re-search via the
|
||||
existing `watch([fuelType, radius, sort])` — no new emit wiring.
|
||||
- The popover "Fuel" section is relabeled **"More fuels"** and renders only the
|
||||
long-tail fuels. Selecting one re-searches and drops both pill highlights.
|
||||
|
||||
## Highlighting — the selected fuel is always visible
|
||||
|
||||
- `e10` active → **Petrol** pill `is-active`.
|
||||
- `b7_standard` active → **Diesel** pill `is-active`.
|
||||
- A long-tail fuel active → neither pill highlighted, **but** the selected fuel
|
||||
is highlighted inside the "More fuels" grid, and the Filters pill shows its
|
||||
active state + badge. Selection is never ambiguous in any state.
|
||||
|
||||
## Filters badge / active count
|
||||
|
||||
- Fuel contributes to the Filters badge (`activeCount`) **only when a long-tail
|
||||
fuel is active** — the pills already display Petrol/Diesel selection, so those
|
||||
need no badge.
|
||||
- Radius, sort, and brand contribute to `activeCount` as before.
|
||||
- `hasActive` (which controls the "Clear all" affordance) likewise treats fuel
|
||||
as active only when a long-tail fuel is selected.
|
||||
|
||||
## Clear all
|
||||
|
||||
- Resets radius / sort / brand to their defaults.
|
||||
- If a long-tail fuel is selected, snaps fuel back to Petrol (`e10`) so the badge
|
||||
clears.
|
||||
- A Petrol/Diesel pill choice is left alone (the default is Petrol anyway).
|
||||
|
||||
## Accessibility
|
||||
|
||||
- The two pills form a `role="radiogroup"` labelled "Fuel" with `aria-checked`
|
||||
on each, consistent with the existing radio pattern already used in this
|
||||
component for radius/sort/brand.
|
||||
- The "More fuels" grid remains its own radiogroup.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- No new filter criteria (supermarket-only, open-now, amenities, price ceiling).
|
||||
- No hero fuel selector.
|
||||
- No server-side brand filtering (brand stays a client-side filter in `Home.vue`).
|
||||
- No change to the radius or sort options.
|
||||
|
||||
## Testing
|
||||
|
||||
- `npm run build` must succeed (no Vite/compile regressions).
|
||||
- Behavioural verification of: Petrol/Diesel toggles re-search and highlight;
|
||||
selecting a long-tail fuel via the popover shows the badge with neither pill
|
||||
highlighted; "Clear all" resets radius/sort/brand and snaps a long-tail fuel
|
||||
back to Petrol.
|
||||
- The exact mechanism (Pest 4 browser test vs. a JS unit harness vs. manual) is
|
||||
decided in the implementation plan after confirming what frontend test
|
||||
infrastructure exists in the repo.
|
||||
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)
|
||||
@@ -1,12 +1,27 @@
|
||||
<template>
|
||||
<div ref="popoverRoot">
|
||||
<div class="flex flex-wrap items-center justify-end gap-2 md:gap-2.5 py-2 border-b border-zinc-200">
|
||||
<div class="flex flex-wrap items-center gap-2 md:gap-2.5 py-2 border-b border-zinc-200">
|
||||
<div class="grid grid-cols-2 gap-2 md:gap-2.5" role="radiogroup" aria-label="Fuel">
|
||||
<button
|
||||
v-for="fuel in PRIMARY_FUELS"
|
||||
:key="fuel.value"
|
||||
:aria-checked="fuelType === fuel.value"
|
||||
:class="{ 'is-active': fuelType === fuel.value }"
|
||||
class="pill !rounded-xl w-full justify-center"
|
||||
role="radio"
|
||||
type="button"
|
||||
@click="fuelType = fuel.value"
|
||||
>
|
||||
<span class="text-sm font-medium">{{ fuel.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
:aria-expanded="open"
|
||||
:class="{ 'is-active': activeCount > 0 || open }"
|
||||
aria-controls="post-search-filters-panel"
|
||||
aria-haspopup="dialog"
|
||||
class="pill !rounded-xl"
|
||||
class="pill !rounded-xl ml-auto"
|
||||
type="button"
|
||||
@click="open = !open"
|
||||
>
|
||||
@@ -35,11 +50,11 @@
|
||||
aria-label="Filters"
|
||||
class="mt-3 rounded-2xl border border-zinc-200 bg-white shadow-sm p-4 space-y-4 max-h-[70vh] overflow-y-auto"
|
||||
>
|
||||
<div>
|
||||
<span class="block text-[10px] font-mono uppercase tracking-widest text-zinc-500 mb-2">Fuel</span>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2" role="radiogroup" aria-label="Fuel type">
|
||||
<div v-if="SECONDARY_FUELS.length">
|
||||
<span class="block text-[10px] font-mono uppercase tracking-widest text-zinc-500 mb-2">More fuels</span>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2" role="radiogroup" aria-label="More fuel types">
|
||||
<button
|
||||
v-for="fuel in FUEL_TYPES"
|
||||
v-for="fuel in SECONDARY_FUELS"
|
||||
:key="fuel.value"
|
||||
:aria-checked="fuelType === fuel.value"
|
||||
:class="{ 'is-active': fuelType === fuel.value }"
|
||||
@@ -152,6 +167,18 @@ const DEFAULTS = Object.freeze({
|
||||
sort: 'reliable',
|
||||
})
|
||||
|
||||
// The two everyday fuels are surfaced as equal-width quick pills outside the
|
||||
// popover; everything else in the shared FUEL_TYPES source stays under
|
||||
// "More fuels".
|
||||
const PRIMARY_FUEL_VALUES = Object.freeze(['e10', 'b7_standard'])
|
||||
|
||||
const PRIMARY_FUELS = Object.freeze([
|
||||
{ value: 'e10', label: 'Petrol' },
|
||||
{ value: 'b7_standard', label: 'Diesel' },
|
||||
])
|
||||
|
||||
const SECONDARY_FUELS = FUEL_TYPES.filter((fuel) => !PRIMARY_FUEL_VALUES.includes(fuel.value))
|
||||
|
||||
const sortOptions = [
|
||||
{ label: 'Reliable', value: 'reliable', icon: 'lucide:shield-check' },
|
||||
{ label: 'Price', value: 'price', icon: 'lucide:pound-sterling' },
|
||||
@@ -195,8 +222,12 @@ watch([fuelType, radius, sort], () => {
|
||||
if (postcode.value.trim() || coords.value) emitSearch()
|
||||
})
|
||||
|
||||
// Petrol/Diesel are shown as pills, so only a long-tail fuel counts as a
|
||||
// "hidden" filter on the Filters badge.
|
||||
const isSecondaryFuel = computed(() => !PRIMARY_FUEL_VALUES.includes(fuelType.value))
|
||||
|
||||
const hasActive = computed(() => (
|
||||
fuelType.value !== DEFAULTS.fuelType
|
||||
isSecondaryFuel.value
|
||||
|| radius.value !== DEFAULTS.radius
|
||||
|| sort.value !== DEFAULTS.sort
|
||||
|| Boolean(props.brandFilter)
|
||||
@@ -204,7 +235,7 @@ const hasActive = computed(() => (
|
||||
|
||||
const activeCount = computed(() => {
|
||||
let count = 0
|
||||
if (fuelType.value !== DEFAULTS.fuelType) count++
|
||||
if (isSecondaryFuel.value) count++
|
||||
if (radius.value !== DEFAULTS.radius) count++
|
||||
if (sort.value !== DEFAULTS.sort) count++
|
||||
if (props.brandFilter) count++
|
||||
@@ -212,7 +243,8 @@ const activeCount = computed(() => {
|
||||
})
|
||||
|
||||
function resetFilters() {
|
||||
fuelType.value = DEFAULTS.fuelType
|
||||
// Leave a Petrol/Diesel pill choice alone; only snap a long-tail fuel back.
|
||||
if (isSecondaryFuel.value) fuelType.value = DEFAULTS.fuelType
|
||||
radius.value = DEFAULTS.radius
|
||||
sort.value = DEFAULTS.sort
|
||||
if (props.brandFilter) emit('update:brandFilter', '')
|
||||
|
||||
@@ -80,13 +80,16 @@
|
||||
<button v-else type="button" disabled class="w-full py-3 px-4 bg-zinc-100 rounded-xl text-center font-bold text-zinc-400 cursor-not-allowed">Coming soon</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WaitlistForm v-if="hasComingSoon" source="pricing" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useAuth } from '../composables/useAuth.js'
|
||||
import WaitlistForm from './WaitlistForm.vue'
|
||||
|
||||
const { isAuthenticated, userTier } = useAuth()
|
||||
|
||||
@@ -102,6 +105,8 @@ const PRICE_SUFFIX = { monthly: '/mo', annual: '/yr' }
|
||||
// disabled until then. Remove a tier from this list to make its button live.
|
||||
const COMING_SOON = ['basic', 'plus']
|
||||
|
||||
const hasComingSoon = computed(() => COMING_SOON.length > 0)
|
||||
|
||||
function isComingSoon(tier) {
|
||||
return COMING_SOON.includes(tier)
|
||||
}
|
||||
|
||||
67
resources/js/components/WaitlistForm.vue
Normal file
67
resources/js/components/WaitlistForm.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="max-w-2xl mx-auto mt-12">
|
||||
<div class="bg-white border border-zinc-300 rounded-3xl p-8 text-center">
|
||||
<template v-if="success">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<span class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-accent/10 text-accent">
|
||||
<iconify-icon class="text-2xl" icon="lucide:check"></iconify-icon>
|
||||
</span>
|
||||
<h3 class="text-xl font-bold font-display text-zinc-800">You're on the list</h3>
|
||||
<p class="text-zinc-500 text-sm">We'll email you the moment price alerts go live.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<h3 class="text-xl font-bold font-display text-zinc-800 mb-1">Want in when alerts launch?</h3>
|
||||
<p class="text-zinc-500 text-sm mb-6">Leave your details and we'll let you know the day alerts go live.</p>
|
||||
|
||||
<form class="flex flex-col sm:flex-row gap-3" @submit.prevent="onSubmit">
|
||||
<input
|
||||
v-model.trim="name"
|
||||
type="text"
|
||||
autocomplete="name"
|
||||
placeholder="Your name"
|
||||
required
|
||||
:disabled="loading"
|
||||
class="flex-1 px-4 py-3 border border-zinc-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-accent disabled:opacity-60"
|
||||
>
|
||||
<input
|
||||
v-model.trim="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
:disabled="loading"
|
||||
class="flex-1 px-4 py-3 border border-zinc-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-accent disabled:opacity-60"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="px-6 py-3 bg-accent text-white rounded-xl font-bold text-sm shadow-lg hover:bg-primary-dark transition-all disabled:opacity-60 disabled:cursor-not-allowed whitespace-nowrap"
|
||||
>
|
||||
{{ loading ? 'Joining…' : 'Notify me' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p v-if="error" class="text-red-600 text-sm mt-3 text-left">{{ error }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useWaitlist } from '../composables/useWaitlist.js'
|
||||
|
||||
const props = defineProps({
|
||||
source: { type: String, default: 'pricing' },
|
||||
})
|
||||
|
||||
const name = ref('')
|
||||
const email = ref('')
|
||||
const { loading, success, error, submit } = useWaitlist()
|
||||
|
||||
function onSubmit() {
|
||||
submit(name.value, email.value, props.source)
|
||||
}
|
||||
</script>
|
||||
31
resources/js/composables/useWaitlist.js
Normal file
31
resources/js/composables/useWaitlist.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ref } from 'vue'
|
||||
import api from '../axios.js'
|
||||
|
||||
export function useWaitlist() {
|
||||
const loading = ref(false)
|
||||
const success = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
async function submit(name, email, source = null) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await api.post('/waitlist', {
|
||||
name,
|
||||
email,
|
||||
source,
|
||||
referrer: document.referrer || null,
|
||||
})
|
||||
success.value = true
|
||||
} catch (e) {
|
||||
const fieldErrors = e.response?.data?.errors
|
||||
error.value = fieldErrors
|
||||
? Object.values(fieldErrors)[0][0]
|
||||
: (e.response?.data?.message || 'Something went wrong — please try again.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, success, error, submit }
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use App\Http\Controllers\Api\AuthController;
|
||||
use App\Http\Controllers\Api\StationController;
|
||||
use App\Http\Controllers\Api\StatsController;
|
||||
use App\Http\Controllers\Api\UserController;
|
||||
use App\Http\Controllers\Api\WaitlistController;
|
||||
use App\Http\Middleware\VerifyApiKey;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -22,6 +23,9 @@ Route::get('/fuel-types', function () {
|
||||
|
||||
Route::get('/stats/live', [StatsController::class, 'live']);
|
||||
|
||||
// Feature-launch waitlist signup (public, separate from registered users)
|
||||
Route::post('/waitlist', [WaitlistController::class, 'store'])->middleware('throttle:10,1');
|
||||
|
||||
// Protected endpoints (API key required)
|
||||
Route::middleware(['throttle:60,1', VerifyApiKey::class])->group(function (): void {
|
||||
Route::get('/stations', [StationController::class, 'index']);
|
||||
|
||||
31
tests/Feature/Admin/WaitlistSubscriberResourceTest.php
Normal file
31
tests/Feature/Admin/WaitlistSubscriberResourceTest.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\WaitlistSubscriberResource\Pages\ListWaitlistSubscribers;
|
||||
use App\Models\User;
|
||||
use App\Models\WaitlistSubscriber;
|
||||
use Livewire\Livewire;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->actingAs(User::factory()->admin()->create());
|
||||
});
|
||||
|
||||
it('lists waitlist subscribers', function () {
|
||||
$subscribers = WaitlistSubscriber::factory()->count(3)->create();
|
||||
|
||||
Livewire::test(ListWaitlistSubscribers::class)
|
||||
->assertOk()
|
||||
->assertCanSeeTableRecords($subscribers);
|
||||
});
|
||||
|
||||
it('exposes a CSV export header action', function () {
|
||||
Livewire::test(ListWaitlistSubscribers::class)
|
||||
->assertActionExists('export');
|
||||
});
|
||||
|
||||
it('exports a CSV download when the action runs against real subscribers', function () {
|
||||
WaitlistSubscriber::factory()->count(2)->create();
|
||||
|
||||
Livewire::test(ListWaitlistSubscribers::class)
|
||||
->callAction('export')
|
||||
->assertFileDownloaded('waitlist.csv');
|
||||
});
|
||||
64
tests/Feature/Api/WaitlistTest.php
Normal file
64
tests/Feature/Api/WaitlistTest.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
use App\Models\WaitlistSubscriber;
|
||||
|
||||
it('stores a subscriber and returns 201', function () {
|
||||
$response = $this->postJson('/api/waitlist', [
|
||||
'name' => 'Ada Lovelace',
|
||||
'email' => 'ada@example.com',
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonStructure(['message']);
|
||||
|
||||
$this->assertDatabaseHas('waitlist_subscribers', [
|
||||
'name' => 'Ada Lovelace',
|
||||
'email' => 'ada@example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats a duplicate email as success without creating a second row', function () {
|
||||
WaitlistSubscriber::factory()->create(['email' => 'ada@example.com']);
|
||||
|
||||
$this->postJson('/api/waitlist', [
|
||||
'name' => 'Someone Else',
|
||||
'email' => 'ada@example.com',
|
||||
])->assertCreated();
|
||||
|
||||
expect(WaitlistSubscriber::where('email', 'ada@example.com')->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('stores the source and referrer sent with the request', function () {
|
||||
$this->postJson('/api/waitlist', [
|
||||
'name' => 'Ada Lovelace',
|
||||
'email' => 'ada@example.com',
|
||||
'source' => 'pricing',
|
||||
'referrer' => 'https://duckduckgo.com/',
|
||||
])->assertCreated();
|
||||
|
||||
$this->assertDatabaseHas('waitlist_subscribers', [
|
||||
'email' => 'ada@example.com',
|
||||
'source' => 'pricing',
|
||||
'referrer' => 'https://duckduckgo.com/',
|
||||
]);
|
||||
});
|
||||
|
||||
it('requires a name', function () {
|
||||
$this->postJson('/api/waitlist', [
|
||||
'email' => 'ada@example.com',
|
||||
])->assertStatus(422)->assertJsonValidationErrors('name');
|
||||
});
|
||||
|
||||
it('rejects an invalid email', function () {
|
||||
$this->postJson('/api/waitlist', [
|
||||
'name' => 'Ada Lovelace',
|
||||
'email' => 'not-an-email',
|
||||
])->assertStatus(422)->assertJsonValidationErrors('email');
|
||||
});
|
||||
|
||||
it('throttles the endpoint', function () {
|
||||
$route = collect(app('router')->getRoutes())
|
||||
->first(fn ($route) => $route->uri() === 'api/waitlist' && in_array('POST', $route->methods(), true));
|
||||
|
||||
expect($route?->gatherMiddleware())->toContain('throttle:10,1');
|
||||
});
|
||||
47
tests/Unit/Services/WaitlistServiceTest.php
Normal file
47
tests/Unit/Services/WaitlistServiceTest.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use App\Models\WaitlistSubscriber;
|
||||
use App\Services\WaitlistService;
|
||||
|
||||
it('normalises the email to trimmed lowercase before storing', function () {
|
||||
$subscriber = app(WaitlistService::class)->subscribe('Ada Lovelace', ' Ada@Example.COM ');
|
||||
|
||||
expect($subscriber->email)->toBe('ada@example.com')
|
||||
->and($subscriber->name)->toBe('Ada Lovelace');
|
||||
|
||||
$this->assertDatabaseHas('waitlist_subscribers', ['email' => 'ada@example.com']);
|
||||
});
|
||||
|
||||
it('is idempotent — re-subscribing the same email keeps a single row and returns the original', function () {
|
||||
$service = app(WaitlistService::class);
|
||||
|
||||
$first = $service->subscribe('Ada', 'ada@example.com');
|
||||
$second = $service->subscribe('Different Name', 'ADA@example.com');
|
||||
|
||||
expect($second->id)->toBe($first->id)
|
||||
->and($second->name)->toBe('Ada');
|
||||
|
||||
expect(WaitlistSubscriber::where('email', 'ada@example.com')->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('stores the source and referrer on a new subscriber', function () {
|
||||
$subscriber = app(WaitlistService::class)->subscribe(
|
||||
'Ada',
|
||||
'ada@example.com',
|
||||
source: 'pricing',
|
||||
referrer: 'https://duckduckgo.com/',
|
||||
);
|
||||
|
||||
expect($subscriber->source)->toBe('pricing')
|
||||
->and($subscriber->referrer)->toBe('https://duckduckgo.com/');
|
||||
});
|
||||
|
||||
it('preserves the original meta when an existing email re-subscribes', function () {
|
||||
$service = app(WaitlistService::class);
|
||||
|
||||
$service->subscribe('Ada', 'ada@example.com', source: 'pricing', referrer: 'https://a.test');
|
||||
$second = $service->subscribe('Ada', 'ada@example.com', source: 'home', referrer: 'https://b.test');
|
||||
|
||||
expect($second->source)->toBe('pricing')
|
||||
->and($second->referrer)->toBe('https://a.test');
|
||||
});
|
||||
Reference in New Issue
Block a user