feat: add user geolocation marker and auto-zoom to map based on search radius
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

This commit is contained in:
Ovidiu U
2026-04-07 20:21:31 +01:00
parent 0b26c4c257
commit 4e9b809a10
7 changed files with 883 additions and 36 deletions

View File

@@ -2,53 +2,148 @@
<x-mobile-header />
{{-- Scrollable main content, offset for fixed header (~80px) and footer (~80px) --}}
{{-- Scrollable main content, offset for fixed header (~112px) and footer (~80px) --}}
<main
class="flex-1 overflow-y-auto pt-[80px] pb-[80px]"
class="flex-1 overflow-y-auto pt-28 pb-20"
style="-ms-overflow-style:none;scrollbar-width:none;"
>
{{-- #search --}}
<section class="space-y-3 px-5 pt-5 pb-4">
<form wire:submit="findStations">
<div class="relative mb-3">
<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="absolute left-4 top-1/2 -translate-y-1/2 text-xl text-[#89726c] pointer-events-none"
></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-[#e5ded7] bg-[#faf6f3] pl-12 pr-4 text-base font-semibold text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e] focus:border-transparent"
class="h-14 w-full rounded-xl border border-[#e5ded7] bg-[#faf6f3] pl-12 pr-36 text-base font-semibold text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e] focus:border-transparent"
/>
{{-- Right-side controls --}}
<div class="absolute inset-y-0 right-0 flex items-center gap-2 pr-3">
{{-- Clear --}}
<button
x-show="query.length > 0"
x-cloak
type="button"
@click="query = ''; $wire.set('search', ''); _usedIpFallback = false"
class="text-[#89726c] hover:text-[#4a3f3b]"
>
<iconify-icon icon="lucide:x" class="text-base"></iconify-icon>
</button>
{{-- Near me pill --}}
<button
type="button"
@click="locateUser()"
:disabled="locatingUser"
class="flex items-center gap-1.5 rounded-full bg-[#e5ded7] px-3 py-1.5 text-sm font-semibold text-[#4a3f3b] 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="text-sm animate-spin"></iconify-icon>
<span x-text="locatingUser ? 'Finding...' : 'Near me'">Near me</span>
</button>
{{-- Search --}}
<button
type="submit"
wire:loading.attr="disabled"
class="text-[#89726c] 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="text-xl animate-spin"></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 top-full left-0 right-0 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
{{-- Filter pills (scrollable row) --}}
<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" />
{{-- Filter rows --}}
<div class="space-y-2 mb-3">
<div class="flex gap-2">
<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>
<div class="shrink-0">
<x-fuel.radius-select wire:model.live="radius" />
</div>
<div class="shrink-0">
<x-fuel.sort-select wire:model.live="sort" />
<div class="flex">
<div class="shrink-0">
<x-fuel.radius-select wire:model.live="radius" />
</div>
</div>
</div>
<button
type="submit"
wire:loading.attr="disabled"
class="mt-3 w-full rounded-xl bg-[#bb5b3e] py-3.5 text-sm font-bold text-white shadow-md disabled:opacity-60"
>
<span wire:loading.remove wire:target="findStations">Find Stations</span>
<span wire:loading wire:target="findStations">Searching…</span>
</button>
</form>
@if ($apiError)
@@ -59,14 +154,14 @@
</section>
{{-- #recommendation --}}
@if ($prediction)
{{-- @if ($prediction)
<section class="px-5 pb-5">
<x-fuel.recommendation :prediction="$prediction" />
</section>
@endif
@endif --}}
{{-- #map --}}
<section class="mb-4">
<section class="mb-4" wire:ignore>
<x-fuel.station-map :results="$results" />
</section>