11 Commits

Author SHA1 Message Date
Ovidiu U
08afafe6bd Make Petrol/Diesel pills equal width, drop the Petrol icon
Switch the fuel-pill group to a 2-column grid with full-width centered
buttons so both pills are identical size, and remove the fuel icon so
the two read as a clean matched pair.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 12:01:12 +01:00
Ovidiu U
685a84e159 Surface Petrol/Diesel as quick pills in the results filter bar
Add Petrol (e10) and Diesel (b7_standard) pills to the left of the
Filters pill, same row and style, each re-searching on tap via the
existing fuelType watcher. The popover's fuel section becomes
"More fuels" listing only the four long-tail fuels (E5, Prem Diesel,
B10, HVO), derived from the shared FUEL_TYPES source.

Fuel only counts toward the Filters badge when a long-tail fuel is
active; "Clear all" snaps a long-tail fuel back to Petrol but leaves a
pill choice alone. Results-bar only, no API change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:55:04 +01:00
Ovidiu U
4c0017cb91 Add design spec for inline Petrol/Diesel quick pills
Surfaces the two everyday fuels as one-tap pills in the results filter
bar, left of the Filters pill; the popover keeps the four long-tail
fuels under a "More fuels" section. Results-bar only, no API change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:28:26 +01:00
Ovidiu U
347a71154b Fix deploy.sh skipping SPA build on same-ref/aborted deploys
The build was gated on `git diff BEFORE AFTER`, where BEFORE was HEAD
before checkout. Re-deploying the same ref (or re-running after an
aborted deploy) made BEFORE == AFTER, so the diff was empty and the SPA
build silently skipped — shipping stale assets while migrations still ran.

- Always rebuild the SPA; only gate the heavy dep installs.
- npm ci / composer install also run when node_modules / vendor are missing.
- Track the last successfully deployed commit in .deploy-last-commit and
  diff against that, so an aborted run never advances the baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:41:47 +01:00
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
Ovidiu U
5ca7232029 Replace Laravel favicon with beer mug icon and regenerate touch/favicon assets 2026-06-11 14:05:51 +01:00
38 changed files with 1305 additions and 18 deletions

1
.gitignore vendored
View File

@@ -24,4 +24,5 @@ yarn-error.log
/.zed
/.tmp/
/.worktrees/
/.deploy-last-commit
/ONSPD_Online_Latest_Centroids_*.csv

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

89
deploy.sh Executable file
View File

@@ -0,0 +1,89 @@
#!/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. 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
cd "$(dirname "$0")"
REF="${1:-main}"
echo "==> Deploying ref: ${REF}"
# 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
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 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
# 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
else
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
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
# 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

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

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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 823 B

View File

@@ -1,3 +1,3 @@
<svg width="166" height="166" viewBox="0 0 166 166" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M162.041 38.7592C162.099 38.9767 162.129 39.201 162.13 39.4264V74.4524C162.13 74.9019 162.011 75.3435 161.786 75.7325C161.561 76.1216 161.237 76.4442 160.847 76.6678L131.462 93.5935V127.141C131.462 128.054 130.977 128.897 130.186 129.357L68.8474 164.683C68.707 164.763 68.5538 164.814 68.4007 164.868C68.3432 164.887 68.289 164.922 68.2284 164.938C67.7996 165.051 67.3489 165.051 66.9201 164.938C66.8499 164.919 66.7861 164.881 66.7191 164.855C66.5787 164.804 66.4319 164.76 66.2979 164.683L4.97219 129.357C4.58261 129.133 4.2589 128.81 4.0337 128.421C3.8085 128.032 3.68976 127.591 3.68945 127.141L3.68945 22.0634C3.68945 21.8336 3.72136 21.6101 3.7788 21.393C3.79794 21.3196 3.84262 21.2526 3.86814 21.1791C3.91601 21.0451 3.96068 20.9078 4.03088 20.7833C4.07874 20.7003 4.14894 20.6333 4.20638 20.5566C4.27977 20.4545 4.34678 20.3491 4.43293 20.2598C4.50632 20.1863 4.60205 20.1321 4.68501 20.0682C4.77755 19.9916 4.86051 19.9086 4.96581 19.848L35.6334 2.18492C36.0217 1.96139 36.4618 1.84375 36.9098 1.84375C37.3578 1.84375 37.7979 1.96139 38.1862 2.18492L68.8506 19.848H68.857C68.9591 19.9118 69.0452 19.9916 69.1378 20.065C69.2207 20.1289 69.3133 20.1863 69.3867 20.2566C69.476 20.3491 69.5398 20.4545 69.6164 20.5566C69.6707 20.6333 69.7441 20.7003 69.7887 20.7833C69.8621 20.911 69.9036 21.0451 69.9546 21.1791C69.9802 21.2526 70.0248 21.3196 70.044 21.3962C70.1027 21.6138 70.1328 21.8381 70.1333 22.0634V87.6941L95.686 72.9743V39.4232C95.686 39.1997 95.7179 38.9731 95.7753 38.7592C95.7977 38.6826 95.8391 38.6155 95.8647 38.5421C95.9157 38.408 95.9604 38.2708 96.0306 38.1463C96.0785 38.0633 96.1487 37.9962 96.2029 37.9196C96.2795 37.8175 96.3433 37.7121 96.4326 37.6227C96.506 37.5493 96.5986 37.495 96.6815 37.4312C96.7773 37.3546 96.8602 37.2716 96.9623 37.2109L127.633 19.5479C128.021 19.324 128.461 19.2062 128.91 19.2062C129.358 19.2062 129.798 19.324 130.186 19.5479L160.85 37.2109C160.959 37.2748 161.042 37.3546 161.137 37.428C161.217 37.4918 161.31 37.5493 161.383 37.6195C161.473 37.7121 161.536 37.8175 161.613 37.9196C161.67 37.9962 161.741 38.0633 161.785 38.1463C161.859 38.2708 161.9 38.408 161.951 38.5421C161.98 38.6155 162.021 38.6826 162.041 38.7592ZM157.018 72.9743V43.8477L146.287 50.028L131.462 58.5675V87.6941L157.021 72.9743H157.018ZM126.354 125.663V96.5176L111.771 104.85L70.1301 128.626V158.046L126.354 125.663ZM8.80126 26.4848V125.663L65.0183 158.043V128.629L35.6494 112L35.6398 111.994L35.6271 111.988C35.5281 111.93 35.4452 111.847 35.3526 111.777C35.2729 111.713 35.1803 111.662 35.1101 111.592L35.1038 111.582C35.0208 111.502 34.9634 111.403 34.8932 111.314C34.8293 111.228 34.7528 111.154 34.7017 111.065L34.6985 111.055C34.6411 110.96 34.606 110.845 34.5645 110.736C34.523 110.64 34.4688 110.551 34.4432 110.449C34.4113 110.328 34.4049 110.197 34.3922 110.072C34.3794 109.976 34.3539 109.881 34.3539 109.785V109.778V41.2045L19.5322 32.6619L8.80126 26.4848ZM36.913 7.35007L11.3635 22.0634L36.9066 36.7768L62.4529 22.0602L36.9066 7.35007H36.913ZM50.1999 99.1736L65.0215 90.6374V26.4848L54.2906 32.6651L39.4657 41.2045V105.357L50.1999 99.1736ZM128.91 24.713L103.363 39.4264L128.91 54.1397L154.453 39.4232L128.91 24.713ZM126.354 58.5675L111.529 50.028L100.798 43.8477V72.9743L115.619 81.5106L126.354 87.6941V58.5675ZM67.5711 124.205L105.042 102.803L123.772 92.109L98.2451 77.4053L68.8538 94.3341L42.0663 109.762L67.5711 124.205Z" fill="#FF2D20"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="166" height="166" viewBox="0 0 512 512">
<path fill="#bb5b3e" d="M32 64C32 28.7 60.7 0 96 0L256 0c35.3 0 64 28.7 64 64l0 192 8 0c48.6 0 88 39.4 88 88l0 32c0 13.3 10.7 24 24 24s24-10.7 24-24l0-154c-27.6-7.1-48-32.2-48-62l0-59.5-25.8-28.3c-8.9-9.8-8.2-25 1.6-33.9s25-8.2 33.9 1.6l71.7 78.8c9.4 10.3 14.6 23.7 14.6 37.7L512 376c0 39.8-32.2 72-72 72s-72-32.2-72-72l0-32c0-22.1-17.9-40-40-40l-8 0 0 161.4c9.3 3.3 16 12.2 16 22.6 0 13.3-10.7 24-24 24L40 512c-13.3 0-24-10.7-24-24 0-10.5 6.7-19.3 16-22.6L32 64zM96 80l0 96c0 8.8 7.2 16 16 16l128 0c8.8 0 16-7.2 16-16l0-96c0-8.8-7.2-16-16-16L112 64c-8.8 0-16 7.2-16 16z"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 672 B

View File

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

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