Compare commits
22 Commits
7e1a000e2a
...
5bc6ca720c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bc6ca720c | ||
|
|
f5b39e8dc4 | ||
|
|
4f695ca37c | ||
|
|
40c0815a2c | ||
|
|
ffdcb8baff | ||
|
|
a819292b40 | ||
|
|
9d591a1d5a | ||
|
|
21aea84797 | ||
|
|
cef21a4f0f | ||
|
|
0955873221 | ||
|
|
68c535b348 | ||
|
|
dce2bd6e50 | ||
|
|
d4d532000d | ||
|
|
fa94bb537b | ||
|
|
cd9d833e44 | ||
|
|
eed6ef9c81 | ||
|
|
a11f0ba186 | ||
|
|
f05a617af0 | ||
|
|
a034e5cd6d | ||
|
|
93bd63aefb | ||
|
|
649772f65f | ||
|
|
130576c9ba |
49
app/Enums/PriceClassification.php
Normal file
49
app/Enums/PriceClassification.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
enum PriceClassification: string
|
||||||
|
{
|
||||||
|
case Current = 'current';
|
||||||
|
case Recent = 'recent';
|
||||||
|
case Stale = 'stale';
|
||||||
|
case Outdated = 'outdated';
|
||||||
|
|
||||||
|
public static function fromUpdatedAt(?Carbon $updatedAt): self
|
||||||
|
{
|
||||||
|
if ($updatedAt === null) {
|
||||||
|
return self::Outdated;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hours = $updatedAt->diffInHours(now());
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
$hours < 24 => self::Current,
|
||||||
|
$hours < 48 => self::Recent,
|
||||||
|
$hours < 120 => self::Stale,
|
||||||
|
default => self::Outdated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function weight(): int
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Current => 0,
|
||||||
|
self::Recent => 1,
|
||||||
|
self::Stale => 2,
|
||||||
|
self::Outdated => 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Current => 'Current',
|
||||||
|
self::Recent => 'Recent',
|
||||||
|
self::Stale => 'Stale',
|
||||||
|
self::Outdated => 'Outdated',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Enums\PriceClassification;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Api\NearbyStationsRequest;
|
use App\Http\Requests\Api\NearbyStationsRequest;
|
||||||
use App\Http\Resources\Api\StationResource;
|
use App\Http\Resources\Api\StationResource;
|
||||||
@@ -10,6 +11,7 @@ use App\Models\Station;
|
|||||||
use App\Services\PostcodeService;
|
use App\Services\PostcodeService;
|
||||||
use Illuminate\Database\Query\JoinClause;
|
use Illuminate\Database\Query\JoinClause;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class StationController extends Controller
|
class StationController extends Controller
|
||||||
@@ -52,15 +54,21 @@ class StationController extends Controller
|
|||||||
->where('stations.permanent_closure', false)
|
->where('stations.permanent_closure', false)
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$stations = $all
|
$filtered = $all->filter(fn ($s) => (float) $s->distance_km <= $radius);
|
||||||
->filter(fn ($s) => (float) $s->distance_km <= $radius)
|
|
||||||
->sortBy(match ($sort) {
|
$stations = $sort === 'reliable'
|
||||||
|
? $filtered->sortBy([
|
||||||
|
fn ($s) => PriceClassification::fromUpdatedAt(
|
||||||
|
$s->price_effective_at ? Carbon::parse($s->price_effective_at) : null
|
||||||
|
)->weight(),
|
||||||
|
fn ($s) => (int) $s->price_pence,
|
||||||
|
])->values()
|
||||||
|
: $filtered->sortBy(match ($sort) {
|
||||||
'price' => fn ($s) => (int) $s->price_pence,
|
'price' => fn ($s) => (int) $s->price_pence,
|
||||||
'updated' => fn ($s) => $s->price_effective_at ? -strtotime($s->price_effective_at) : PHP_INT_MAX,
|
'updated' => fn ($s) => $s->price_effective_at ? -strtotime($s->price_effective_at) : PHP_INT_MAX,
|
||||||
'brand' => fn ($s) => strtolower((string) $s->brand_name),
|
'brand' => fn ($s) => strtolower((string) $s->brand_name),
|
||||||
default => fn ($s) => (float) $s->distance_km,
|
default => fn ($s) => (float) $s->distance_km,
|
||||||
})
|
})->values();
|
||||||
->values();
|
|
||||||
|
|
||||||
$prices = $stations->pluck('price_pence');
|
$prices = $stations->pluck('price_pence');
|
||||||
|
|
||||||
@@ -82,6 +90,8 @@ class StationController extends Controller
|
|||||||
'count' => $stations->count(),
|
'count' => $stations->count(),
|
||||||
'fuel_type' => $fuelType->value,
|
'fuel_type' => $fuelType->value,
|
||||||
'radius_km' => $radius,
|
'radius_km' => $radius,
|
||||||
|
'lat' => $lat,
|
||||||
|
'lng' => $lng,
|
||||||
'lowest_pence' => $prices->min(),
|
'lowest_pence' => $prices->min(),
|
||||||
'highest_pence' => $prices->max(),
|
'highest_pence' => $prices->max(),
|
||||||
'cheapest_price_pence' => $prices->min(),
|
'cheapest_price_pence' => $prices->min(),
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ class NearbyStationsRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'postcode' => ['nullable', 'string', 'max:10'],
|
'postcode' => ['nullable', 'string', 'max:100'],
|
||||||
'lat' => ['required_without:postcode', 'nullable', 'numeric', 'between:-90,90'],
|
'lat' => ['required_without:postcode', 'nullable', 'numeric', 'between:-90,90'],
|
||||||
'lng' => ['required_without:postcode', 'nullable', 'numeric', 'between:-180,180'],
|
'lng' => ['required_without:postcode', 'nullable', 'numeric', 'between:-180,180'],
|
||||||
'fuel_type' => ['required', 'string'],
|
'fuel_type' => ['required', 'string'],
|
||||||
'radius' => ['nullable', 'numeric', 'between:0.1,50'],
|
'radius' => ['nullable', 'numeric', 'between:0.1,50'],
|
||||||
'sort' => ['nullable', 'string', 'in:price,distance,updated,brand'],
|
'sort' => ['nullable', 'string', 'in:price,distance,updated,brand,reliable'],
|
||||||
'pricing_mode' => ['nullable', 'string', 'in:pump'],
|
'pricing_mode' => ['nullable', 'string', 'in:pump'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Resources\Api;
|
namespace App\Http\Resources\Api;
|
||||||
|
|
||||||
|
use App\Enums\PriceClassification;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -26,6 +27,12 @@ class StationResource extends JsonResource
|
|||||||
'price_updated_at' => $this->price_effective_at
|
'price_updated_at' => $this->price_effective_at
|
||||||
? Carbon::parse($this->price_effective_at)->toISOString()
|
? Carbon::parse($this->price_effective_at)->toISOString()
|
||||||
: null,
|
: null,
|
||||||
|
'price_classification' => PriceClassification::fromUpdatedAt(
|
||||||
|
$this->price_effective_at ? Carbon::parse($this->price_effective_at) : null
|
||||||
|
)->value,
|
||||||
|
'price_classification_label' => PriceClassification::fromUpdatedAt(
|
||||||
|
$this->price_effective_at ? Carbon::parse($this->price_effective_at) : null
|
||||||
|
)->label(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
99
app/Livewire/Public/StationSearch.php
Normal file
99
app/Livewire/Public/StationSearch.php
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Public;
|
||||||
|
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
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 = 'petrol';
|
||||||
|
|
||||||
|
#[Validate('required|integer|min:1|max:20')]
|
||||||
|
public int $radius = 5;
|
||||||
|
|
||||||
|
#[Validate('nullable|string|in:price,distance,updated,brand,reliable')]
|
||||||
|
public string $sort = 'reliable';
|
||||||
|
|
||||||
|
public array $results = [];
|
||||||
|
|
||||||
|
public array $meta = [];
|
||||||
|
|
||||||
|
public ?string $apiError = null;
|
||||||
|
|
||||||
|
public function updatedFuelType(): void
|
||||||
|
{
|
||||||
|
if (! empty($this->meta)) {
|
||||||
|
$this->findStations();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedSort(): void
|
||||||
|
{
|
||||||
|
if (! empty($this->meta)) {
|
||||||
|
$this->findStations();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedRadius(): void
|
||||||
|
{
|
||||||
|
if (! empty($this->meta)) {
|
||||||
|
$this->findStations();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findStations(): void
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
$this->results = [];
|
||||||
|
$this->meta = [];
|
||||||
|
$this->apiError = null;
|
||||||
|
|
||||||
|
$radiusKm = round($this->radius * 1.60934, 2);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::timeout(10)
|
||||||
|
->withHeaders(['X-Api-Key' => config('app.api_secret_key')])
|
||||||
|
->get(url('/api/stations'), [
|
||||||
|
'postcode' => $this->search,
|
||||||
|
'fuel_type' => $this->fuelType,
|
||||||
|
'radius' => $radiusKm,
|
||||||
|
'sort' => $this->sort,
|
||||||
|
]);
|
||||||
|
} catch (ConnectionException) {
|
||||||
|
$this->apiError = 'Unable to fetch stations. Please try again.';
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(): View
|
||||||
|
{
|
||||||
|
return view('livewire.public.station-search');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ class PostcodeService
|
|||||||
|
|
||||||
$cached = Cache::get($cacheKey);
|
$cached = Cache::get($cacheKey);
|
||||||
|
|
||||||
if ($cached !== null) {
|
if ($cached instanceof LocationResult) {
|
||||||
return $cached;
|
return $cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
490
docs/superpowers/plans/2026-04-06-station-map.md
Normal file
490
docs/superpowers/plans/2026-04-06-station-map.md
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
# Station Map (Leaflet + OSM) 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:** Add an interactive Leaflet/OSM map to the station search page that plots colour-coded markers for each result and centres on the searched location.
|
||||||
|
|
||||||
|
**Architecture:** A Leaflet map is managed by an Alpine.js component (`stationMap`) registered via `alpine:init`. The component receives the Livewire `results` array via `@entangle` and re-plots markers reactively whenever the data changes. The API meta response is extended to include the resolved search `lat`/`lng` so the map can centre precisely on the search point.
|
||||||
|
|
||||||
|
**Tech Stack:** Leaflet 1.x (npm), Alpine.js (bundled with Livewire 4), Livewire `@entangle`, Tailwind CSS v4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| Action | File | Responsibility |
|
||||||
|
|--------|------|----------------|
|
||||||
|
| Modify | `package.json` | Add `leaflet` npm dependency |
|
||||||
|
| Modify | `resources/css/app.css` | Import Leaflet CSS |
|
||||||
|
| Create | `resources/js/maps/station-map.js` | Alpine component: map init, marker plotting, colour logic, popups |
|
||||||
|
| Modify | `resources/js/app.js` | Register `stationMap` Alpine component on `alpine:init` |
|
||||||
|
| Modify | `app/Http/Controllers/Api/StationController.php` | Add `lat`/`lng` to `meta` response |
|
||||||
|
| Modify | `resources/views/livewire/public/station-search.blade.php` | Add map `<div>` with `x-data` above the results list |
|
||||||
|
| Modify | `tests/Feature/Api/StationControllerTest.php` | Assert `meta.lat` and `meta.lng` are present |
|
||||||
|
| Modify | `tests/Feature/Livewire/StationSearchTest.php` | Add `lat`/`lng` to the faked `meta` fixture |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Install Leaflet and import its CSS
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `package.json`
|
||||||
|
- Modify: `resources/css/app.css`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Install leaflet via npm**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/bitstream/code/fuel-price && npm install leaflet
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `leaflet` appears in `node_modules/leaflet` and `package.json` `dependencies`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Import Leaflet CSS in app.css**
|
||||||
|
|
||||||
|
Add this line at the top of `resources/css/app.css` (before the Tailwind import):
|
||||||
|
|
||||||
|
```css
|
||||||
|
@import 'leaflet/dist/leaflet.css';
|
||||||
|
@import 'tailwindcss';
|
||||||
|
@import '../../vendor/livewire/flux/dist/flux.css';
|
||||||
|
/* … rest unchanged … */
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify build succeeds**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/bitstream/code/fuel-price && npm run build 2>&1 | tail -5
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build completes without errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add package.json package-lock.json resources/css/app.css
|
||||||
|
git commit -m "feat: install leaflet and import CSS"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Add lat/lng to API meta response + test
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/Http/Controllers/Api/StationController.php:87-98`
|
||||||
|
- Modify: `tests/Feature/Api/StationControllerTest.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write a failing test**
|
||||||
|
|
||||||
|
Add this test to `tests/Feature/Api/StationControllerTest.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
it('includes resolved lat and lng in meta', function () {
|
||||||
|
$station = Station::factory()->create(['lat' => 52.555064, 'lng' => -0.256119]);
|
||||||
|
StationPriceCurrent::factory()->create([
|
||||||
|
'station_id' => $station->node_id,
|
||||||
|
'fuel_type' => FuelType::B7Standard,
|
||||||
|
'price_pence' => 14500,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('meta.lat', 52.555064)
|
||||||
|
->assertJsonPath('meta.lng', -0.256119);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to confirm it fails**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/bitstream/code/fuel-price && php artisan test --compact tests/Feature/Api/StationControllerTest.php --timeout=10 2>&1 | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — `meta.lat` not found.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add lat/lng to the meta array in the controller**
|
||||||
|
|
||||||
|
In `app/Http/Controllers/Api/StationController.php`, change the `return response()->json([...])` block (around line 87) from:
|
||||||
|
|
||||||
|
```php
|
||||||
|
return response()->json([
|
||||||
|
'data' => StationResource::collection($stations),
|
||||||
|
'meta' => [
|
||||||
|
'count' => $stations->count(),
|
||||||
|
'fuel_type' => $fuelType->value,
|
||||||
|
'radius_km' => $radius,
|
||||||
|
'lowest_pence' => $prices->min(),
|
||||||
|
'highest_pence' => $prices->max(),
|
||||||
|
'cheapest_price_pence' => $prices->min(),
|
||||||
|
'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```php
|
||||||
|
return response()->json([
|
||||||
|
'data' => StationResource::collection($stations),
|
||||||
|
'meta' => [
|
||||||
|
'count' => $stations->count(),
|
||||||
|
'fuel_type' => $fuelType->value,
|
||||||
|
'radius_km' => $radius,
|
||||||
|
'lat' => $lat,
|
||||||
|
'lng' => $lng,
|
||||||
|
'lowest_pence' => $prices->min(),
|
||||||
|
'highest_pence' => $prices->max(),
|
||||||
|
'cheapest_price_pence' => $prices->min(),
|
||||||
|
'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to confirm they pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/bitstream/code/fuel-price && php artisan test --compact tests/Feature/Api/StationControllerTest.php --timeout=10 2>&1 | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: All tests PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run pint**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/bitstream/code/fuel-price && vendor/bin/pint --dirty --format agent
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/Http/Controllers/Api/StationController.php tests/Feature/Api/StationControllerTest.php
|
||||||
|
git commit -m "feat: include search lat/lng in station API meta response"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Create the station-map Alpine component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `resources/js/maps/station-map.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the directory and file**
|
||||||
|
|
||||||
|
Create `resources/js/maps/station-map.js` with this content:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import L from 'leaflet';
|
||||||
|
|
||||||
|
// Fix Leaflet's broken default icon paths when bundled with Vite
|
||||||
|
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
|
||||||
|
import markerIcon from 'leaflet/dist/images/marker-icon.png';
|
||||||
|
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
|
||||||
|
|
||||||
|
delete L.Icon.Default.prototype._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconUrl: markerIcon,
|
||||||
|
iconRetinaUrl: markerIcon2x,
|
||||||
|
shadowUrl: markerShadow,
|
||||||
|
});
|
||||||
|
|
||||||
|
const CLASSIFICATION_COLOURS = {
|
||||||
|
current: '#22c55e', // green-500 — price updated within 24h
|
||||||
|
recent: '#64748b', // slate-500 — 24–48h
|
||||||
|
stale: '#f59e0b', // amber-500 — 48–120h
|
||||||
|
outdated: '#ef4444', // red-500 — 120h+
|
||||||
|
};
|
||||||
|
|
||||||
|
const UK_CENTRE = [54.0, -2.0];
|
||||||
|
const UK_ZOOM = 6;
|
||||||
|
|
||||||
|
export function stationMap(results, meta) {
|
||||||
|
return {
|
||||||
|
results,
|
||||||
|
meta,
|
||||||
|
_map: null,
|
||||||
|
_markers: [],
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this._map = L.map(this.$el, { zoomControl: true }).setView(UK_CENTRE, UK_ZOOM);
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
|
maxZoom: 19,
|
||||||
|
}).addTo(this._map);
|
||||||
|
|
||||||
|
// Plot immediately if results are already available (e.g. after page reload)
|
||||||
|
if (this.results && this.results.length > 0) {
|
||||||
|
this._plotMarkers();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$watch('results', () => this._plotMarkers());
|
||||||
|
},
|
||||||
|
|
||||||
|
_clearMarkers() {
|
||||||
|
this._markers.forEach((m) => m.remove());
|
||||||
|
this._markers = [];
|
||||||
|
},
|
||||||
|
|
||||||
|
_plotMarkers() {
|
||||||
|
this._clearMarkers();
|
||||||
|
|
||||||
|
if (!this.results || this.results.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bounds = [];
|
||||||
|
|
||||||
|
this.results.forEach((station) => {
|
||||||
|
const colour = CLASSIFICATION_COLOURS[station.price_classification] ?? '#64748b';
|
||||||
|
const miles = (station.distance_km * 0.621371).toFixed(1);
|
||||||
|
const supermarketTag = station.is_supermarket
|
||||||
|
? '<span style="display:inline-block;background:#84cc16;color:#fff;font-size:10px;padding:1px 5px;border-radius:3px;margin-left:4px;">Supermarket</span>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const popup = `
|
||||||
|
<div style="min-width:160px">
|
||||||
|
<strong style="font-size:13px">${station.name}</strong>${supermarketTag}<br>
|
||||||
|
<span style="font-size:20px;font-weight:700;color:${colour}">${Number(station.price).toFixed(1)}p</span><br>
|
||||||
|
<span style="font-size:12px;color:#6b7280">${miles} miles away</span><br>
|
||||||
|
<span style="font-size:11px;color:#9ca3af">${station.address}, ${station.postcode}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const marker = L.circleMarker([station.lat, station.lng], {
|
||||||
|
radius: 9,
|
||||||
|
fillColor: colour,
|
||||||
|
color: '#ffffff',
|
||||||
|
weight: 2,
|
||||||
|
opacity: 1,
|
||||||
|
fillOpacity: 0.85,
|
||||||
|
}).bindPopup(popup);
|
||||||
|
|
||||||
|
marker.addTo(this._map);
|
||||||
|
this._markers.push(marker);
|
||||||
|
bounds.push([station.lat, station.lng]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Centre on search point from meta if available, else fit station bounds
|
||||||
|
if (this.meta && this.meta.lat && this.meta.lng) {
|
||||||
|
this._map.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 });
|
||||||
|
} else {
|
||||||
|
this._map.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify no syntax errors**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/bitstream/code/fuel-price && node --input-type=module < resources/js/maps/station-map.js 2>&1 | head -5
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Either no output (success) or only an import error about Leaflet not being in Node context — that's fine; the goal is no syntax errors.
|
||||||
|
|
||||||
|
Actually run the build instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/bitstream/code/fuel-price && npm run build 2>&1 | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeds (no parse/import errors).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add resources/js/maps/station-map.js
|
||||||
|
git commit -m "feat: create station-map Alpine/Leaflet component"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Register the Alpine component in app.js
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `resources/js/app.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update app.js**
|
||||||
|
|
||||||
|
Replace the entire content of `resources/js/app.js` with:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { stationMap } from './maps/station-map.js';
|
||||||
|
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('stationMap', stationMap);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/bitstream/code/fuel-price && npm run build 2>&1 | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add resources/js/app.js
|
||||||
|
git commit -m "feat: register stationMap Alpine component"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Add map to the station-search Blade template
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `resources/views/livewire/public/station-search.blade.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add map legend and map div inside the results section**
|
||||||
|
|
||||||
|
In `resources/views/livewire/public/station-search.blade.php`, locate the `@if (! empty($results))` block (around line 71). Replace the existing `<div class="space-y-2">` results list section with a version that includes the map above it:
|
||||||
|
|
||||||
|
Replace this block (starting at line 71):
|
||||||
|
|
||||||
|
```blade
|
||||||
|
@if (! empty($results))
|
||||||
|
<p class="mb-3 text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
{{ $meta['count'] }} {{ str('station')->plural($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">
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
@if (! empty($results))
|
||||||
|
<p class="mb-3 text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
{{ $meta['count'] }} {{ str('station')->plural($meta['count']) }} found
|
||||||
|
· Cheapest: {{ number_format($meta['lowest_pence'] / 100, 1) }}p
|
||||||
|
· Average: {{ number_format($meta['avg_pence'] / 100, 1) }}p
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{-- Map --}}
|
||||||
|
<div
|
||||||
|
x-data="stationMap(@entangle('results'), @entangle('meta'))"
|
||||||
|
class="mb-4 h-72 overflow-hidden rounded-xl border border-neutral-200 sm:h-96 dark:border-neutral-700"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
{{-- Legend --}}
|
||||||
|
<div class="mb-3 flex flex-wrap gap-3 text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
<span class="flex items-center gap-1"><span class="inline-block size-3 rounded-full bg-green-500"></span> Current (<24h)</span>
|
||||||
|
<span class="flex items-center gap-1"><span class="inline-block size-3 rounded-full bg-slate-500"></span> Recent (24–48h)</span>
|
||||||
|
<span class="flex items-center gap-1"><span class="inline-block size-3 rounded-full bg-amber-500"></span> Stale (2–5 days)</span>
|
||||||
|
<span class="flex items-center gap-1"><span class="inline-block size-3 rounded-full bg-red-500"></span> Outdated (5+ days)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/bitstream/code/fuel-price && npm run build 2>&1 | tail -5
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add resources/views/livewire/public/station-search.blade.php
|
||||||
|
git commit -m "feat: add Leaflet map and colour legend to station search results"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Update Livewire component test meta fixture
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/Feature/Livewire/StationSearchTest.php`
|
||||||
|
|
||||||
|
The existing tests fake the API response with a `meta` array. Now that the real API returns `lat`/`lng`, the fakes should match to prevent subtle mismatches in future tests.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update the faked meta in `populates results and meta on successful search`**
|
||||||
|
|
||||||
|
In `tests/Feature/Livewire/StationSearchTest.php`, find the `'meta'` array in the `Http::fake` response (around line 55) and add `lat` and `lng`:
|
||||||
|
|
||||||
|
Change:
|
||||||
|
|
||||||
|
```php
|
||||||
|
'meta' => [
|
||||||
|
'count' => 1,
|
||||||
|
'fuel_type' => 'e10',
|
||||||
|
'radius_km' => 8.05,
|
||||||
|
'lowest_pence' => 14390,
|
||||||
|
'highest_pence' => 14390,
|
||||||
|
'cheapest_price_pence' => 14390,
|
||||||
|
'avg_pence' => 14390.0,
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```php
|
||||||
|
'meta' => [
|
||||||
|
'count' => 1,
|
||||||
|
'fuel_type' => 'e10',
|
||||||
|
'radius_km' => 8.05,
|
||||||
|
'lat' => 51.5010,
|
||||||
|
'lng' => -0.1415,
|
||||||
|
'lowest_pence' => 14390,
|
||||||
|
'highest_pence' => 14390,
|
||||||
|
'cheapest_price_pence' => 14390,
|
||||||
|
'avg_pence' => 14390.0,
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run full Livewire test file**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/bitstream/code/fuel-price && php artisan test --compact tests/Feature/Livewire/StationSearchTest.php --timeout=10 2>&1 | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: All tests PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run pint**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/bitstream/code/fuel-price && vendor/bin/pint --dirty --format agent
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run all modified test files one final time**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/bitstream/code/fuel-price && php artisan test --compact tests/Feature/Api/StationControllerTest.php tests/Feature/Livewire/StationSearchTest.php --timeout=10 2>&1 | tail -15
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: All tests PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/Feature/Livewire/StationSearchTest.php
|
||||||
|
git commit -m "test: add lat/lng to faked meta fixture in StationSearchTest"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage:**
|
||||||
|
|
||||||
|
| Requirement | Task |
|
||||||
|
|---|---|
|
||||||
|
| Map centred on user's location | Task 2 adds lat/lng to meta; Task 3 uses it for `fitBounds` |
|
||||||
|
| Plots station markers with price info in popup | Task 3 — `_plotMarkers()` with `bindPopup` |
|
||||||
|
| Colour-codes markers by price classification | Task 3 — `CLASSIFICATION_COLOURS` map; Task 5 — legend |
|
||||||
|
| Leaflet + OSM (no API costs) | Task 1 — OSM tile layer, no API key needed |
|
||||||
|
|
||||||
|
**Placeholder scan:** No TBD or TODO in plan tasks. All code blocks are complete.
|
||||||
|
|
||||||
|
**Type consistency:**
|
||||||
|
- `stationMap(results, meta)` — factory signature matches `x-data="stationMap(@entangle('results'), @entangle('meta'))"` in Task 5.
|
||||||
|
- `_plotMarkers()`, `_clearMarkers()` — consistent across Task 3.
|
||||||
|
- `CLASSIFICATION_COLOURS` keyed on `current/recent/stale/outdated` — matches `PriceClassification` enum values and `StationResource` output.
|
||||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
"laravel-vite-plugin": "^3.0.0",
|
"laravel-vite-plugin": "^3.0.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"tailwindcss": "^4.0.7",
|
"tailwindcss": "^4.0.7",
|
||||||
"vite": "^8.0.0"
|
"vite": "^8.0.0"
|
||||||
},
|
},
|
||||||
@@ -1038,6 +1039,12 @@
|
|||||||
"vite": "^8.0.0"
|
"vite": "^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
"laravel-vite-plugin": "^3.0.0",
|
"laravel-vite-plugin": "^3.0.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"tailwindcss": "^4.0.7",
|
"tailwindcss": "^4.0.7",
|
||||||
"vite": "^8.0.0"
|
"vite": "^8.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
@import 'leaflet/dist/leaflet.css';
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
@import '../../vendor/livewire/flux/dist/flux.css';
|
@import '../../vendor/livewire/flux/dist/flux.css';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { stationMap } from './maps/station-map.js';
|
||||||
|
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('stationMap', stationMap);
|
||||||
|
});
|
||||||
|
|||||||
114
resources/js/maps/station-map.js
Normal file
114
resources/js/maps/station-map.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import L from 'leaflet';
|
||||||
|
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
|
||||||
|
import markerIcon from 'leaflet/dist/images/marker-icon.png';
|
||||||
|
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
|
||||||
|
|
||||||
|
delete L.Icon.Default.prototype._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconUrl: markerIcon,
|
||||||
|
iconRetinaUrl: markerIcon2x,
|
||||||
|
shadowUrl: markerShadow,
|
||||||
|
});
|
||||||
|
|
||||||
|
function escHtml(str) {
|
||||||
|
return String(str ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLASSIFICATION_COLOURS = {
|
||||||
|
current: '#22c55e',
|
||||||
|
recent: '#64748b',
|
||||||
|
stale: '#f59e0b',
|
||||||
|
outdated: '#ef4444',
|
||||||
|
};
|
||||||
|
|
||||||
|
const UK_CENTRE = [54.0, -2.0];
|
||||||
|
const UK_ZOOM = 6;
|
||||||
|
|
||||||
|
export function stationMap(results) {
|
||||||
|
return {
|
||||||
|
results,
|
||||||
|
_map: null,
|
||||||
|
_markers: [],
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this._map = L.map(this.$el, { zoomControl: true }).setView(UK_CENTRE, UK_ZOOM);
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
|
maxZoom: 19,
|
||||||
|
}).addTo(this._map);
|
||||||
|
|
||||||
|
if (this.results && this.results.length > 0) {
|
||||||
|
this._plotMarkers();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$watch('results', () => this._plotMarkers());
|
||||||
|
},
|
||||||
|
|
||||||
|
_clearMarkers() {
|
||||||
|
this._markers.forEach((m) => m.remove());
|
||||||
|
this._markers = [];
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this._map) {
|
||||||
|
this._map.remove();
|
||||||
|
this._map = null;
|
||||||
|
this._markers = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_plotMarkers() {
|
||||||
|
if (!this._map) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._clearMarkers();
|
||||||
|
|
||||||
|
if (!this.results || this.results.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bounds = [];
|
||||||
|
|
||||||
|
this.results.forEach((station) => {
|
||||||
|
const colour = escHtml(CLASSIFICATION_COLOURS[station.price_classification] ?? '#64748b');
|
||||||
|
const miles = (station.distance_km * 0.621371).toFixed(1);
|
||||||
|
const supermarketTag = station.is_supermarket
|
||||||
|
? '<span style="display:inline-block;background:#84cc16;color:#fff;font-size:10px;padding:1px 5px;border-radius:3px;margin-left:4px;">Supermarket</span>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const popup = `
|
||||||
|
<div style="min-width:160px">
|
||||||
|
<strong style="font-size:13px">${escHtml(station.name)}</strong>${supermarketTag}<br>
|
||||||
|
<span style="font-size:20px;font-weight:700;color:${colour}">${Number(station.price).toFixed(1)}p</span><br>
|
||||||
|
<span style="font-size:12px;color:#6b7280">${escHtml(miles)} miles away</span><br>
|
||||||
|
<span style="font-size:11px;color:#9ca3af">${escHtml(station.address)}, ${escHtml(station.postcode)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const marker = L.circleMarker([station.lat, station.lng], {
|
||||||
|
radius: 9,
|
||||||
|
fillColor: colour,
|
||||||
|
color: '#ffffff',
|
||||||
|
weight: 2,
|
||||||
|
opacity: 1,
|
||||||
|
fillOpacity: 0.85,
|
||||||
|
}).bindPopup(popup);
|
||||||
|
|
||||||
|
marker.addTo(this._map);
|
||||||
|
this._markers.push(marker);
|
||||||
|
bounds.push([station.lat, station.lng]);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bounds.length === 1) {
|
||||||
|
this._map.setView(bounds[0], 14);
|
||||||
|
} else {
|
||||||
|
this._map.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
138
resources/views/livewire/public/station-search.blade.php
Normal file
138
resources/views/livewire/public/station-search.blade.php
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<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.live="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.live="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 class="w-full sm:w-40">
|
||||||
|
<flux:select wire:model.live="sort" name="sort" label="Sort by">
|
||||||
|
<flux:select.option value="reliable">Best price (reliable)</flux:select.option>
|
||||||
|
<flux:select.option value="price">Cheapest first</flux:select.option>
|
||||||
|
<flux:select.option value="distance">Nearest first</flux:select.option>
|
||||||
|
<flux:select.option value="updated">Recently updated</flux:select.option>
|
||||||
|
<flux:select.option value="brand">Brand A–Z</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('station')->plural($meta['count']) }} found
|
||||||
|
· Cheapest: {{ number_format($meta['lowest_pence'] / 100, 1) }}p
|
||||||
|
· Average: {{ number_format($meta['avg_pence'] / 100, 1) }}p
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{-- Map --}}
|
||||||
|
<div
|
||||||
|
x-data="stationMap(@entangle('results'))"
|
||||||
|
class="mb-4 h-72 overflow-hidden rounded-xl border border-neutral-200 sm:h-96 dark:border-neutral-700"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
{{-- Legend --}}
|
||||||
|
<div class="mb-3 flex flex-wrap gap-3 text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
<span class="flex items-center gap-1"><span class="inline-block size-3 rounded-full bg-green-500"></span> Current (<24h)</span>
|
||||||
|
<span class="flex items-center gap-1"><span class="inline-block size-3 rounded-full bg-slate-500"></span> Recent (24–48h)</span>
|
||||||
|
<span class="flex items-center gap-1"><span class="inline-block size-3 rounded-full bg-amber-500"></span> Stale (2–5 days)</span>
|
||||||
|
<span class="flex items-center gap-1"><span class="inline-block size-3 rounded-full bg-red-500"></span> Outdated (5+ days)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
{{ number_format($station['price'], 1) }}p
|
||||||
|
</p>
|
||||||
|
<p class="text-xs {{ match($station['price_classification']) {
|
||||||
|
'current' => 'text-green-500 dark:text-green-400',
|
||||||
|
'recent' => 'text-zinc-400 dark:text-zinc-500',
|
||||||
|
'stale' => 'text-amber-500 dark:text-amber-400',
|
||||||
|
'outdated' => 'text-red-500 dark:text-red-400',
|
||||||
|
default => 'text-zinc-400 dark:text-zinc-500',
|
||||||
|
} }}">
|
||||||
|
{{ $station['price_classification_label'] }}
|
||||||
|
·
|
||||||
|
{{ $station['price_updated_at'] ? \Carbon\Carbon::parse($station['price_updated_at'])->diffForHumans() : 'Unknown' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<p class="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
|
No stations found within {{ $radius }} {{ str('mile')->plural($radius) }} of "{{ $search }}".
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@@ -1,13 +1,25 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\AuthController;
|
||||||
use App\Http\Controllers\Api\PredictionController;
|
use App\Http\Controllers\Api\PredictionController;
|
||||||
use App\Http\Controllers\Api\StationController;
|
use App\Http\Controllers\Api\StationController;
|
||||||
use App\Http\Controllers\Api\StatsController;
|
use App\Http\Controllers\Api\StatsController;
|
||||||
use App\Http\Middleware\VerifyApiKey;
|
use App\Http\Middleware\VerifyApiKey;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
// Public endpoints (no API key required)
|
||||||
|
Route::post('/auth/register', [AuthController::class, 'register']);
|
||||||
|
Route::post('/auth/login', [AuthController::class, 'login']);
|
||||||
|
|
||||||
|
// Protected endpoints (API key required)
|
||||||
Route::middleware(['throttle:60,1', VerifyApiKey::class])->group(function (): void {
|
Route::middleware(['throttle:60,1', VerifyApiKey::class])->group(function (): void {
|
||||||
Route::get('/stations', [StationController::class, 'index']);
|
Route::get('/stations', [StationController::class, 'index']);
|
||||||
Route::get('/stats/searches', [StatsController::class, 'searches']);
|
Route::get('/stats/searches', [StatsController::class, 'searches']);
|
||||||
Route::get('/prediction', [PredictionController::class, 'index']);
|
Route::get('/prediction', [PredictionController::class, 'index']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sanctum-authenticated endpoints
|
||||||
|
Route::middleware('auth:sanctum')->group(function (): void {
|
||||||
|
Route::get('/auth/me', [AuthController::class, 'me']);
|
||||||
|
Route::post('/auth/logout', [AuthController::class, 'logout']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
|||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]);
|
||||||
|
});
|
||||||
|
|
||||||
it('registers a new user and returns a token', function () {
|
it('registers a new user and returns a token', function () {
|
||||||
$this->postJson('/api/auth/register', [
|
$this->postJson('/api/auth/register', [
|
||||||
'name' => 'Test User',
|
'name' => 'Test User',
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
|||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns a prediction response for diesel', function () {
|
it('returns a prediction response for diesel', function () {
|
||||||
$this->getJson('/api/prediction?fuel_type=diesel')
|
$this->getJson('/api/prediction?fuel_type=diesel')
|
||||||
->assertOk()
|
->assertOk()
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ use Illuminate\Support\Facades\Http;
|
|||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns stations near coordinates filtered by fuel type', function () {
|
it('returns stations near coordinates filtered by fuel type', function () {
|
||||||
$station = Station::factory()->create(['lat' => 52.555064, 'lng' => -0.256119]);
|
$station = Station::factory()->create(['lat' => 52.555064, 'lng' => -0.256119]);
|
||||||
StationPriceCurrent::factory()->create([
|
StationPriceCurrent::factory()->create([
|
||||||
@@ -153,3 +157,38 @@ it('returns 422 when postcode cannot be resolved', function () {
|
|||||||
->assertUnprocessable()
|
->assertUnprocessable()
|
||||||
->assertJsonValidationErrors(['postcode']);
|
->assertJsonValidationErrors(['postcode']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes resolved lat and lng in meta', function () {
|
||||||
|
$station = Station::factory()->create(['lat' => 52.555064, 'lng' => -0.256119]);
|
||||||
|
StationPriceCurrent::factory()->create([
|
||||||
|
'station_id' => $station->node_id,
|
||||||
|
'fuel_type' => FuelType::B7Standard,
|
||||||
|
'price_pence' => 14500,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('meta.lat', 52.555064)
|
||||||
|
->assertJsonPath('meta.lng', -0.256119);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes resolved lat and lng in meta when postcode is provided', function () {
|
||||||
|
$station = Station::factory()->create(['lat' => 51.5010, 'lng' => -0.1415]);
|
||||||
|
StationPriceCurrent::factory()->create([
|
||||||
|
'station_id' => $station->node_id,
|
||||||
|
'fuel_type' => FuelType::E10,
|
||||||
|
'price_pence' => 14200,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'api.postcodes.io/postcodes/SW1A1AA' => Http::response([
|
||||||
|
'status' => 200,
|
||||||
|
'result' => ['postcode' => 'SW1A 1AA', 'latitude' => 51.5010, 'longitude' => -0.1415],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->getJson('/api/stations?postcode=SW1A+1AA&fuel_type=e10&radius=1')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('meta.lat', 51.5010)
|
||||||
|
->assertJsonPath('meta.lng', -0.1415);
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
|
|||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns search stats for current week', function () {
|
it('returns search stats for current week', function () {
|
||||||
// 10 searches within the rolling 7 days (3 unique IPs)
|
// 10 searches within the rolling 7 days (3 unique IPs)
|
||||||
Search::factory()->count(5)->create([
|
Search::factory()->count(5)->create([
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ it('validates search is required', function () {
|
|||||||
it('validates fuelType is required', function () {
|
it('validates fuelType is required', function () {
|
||||||
Livewire::test(StationSearch::class)
|
Livewire::test(StationSearch::class)
|
||||||
->set('search', 'SW1A 1AA')
|
->set('search', 'SW1A 1AA')
|
||||||
|
->set('fuelType', '')
|
||||||
->call('findStations')
|
->call('findStations')
|
||||||
->assertHasErrors(['fuelType' => 'required']);
|
->assertHasErrors(['fuelType' => 'required']);
|
||||||
});
|
});
|
||||||
@@ -46,12 +47,16 @@ it('populates results and meta on successful search', function () {
|
|||||||
'price_pence' => 14390,
|
'price_pence' => 14390,
|
||||||
'price' => 143.9,
|
'price' => 143.9,
|
||||||
'price_updated_at' => '2026-04-05T08:00:00.000Z',
|
'price_updated_at' => '2026-04-05T08:00:00.000Z',
|
||||||
|
'price_classification' => 'current',
|
||||||
|
'price_classification_label' => 'Current',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'count' => 1,
|
'count' => 1,
|
||||||
'fuel_type' => 'e10',
|
'fuel_type' => 'e10',
|
||||||
'radius_km' => 8.05,
|
'radius_km' => 8.05,
|
||||||
|
'lat' => 51.5010,
|
||||||
|
'lng' => -0.1415,
|
||||||
'lowest_pence' => 14390,
|
'lowest_pence' => 14390,
|
||||||
'highest_pence' => 14390,
|
'highest_pence' => 14390,
|
||||||
'cheapest_price_pence' => 14390,
|
'cheapest_price_pence' => 14390,
|
||||||
|
|||||||
Reference in New Issue
Block a user