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

490 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
<?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**
```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
<?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**
```bash
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**
```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
<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**
```bash
php artisan test --compact tests/Feature/Livewire/StationSearchTest.php
```
Expected: all 7 tests pass.
- [ ] **Step 3: Format both files**
```bash
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**
```bash
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
- [x] Config + env key — Task 1
- [x] Route `GET /stations` — Task 1
- [x] Form fields: search, fuelType, radius — Task 3 + 4
- [x] Validation (required) — Task 3, tested in Task 2
- [x] `findStations()` makes server-side HTTP call with API key — Task 3
- [x] Miles → km conversion (× 1.60934, rounded to 2dp) — Task 3, tested in Task 2
- [x] 422 error handling → `$apiError` — Task 3, tested in Task 2
- [x] Non-2xx error → generic `$apiError` — Task 3, tested in Task 2
- [x] Results list: name, brand/supermarket badge, address, distance in miles, price, updated-at — Task 4
- [x] Meta bar: count, cheapest, average — Task 4
- [x] Empty state message — Task 4
- [x] Loading state on submit button — Task 4
- [x] `wire:loading` disables button during request — Task 4