From 7b6aaac661c0f7717bc3ae7edad6e401243d6421 Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Wed, 8 Apr 2026 09:24:40 +0100 Subject: [PATCH] chore: remove StationSearch, dead Volt SFCs, mobile prototype, and fix homepage CTAs --- .DS_Store | Bin 0 -> 6148 bytes app/Livewire/Public/StationSearch.php | 99 -- ...026-04-07-fuelfinder-subcomponent-split.md | 1249 +++++++++++++++++ resources/css/app.css | 79 +- .../views/components/fuel/forecast.blade.php | 30 +- .../components/fuel/radius-select.blade.php | 17 +- .../components/fuel/recommendation.blade.php | 29 +- .../components/fuel/sort-select.blade.php | 17 +- .../components/fuel/station-card.blade.php | 20 +- .../components/fuel/type-select.blade.php | 19 +- .../views/components/mobile-footer.blade.php | 26 - .../views/components/mobile-header.blade.php | 17 - .../components/public/⚡fuel-finder.blade.php | 13 - resources/views/homepage.blade.php | 8 +- resources/views/layouts/shell.blade.php | 9 + .../livewire/public/station-search.blade.php | 138 -- resources/views/mobile.blade.php | 620 -------- resources/views/partials/head.blade.php | 2 +- routes/web.php | 5 - tests/Feature/Livewire/StationSearchTest.php | 140 -- 20 files changed, 1379 insertions(+), 1158 deletions(-) create mode 100644 .DS_Store delete mode 100644 app/Livewire/Public/StationSearch.php create mode 100644 docs/superpowers/plans/2026-04-07-fuelfinder-subcomponent-split.md delete mode 100644 resources/views/components/mobile-footer.blade.php delete mode 100644 resources/views/components/mobile-header.blade.php delete mode 100644 resources/views/components/public/⚡fuel-finder.blade.php create mode 100644 resources/views/layouts/shell.blade.php delete mode 100644 resources/views/livewire/public/station-search.blade.php delete mode 100644 resources/views/mobile.blade.php delete mode 100644 tests/Feature/Livewire/StationSearchTest.php diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..6036a8475b497112a5b97eee2bc38ad722f46099 GIT binary patch literal 6148 zcmeH~ze)o^5XNUTLcqe5mir36!4hI&?F+~y3c|t_!T!2DwtnBvC^?RRm4$o*v)}FP z%ir$Zr#9|~HV%v2O99s2*qgmuvsXK> z=O^9KKhx%!hvU8thM^_H5evHdL($h=2(E5a9Yy;MAHrh4!Tb z4Ug{}Ur}jz?so}j$$m|pLNNmKD1}C;=M}@F9PyHQHFXM&a`Qej-jg$X-cY>Hj(F+m z<~3BU2#CO(z;$#t-2Wfp7w-S_BK1T-1pXBPU+tcETl`Y)t&5-IUR&TV@NYwIPe(I5 h1favM`2H-f@ELhEbqej}i1%`+4g%yN6M?@V@C8k~Avgd4 literal 0 HcmV?d00001 diff --git a/app/Livewire/Public/StationSearch.php b/app/Livewire/Public/StationSearch.php deleted file mode 100644 index 578fe83..0000000 --- a/app/Livewire/Public/StationSearch.php +++ /dev/null @@ -1,99 +0,0 @@ -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'); - } -} diff --git a/docs/superpowers/plans/2026-04-07-fuelfinder-subcomponent-split.md b/docs/superpowers/plans/2026-04-07-fuelfinder-subcomponent-split.md new file mode 100644 index 0000000..5f7b079 --- /dev/null +++ b/docs/superpowers/plans/2026-04-07-fuelfinder-subcomponent-split.md @@ -0,0 +1,1249 @@ +# FuelFinder Sub-Component Split 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:** Split the monolithic `FuelFinder` Livewire component into four focused sub-components (`Search`, `Map`, `StationList`, `Recommendation`) communicating via a `stations-found` browser event, so each part re-renders independently without re-mounting Leaflet. + +**Architecture:** `FuelFinder` becomes a layout-only shell. `Search` owns all API calls and dispatches `stations-found` with results, meta, prediction, and radius. `Map` relays the event to Alpine/Leaflet via `map-update`. `StationList` and `Recommendation` each listen for `stations-found` and re-render their slice of the UI. `Forecast` is unchanged. + +**Tech Stack:** Laravel 13, Livewire 4, Alpine.js, Leaflet.js, Pest 4 + +--- + +## File Map + +**New PHP classes:** +- `app/Livewire/Public/Fuel/Search.php` — search state, API calls, dispatcher +- `app/Livewire/Public/Fuel/Map.php` — event relay, no state +- `app/Livewire/Public/Fuel/StationList.php` — station card list +- `app/Livewire/Public/Fuel/Recommendation.php` — prediction card + +**New Blade views:** +- `resources/views/livewire/public/fuel/search.blade.php` +- `resources/views/livewire/public/fuel/map.blade.php` +- `resources/views/livewire/public/fuel/station-list.blade.php` +- `resources/views/livewire/public/fuel/recommendation.blade.php` + +**New tests:** +- `tests/Feature/Livewire/Fuel/SearchTest.php` +- `tests/Feature/Livewire/Fuel/StationListTest.php` +- `tests/Feature/Livewire/Fuel/RecommendationTest.php` +- `tests/Feature/Livewire/Fuel/MapTest.php` + +**Modified files:** +- `app/Livewire/Public/FuelFinder.php` — strip all state and methods +- `resources/views/livewire/public/fuel-finder.blade.php` — replace sections with `` tags +- `resources/js/maps/station-map.js` — replace `$watch` with `map-update` window event listener +- `resources/views/components/fuel/station-map.blade.php` — remove `@entangle` props +- `tests/Feature/Livewire/FuelFinderTest.php` — strip to render-only test + +--- + +## Task 1: fuel.search component + +**Files:** +- Create: `app/Livewire/Public/Fuel/Search.php` +- Create: `resources/views/livewire/public/fuel/search.blade.php` +- Create: `tests/Feature/Livewire/Fuel/SearchTest.php` + +- [ ] **Step 1: Scaffold the component and test** + +```bash +php artisan make:livewire Public/Fuel/Search --no-interaction +php artisan make:test Feature/Livewire/Fuel/SearchTest --pest --no-interaction +``` + +- [ ] **Step 2: Write the failing tests** + +Replace the contents of `tests/Feature/Livewire/Fuel/SearchTest.php`: + +```php +assertStatus(200) + ->assertSeeHtml('name="search"'); +}); + +it('has default property values', function () { + Livewire::test(Search::class) + ->assertSet('search', '') + ->assertSet('fuelType', 'petrol') + ->assertSet('radius', 5) + ->assertSet('sort', 'reliable') + ->assertSet('apiError', null) + ->assertSet('hasSearched', false); +}); + +it('validates search is required', function () { + Livewire::test(Search::class) + ->call('findStations') + ->assertHasErrors(['search' => 'required']); +}); + +it('validates fuelType is required', function () { + Livewire::test(Search::class) + ->set('search', 'SW1A 1AA') + ->set('fuelType', '') + ->call('findStations') + ->assertHasErrors(['fuelType' => 'required']); +}); + +it('dispatches stations-found with results, meta, prediction and radius 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', + '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', + 'price_classification' => 'current', + 'price_classification_label' => 'Current', + ], + ], + 'meta' => ['count' => 1, 'lowest_pence' => 14390, 'avg_pence' => 14390.0], + ], 200), + '*/api/prediction*' => Http::response([ + 'action' => 'fill_now', + 'confidence_score' => 80.0, + 'confidence_label' => 'high', + 'reasoning' => 'Prices rising.', + 'predicted_direction' => 'up', + 'predicted_change_pence' => 3.5, + ], 200), + ]); + + Livewire::test(Search::class) + ->set('search', 'SW1A 1AA') + ->set('fuelType', 'petrol') + ->call('findStations') + ->assertSet('hasSearched', true) + ->assertSet('apiError', null) + ->assertDispatched('stations-found', fn ($event, $params) => + count($params['results']) === 1 + && $params['results'][0]['name'] === 'BP Garage' + && $params['meta']['count'] === 1 + && $params['prediction']['action'] === 'fill_now' + && $params['radius'] === 5 + ); +}); + +it('sets apiError from 422 station response and does not dispatch stations-found', function () { + Http::fake([ + '*/api/stations*' => Http::response([ + 'errors' => ['postcode' => ['Postcode not found.']], + ], 422), + ]); + + Livewire::test(Search::class) + ->set('search', 'ZZ99 9ZZ') + ->set('fuelType', 'petrol') + ->call('findStations') + ->assertSet('hasSearched', false) + ->assertSet('apiError', 'Postcode not found.') + ->assertNotDispatched('stations-found'); +}); + +it('sets generic apiError on server error', function () { + Http::fake([ + '*/api/stations*' => Http::response([], 500), + ]); + + Livewire::test(Search::class) + ->set('search', 'SW1A 1AA') + ->set('fuelType', 'petrol') + ->call('findStations') + ->assertSet('apiError', 'Unable to fetch stations. Please try again.'); +}); + +it('converts radius from miles to km in the outgoing stations request', function () { + Http::fake([ + '*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200), + '*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200), + ]); + + Livewire::test(Search::class) + ->set('search', 'SW1A 1AA') + ->set('fuelType', 'petrol') + ->set('radius', 5) + ->call('findStations'); + + Http::assertSent(function ($request) { + if (! str_contains($request->url(), 'api/stations')) { + return false; + } + $data = $request->data(); + return isset($data['radius']) && abs((float) $data['radius'] - 8.05) < 0.01; + }); +}); + +it('resets apiError before each new search', function () { + Http::fake([ + '*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200), + '*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200), + ]); + + Livewire::test(Search::class) + ->set('search', 'SW1A 1AA') + ->set('fuelType', 'petrol') + ->set('apiError', 'Old error') + ->call('findStations') + ->assertSet('apiError', null); +}); + +it('does not call findStations on updatedFuelType if not yet searched', function () { + Http::fake(); + + Livewire::test(Search::class) + ->set('fuelType', 'diesel'); + + Http::assertNothingSent(); +}); + +it('re-runs findStations on updatedFuelType when already searched', function () { + Http::fake([ + '*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200), + '*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200), + ]); + + Livewire::test(Search::class) + ->set('hasSearched', true) + ->set('search', 'SW1A 1AA') + ->set('fuelType', 'diesel'); + + Http::assertSentCount(2); +}); + +it('re-runs findStations on updatedRadius when already searched', function () { + Http::fake([ + '*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200), + '*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200), + ]); + + Livewire::test(Search::class) + ->set('hasSearched', true) + ->set('search', 'SW1A 1AA') + ->set('radius', 10); + + Http::assertSentCount(2); +}); + +it('re-runs findStations on updatedSort when already searched', function () { + Http::fake([ + '*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200), + '*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200), + ]); + + Livewire::test(Search::class) + ->set('hasSearched', true) + ->set('search', 'SW1A 1AA') + ->set('sort', 'price'); + + Http::assertSentCount(2); +}); + +it('prediction is null in stations-found payload when prediction api fails', function () { + Http::fake([ + '*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200), + '*/api/prediction*' => Http::response([], 500), + ]); + + Livewire::test(Search::class) + ->set('search', 'SW1A 1AA') + ->set('fuelType', 'petrol') + ->call('findStations') + ->assertSet('hasSearched', true) + ->assertDispatched('stations-found', fn ($event, $params) => + $params['prediction'] === null + ); +}); +``` + +- [ ] **Step 3: Run tests to confirm they fail** + +```bash +php artisan test --compact --filter="SearchTest" --timeout=10 +``` + +Expected: all fail with `Class "App\Livewire\Public\Fuel\Search" not found` or similar. + +- [ ] **Step 4: Implement Search.php** + +Replace the contents of `app/Livewire/Public/Fuel/Search.php`: + +```php +hasSearched) { + $this->findStations(); + } + } + + public function updatedRadius(): void + { + if ($this->hasSearched) { + $this->findStations(); + } + } + + public function updatedSort(): void + { + if ($this->hasSearched) { + $this->findStations(); + } + } + + public function findStations(): void + { + $this->validate(); + + $this->apiError = null; + $this->hasSearched = false; + + $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; + } + + $results = $response->json('data', []); + $meta = $response->json('meta', []); + $this->hasSearched = true; + + $prediction = null; + + try { + $predictionResponse = Http::timeout(10) + ->withHeaders(['X-Api-Key' => config('app.api_secret_key')]) + ->get(url('/api/prediction')); + + if ($predictionResponse->successful()) { + $prediction = $predictionResponse->json(); + } + } catch (ConnectionException) { + // Prediction failure is silent — stations are more important + } + + $this->dispatch('stations-found', + results: $results, + meta: $meta, + prediction: $prediction, + radius: $this->radius, + ); + } + + public function render(): View + { + return view('livewire.public.fuel.search'); + } +} +``` + +- [ ] **Step 5: Implement search.blade.php** + +Replace the contents of `resources/views/livewire/public/fuel/search.blade.php`: + +```blade +
+
+ +
+ + +
+ + + +
+ + {{-- IP fallback nudge --}} +
+
+

+ Showing approximate location. + Enter your postcode above for exact results. +

+
+
+
+ + @error('search') +

{{ $message }}

+ @enderror + +
+
+
+
+
+ +
+ + @if ($apiError) +
+ {{ $apiError }} +
+ @endif +
+``` + +- [ ] **Step 6: Run tests to confirm they pass** + +```bash +php artisan test --compact --filter="SearchTest" --timeout=10 +``` + +Expected: all 13 tests pass. + +- [ ] **Step 7: Run Pint** + +```bash +vendor/bin/pint app/Livewire/Public/Fuel/Search.php --format agent +``` + +- [ ] **Step 8: Commit** + +```bash +git add app/Livewire/Public/Fuel/Search.php resources/views/livewire/public/fuel/search.blade.php tests/Feature/Livewire/Fuel/SearchTest.php +git commit -m "feat: extract fuel.search Livewire component with stations-found dispatch" +``` + +--- + +## Task 2: fuel.station-list component + +**Files:** +- Create: `app/Livewire/Public/Fuel/StationList.php` +- Create: `resources/views/livewire/public/fuel/station-list.blade.php` +- Create: `tests/Feature/Livewire/Fuel/StationListTest.php` + +- [ ] **Step 1: Scaffold** + +```bash +php artisan make:livewire Public/Fuel/StationList --no-interaction +php artisan make:test Feature/Livewire/Fuel/StationListTest --pest --no-interaction +``` + +- [ ] **Step 2: Write the failing tests** + +Replace contents of `tests/Feature/Livewire/Fuel/StationListTest.php`: + +```php +assertStatus(200) + ->assertSet('hasSearched', false) + ->assertDontSee('Stations Nearby'); +}); + +it('shows station cards after stations-found event', function () { + $station = [ + 'station_id' => 'abc123', + 'name' => 'BP Garage', + 'brand' => 'BP', + 'is_supermarket' => false, + 'address' => '1 High Street', + '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', + 'price_classification' => 'current', + 'price_classification_label' => 'Current', + ]; + $meta = ['count' => 1, 'lowest_pence' => 14390, 'avg_pence' => 14390.0]; + + Livewire::test(StationList::class) + ->dispatch('stations-found', results: [$station], meta: $meta, prediction: null, radius: 5) + ->assertSet('hasSearched', true) + ->assertSee('Stations Nearby') + ->assertSee('BP Garage') + ->assertSee('1 Result'); +}); + +it('shows empty state message when stations-found has no results', function () { + Livewire::test(StationList::class) + ->set('search', 'ZZ99 9ZZ') + ->dispatch('stations-found', results: [], meta: ['count' => 0], prediction: null, radius: 5) + ->assertSet('hasSearched', true) + ->assertSee('No stations found'); +}); + +it('updates results when stations-found fires again', function () { + $station = [ + 'station_id' => 'abc123', + 'name' => 'BP Garage', + 'brand' => 'BP', + 'is_supermarket' => false, + 'address' => '1 High Street', + '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', + 'price_classification' => 'current', + 'price_classification_label' => 'Current', + ]; + + Livewire::test(StationList::class) + ->dispatch('stations-found', results: [$station], meta: ['count' => 1], prediction: null, radius: 5) + ->assertSee('BP Garage') + ->dispatch('stations-found', results: [], meta: ['count' => 0], prediction: null, radius: 5) + ->assertDontSee('BP Garage'); +}); +``` + +- [ ] **Step 3: Run tests to confirm they fail** + +```bash +php artisan test --compact --filter="StationListTest" --timeout=10 +``` + +Expected: all fail — class not found or component has no `handle` method yet. + +- [ ] **Step 4: Implement StationList.php** + +Replace contents of `app/Livewire/Public/Fuel/StationList.php`: + +```php +results = $results; + $this->meta = $meta; + $this->radius = $radius; + $this->hasSearched = true; + } + + public function render(): View + { + return view('livewire.public.fuel.station-list'); + } +} +``` + +- [ ] **Step 5: Implement station-list.blade.php** + +Replace contents of `resources/views/livewire/public/fuel/station-list.blade.php`: + +```blade +
+ @if ($hasSearched) +
+ @if (! empty($meta)) +
+

Stations Nearby

+ + {{ $meta['count'] ?? 0 }} {{ str('Result')->plural($meta['count'] ?? 0) }} + +
+ @endif + + @forelse ($results as $station) +
+ +
+ @empty +

+ No stations found within {{ $radius }} {{ str('mile')->plural($radius) }} of "{{ $search }}". +

+ @endforelse +
+ @endif +
+``` + +- [ ] **Step 6: Run tests to confirm they pass** + +```bash +php artisan test --compact --filter="StationListTest" --timeout=10 +``` + +Expected: all 4 tests pass. + +- [ ] **Step 7: Run Pint** + +```bash +vendor/bin/pint app/Livewire/Public/Fuel/StationList.php --format agent +``` + +- [ ] **Step 8: Commit** + +```bash +git add app/Livewire/Public/Fuel/StationList.php resources/views/livewire/public/fuel/station-list.blade.php tests/Feature/Livewire/Fuel/StationListTest.php +git commit -m "feat: extract fuel.station-list Livewire component" +``` + +--- + +## Task 3: fuel.recommendation component + +**Files:** +- Create: `app/Livewire/Public/Fuel/Recommendation.php` +- Create: `resources/views/livewire/public/fuel/recommendation.blade.php` +- Create: `tests/Feature/Livewire/Fuel/RecommendationTest.php` + +- [ ] **Step 1: Scaffold** + +```bash +php artisan make:livewire Public/Fuel/Recommendation --no-interaction +php artisan make:test Feature/Livewire/Fuel/RecommendationTest --pest --no-interaction +``` + +- [ ] **Step 2: Write the failing tests** + +Replace contents of `tests/Feature/Livewire/Fuel/RecommendationTest.php`: + +```php +assertStatus(200) + ->assertSet('prediction', null) + ->assertDontSee('Recommendation'); +}); + +it('shows recommendation card when stations-found includes a prediction', function () { + $prediction = [ + 'action' => 'fill_now', + 'confidence_score' => 80.0, + 'confidence_label' => 'high', + 'reasoning' => 'Prices are rising sharply.', + 'predicted_direction' => 'up', + 'predicted_change_pence' => 3.5, + ]; + + Livewire::test(Recommendation::class) + ->dispatch('stations-found', results: [], meta: [], prediction: $prediction, radius: 5) + ->assertSet('prediction', $prediction) + ->assertSee('Recommendation') + ->assertSee('Fill up now'); +}); + +it('shows nothing when stations-found has null prediction', function () { + Livewire::test(Recommendation::class) + ->dispatch('stations-found', results: [], meta: [], prediction: null, radius: 5) + ->assertSet('prediction', null) + ->assertDontSee('Recommendation'); +}); + +it('clears previous prediction when new stations-found fires with null prediction', function () { + $prediction = [ + 'action' => 'fill_now', + 'confidence_score' => 80.0, + 'confidence_label' => 'high', + 'reasoning' => 'Prices rising.', + 'predicted_direction' => 'up', + 'predicted_change_pence' => 3.5, + ]; + + Livewire::test(Recommendation::class) + ->dispatch('stations-found', results: [], meta: [], prediction: $prediction, radius: 5) + ->assertSee('Recommendation') + ->dispatch('stations-found', results: [], meta: [], prediction: null, radius: 5) + ->assertDontSee('Recommendation'); +}); +``` + +- [ ] **Step 3: Run tests to confirm they fail** + +```bash +php artisan test --compact --filter="RecommendationTest" --timeout=10 +``` + +Expected: all fail — class not found or no `handle` method. + +- [ ] **Step 4: Implement Recommendation.php** + +Replace contents of `app/Livewire/Public/Fuel/Recommendation.php`: + +```php +prediction = $prediction; + } + + public function render(): View + { + return view('livewire.public.fuel.recommendation'); + } +} +``` + +- [ ] **Step 5: Implement recommendation.blade.php** + +Replace contents of `resources/views/livewire/public/fuel/recommendation.blade.php`: + +```blade +
+ @if ($prediction) +
+ +
+ @endif +
+``` + +- [ ] **Step 6: Run tests to confirm they pass** + +```bash +php artisan test --compact --filter="RecommendationTest" --timeout=10 +``` + +Expected: all 4 tests pass. + +- [ ] **Step 7: Run Pint** + +```bash +vendor/bin/pint app/Livewire/Public/Fuel/Recommendation.php --format agent +``` + +- [ ] **Step 8: Commit** + +```bash +git add app/Livewire/Public/Fuel/Recommendation.php resources/views/livewire/public/fuel/recommendation.blade.php tests/Feature/Livewire/Fuel/RecommendationTest.php +git commit -m "feat: extract fuel.recommendation Livewire component" +``` + +--- + +## Task 4: fuel.map component + JS update + +**Files:** +- Create: `app/Livewire/Public/Fuel/Map.php` +- Create: `resources/views/livewire/public/fuel/map.blade.php` +- Create: `tests/Feature/Livewire/Fuel/MapTest.php` +- Modify: `resources/js/maps/station-map.js` +- Modify: `resources/views/components/fuel/station-map.blade.php` + +- [ ] **Step 1: Scaffold** + +```bash +php artisan make:livewire Public/Fuel/Map --no-interaction +php artisan make:test Feature/Livewire/Fuel/MapTest --pest --no-interaction +``` + +- [ ] **Step 2: Write the failing tests** + +Replace contents of `tests/Feature/Livewire/Fuel/MapTest.php`: + +```php +assertStatus(200); +}); + +it('dispatches map-update browser event when stations-found is received', function () { + Livewire::test(Map::class) + ->dispatch('stations-found', + results: [['name' => 'BP Garage']], + meta: ['lat' => 51.5, 'lng' => -0.1, 'count' => 1], + radius: 5, + prediction: null + ) + ->assertDispatched('map-update'); +}); + +it('passes radius in map-update payload', function () { + Livewire::test(Map::class) + ->dispatch('stations-found', + results: [], + meta: ['lat' => 51.5, 'lng' => -0.1, 'count' => 0], + radius: 10, + prediction: null + ) + ->assertDispatched('map-update', fn ($event, $params) => + $params['radius'] === 10 + ); +}); +``` + +- [ ] **Step 3: Run tests to confirm they fail** + +```bash +php artisan test --compact --filter="MapTest" --timeout=10 +``` + +Expected: all fail — class not found. + +- [ ] **Step 4: Implement Map.php** + +Replace contents of `app/Livewire/Public/Fuel/Map.php`: + +```php +dispatch('map-update', results: $results, meta: $meta, radius: $radius); + } + + public function render(): View + { + return view('livewire.public.fuel.map'); + } +} +``` + +- [ ] **Step 5: Implement map.blade.php** + +Replace contents of `resources/views/livewire/public/fuel/map.blade.php`: + +```blade +
+ +
+``` + +- [ ] **Step 6: Run PHP tests to confirm they pass** + +```bash +php artisan test --compact --filter="MapTest" --timeout=10 +``` + +Expected: all 3 tests pass. + +- [ ] **Step 7: Update station-map.js** + +Replace the `init()` method in `resources/js/maps/station-map.js` (lines 59–74). The old `init()`: + +```js +init() { + injectUserMarkerStyles(); + 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: '© OpenStreetMap contributors', + maxZoom: 19, + }).addTo(this._map); + + if (this.results && this.results.length > 0) { + this._plotMarkers(); + } + + this.$watch('results', () => this._plotMarkers()); + this.locateUser(); +}, +``` + +New `init()`: + +```js +init() { + injectUserMarkerStyles(); + 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: '© OpenStreetMap contributors', + maxZoom: 19, + }).addTo(this._map); + + window.addEventListener('map-update', (e) => { + this.results = e.detail.results; + this.meta = e.detail.meta; + this.radius = e.detail.radius; + this._plotMarkers(); + }); + + this.locateUser(); +}, +``` + +- [ ] **Step 8: Update station-map.blade.php** + +Replace the full contents of `resources/views/components/fuel/station-map.blade.php`: + +```blade +
+``` + +- [ ] **Step 9: Run Pint** + +```bash +vendor/bin/pint app/Livewire/Public/Fuel/Map.php --format agent +``` + +- [ ] **Step 10: Commit** + +```bash +git add app/Livewire/Public/Fuel/Map.php resources/views/livewire/public/fuel/map.blade.php tests/Feature/Livewire/Fuel/MapTest.php resources/js/maps/station-map.js resources/views/components/fuel/station-map.blade.php +git commit -m "feat: extract fuel.map component and wire Leaflet to map-update browser event" +``` + +--- + +## Task 5: Strip FuelFinder to shell + +**Files:** +- Modify: `app/Livewire/Public/FuelFinder.php` +- Modify: `resources/views/livewire/public/fuel-finder.blade.php` +- Modify: `tests/Feature/Livewire/FuelFinderTest.php` + +- [ ] **Step 1: Update FuelFinder.php** + +Replace the full contents of `app/Livewire/Public/FuelFinder.php`: + +```php + + + {{-- HEADER --}} +
+ + +
+ +
+ FuelAlert +
+ + + + @auth + + + + @else + + + + @endauth +
+ + {{-- MAIN --}} +
+
+ + + + + + +
+ +
+ +
+
+ + {{-- BOTTOM TAB BAR --}} + @php + $tabs = [ + ['label' => 'Prices', 'icon' => 'lucide:fuel', 'route' => 'home'], + ['label' => 'Alerts', 'icon' => 'lucide:bell', 'route' => null], + ['label' => 'Forecourts', 'icon' => 'lucide:map-pin', 'route' => null], + ['label' => 'Trends', 'icon' => 'lucide:trending-up', 'route' => null], + ]; + @endphp + + + +``` + +- [ ] **Step 3: Update FuelFinderTest.php** + +Replace the full contents of `tests/Feature/Livewire/FuelFinderTest.php`: + +```php +assertStatus(200); +}); +``` + +- [ ] **Step 4: Run the full test suite** + +```bash +php artisan test --compact --timeout=10 +``` + +Expected: 1 failure in `StatsOverviewWidgetTest` (pre-existing, unrelated to this work). All other tests pass, including the new `SearchTest`, `StationListTest`, `RecommendationTest`, and `MapTest`. + +- [ ] **Step 5: Run Pint** + +```bash +vendor/bin/pint app/Livewire/Public/FuelFinder.php --format agent +``` + +- [ ] **Step 6: Commit** + +```bash +git add app/Livewire/Public/FuelFinder.php resources/views/livewire/public/fuel-finder.blade.php tests/Feature/Livewire/FuelFinderTest.php +git commit -m "feat: strip FuelFinder to layout shell, wire sub-components" +``` diff --git a/resources/css/app.css b/resources/css/app.css index 0a358a6..8b8bbdc 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -9,31 +9,70 @@ @custom-variant dark (&:where(.dark, .dark *)); +/* Remap Flux's zinc scale to FuelAlert's warm brown neutrals */ @theme { - --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; - --color-zinc-50: #fafafa; - --color-zinc-100: #f5f5f5; - --color-zinc-200: #e5e5e5; - --color-zinc-300: #d4d4d4; - --color-zinc-400: #a3a3a3; - --color-zinc-500: #737373; - --color-zinc-600: #525252; - --color-zinc-700: #404040; - --color-zinc-800: #262626; - --color-zinc-900: #171717; - --color-zinc-950: #0a0a0a; + --color-zinc-50: #faf6f3; /* surface */ + --color-zinc-100: #f5ede5; /* surface-page */ + --color-zinc-200: #eeeae5; /* surface-subtle */ + --color-zinc-300: #e5ded7; /* border */ + --color-zinc-400: #c8b8b0; + --color-zinc-500: #89726c; /* text-muted */ + --color-zinc-600: #6b5a55; /* text-dim */ + --color-zinc-700: #5a4a45; + --color-zinc-800: #4a3f3b; /* text-base */ + --color-zinc-900: #3a302c; + --color-zinc-950: #2a2220; - --color-accent: var(--color-neutral-800); - --color-accent-content: var(--color-neutral-800); - --color-accent-foreground: var(--color-white); + /* Brand accent — burnt sienna */ + --color-accent: #bb5b3e; + --color-accent-content: #a34a31; + --color-accent-foreground: #ffffff; + + /* Named semantic tokens */ + --color-primary: #bb5b3e; + --color-primary-dark: #a34a31; + --color-surface: #faf6f3; + --color-surface-page: #f5ede5; + --color-surface-subtle: #eeeae5; + --color-border: #e5ded7; + --color-text-base: #4a3f3b; + --color-text-muted: #89726c; + --color-text-dim: #6b5a55; + + /* Accent palette */ + --color-teal: #4a7c7e; + --color-mauve: #8b4860; + --color-tan: #9b8b6b; + + /* Status */ + --color-status-good: #22c55e; + --color-status-warn: #f59e0b; + --color-status-bad: #ef4444; + + /* Display font */ + --font-display: 'Manrope', ui-sans-serif, system-ui, sans-serif; } -@layer theme { - .dark { - --color-accent: var(--color-white); - --color-accent-content: var(--color-white); - --color-accent-foreground: var(--color-neutral-800); +@layer base { + h1, h2, h3, h4 { + font-family: var(--font-display); + letter-spacing: -0.02em; + } +} + +@layer utilities { + .hero-gradient { + background: + radial-gradient(circle at top right, color-mix(in oklch, var(--color-primary) 8%, transparent), transparent 50%), + radial-gradient(circle at bottom left, color-mix(in oklch, var(--color-primary) 5%, transparent), transparent 40%); + } + + .glass-card { + background: color-mix(in oklch, var(--color-surface) 90%, transparent); + backdrop-filter: blur(12px); + border: 1px solid color-mix(in oklch, var(--color-border) 60%, transparent); } } diff --git a/resources/views/components/fuel/forecast.blade.php b/resources/views/components/fuel/forecast.blade.php index 19736d3..9be214f 100644 --- a/resources/views/components/fuel/forecast.blade.php +++ b/resources/views/components/fuel/forecast.blade.php @@ -1,29 +1,23 @@ -
- {{-- Pro badge --}} +
-

14-Day Forecast

-

Price Trend

+

14-Day Forecast

+

Price Trend

- - Pro - + Pro
- {{-- Decorative squiggle chart --}} - + - {{-- Blurred overlay --}} - diff --git a/resources/views/components/fuel/radius-select.blade.php b/resources/views/components/fuel/radius-select.blade.php index 3571eb1..f8ab78a 100644 --- a/resources/views/components/fuel/radius-select.blade.php +++ b/resources/views/components/fuel/radius-select.blade.php @@ -1,10 +1,7 @@ - + + 1 mile + 2 miles + 5 miles + 10 miles + 20 miles + diff --git a/resources/views/components/fuel/recommendation.blade.php b/resources/views/components/fuel/recommendation.blade.php index ca16af1..c410ba2 100644 --- a/resources/views/components/fuel/recommendation.blade.php +++ b/resources/views/components/fuel/recommendation.blade.php @@ -19,13 +19,13 @@ }; @endphp -
+
-

+

Recommendation

-

+

{{ $headline }}

@@ -33,31 +33,30 @@
- - + + stroke-linecap="round" /> - + {{ (int) $score }}%
- Confidence + Confidence
-

+

{{ $prediction['reasoning'] ?? '' }}

- - {{ ucfirst($label) }} confidence - + {{ ucfirst($label) }} confidence
@endif diff --git a/resources/views/components/fuel/sort-select.blade.php b/resources/views/components/fuel/sort-select.blade.php index 07784b5..cf0fa09 100644 --- a/resources/views/components/fuel/sort-select.blade.php +++ b/resources/views/components/fuel/sort-select.blade.php @@ -1,10 +1,7 @@ - + + Best price (reliable) + Cheapest first + Nearest first + Recently updated + Brand A–Z + diff --git a/resources/views/components/fuel/station-card.blade.php b/resources/views/components/fuel/station-card.blade.php index 556f022..0ac8aea 100644 --- a/resources/views/components/fuel/station-card.blade.php +++ b/resources/views/components/fuel/station-card.blade.php @@ -2,36 +2,34 @@ @php $colourClass = match($station['price_classification'] ?? '') { - 'current' => 'text-green-500', + 'current' => 'text-status-good', 'recent' => 'text-slate-500', - 'stale' => 'text-amber-500', - 'outdated' => 'text-red-500', + 'stale' => 'text-status-warn', + 'outdated' => 'text-status-bad', default => 'text-zinc-400', }; $miles = number_format(($station['distance_km'] ?? 0) * 0.621371, 1); $price = number_format($station['price'] ?? 0, 1); @endphp -
+
-

+

{{ $station['name'] ?? '' }}

@if (! empty($station['is_supermarket'])) - - Supermarket - + Supermarket @endif
-

+

{{ $station['address'] ?? '' }}, {{ $station['postcode'] ?? '' }}

-

{{ $miles }} miles away

+

{{ $miles }} miles away

-

{{ $price }}p

+

{{ $price }}p

{{ $station['price_classification_label'] ?? '' }} @if (! empty($station['price_updated_at'])) diff --git a/resources/views/components/fuel/type-select.blade.php b/resources/views/components/fuel/type-select.blade.php index 1e6bd67..fde9c18 100644 --- a/resources/views/components/fuel/type-select.blade.php +++ b/resources/views/components/fuel/type-select.blade.php @@ -1,11 +1,8 @@ - + + Petrol (E10) + Super Unleaded (E5) + Diesel + Premium Diesel + B10 Biodiesel + HVO + diff --git a/resources/views/components/mobile-footer.blade.php b/resources/views/components/mobile-footer.blade.php deleted file mode 100644 index f214b2f..0000000 --- a/resources/views/components/mobile-footer.blade.php +++ /dev/null @@ -1,26 +0,0 @@ -@php - $tabs = [ - ['label' => 'Prices', 'icon' => 'lucide:fuel', 'route' => 'home'], - ['label' => 'Alerts', 'icon' => 'lucide:bell', 'route' => null], - ['label' => 'Forecourts', 'icon' => 'lucide:map-pin', 'route' => null], - ['label' => 'Trends', 'icon' => 'lucide:trending-up','route' => null], - ]; - $currentRoute = request()->routeIs('home') ? 'home' : null; -@endphp - -

diff --git a/resources/views/components/mobile-header.blade.php b/resources/views/components/mobile-header.blade.php deleted file mode 100644 index b6da3a6..0000000 --- a/resources/views/components/mobile-header.blade.php +++ /dev/null @@ -1,17 +0,0 @@ -
-
-
- -
- FuelAlert -
- @auth - - - - @else - - - - @endauth -
diff --git a/resources/views/components/public/⚡fuel-finder.blade.php b/resources/views/components/public/⚡fuel-finder.blade.php deleted file mode 100644 index e625966..0000000 --- a/resources/views/components/public/⚡fuel-finder.blade.php +++ /dev/null @@ -1,13 +0,0 @@ - - -
- {{-- Nothing in life is to be feared, it is only to be understood. Now is the time to understand more, so that we may fear less. - Maria Skłodowska-Curie --}} -
\ No newline at end of file diff --git a/resources/views/homepage.blade.php b/resources/views/homepage.blade.php index 804e018..7b8f24b 100644 --- a/resources/views/homepage.blade.php +++ b/resources/views/homepage.blade.php @@ -277,7 +277,7 @@ Join 50,000+ UK drivers using real-time insights to find the cheapest petrol and time their fill-ups perfectly.

-
+
@@ -416,7 +416,7 @@ Historic Price Benchmarking - + Find prices near you @@ -558,7 +558,7 @@

Sign up free today and never pay over the odds for fuel again.

@@ -583,7 +583,7 @@
diff --git a/resources/views/layouts/shell.blade.php b/resources/views/layouts/shell.blade.php new file mode 100644 index 0000000..3a88de5 --- /dev/null +++ b/resources/views/layouts/shell.blade.php @@ -0,0 +1,9 @@ + + + + @include('partials.head', ['title' => $title ?? null]) + + + {{ $slot }} + + diff --git a/resources/views/livewire/public/station-search.blade.php b/resources/views/livewire/public/station-search.blade.php deleted file mode 100644 index a47fcd3..0000000 --- a/resources/views/livewire/public/station-search.blade.php +++ /dev/null @@ -1,138 +0,0 @@ -
- Find Cheap Fuel Near You - Search by postcode, town or city - - -
-
- - @error('search') -

{{ $message }}

- @enderror -
- -
- - Select fuel type - Petrol (E10) - Super Unleaded (E5) - Diesel - Premium Diesel - B10 Biodiesel - HVO - - @error('fuelType') -

{{ $message }}

- @enderror -
- -
- - 1 mile - 2 miles - 5 miles - 10 miles - 20 miles - -
- -
- - Best price (reliable) - Cheapest first - Nearest first - Recently updated - Brand A–Z - -
- -
- - Search - Searching… - -
-
- - - @if ($apiError) -
- {{ $apiError }} -
- @endif - - @if (! empty($meta)) -
- @if (! empty($results)) -

- {{ $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 -

- - {{-- Map --}} -
- - {{-- Legend --}} -
- Current (<24h) - Recent (24–48h) - Stale (2–5 days) - Outdated (5+ days) -
- -
- @foreach ($results as $station) -
-
-
-

- {{ $station['name'] }} -

- @if ($station['is_supermarket']) - Supermarket - @endif -
-

- {{ $station['address'] }}, {{ $station['postcode'] }} -

-

- {{ number_format($station['distance_km'] * 0.621371, 1) }} miles away -

-
- -
-

- {{ number_format($station['price'], 1) }}p -

-

- {{ $station['price_classification_label'] }} - · - {{ $station['price_updated_at'] ? \Carbon\Carbon::parse($station['price_updated_at'])->diffForHumans() : 'Unknown' }} -

-
-
- @endforeach -
- @else -

- No stations found within {{ $radius }} {{ str('mile')->plural($radius) }} of "{{ $search }}". -

- @endif -
- @endif -
diff --git a/resources/views/mobile.blade.php b/resources/views/mobile.blade.php deleted file mode 100644 index 000f2af..0000000 --- a/resources/views/mobile.blade.php +++ /dev/null @@ -1,620 +0,0 @@ - - - - - - FuelAlert | Stop Overpaying for Fuel - - - - - - - @vite(['resources/css/app.css', 'resources/js/app.js']) - - - - -{{-- Mobile App Layout (hidden on desktop) --}} -
- - {{-- Mobile Header --}} -
-
-
- -
- FuelAlert -
- -
- - {{-- Mobile Scrollable Main --}} -
- - {{-- Search & Filters --}} -
-
- - -
-
- - - -
-
- - {{-- Recommendation Card --}} -
-
-
-
-

Recommendation

-

Fill up now

-
-
-
- - - - - 80% -
- Confidence -
-
-

- Local prices are at a 30-day low. Regional wholesale trends indicate a 3p/litre increase starting Monday. Securing fuel today is highly advised. -

-
-
- - {{-- Map Section --}} -
- {{-- Simulated map grid --}} -
-
- - {{-- Map Markers --}} -
-
142.9p
- -
-
-
145.7p
- -
-
-
148.9p
- -
- - {{-- Legend --}} -
-
- - Current -
-
- - Recent -
-
- - Stale -
-
-
- - {{-- Nearby Stations --}} -
-
-

Stations Nearby

- 26 Results -
-
-
-

Tesco Superstore

-
142.9p
-
-
-

Sainsbury's Fuel

-
143.1p
-
-
-

BP Connect

-
145.7p
-
-
-

Shell V-Power

-
148.9p
-
-
-

Esso Express

-
151.2p
-
-
-
- - {{-- 14-Day Forecast (Pro) --}} -
-
-
-
-

14-Day Forecast

-
- - Pro -
-
-
- - - -
- -
-
-
-
-
- -
- - {{-- Mobile Tab Bar --}} - - -
{{-- end mobile layout --}} - -{{-- Desktop Layout (hidden on mobile) --}} -{{-- end desktop layout --}} - - - diff --git a/resources/views/partials/head.blade.php b/resources/views/partials/head.blade.php index 52703a4..f153994 100644 --- a/resources/views/partials/head.blade.php +++ b/resources/views/partials/head.blade.php @@ -10,7 +10,7 @@ - + @vite(['resources/css/app.css', 'resources/js/app.js']) @fluxAppearance diff --git a/routes/web.php b/routes/web.php index b31c511..693348a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,17 +1,12 @@ name('home'); - Route::view('/', 'homepage')->name('home'); Route::get('/fuel-finder', FuelFinder::class)->name('fuel-finder'); -Route::get('/stations', StationSearch::class)->name('stations.search'); - Route::middleware(['auth', 'verified'])->group(function () { Route::view('dashboard', 'dashboard')->name('dashboard'); }); diff --git a/tests/Feature/Livewire/StationSearchTest.php b/tests/Feature/Livewire/StationSearchTest.php deleted file mode 100644 index d57767f..0000000 --- a/tests/Feature/Livewire/StationSearchTest.php +++ /dev/null @@ -1,140 +0,0 @@ -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') - ->set('fuelType', '') - ->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', - 'price_classification' => 'current', - 'price_classification_label' => 'Current', - ], - ], - '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, - ], - ], 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', []); -});