docs: add station search page implementation plan

This commit is contained in:
Ovidiu U
2026-04-05 20:19:37 +01:00
parent 156d925a35
commit a80320bc27

View File

@@ -0,0 +1,489 @@
# 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