# 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: ```php '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';`: ```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** ```bash 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** ```bash php artisan make:test --pest Livewire/StationSearchTest ``` - [ ] **Step 2: Replace the generated file contents** ```php 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** ```bash 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** ```bash 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** ```bash php artisan make:livewire Public/StationSearch --no-interaction ``` - [ ] **Step 2: Replace the generated class with the full implementation** ```php 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** ```bash php artisan test --compact tests/Feature/Livewire/StationSearchTest.php ``` Expected: 6–7 pass, the `renders the station search form` test may fail until the view exists. - [ ] **Step 4: Format** ```bash 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** ```blade
{{ $meta['count'] }} {{ Str::plural('station', $meta['count']) }} found · Cheapest: {{ number_format($meta['lowest_pence'] / 100, 1) }}p · Average: {{ number_format($meta['avg_pence'] / 100, 1) }}p
{{ $station['name'] }}
@if ($station['is_supermarket']){{ $station['address'] }}, {{ $station['postcode'] }}
{{ number_format($station['distance_km'] * 0.621371, 1) }} miles away
{{ $station['price'] }}p
{{ \Carbon\Carbon::parse($station['price_updated_at'])->diffForHumans() }}
No stations found within {{ $radius }} {{ Str::plural('mile', $radius) }} of "{{ $search }}".
@endif