Compare commits
6 Commits
6176359d1f
...
1860cf0a49
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1860cf0a49 | ||
|
|
1318e3ac3b | ||
|
|
55cd68fbaa | ||
|
|
279ed529ab | ||
|
|
a80320bc27 | ||
|
|
156d925a35 |
@@ -63,3 +63,5 @@ AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
FUELALERT_API_KEY=
|
||||
|
||||
@@ -118,6 +118,8 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'api_secret_key' => env('API_SECRET_KEY'),
|
||||
|
||||
'maintenance' => [
|
||||
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||
|
||||
@@ -50,4 +50,8 @@ return [
|
||||
'model' => env('ANTHROPIC_MODEL', 'claude-sonnet-4-6'),
|
||||
],
|
||||
|
||||
'fuelalert' => [
|
||||
'api_key' => env('FUELALERT_API_KEY'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
# FuelAlert API Reference
|
||||
|
||||
Base URL: `https://fuel-price.test/api`
|
||||
All endpoints return JSON. No auth required on public endpoints (same-origin only for now — token auth planned).
|
||||
All endpoints return JSON. All endpoints require an `X-Api-Key` header.
|
||||
|
||||
**Authentication:**
|
||||
|
||||
```
|
||||
X-Api-Key: your-secret-key
|
||||
```
|
||||
|
||||
All requests without a valid key return `403 Forbidden`.
|
||||
|
||||
---
|
||||
|
||||
@@ -24,9 +32,18 @@ Returns nearby petrol stations with live prices for a given fuel type.
|
||||
|---|---|---|---|
|
||||
| `fuel_type` | string | — | **Required.** See fuel type aliases below |
|
||||
| `radius` | float | `10.0` | Search radius in km (0.1–50) |
|
||||
| `sort` | string | `"price"` | `"price"` or `"distance"` |
|
||||
| `sort` | string | `"price"` | `"price"`, `"distance"`, `"updated"`, or `"brand"` |
|
||||
| `pricing_mode` | string | — | `"pump"` (reserved, no effect yet) |
|
||||
|
||||
**Sort values:**
|
||||
|
||||
| Value | Sorts by |
|
||||
|---|---|
|
||||
| `price` | Price ascending (cheapest first) — **default** |
|
||||
| `distance` | Distance ascending (closest first) |
|
||||
| `updated` | Price freshness descending (most recently updated first) |
|
||||
| `brand` | Brand name A–Z |
|
||||
|
||||
**Fuel type aliases** (`fuel_type` accepts any of these):
|
||||
|
||||
| Alias | Maps to |
|
||||
@@ -42,6 +59,8 @@ Returns nearby petrol stations with live prices for a given fuel type.
|
||||
```
|
||||
GET /api/stations?postcode=SW1A1AA&fuel_type=petrol&radius=5&sort=price
|
||||
GET /api/stations?lat=51.5074&lng=-0.1278&fuel_type=diesel&radius=10&sort=distance
|
||||
GET /api/stations?postcode=M11AE&fuel_type=petrol&sort=updated
|
||||
GET /api/stations?postcode=M11AE&fuel_type=petrol&sort=brand
|
||||
```
|
||||
|
||||
**Response:**
|
||||
@@ -244,96 +263,6 @@ GET /api/prediction?fuel_type=petrol&lat=51.5074&lng=-0.1278
|
||||
|
||||
---
|
||||
|
||||
## Auth
|
||||
|
||||
> **Note:** Auth routes are implemented (`AuthController` exists) but not yet wired into `routes/api.php`. Add when token-based access is needed.
|
||||
|
||||
### POST `/api/auth/register`
|
||||
|
||||
Create a new account and receive a Sanctum token.
|
||||
|
||||
**Body (JSON):**
|
||||
```json
|
||||
{
|
||||
"name": "Jane Smith",
|
||||
"email": "jane@example.com",
|
||||
"password": "secret123",
|
||||
"password_confirmation": "secret123"
|
||||
}
|
||||
```
|
||||
|
||||
**Response `201`:**
|
||||
```json
|
||||
{
|
||||
"token": "1|abc123...",
|
||||
"user": {
|
||||
"id": 42,
|
||||
"name": "Jane Smith",
|
||||
"email": "jane@example.com",
|
||||
"created_at": "2026-04-05T10:00:00.000000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST `/api/auth/login`
|
||||
|
||||
**Body (JSON):**
|
||||
```json
|
||||
{
|
||||
"email": "jane@example.com",
|
||||
"password": "secret123"
|
||||
}
|
||||
```
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
{
|
||||
"token": "1|abc123...",
|
||||
"user": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Response `401` (wrong credentials):**
|
||||
```json
|
||||
{ "message": "Invalid credentials." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST `/api/auth/logout`
|
||||
|
||||
Revokes the current token.
|
||||
|
||||
**Headers:** `Authorization: Bearer {token}`
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
{ "message": "Logged out." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET `/api/auth/me`
|
||||
|
||||
Returns the authenticated user.
|
||||
|
||||
**Headers:** `Authorization: Bearer {token}`
|
||||
|
||||
**Response `200`:** Full `User` model JSON.
|
||||
|
||||
---
|
||||
|
||||
## Using the Token (when auth is wired up)
|
||||
|
||||
```
|
||||
Authorization: Bearer 1|abc123...
|
||||
```
|
||||
|
||||
All protected routes must include this header.
|
||||
|
||||
---
|
||||
|
||||
## Error Shapes
|
||||
|
||||
@@ -347,7 +276,7 @@ All protected routes must include this header.
|
||||
}
|
||||
```
|
||||
|
||||
**Unauthenticated (401):**
|
||||
**Forbidden (403)** — missing or invalid `X-Api-Key`:
|
||||
```json
|
||||
{ "message": "Unauthenticated." }
|
||||
{ "message": "Forbidden." }
|
||||
```
|
||||
|
||||
489
docs/superpowers/plans/2026-04-05-station-search-page.md
Normal file
489
docs/superpowers/plans/2026-04-05-station-search-page.md
Normal 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: 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
|
||||
<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
|
||||
· Cheapest: {{ number_format($meta['lowest_pence'] / 100, 1) }}p
|
||||
· 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
|
||||
143
docs/superpowers/specs/2026-04-05-station-search-page-design.md
Normal file
143
docs/superpowers/specs/2026-04-05-station-search-page-design.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Station Search Page — Design Spec
|
||||
|
||||
**Date:** 2026-04-05
|
||||
**Status:** Approved
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A public-facing Livewire page that lets anyone search for nearby petrol stations by postcode, town, or city. The user selects a fuel type and search radius in miles. On submit, results are fetched server-side from `/api/stations` and displayed as a list.
|
||||
|
||||
---
|
||||
|
||||
## Routing
|
||||
|
||||
- Route: `GET /stations` — public, no auth middleware
|
||||
- Registered in `routes/web.php` as a full-page Livewire component
|
||||
- Uses the existing `x-layouts::app` (sidebar) layout
|
||||
|
||||
---
|
||||
|
||||
## Component
|
||||
|
||||
**Class:** `app/Livewire/Public/StationSearch.php`
|
||||
**View:** `resources/views/livewire/public/station-search.blade.php`
|
||||
|
||||
### Public properties
|
||||
|
||||
| Property | Type | Default | Notes |
|
||||
|---|---|---|---|
|
||||
| `$search` | string | `''` | Postcode, outcode, town or city |
|
||||
| `$fuelType` | string | `''` | API fuel type alias (e.g. `petrol`, `diesel`) |
|
||||
| `$radius` | int | `5` | In miles — converted to km before API call |
|
||||
| `$results` | array | `[]` | Populated from API `data` array |
|
||||
| `$meta` | array | `[]` | Populated from API `meta` object |
|
||||
| `$apiError` | string\|null | `null` | Human-readable error from API or network failure |
|
||||
|
||||
### Method: `findStations()`
|
||||
|
||||
1. Validate: `$search` required, `$fuelType` required, `$radius` integer between 1–20
|
||||
2. Reset `$results`, `$meta`, `$apiError`
|
||||
3. Convert radius: `$radiusKm = $this->radius * 1.60934`
|
||||
4. Call `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'])`
|
||||
5. On 422 — extract `errors.postcode[0]` or `message` and set `$apiError`
|
||||
6. On other non-2xx — set `$apiError` to a generic message
|
||||
7. On success — set `$results` and `$meta` from response JSON
|
||||
|
||||
### Configuration
|
||||
|
||||
- API key read from `config('services.fuelalert.api_key')`
|
||||
- `.env` key: `FUELALERT_API_KEY`
|
||||
- Add to `config/services.php` under `fuelalert`
|
||||
|
||||
---
|
||||
|
||||
## Form UI
|
||||
|
||||
Three fields in a horizontal row (stacks vertically on mobile):
|
||||
|
||||
1. **Search** — text input, placeholder "Postcode, town or city"
|
||||
2. **Fuel type** — select with options:
|
||||
- Petrol (E10) → `petrol`
|
||||
- Super Unleaded (E5) → `e5`
|
||||
- Diesel → `diesel`
|
||||
- Premium Diesel → `b7_premium`
|
||||
- B10 Biodiesel → `b10`
|
||||
- HVO → `hvo`
|
||||
3. **Radius** — select: 1, 2, 5, 10, 20 miles (default 5)
|
||||
|
||||
Submit button with `wire:loading` spinner and disabled state while request is in flight.
|
||||
|
||||
Inline validation errors shown below each field using `@error`.
|
||||
|
||||
---
|
||||
|
||||
## Results List
|
||||
|
||||
Shown below the form after a successful search.
|
||||
|
||||
**Meta bar** (above results): "{count} stations found · Cheapest: {lowest}p · Average: {avg}p"
|
||||
|
||||
**Each station row:**
|
||||
- Station name + brand (badge if supermarket)
|
||||
- Address / postcode
|
||||
- Distance in miles (convert `distance_km` from response: `× 0.621371`, rounded to 1dp)
|
||||
- Price: formatted as `{price}p` per litre (e.g. `143.9p`)
|
||||
- Last updated: human-readable relative time (e.g. "2 hours ago") using `Carbon::parse(...)->diffForHumans()`
|
||||
|
||||
Sorted cheapest first (default). No client-side re-sorting in v1.
|
||||
|
||||
---
|
||||
|
||||
## States
|
||||
|
||||
| State | UI |
|
||||
|---|---|
|
||||
| Initial | Form only, no results area |
|
||||
| Loading | Submit button shows spinner + disabled, no results change |
|
||||
| Results | Meta bar + station list below form |
|
||||
| API error | Error alert above results (e.g. "Postcode not found.") |
|
||||
| Empty results | "No stations found within {radius} miles of {search}." |
|
||||
|
||||
---
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
User fills form → submit → findStations() → Http::get /api/stations
|
||||
→ success → $results + $meta populated → Livewire re-renders list
|
||||
→ 422 → $apiError = postcode error message
|
||||
→ other → $apiError = "Unable to fetch stations. Please try again."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Config
|
||||
|
||||
Add to `config/services.php`:
|
||||
```php
|
||||
'fuelalert' => [
|
||||
'api_key' => env('FUELALERT_API_KEY'),
|
||||
],
|
||||
```
|
||||
|
||||
Add to `.env` and `.env.example`:
|
||||
```
|
||||
FUELALERT_API_KEY=
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Feature test: `tests/Feature/Livewire/StationSearchTest.php`
|
||||
|
||||
Test cases:
|
||||
- Form renders with empty state
|
||||
- Validation: search required, fuel type required, radius required
|
||||
- Successful search populates `$results` and `$meta` (Http::fake)
|
||||
- 422 response sets `$apiError`
|
||||
- Network failure sets `$apiError`
|
||||
- Distance converted correctly from km to miles
|
||||
- Radius converted correctly from miles to km in outgoing request
|
||||
@@ -3,8 +3,11 @@
|
||||
use App\Http\Controllers\Api\PredictionController;
|
||||
use App\Http\Controllers\Api\StationController;
|
||||
use App\Http\Controllers\Api\StatsController;
|
||||
use App\Http\Middleware\VerifyApiKey;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware(['throttle:60,1', VerifyApiKey::class])->group(function (): void {
|
||||
Route::get('/stations', [StationController::class, 'index']);
|
||||
Route::get('/stats/searches', [StatsController::class, 'searches']);
|
||||
Route::get('/prediction', [PredictionController::class, 'index']);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Public\StationSearch;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::view('/', 'welcome')->name('home');
|
||||
|
||||
Route::get('/stations', StationSearch::class)->name('stations.search');
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::view('dashboard', 'dashboard')->name('dashboard');
|
||||
});
|
||||
|
||||
135
tests/Feature/Livewire/StationSearchTest.php
Normal file
135
tests/Feature/Livewire/StationSearchTest.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Public\StationSearch;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
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('meta', [])
|
||||
->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('meta', [])
|
||||
->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
|
||||
&& isset($data['fuel_type']) && $data['fuel_type'] === 'petrol';
|
||||
});
|
||||
});
|
||||
|
||||
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', []);
|
||||
});
|
||||
Reference in New Issue
Block a user