diff --git a/app/Livewire/Public/Fuel/Search.php b/app/Livewire/Public/Fuel/Search.php
new file mode 100644
index 0000000..3fa58cc
--- /dev/null
+++ b/app/Livewire/Public/Fuel/Search.php
@@ -0,0 +1,118 @@
+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');
+ }
+}
diff --git a/resources/views/livewire/public/fuel/search.blade.php b/resources/views/livewire/public/fuel/search.blade.php
new file mode 100644
index 0000000..4c871db
--- /dev/null
+++ b/resources/views/livewire/public/fuel/search.blade.php
@@ -0,0 +1,130 @@
+
+
+
+ @if ($apiError)
+
+ {{ $apiError }}
+
+ @endif
+
diff --git a/tests/Feature/Livewire/Fuel/SearchTest.php b/tests/Feature/Livewire/Fuel/SearchTest.php
new file mode 100644
index 0000000..27af670
--- /dev/null
+++ b/tests/Feature/Livewire/Fuel/SearchTest.php
@@ -0,0 +1,217 @@
+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
+ );
+});