feat: extract fuel.search Livewire component with stations-found dispatch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ovidiu U
2026-04-07 21:58:01 +01:00
parent f4a958a76c
commit 0b289c8ec2
3 changed files with 465 additions and 0 deletions

View File

@@ -0,0 +1,118 @@
<?php
namespace App\Livewire\Public\Fuel;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\View\View;
use Livewire\Attributes\Validate;
use Livewire\Component;
final class Search 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 ?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->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');
}
}

View File

@@ -0,0 +1,130 @@
<div class="space-y-3 px-5 pb-4 pt-5">
<form wire:submit="findStations">
<div
x-data="{
query: @js($search),
locatingUser: false,
_usedIpFallback: false,
async _postcodeFromLatLng(lat, lng) {
const res = await fetch(`https://api.postcodes.io/postcodes?lon=${lng}&lat=${lat}&limit=1&radius=1000`);
const data = await res.json();
return data?.result?.[0]?.postcode ?? null;
},
async locateUser() {
this.locatingUser = true;
this._usedIpFallback = false;
try {
let lat, lng;
try {
const pos = await new Promise((resolve, reject) =>
navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 })
);
lat = pos.coords.latitude;
lng = pos.coords.longitude;
} catch (e) {
const d = await fetch('https://ipapi.co/json/').then(r => r.json());
lat = d.latitude;
lng = d.longitude;
this._usedIpFallback = true;
}
const postcode = await this._postcodeFromLatLng(lat, lng);
if (postcode) {
this.query = postcode;
this.$wire.set('search', postcode);
this.$wire.findStations();
}
} catch (e) {
// silent
} finally {
this.locatingUser = false;
}
}
}"
class="relative mb-3"
>
<iconify-icon
icon="lucide:map-pin"
class="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-xl text-text-muted"
></iconify-icon>
<input
wire:model="search"
x-model="query"
x-ref="searchInput"
type="text"
name="search"
@focus="_usedIpFallback = false"
placeholder="Postcode, town or city"
class="h-14 w-full rounded-xl border border-border bg-surface pl-12 pr-36 text-base font-semibold text-text-base focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary"
/>
<div class="absolute inset-y-0 right-0 flex items-center gap-2 pr-3">
<button
x-show="query.length > 0"
x-cloak
type="button"
@click="query = ''; $wire.set('search', ''); _usedIpFallback = false"
class="text-text-muted hover:text-text-base"
>
<iconify-icon icon="lucide:x" class="text-base"></iconify-icon>
</button>
<button
type="button"
@click="locateUser()"
:disabled="locatingUser"
class="flex items-center gap-1.5 rounded-full bg-surface-subtle px-3 py-1.5 text-sm font-semibold text-text-base disabled:opacity-40"
>
<iconify-icon x-show="!locatingUser" icon="lucide:locate-fixed" class="text-sm"></iconify-icon>
<iconify-icon x-show="locatingUser" icon="lucide:loader-circle" class="animate-spin text-sm"></iconify-icon>
<span x-text="locatingUser ? 'Finding...' : 'Near me'">Near me</span>
</button>
<button
type="submit"
wire:loading.attr="disabled"
class="text-text-muted disabled:opacity-60"
>
<iconify-icon wire:loading.remove wire:target="findStations" icon="lucide:search" class="text-xl"></iconify-icon>
<iconify-icon wire:loading wire:target="findStations" icon="lucide:loader-circle" class="animate-spin text-xl"></iconify-icon>
</button>
</div>
{{-- IP fallback nudge --}}
<div
x-show="_usedIpFallback"
x-cloak
x-transition:enter="transition ease-out duration-300 delay-500"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-end="opacity-0"
class="absolute left-0 right-0 top-full z-40 mt-2"
>
<div
@click="$refs.searchInput.focus(); _usedIpFallback = false"
class="cursor-pointer rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 transition-colors hover:bg-amber-100"
>
<p class="text-center text-xs text-amber-800">
<span class="font-medium">Showing approximate location.</span>
<span class="underline">Enter your postcode above</span> for exact results.
</p>
</div>
</div>
</div>
@error('search')
<p class="mb-2 text-sm text-red-600">{{ $message }}</p>
@enderror
<div class="flex gap-2 overflow-x-auto pb-1" style="-ms-overflow-style:none;scrollbar-width:none;">
<div class="shrink-0"><x-fuel.type-select wire:model.live="fuelType" /></div>
<div class="shrink-0"><x-fuel.sort-select wire:model.live="sort" /></div>
<div class="shrink-0"><x-fuel.radius-select wire:model.live="radius" /></div>
</div>
</form>
@if ($apiError)
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ $apiError }}
</div>
@endif
</div>

View File

@@ -0,0 +1,217 @@
<?php
use App\Livewire\Public\Fuel\Search;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('renders the search component', function () {
Livewire::test(Search::class)
->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
);
});