6 Commits

Author SHA1 Message Date
Ovidiu U
61adc133aa Add pricing-page waitlist (name + email signup)
Replaces the disabled "Coming soon" buttons on the pricing page with a
waitlist band so visitors can be notified when alerts launch — separate
from registered users.

- waitlist_subscribers table (name, email unique, source, referrer)
- WaitlistService::subscribe — normalises email, idempotent
- Public POST /api/waitlist (throttle:10,1), thin controller + form request
- Read-only Filament resource with streamed CSV export
- Vue: useWaitlist composable + WaitlistForm, rendered below the grid
  while any tier is still "coming soon"; sends source + document.referrer

Announcement send mechanism deferred to a later task.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:27:25 +01:00
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
Ovidiu U
fdcf253ca7 Skip placeholder-coordinate postcodes (lat >= 90) in ONSPD import
ONS marks non-geographic postcodes (no grid reference) with a placeholder
latitude of 99.999999. The "Latest Centroids" export shipped ~12,789 such
rows, which were imported as real postcodes pointing at lat 99.99 and would
poison nearest-station distance maths. Drop them at ingest, with a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 09:26:56 +01:00
Ovidiu U
cf373a85f9 Add deploy.sh for repeatable VPS deploys
One-command deploy: maintenance mode, checkout ref (branch or tag), conditional
composer install / npm build (only when their inputs changed), migrate, cache
rebuilds, queue restart, back up. Aborts and stays in maintenance mode on
failure. Mirrors docs/ops/deployment.md §8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 09:13:35 +01:00
Ovidiu U
ea22387c9d Reverse-geocode a general area label for logged searches
Each search now stores an `area_label` (district/town) reverse-geocoded from its
coarsened ~1km lat/lng bucket via postcodes.io, surfaced in the Filament Searches
admin as a sortable/searchable column plus an area filter. Geocoding is cached 30
days per bucket, queries a 2km radius so low-density buckets still match the
default 100m miss, and fails gracefully to null. Adds `searches:backfill-areas`
(scheduled hourly) to label existing rows and retry stragglers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 09:05:03 +01:00
Ovidiu U
040b2f627e Fix ICO no. 2026-06-12 08:37:37 +01:00
31 changed files with 1131 additions and 6 deletions

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands;
use App\Models\Search;
use App\Services\PostcodeService;
use Illuminate\Console\Command;
class BackfillSearchAreas extends Command
{
protected $signature = 'searches:backfill-areas {--limit=0 : Max distinct areas to resolve this run (0 = no limit)}';
protected $description = 'Reverse-geocode searches that have no area_label yet';
public function handle(PostcodeService $postcodes): int
{
$limit = (int) $this->option('limit');
$buckets = Search::query()
->whereNull('area_label')
->select('lat_bucket', 'lng_bucket')
->distinct()
->when($limit > 0, fn ($query) => $query->limit($limit))
->get();
if ($buckets->isEmpty()) {
$this->info('No searches need an area label.');
return self::SUCCESS;
}
$resolved = 0;
$rowsUpdated = 0;
foreach ($buckets as $bucket) {
$label = $postcodes->reverseResolve((float) $bucket->lat_bucket, (float) $bucket->lng_bucket);
if ($label === null) {
continue;
}
$resolved++;
$rowsUpdated += Search::query()
->whereNull('area_label')
->where('lat_bucket', $bucket->lat_bucket)
->where('lng_bucket', $bucket->lng_bucket)
->update(['area_label' => $label]);
}
$this->info("Resolved {$resolved} of {$buckets->count()} areas, updated {$rowsUpdated} search rows.");
return self::SUCCESS;
}
}

View File

@@ -109,6 +109,13 @@ final class ImportPostcodes extends Command
continue;
}
// ONS marks non-geographic postcodes (no grid reference) with a
// placeholder latitude of 99.999999 — drop them so they don't
// poison nearest-station distance maths with a bogus location.
if (abs((float) $lat) >= 90) {
continue;
}
$pcd = strtoupper(preg_replace('/\s+/', '', (string) $row[$columns[$pcdColumn]]));
if ($pcd === '' || strlen($pcd) < 5) {

View File

@@ -28,6 +28,11 @@ class SearchResource extends Resource
->label('Searched At')
->dateTime('d M Y H:i')
->sortable(),
TextColumn::make('area_label')
->label('Area')
->placeholder('Unknown')
->searchable()
->sortable(),
TextColumn::make('fuel_type')
->label('Fuel Type')
->badge(),
@@ -67,6 +72,15 @@ class SearchResource extends Resource
'B10' => 'B10',
'HVO' => 'HVO',
]),
SelectFilter::make('area_label')
->label('Area')
->searchable()
->options(fn (): array => Search::query()
->whereNotNull('area_label')
->distinct()
->orderBy('area_label')
->pluck('area_label', 'area_label')
->all()),
])
->recordActions([])
->toolbarActions([]);

View 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('/'),
];
}
}

View File

@@ -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'])),
];
}
}

View 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);
}
}

View 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'],
];
}
}

View File

@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['lat_bucket', 'lng_bucket', 'fuel_type', 'results_count', 'lowest_pence', 'highest_pence', 'avg_pence', 'searched_at', 'ip_hash'])]
#[Fillable(['lat_bucket', 'lng_bucket', 'area_label', 'fuel_type', 'results_count', 'lowest_pence', 'highest_pence', 'avg_pence', 'searched_at', 'ip_hash'])]
class Search extends Model
{
/** @use HasFactory<SearchFactory> */

View 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;
}

View File

@@ -52,6 +52,77 @@ class PostcodeService
return $result;
}
/**
* Reverse-geocode coordinates to a general UK area label (e.g. "Peterborough").
*
* Coordinates are bucketed to ~1km (2dp) before lookup so the cache is shared
* across nearby searches and nothing more precise than the stored bucket is
* ever queried. Returns null if the area cannot be determined.
*/
public function reverseResolve(float $lat, float $lng): ?string
{
$latBucket = round($lat, 2);
$lngBucket = round($lng, 2);
$cacheKey = "revgeo:{$latBucket},{$lngBucket}";
$cached = Cache::get($cacheKey);
if (is_string($cached)) {
return $cached;
}
$label = $this->lookupArea($latBucket, $lngBucket);
if ($label !== null) {
Cache::put($cacheKey, $label, self::CACHE_TTL);
}
return $label;
}
private function lookupArea(float $lat, float $lng): ?string
{
$url = self::BASE_URL.'/postcodes';
// radius=2000 (postcodes.io max): we query the ~1km bucket centroid, which
// can sit up to ~780m from any real point in the bucket. The default 100m
// radius misses in low-density areas, so widen it to guarantee a hit.
$logUrl = $url.'?lon='.$lng.'&lat='.$lat.'&radius=2000&limit=1';
try {
$response = $this->apiLogger->send('postcodes_io', 'GET', $logUrl, fn () => Http::timeout(5)
->get($url, ['lon' => $lng, 'lat' => $lat, 'radius' => 2000, 'limit' => 1]));
if (! $response->successful()) {
return null;
}
$results = $response->json('result');
if (! is_array($results) || ! isset($results[0]) || ! is_array($results[0])) {
return null;
}
// Prefer the most human "town/district" field, falling back to broader areas.
foreach (['admin_district', 'parish', 'admin_ward', 'region', 'country'] as $field) {
$value = $results[0][$field] ?? null;
if (is_string($value) && $value !== '') {
return $value;
}
}
return null;
} catch (Throwable $e) {
Log::error('PostcodeService: reverse geocode failed', [
'lat' => $lat,
'lng' => $lng,
'error' => $e->getMessage(),
]);
return null;
}
}
private function normalisePostcode(string $value): string
{
return strtoupper(preg_replace('/\s+/', '', $value));

View File

@@ -11,6 +11,7 @@ use App\Services\Forecasting\LocalSnapshotService;
use App\Services\Forecasting\WeeklyForecastService;
use App\Services\HaversineQuery;
use App\Services\PlanFeatures;
use App\Services\PostcodeService;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
@@ -20,6 +21,7 @@ final class StationSearchService
public function __construct(
private readonly WeeklyForecastService $weeklyForecast,
private readonly LocalSnapshotService $localSnapshot,
private readonly PostcodeService $postcodeService,
) {}
public function search(SearchCriteria $criteria, ?User $user, ?string $ipHash): SearchResult
@@ -118,6 +120,7 @@ final class StationSearchService
Search::create([
'lat_bucket' => round($criteria->lat, 2),
'lng_bucket' => round($criteria->lng, 2),
'area_label' => $this->postcodeService->reverseResolve($criteria->lat, $criteria->lng),
'fuel_type' => $criteria->fuelType->value,
'results_count' => $resultsCount,
'lowest_pence' => $prices->min(),

View 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],
);
}
}

View 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(),
];
}
}

View File

@@ -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::table('searches', function (Blueprint $table): void {
$table->string('area_label', 100)
->nullable()
->after('lng_bucket')
->comment('General UK area (e.g. district/town) reverse-geocoded from the lat/lng bucket');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('searches', function (Blueprint $table): void {
$table->dropColumn('area_label');
});
}
};

View File

@@ -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');
}
};

72
deploy.sh Executable file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env bash
#
# FuelAlert deploy script — run on the VPS from the project root:
#
# ./deploy.sh # deploy the latest main
# ./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.
#
# See docs/ops/deployment.md for first-time setup and troubleshooting.
set -euo pipefail
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)"
echo "==> Maintenance mode on"
php artisan down --retry=15
git fetch --tags --prune origin
git checkout "${REF}"
# Fast-forward to the remote only when on a branch (a tag leaves a detached HEAD).
if git symbolic-ref -q HEAD >/dev/null; then
git pull --ff-only
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"
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"
npm ci
npm run build
else
echo "==> No frontend changes — skipping npm build"
fi
echo "==> Running migrations"
php artisan migrate --force
echo "==> Rebuilding caches"
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
echo "==> Restarting queue workers"
php artisan queue:restart
echo "==> Maintenance mode off"
php artisan up
echo "==> Deploy complete"
php artisan about

View 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)

View File

@@ -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)
}

View 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>

View 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 }
}

View File

@@ -14,7 +14,7 @@
</p>
<p>
Ovidiu Ungureanu is registered with the UK Information Commissioner's Office (ICO) as a
data controller. <strong>ICO registration reference: 00014395133.</strong>
data controller. <strong>ICO registration reference: ZC171362.</strong>
</p>
<p>
If you have any questions about this policy or how we handle your personal data, contact us at
@@ -276,7 +276,7 @@
</p>
<p class="text-sm text-zinc-600">
Data controller: Ovidiu Ungureanu trading as FuelAlert, Peterborough, United Kingdom.
ICO registration reference: 00014395133.
ICO registration reference: ZC171362.
</p>
</section>
</x-layouts.legal>

View File

@@ -11,7 +11,7 @@
FuelAlert is a trading name of <strong>Ovidiu Ungureanu</strong>, a sole trader based in
Peterborough, United Kingdom ("we", "us", "our"). These terms form a legally binding
contract between you and Ovidiu Ungureanu trading as FuelAlert.
ICO registration reference: 00014395133.
ICO registration reference: ZC171362.
</p>
<p>
By creating an account or using the service, you confirm that you have read, understood
@@ -242,7 +242,7 @@
</p>
<p class="text-sm text-zinc-600">
Ovidiu Ungureanu trading as FuelAlert, Peterborough, United Kingdom.
ICO registration reference: 00014395133.
ICO registration reference: ZC171362.
</p>
</section>
</x-layouts.legal>

View File

@@ -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']);

View File

@@ -77,6 +77,16 @@ Schedule::command('fuel:archive')
->onOneServer()
->runInBackground();
// Retry area labels that failed to reverse-geocode at search time (transient
// postcodes.io blip, or a genuinely remote point). Searches normally get their
// area_label inline; this just mops up stragglers. Cached per bucket, so it
// only calls the API for buckets it hasn't resolved yet.
Schedule::command('searches:backfill-areas')
->hourly()
->withoutOverlapping()
->onOneServer()
->runInBackground();
// Scheduled WhatsApp updates — morning and evening
Schedule::job(new SendScheduledWhatsAppJob('morning'))->dailyAt('07:30')->onOneServer();
Schedule::job(new SendScheduledWhatsAppJob('evening'))->dailyAt('18:00')->onOneServer();

View 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');
});

View File

@@ -12,6 +12,17 @@ uses(RefreshDatabase::class);
beforeEach(function () {
$this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]);
// Every search reverse-geocodes its lat/lng bucket to an area label. Fake the
// postcodes.io reverse endpoint (query-string form) so tests never hit the
// network; the path form (/postcodes/SW1A1AA) used for forward lookups is
// matched by per-test stubs and is unaffected by this.
Http::fake([
'api.postcodes.io/postcodes?*' => Http::response([
'status' => 200,
'result' => [['admin_district' => 'Testshire']],
]),
]);
});
function asPaidUserOnStations(string $tier = 'plus'): User
@@ -114,6 +125,19 @@ it('logs a search record for each request', function () {
]);
});
it('stores the reverse-geocoded area label on the search record', function () {
$station = Station::factory()->create(['lat' => 52.555064, 'lng' => -0.256119]);
StationPriceCurrent::factory()->create(['station_id' => $station->node_id, 'fuel_type' => FuelType::B7Standard, 'price_pence' => 14500]);
$this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=b7_standard&radius=10');
$this->assertDatabaseHas('searches', [
'lat_bucket' => '52.56',
'lng_bucket' => '-0.26',
'area_label' => 'Testshire',
]);
});
it('returns 422 when required params are missing', function () {
$this->getJson('/api/stations?lat=52.5')
->assertUnprocessable();

View 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');
});

View File

@@ -0,0 +1,50 @@
<?php
use App\Models\Search;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
it('fills the area label for searches missing one, once per bucket', function () {
Http::fake([
'*/postcodes?*' => Http::response([
'status' => 200,
'result' => [['admin_district' => 'Peterborough']],
]),
]);
// Two searches share a bucket; a third is in a different bucket.
Search::factory()->count(2)->create(['lat_bucket' => 52.54, 'lng_bucket' => -0.21, 'area_label' => null]);
Search::factory()->create(['lat_bucket' => 51.50, 'lng_bucket' => -0.14, 'area_label' => null]);
$this->artisan('searches:backfill-areas')->assertSuccessful();
expect(Search::whereNull('area_label')->count())->toBe(0)
->and(Search::where('area_label', 'Peterborough')->count())->toBe(3);
// One reverse-geocode call per distinct bucket, not per row.
Http::assertSentCount(2);
});
it('leaves searches that already have an area label untouched', function () {
Http::fake([
'*/postcodes?*' => Http::response([
'status' => 200,
'result' => [['admin_district' => 'Peterborough']],
]),
]);
Search::factory()->create(['lat_bucket' => 52.54, 'lng_bucket' => -0.21, 'area_label' => 'Manchester']);
$this->artisan('searches:backfill-areas')->assertSuccessful();
expect(Search::where('area_label', 'Manchester')->count())->toBe(1);
Http::assertNothingSent();
});
it('reports when there is nothing to backfill', function () {
$this->artisan('searches:backfill-areas')
->expectsOutputToContain('No searches need an area label.')
->assertSuccessful();
});

View File

@@ -63,6 +63,21 @@ CSV;
->and(Postcode::find('BT11AA'))->toBeNull();
});
it('skips postcodes with placeholder coordinates (no grid reference)', function (): void {
$csv = <<<'CSV'
pcd,pcds,doterm,lat,long
"SW1A1AA","SW1A 1AA","",51.501009,-0.141588
"GIR0AA","GIR 0AA","",99.999999,0.000000
CSV;
$path = writeOnspdFixture($csv);
$this->artisan('postcodes:import', ['--file' => $path])->assertSuccessful();
expect(Postcode::count())->toBe(1)
->and(Postcode::find('GIR0AA'))->toBeNull();
});
it('accepts ArcGIS ONSPD exports that use PCD7 instead of PCD', function (): void {
$csv = <<<'CSV'
OBJECTID,PCD7,PCD8,PCDS,DOTERM,LAT,LONG,x,y

View File

@@ -279,3 +279,76 @@ it('persists an outcode resolved via HTTP fallback', function (): void {
->and((float) $row->lat)->toBe(52.536397)
->and((float) $row->lng)->toBe(-0.210181);
});
// --- Reverse geocoding (area label) ---
it('reverse-geocodes coordinates to the admin district', function (): void {
Http::fake([
'*/postcodes?*' => Http::response([
'status' => 200,
'result' => [
['admin_district' => 'Peterborough', 'region' => 'East of England'],
],
]),
]);
expect($this->service->reverseResolve(52.5364, -0.2102))->toBe('Peterborough');
});
it('falls back to a broader area when admin district is missing', function (): void {
Http::fake([
'*/postcodes?*' => Http::response([
'status' => 200,
'result' => [
['admin_district' => null, 'region' => 'Scotland'],
],
]),
]);
expect($this->service->reverseResolve(57.4, -4.2))->toBe('Scotland');
});
it('returns null when reverse geocoding finds no area', function (): void {
Http::fake([
'*/postcodes?*' => Http::response(['status' => 200, 'result' => null]),
]);
expect($this->service->reverseResolve(0.0, 0.0))->toBeNull();
});
it('returns null when the reverse geocode request fails', function (): void {
Http::fake([
'*/postcodes?*' => Http::response([], 500),
]);
expect($this->service->reverseResolve(52.5364, -0.2102))->toBeNull();
});
it('caches the reverse-geocoded area per bucket', function (): void {
Http::fake([
'*/postcodes?*' => Http::response([
'status' => 200,
'result' => [['admin_district' => 'Peterborough']],
]),
]);
// Two coordinates inside the same ~1km (2dp) bucket → one HTTP call.
$this->service->reverseResolve(52.5364, -0.2102);
$this->service->reverseResolve(52.5359, -0.2148);
Http::assertSentCount(1);
});
it('queries postcodes.io with a wide radius so low-density buckets still match', function (): void {
Http::fake([
'*/postcodes?*' => Http::response([
'status' => 200,
'result' => [['admin_district' => 'Peterborough']],
]),
]);
$this->service->reverseResolve(52.54, -0.25);
// The default 100m radius misses the bucket centroid in rural areas; we send 2000m.
Http::assertSent(fn ($request) => str_contains($request->url(), 'radius=2000'));
});

View 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');
});