Files
fuel-price/docs/superpowers/plans/2026-04-05-station-search-page.md

16 KiB
Raw Permalink Blame History

Station Search Page Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a public Livewire page at /stations where users search for nearby petrol stations by location, fuel type, and radius.

Architecture: A classic two-file Livewire component (StationSearch) renders a search form and handles submission by calling /api/stations server-side via Laravel's Http facade. The API key stays on the server. Results are displayed as a list below the form.

Tech Stack: Laravel 11, Livewire 4 (classic), Flux UI v2, Tailwind CSS v4, Pest v4


File Map

Action Path Responsibility
Modify config/services.php Add fuelalert.api_key entry
Modify .env.example Document FUELALERT_API_KEY
Modify routes/web.php Add public GET /stations route
Create app/Livewire/Public/StationSearch.php Component: properties, validation, findStations()
Create resources/views/livewire/public/station-search.blade.php Form, loading state, meta bar, results list, error/empty states
Create tests/Feature/Livewire/StationSearchTest.php All feature tests for the component

Task 1: Config, env, and route

Files:

  • Modify: config/services.php

  • Modify: .env.example

  • Modify: routes/web.php

  • Step 1: Add fuelalert config to services.php

In config/services.php, add after the fred block:

'fuelalert' => [
    'api_key' => env('FUELALERT_API_KEY'),
],
  • Step 2: Document env key in .env.example

Append to .env.example:

FUELALERT_API_KEY=
  • Step 3: Register the public route

In routes/web.php, add before require __DIR__.'/settings.php';:

use App\Livewire\Public\StationSearch;

Route::get('/stations', StationSearch::class)->name('stations.search');

Also add the import at the top of the use block with the other imports.

  • Step 4: Commit
git add config/services.php .env.example routes/web.php
git commit -m "feat: add fuelalert config and public /stations route"

Task 2: Write failing tests

Files:

  • Create: tests/Feature/Livewire/StationSearchTest.php

  • Step 1: Create the test file

php artisan make:test --pest Livewire/StationSearchTest
  • Step 2: Replace the generated file contents
<?php

use App\Livewire\Public\StationSearch;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;

it('renders the station search form', function () {
    Livewire::test(StationSearch::class)
        ->assertStatus(200)
        ->assertSeeHtml('name="search"')
        ->assertSeeHtml('name="fuelType"')
        ->assertSeeHtml('name="radius"');
});

it('validates search is required', function () {
    Livewire::test(StationSearch::class)
        ->call('findStations')
        ->assertHasErrors(['search' => 'required']);
});

it('validates fuelType is required', function () {
    Livewire::test(StationSearch::class)
        ->set('search', 'SW1A 1AA')
        ->call('findStations')
        ->assertHasErrors(['fuelType' => 'required']);
});

it('populates results and meta on successful search', function () {
    Http::fake([
        '*/api/stations*' => Http::response([
            'data' => [
                [
                    'station_id' => 'abc123',
                    'name' => 'BP Garage',
                    'brand' => 'BP',
                    'is_supermarket' => false,
                    'address' => '1 High Street, London',
                    'postcode' => 'SW1A 1AA',
                    'lat' => 51.5074,
                    'lng' => -0.1278,
                    'distance_km' => 1.5,
                    'fuel_type' => 'e10',
                    'price_pence' => 14390,
                    'price' => 143.9,
                    'price_updated_at' => '2026-04-05T08:00:00.000Z',
                ],
            ],
            'meta' => [
                'count' => 1,
                'fuel_type' => 'e10',
                'radius_km' => 8.05,
                'lowest_pence' => 14390,
                'highest_pence' => 14390,
                'cheapest_price_pence' => 14390,
                'avg_pence' => 14390.0,
            ],
        ], 200),
    ]);

    Livewire::test(StationSearch::class)
        ->set('search', 'SW1A 1AA')
        ->set('fuelType', 'petrol')
        ->set('radius', 5)
        ->call('findStations')
        ->assertSet('apiError', null)
        ->assertSet('results', fn (array $r) => count($r) === 1 && $r[0]['name'] === 'BP Garage')
        ->assertSet('meta', fn (array $m) => $m['count'] === 1);
});

it('sets apiError from 422 postcode validation response', function () {
    Http::fake([
        '*/api/stations*' => Http::response([
            'errors' => ['postcode' => ['Postcode not found.']],
        ], 422),
    ]);

    Livewire::test(StationSearch::class)
        ->set('search', 'ZZ99 9ZZ')
        ->set('fuelType', 'petrol')
        ->call('findStations')
        ->assertSet('results', [])
        ->assertSet('apiError', 'Postcode not found.');
});

it('sets generic apiError on server error', function () {
    Http::fake([
        '*/api/stations*' => Http::response([], 500),
    ]);

    Livewire::test(StationSearch::class)
        ->set('search', 'SW1A 1AA')
        ->set('fuelType', 'petrol')
        ->call('findStations')
        ->assertSet('results', [])
        ->assertSet('apiError', 'Unable to fetch stations. Please try again.');
});

it('converts radius from miles to km in the outgoing API request', function () {
    Http::fake([
        '*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
    ]);

    Livewire::test(StationSearch::class)
        ->set('search', 'SW1A 1AA')
        ->set('fuelType', 'petrol')
        ->set('radius', 5)
        ->call('findStations');

    Http::assertSent(function ($request) {
        $data = $request->data();
        return isset($data['radius']) && abs((float) $data['radius'] - 8.05) < 0.01;
    });
});

it('resets results and error before each new search', function () {
    Http::fake([
        '*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
    ]);

    Livewire::test(StationSearch::class)
        ->set('search', 'SW1A 1AA')
        ->set('fuelType', 'petrol')
        ->set('results', [['name' => 'Old Result']])
        ->set('apiError', 'Old error')
        ->call('findStations')
        ->assertSet('apiError', null)
        ->assertSet('results', []);
});
  • Step 3: Run tests — verify they all fail
php artisan test --compact tests/Feature/Livewire/StationSearchTest.php

Expected: all fail with Class "App\Livewire\Public\StationSearch" not found or similar.

  • Step 4: Commit the test file
git add tests/Feature/Livewire/StationSearchTest.php
git commit -m "test: add failing tests for StationSearch Livewire component"

Task 3: Implement the component class

Files:

  • Create: app/Livewire/Public/StationSearch.php

  • Step 1: Create the directory and component

php artisan make:livewire Public/StationSearch --no-interaction
  • Step 2: Replace the generated class with the full implementation
<?php

namespace App\Livewire\Public;

use Illuminate\Support\Facades\Http;
use Livewire\Attributes\Validate;
use Livewire\Component;

class StationSearch extends Component
{
    #[Validate('required|string', message: 'Please enter a postcode, town or city.')]
    public string $search = '';

    #[Validate('required|string', message: 'Please select a fuel type.')]
    public string $fuelType = '';

    #[Validate('required|integer|min:1|max:20')]
    public int $radius = 5;

    public array $results = [];

    public array $meta = [];

    public ?string $apiError = null;

    public function findStations(): void
    {
        $this->validate();

        $this->results = [];
        $this->meta = [];
        $this->apiError = null;

        $radiusKm = round($this->radius * 1.60934, 2);

        $response = Http::timeout(10)
            ->withHeaders(['X-Api-Key' => config('services.fuelalert.api_key')])
            ->get(url('/api/stations'), [
                'postcode' => $this->search,
                'fuel_type' => $this->fuelType,
                'radius' => $radiusKm,
                'sort' => 'price',
            ]);

        if ($response->status() === 422) {
            $errors = $response->json('errors', []);
            $this->apiError = collect($errors)->flatten()->first()
                ?? $response->json('message', 'Validation error.');

            return;
        }

        if (! $response->successful()) {
            $this->apiError = 'Unable to fetch stations. Please try again.';

            return;
        }

        $this->results = $response->json('data', []);
        $this->meta = $response->json('meta', []);
    }

    public function render(): \Illuminate\View\View
    {
        return view('livewire.public.station-search');
    }
}
  • Step 3: Run tests — most should pass, view tests may still fail
php artisan test --compact tests/Feature/Livewire/StationSearchTest.php

Expected: 67 pass, the renders the station search form test may fail until the view exists.

  • Step 4: Format
vendor/bin/pint app/Livewire/Public/StationSearch.php --format agent

Task 4: Implement the view

Files:

  • Modify: resources/views/livewire/public/station-search.blade.php (generated in Task 3, now replace contents)

  • Step 1: Replace the generated view

<div>
    <flux:heading size="xl" class="mb-1">Find Cheap Fuel Near You</flux:heading>
    <flux:subheading class="mb-6">Search by postcode, town or city</flux:subheading>

    <form wire:submit="findStations">
        <div class="flex flex-col gap-3 sm:flex-row sm:items-end">
            <div class="flex-1">
                <flux:input
                    wire:model="search"
                    name="search"
                    label="Location"
                    placeholder="Postcode, town or city"
                />
                @error('search')
                    <p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
                @enderror
            </div>

            <div class="w-full sm:w-48">
                <flux:select wire:model="fuelType" name="fuelType" label="Fuel type">
                    <flux:select.option value="">Select fuel type</flux:select.option>
                    <flux:select.option value="petrol">Petrol (E10)</flux:select.option>
                    <flux:select.option value="e5">Super Unleaded (E5)</flux:select.option>
                    <flux:select.option value="diesel">Diesel</flux:select.option>
                    <flux:select.option value="b7_premium">Premium Diesel</flux:select.option>
                    <flux:select.option value="b10">B10 Biodiesel</flux:select.option>
                    <flux:select.option value="hvo">HVO</flux:select.option>
                </flux:select>
                @error('fuelType')
                    <p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
                @enderror
            </div>

            <div class="w-full sm:w-36">
                <flux:select wire:model="radius" name="radius" label="Radius">
                    <flux:select.option value="1">1 mile</flux:select.option>
                    <flux:select.option value="2">2 miles</flux:select.option>
                    <flux:select.option value="5">5 miles</flux:select.option>
                    <flux:select.option value="10">10 miles</flux:select.option>
                    <flux:select.option value="20">20 miles</flux:select.option>
                </flux:select>
            </div>

            <div>
                <flux:button type="submit" variant="primary" wire:loading.attr="disabled">
                    <span wire:loading.remove wire:target="findStations">Search</span>
                    <span wire:loading wire:target="findStations">Searching…</span>
                </flux:button>
            </div>
        </div>
    </form>

    @if ($apiError)
        <div class="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-400">
            {{ $apiError }}
        </div>
    @endif

    @if (! empty($meta))
        <div class="mt-6">
            @if (! empty($results))
                <p class="mb-3 text-sm text-zinc-500 dark:text-zinc-400">
                    {{ $meta['count'] }} {{ Str::plural('station', $meta['count']) }} found
                    &middot; Cheapest: {{ number_format($meta['lowest_pence'] / 100, 1) }}p
                    &middot; Average: {{ number_format($meta['avg_pence'] / 100, 1) }}p
                </p>

                <div class="space-y-2">
                    @foreach ($results as $station)
                        <div class="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-700">
                            <div class="min-w-0 flex-1">
                                <div class="flex items-center gap-2">
                                    <p class="truncate font-semibold text-zinc-900 dark:text-zinc-100">
                                        {{ $station['name'] }}
                                    </p>
                                    @if ($station['is_supermarket'])
                                        <flux:badge color="lime" size="sm">Supermarket</flux:badge>
                                    @endif
                                </div>
                                <p class="truncate text-sm text-zinc-500 dark:text-zinc-400">
                                    {{ $station['address'] }}, {{ $station['postcode'] }}
                                </p>
                                <p class="text-sm text-zinc-400 dark:text-zinc-500">
                                    {{ number_format($station['distance_km'] * 0.621371, 1) }} miles away
                                </p>
                            </div>

                            <div class="ml-4 shrink-0 text-right">
                                <p class="text-xl font-bold text-zinc-900 dark:text-zinc-100">
                                    {{ $station['price'] }}p
                                </p>
                                <p class="text-xs text-zinc-400 dark:text-zinc-500">
                                    {{ \Carbon\Carbon::parse($station['price_updated_at'])->diffForHumans() }}
                                </p>
                            </div>
                        </div>
                    @endforeach
                </div>
            @else
                <p class="text-sm text-zinc-500 dark:text-zinc-400">
                    No stations found within {{ $radius }} {{ Str::plural('mile', $radius) }} of "{{ $search }}".
                </p>
            @endif
        </div>
    @endif
</div>
  • Step 2: Run all tests — all should pass
php artisan test --compact tests/Feature/Livewire/StationSearchTest.php

Expected: all 7 tests pass.

  • Step 3: Format both files
vendor/bin/pint app/Livewire/Public/StationSearch.php resources/views/livewire/public/station-search.blade.php --format agent
  • Step 4: Confirm page loads in browser

Visit https://fuel-price.test/stations — form should render with all three fields and a Search button.

  • Step 5: Commit
git add app/Livewire/Public/StationSearch.php resources/views/livewire/public/station-search.blade.php
git commit -m "feat: implement StationSearch Livewire component and view"

Self-Review Checklist

  • Config + env key — Task 1
  • Route GET /stations — Task 1
  • Form fields: search, fuelType, radius — Task 3 + 4
  • Validation (required) — Task 3, tested in Task 2
  • findStations() makes server-side HTTP call with API key — Task 3
  • Miles → km conversion (× 1.60934, rounded to 2dp) — Task 3, tested in Task 2
  • 422 error handling → $apiError — Task 3, tested in Task 2
  • Non-2xx error → generic $apiError — Task 3, tested in Task 2
  • Results list: name, brand/supermarket badge, address, distance in miles, price, updated-at — Task 4
  • Meta bar: count, cheapest, average — Task 4
  • Empty state message — Task 4
  • Loading state on submit button — Task 4
  • wire:loading disables button during request — Task 4