diff --git a/app/Livewire/Public/FuelFinder.php b/app/Livewire/Public/FuelFinder.php index 5f0630a..94cb9e0 100644 --- a/app/Livewire/Public/FuelFinder.php +++ b/app/Livewire/Public/FuelFinder.php @@ -2,117 +2,13 @@ namespace App\Livewire\Public; -use Illuminate\Http\Client\ConnectionException; -use Illuminate\Support\Facades\Http; use Illuminate\View\View; use Livewire\Attributes\Layout; -use Livewire\Attributes\Validate; use Livewire\Component; -#[Layout('layouts.guest')] -class FuelFinder extends Component +#[Layout('layouts.shell')] +final class FuelFinder 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 ?array $prediction = null; - - public ?string $apiError = null; - - public bool $hasSearched = false; - - public function updatedFuelType(): void - { - if ($this->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->results = []; - $this->meta = []; - $this->prediction = null; - $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; - } - - $this->results = $response->json('data', []); - $this->meta = $response->json('meta', []); - $this->hasSearched = true; - - try { - $predictionResponse = Http::timeout(10) - ->withHeaders(['X-Api-Key' => config('app.api_secret_key')]) - ->get(url('/api/prediction')); - - if ($predictionResponse->successful()) { - $this->prediction = $predictionResponse->json(); - } - } catch (ConnectionException) { - // Prediction failure is silent — stations are more important - } - } - public function render(): View { return view('livewire.public.fuel-finder'); diff --git a/resources/views/livewire/public/fuel-finder.blade.php b/resources/views/livewire/public/fuel-finder.blade.php index 3acece0..a088a49 100644 --- a/resources/views/livewire/public/fuel-finder.blade.php +++ b/resources/views/livewire/public/fuel-finder.blade.php @@ -1,201 +1,77 @@ -
+
- + {{-- HEADER --}} +
- {{-- Scrollable main content, offset for fixed header (~112px) and footer (~80px) --}} -
+ +
+ +
+ FuelAlert +
- {{-- #search --}} -
-
+ -
- - - {{-- Right-side controls --}} -
- {{-- Clear --}} - - {{-- Near me pill --}} - - {{-- Search --}} - -
- {{-- IP fallback nudge --}} -
-
-

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

-
-
-
- @error('search') -

{{ $message }}

- @enderror + @auth + + + + @else + + + + @endauth +
- {{-- Filter rows --}} -
-
-
- -
-
- -
-
-
-
- -
-
-
+ {{-- MAIN --}} +
+
- + + + + - @if ($apiError) -
- {{ $apiError }} -
- @endif - - - {{-- #recommendation --}} - {{-- @if ($prediction) -
- +
+
- @endif --}} - - {{-- #map --}} -
- -
- - {{-- #stations --}} - @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 - - {{-- #forecast --}} -
- -
+
- + {{-- 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 +
diff --git a/tests/Feature/Livewire/FuelFinderTest.php b/tests/Feature/Livewire/FuelFinderTest.php index 42754fe..f2c861b 100644 --- a/tests/Feature/Livewire/FuelFinderTest.php +++ b/tests/Feature/Livewire/FuelFinderTest.php @@ -1,223 +1,9 @@ assertStatus(200) - ->assertSeeHtml('name="search"'); -}); - -it('has default property values', function () { - Livewire::test(FuelFinder::class) - ->assertSet('search', '') - ->assertSet('fuelType', 'petrol') - ->assertSet('radius', 5) - ->assertSet('sort', 'reliable') - ->assertSet('results', []) - ->assertSet('meta', []) - ->assertSet('prediction', null) - ->assertSet('apiError', null) - ->assertSet('hasSearched', false); -}); - -it('validates search is required', function () { - Livewire::test(FuelFinder::class) - ->call('findStations') - ->assertHasErrors(['search' => 'required']); -}); - -it('validates fuelType is required', function () { - Livewire::test(FuelFinder::class) - ->set('search', 'SW1A 1AA') - ->set('fuelType', '') - ->call('findStations') - ->assertHasErrors(['fuelType' => 'required']); -}); - -it('populates results, meta, and prediction 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(FuelFinder::class) - ->set('search', 'SW1A 1AA') - ->set('fuelType', 'petrol') - ->call('findStations') - ->assertSet('hasSearched', true) - ->assertSet('apiError', null) - ->assertSet('results', fn (array $r) => count($r) === 1 && $r[0]['name'] === 'BP Garage') - ->assertSet('meta', fn (array $m) => $m['count'] === 1) - ->assertSet('prediction', fn (?array $p) => $p !== null && $p['action'] === 'fill_now'); -}); - -it('sets apiError from 422 station response and leaves prediction null', function () { - Http::fake([ - '*/api/stations*' => Http::response([ - 'errors' => ['postcode' => ['Postcode not found.']], - ], 422), - ]); - - Livewire::test(FuelFinder::class) - ->set('search', 'ZZ99 9ZZ') - ->set('fuelType', 'petrol') - ->call('findStations') - ->assertSet('results', []) - ->assertSet('meta', []) - ->assertSet('prediction', null) - ->assertSet('hasSearched', false) - ->assertSet('apiError', 'Postcode not found.'); -}); - -it('sets generic apiError on server error', function () { - Http::fake([ - '*/api/stations*' => Http::response([], 500), - ]); - - Livewire::test(FuelFinder::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(FuelFinder::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 state 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(FuelFinder::class) - ->set('search', 'SW1A 1AA') - ->set('fuelType', 'petrol') - ->set('results', [['name' => 'Old Result']]) - ->set('apiError', 'Old error') - ->call('findStations') - ->assertSet('apiError', null) - ->assertSet('results', []); -}); - -it('does not call findStations on updatedFuelType if not yet searched', function () { - Http::fake(); - - Livewire::test(FuelFinder::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(FuelFinder::class) - ->set('hasSearched', true) - ->set('search', 'SW1A 1AA') - ->set('fuelType', 'diesel'); - - Http::assertSentCount(2); // stations + prediction -}); - -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(FuelFinder::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(FuelFinder::class) - ->set('hasSearched', true) - ->set('search', 'SW1A 1AA') - ->set('sort', 'price'); - - Http::assertSentCount(2); -}); - -it('prediction remains null when prediction api fails', function () { - Http::fake([ - '*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200), - '*/api/prediction*' => Http::response([], 500), - ]); - - Livewire::test(FuelFinder::class) - ->set('search', 'SW1A 1AA') - ->set('fuelType', 'petrol') - ->call('findStations') - ->assertSet('hasSearched', true) - ->assertSet('prediction', null); + ->assertStatus(200); });