feat: add geolocation support with Near Me button and user location marker on map
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

- Add "Near Me" button to SearchBar with loading state and geolocation via postcodes.io API
- Display user location on map with pulsing blue marker using geolocation API with IP fallback
- Adjust map zoom level based on search radius for better context
- Pass radiusMiles prop from
This commit is contained in:
Ovidiu U
2026-04-11 21:27:11 +01:00
parent a969c1b347
commit d25883ead4
3 changed files with 254 additions and 117 deletions

View File

@@ -1,81 +1,82 @@
<template> <template>
<div class="space-y-2"> <div class="space-y-2">
<button <button
@click="toggleMap" class="flex items-center gap-2 text-sm font-bold text-accent hover:text-accent-content transition-colors"
class="flex items-center gap-2 text-sm font-bold text-accent hover:text-accent-content transition-colors" @click="toggleMap"
> >
<iconify-icon :icon="isOpen ? 'lucide:chevron-up' : 'lucide:chevron-down'"></iconify-icon> <iconify-icon :icon="isOpen ? 'lucide:chevron-up' : 'lucide:chevron-down'"></iconify-icon>
{{ isOpen ? 'Hide map' : 'Show map' }} {{ isOpen ? 'Hide map' : 'Show map' }}
</button> </button>
<template v-if="isOpen"> <template v-if="isOpen">
<div <div
ref="mapContainer" ref="mapContainer"
class="w-full h-72 rounded-2xl overflow-hidden border border-zinc-300 shadow-sm" class="w-full h-72 rounded-2xl overflow-hidden border border-zinc-300 shadow-sm"
></div> ></div>
<div class="flex flex-wrap gap-3 text-xs text-zinc-500"> <div class="flex flex-wrap gap-3 text-xs text-zinc-500">
<span class="flex items-center gap-1.5"> <span class="flex items-center gap-1.5">
<span class="inline-block size-3 rounded-full bg-green-500"></span> <span class="inline-block size-3 rounded-full bg-green-500"></span>
Current (&lt;24h) Current (&lt;24h)
</span> </span>
<span class="flex items-center gap-1.5"> <span class="flex items-center gap-1.5">
<span class="inline-block size-3 rounded-full bg-slate-500"></span> <span class="inline-block size-3 rounded-full bg-slate-500"></span>
Recent (2448h) Recent (2448h)
</span> </span>
<span class="flex items-center gap-1.5"> <span class="flex items-center gap-1.5">
<span class="inline-block size-3 rounded-full bg-amber-500"></span> <span class="inline-block size-3 rounded-full bg-amber-500"></span>
Stale (25 days) Stale (25 days)
</span> </span>
<span class="flex items-center gap-1.5"> <span class="flex items-center gap-1.5">
<span class="inline-block size-3 rounded-full bg-red-500"></span> <span class="inline-block size-3 rounded-full bg-red-500"></span>
Outdated (5+ days) Outdated (5+ days)
</span> </span>
</div> </div>
</template> </template>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue' import {ref, watch, onMounted, onUnmounted, nextTick} from 'vue'
import L from 'leaflet' import L from 'leaflet'
import 'leaflet/dist/leaflet.css' import 'leaflet/dist/leaflet.css'
import {zoom} from "leaflet/src/control/Control.Zoom.js";
const CLASSIFICATION_COLOURS = { const CLASSIFICATION_COLOURS = {
current: '#22c55e', current: '#22c55e',
recent: '#64748b', recent: '#64748b',
stale: '#f59e0b', stale: '#f59e0b',
outdated: '#ef4444', outdated: '#ef4444',
} }
const CLASSIFICATION_BORDER_COLOURS = { const CLASSIFICATION_BORDER_COLOURS = {
current: '#16a34a', current: '#16a34a',
recent: '#475569', recent: '#475569',
stale: '#d97706', stale: '#d97706',
outdated: '#dc2626', outdated: '#dc2626',
} }
function buildMarkerHtml(station, index, colour, borderColour) { function buildMarkerHtml(station, index, colour, borderColour) {
const isFirst = index === 0 const isFirst = index === 0
const w = isFirst ? 63 : 59 const w = isFirst ? 63 : 59
const h = isFirst ? 58 : 51 const h = isFirst ? 58 : 51
const bw = isFirst ? 56 : 52 const bw = isFirst ? 56 : 52
const bh = isFirst ? 43 : 38 const bh = isFirst ? 43 : 38
const br = isFirst ? 17 : 15 const br = isFirst ? 17 : 15
const tailTop = isFirst ? 45 : 40 const tailTop = isFirst ? 45 : 40
const tailW = isFirst ? 9 : 7 const tailW = isFirst ? 9 : 7
const tailH = isFirst ? 11 : 9 const tailH = isFirst ? 11 : 9
const badgeSize = isFirst ? 18 : 16 const badgeSize = isFirst ? 18 : 16
const badgeFontSize = isFirst ? 10 : 8 const badgeFontSize = isFirst ? 10 : 8
const priceFontSize = isFirst ? 12 : 11 const priceFontSize = isFirst ? 12 : 11
const initial = escHtml((station.brand || station.name || '?')[0].toUpperCase()) const initial = escHtml((station.brand || station.name || '?')[0].toUpperCase())
const badge = isFirst const badge = isFirst
? `<div style="position:absolute;top:-4px;right:-4px;width:${badgeSize}px;height:${badgeSize}px;background:#facc15;border-radius:50%;display:flex;align-items:center;justify-content:center;border:2px solid white;box-shadow:0 2px 4px rgba(0,0,0,0.2);z-index:30;"><span style="font-size:${badgeFontSize}px;color:#713f12;">★</span></div>` ? `<div style="position:absolute;top:-4px;right:-4px;width:${badgeSize}px;height:${badgeSize}px;background:#facc15;border-radius:50%;display:flex;align-items:center;justify-content:center;border:2px solid white;box-shadow:0 2px 4px rgba(0,0,0,0.2);z-index:30;"><span style="font-size:${badgeFontSize}px;color:#713f12;">★</span></div>`
: `<div style="position:absolute;top:-3px;right:-3px;width:${badgeSize}px;height:${badgeSize}px;background:#374151;border-radius:50%;display:flex;align-items:center;justify-content:center;border:1px solid white;box-shadow:0 1px 3px rgba(0,0,0,0.2);z-index:30;"><span style="font-size:${badgeFontSize}px;font-weight:bold;color:white;">${index + 1}</span></div>` : `<div style="position:absolute;top:-3px;right:-3px;width:${badgeSize}px;height:${badgeSize}px;background:#374151;border-radius:50%;display:flex;align-items:center;justify-content:center;border:1px solid white;box-shadow:0 1px 3px rgba(0,0,0,0.2);z-index:30;"><span style="font-size:${badgeFontSize}px;font-weight:bold;color:white;">${index + 1}</span></div>`
return `<div style="position:relative;width:${w}px;height:${h}px;"> return `<div style="position:relative;width:${w}px;height:${h}px;">
${badge} ${badge}
<div style="position:absolute;top:6px;left:50%;transform:translateX(-50%);width:${bw}px;height:${bh}px;background:${colour};border-radius:${br}px;border:3px solid ${borderColour};box-shadow:0 2px 6px rgba(0,0,0,0.24),inset 0 1px 0 rgba(255,255,255,0.22);overflow:hidden;z-index:10;"> <div style="position:absolute;top:6px;left:50%;transform:translateX(-50%);width:${bw}px;height:${bh}px;background:${colour};border-radius:${br}px;border:3px solid ${borderColour};box-shadow:0 2px 6px rgba(0,0,0,0.24),inset 0 1px 0 rgba(255,255,255,0.22);overflow:hidden;z-index:10;">
<div style="position:absolute;top:0;left:0;right:0;height:11px;background:rgba(15,23,42,0.20);border-bottom:1px solid rgba(255,255,255,0.24);display:flex;align-items:center;justify-content:center;"> <div style="position:absolute;top:0;left:0;right:0;height:11px;background:rgba(15,23,42,0.20);border-bottom:1px solid rgba(255,255,255,0.24);display:flex;align-items:center;justify-content:center;">
@@ -90,53 +91,122 @@ function buildMarkerHtml(station, index, colour, borderColour) {
} }
function escHtml(str) { function escHtml(str) {
return String(str ?? '') return String(str ?? '')
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
.replace(/>/g, '&gt;') .replace(/>/g, '&gt;')
.replace(/"/g, '&quot;') .replace(/"/g, '&quot;')
} }
const props = defineProps({ const props = defineProps({
stations: { type: Array, required: true }, stations: {type: Array, required: true},
defaultOpen: { type: Boolean, default: false }, defaultOpen: {type: Boolean, default: false},
radiusMiles: {type: Number, default: 10},
}) })
const mapContainer = ref(null) const mapContainer = ref(null)
const isOpen = ref(false) const isOpen = ref(false)
let mapInstance = null let mapInstance = null
let markersLayer = null let markersLayer = null
let userMarker = null
function getZoomForRadius(radiusMiles) {
if (radiusMiles <= 1) return 16
if (radiusMiles <= 2) return 15
if (radiusMiles <= 5) return 14
if (radiusMiles <= 10) return 13
if (radiusMiles <= 15) return 11
if (radiusMiles <= 25) return 10
if (radiusMiles <= 50) return 9
return 8
}
function addUserMarker(lat, lng) {
if (userMarker) {
userMarker.remove()
}
const pulseHtml =
'<div class="absolute w-full h-full bg-blue-500/30 rounded-full animate-ping"></div>'
;
const icon = L.divIcon({
html: `
<div class="relative w-10 h-10 flex items-center justify-center">
${pulseHtml}
<div class="relative w-4 h-4 bg-blue-600 border-2 border-white rounded-full shadow-lg"></div>
</div>
`,
className: 'user-location-div-icon',
iconSize: [40, 40],
iconAnchor: [20, 20]
})
userMarker = L.marker([lat, lng], {icon, zIndexOffset: -100})
.bindPopup('Your location')
.addTo(mapInstance)
}
function locateUser() {
if (!navigator.geolocation) return
const ipFallback = () => {
fetch('https://ipapi.co/json/')
.then(r => r.json())
.then(d => d.latitude && d.longitude && addUserMarker(d.latitude, d.longitude))
.catch(() => {
})
}
navigator.geolocation.getCurrentPosition(
pos => {
addUserMarker(pos.coords.latitude, pos.coords.longitude)
navigator.geolocation.getCurrentPosition(
precise => addUserMarker(precise.coords.latitude, precise.coords.longitude),
() => {
},
{enableHighAccuracy: true, timeout: 10000, maximumAge: 0},
)
},
() => ipFallback(),
{enableHighAccuracy: false, timeout: 5000, maximumAge: 60000},
)
}
function initMap() { function initMap() {
if (mapInstance || !mapContainer.value) return if (mapInstance || !mapContainer.value) return
mapInstance = L.map(mapContainer.value) mapInstance = L.map(mapContainer.value)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors', attribution: '© OpenStreetMap contributors',
}).addTo(mapInstance) }).addTo(mapInstance)
markersLayer = L.layerGroup().addTo(mapInstance) markersLayer = L.layerGroup().addTo(mapInstance)
locateUser()
} }
function renderMarkers() { function renderMarkers() {
if (!mapInstance || !markersLayer) return if (!mapInstance || !markersLayer) return
markersLayer.clearLayers() markersLayer.clearLayers()
if (!props.stations.length) return if (!props.stations.length) return
const bounds = [] const bounds = []
props.stations.forEach((station, index) => { props.stations.forEach((station, index) => {
const colour = CLASSIFICATION_COLOURS[station.price_classification] ?? '#64748b' const colour = CLASSIFICATION_COLOURS[station.price_classification] ?? '#64748b'
const borderColour = CLASSIFICATION_BORDER_COLOURS[station.price_classification] ?? '#475569' const borderColour = CLASSIFICATION_BORDER_COLOURS[station.price_classification] ?? '#475569'
const miles = ((station.distance_km ?? 0) * 0.621371).toFixed(1) const miles = ((station.distance_km ?? 0) * 0.621371).toFixed(1)
const supermarketTag = station.is_supermarket const supermarketTag = station.is_supermarket
? '<span style="display:inline-block;background:#84cc16;color:#fff;font-size:10px;padding:1px 5px;border-radius:3px;margin-left:4px;">Supermarket</span>' ? '<span style="display:inline-block;background:#84cc16;color:#fff;font-size:10px;padding:1px 5px;border-radius:3px;margin-left:4px;">Supermarket</span>'
: '' : ''
const popup = ` const popup = `
<div style="min-width:160px"> <div style="min-width:160px">
<strong style="font-size:13px">${escHtml(station.name)}</strong>${supermarketTag}<br> <strong style="font-size:13px">${escHtml(station.name)}</strong>${supermarketTag}<br>
<span style="font-size:20px;font-weight:700;color:${escHtml(colour)}">${Number(station.price).toFixed(1)}p</span><br> <span style="font-size:20px;font-weight:700;color:${escHtml(colour)}">${Number(station.price).toFixed(1)}p</span><br>
@@ -145,61 +215,97 @@ function renderMarkers() {
</div> </div>
` `
const isFirst = index === 0 const isFirst = index === 0
const w = isFirst ? 63 : 59 const w = isFirst ? 63 : 59
const h = isFirst ? 58 : 51 const h = isFirst ? 58 : 51
const icon = L.divIcon({ const icon = L.divIcon({
className: '', className: '',
iconSize: [w, h], iconSize: [w, h],
iconAnchor: [w / 2, h], iconAnchor: [w / 2, h],
html: buildMarkerHtml(station, index, colour, borderColour), html: buildMarkerHtml(station, index, colour, borderColour),
}) })
const marker = L.marker([station.lat, station.lng], { icon }).bindPopup(popup) const marker = L.marker([station.lat, station.lng], {icon}).bindPopup(popup)
markersLayer.addLayer(marker) markersLayer.addLayer(marker)
bounds.push([station.lat, station.lng]) bounds.push([station.lat, station.lng])
}) })
if (bounds.length === 1) { if (bounds.length === 1) {
mapInstance.setView(bounds[0], 14) mapInstance.setView(bounds[0], zoom)
} else { } else {
mapInstance.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 }) mapInstance.fitBounds(bounds, {padding: [40, 40], maxZoom: zoom})
} }
} }
async function toggleMap() { async function toggleMap() {
isOpen.value = !isOpen.value isOpen.value = !isOpen.value
if (isOpen.value) { if (isOpen.value) {
await nextTick() await nextTick()
initMap() initMap()
mapInstance.invalidateSize() mapInstance.invalidateSize()
renderMarkers() renderMarkers()
} }
} }
onMounted(async () => { onMounted(async () => {
if (props.defaultOpen) { if (props.defaultOpen) {
isOpen.value = true isOpen.value = true
await nextTick() await nextTick()
initMap() initMap()
mapInstance.invalidateSize() mapInstance.invalidateSize()
renderMarkers() renderMarkers()
} }
}) })
watch(() => props.stations, () => { watch(() => props.stations, () => {
if (isOpen.value) { if (isOpen.value) {
renderMarkers() renderMarkers()
} }
}) })
onUnmounted(() => { onUnmounted(() => {
if (mapInstance) { if (mapInstance) {
mapInstance.remove() mapInstance.remove()
mapInstance = null mapInstance = null
} }
}) })
</script> </script>
<style>
.fuelalert-user-marker {
position: relative;
width: 16px;
height: 16px;
}
.fuelalert-user-dot {
position: absolute;
inset: 3px;
background: #3b82f6;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
.fuelalert-user-ring {
position: absolute;
inset: 0;
border-radius: 50%;
background: rgba(59, 130, 246, 0.25);
animation: fuelalert-pulse 2s ease-out infinite;
}
@keyframes fuelalert-pulse {
0% {
transform: scale(1);
opacity: 0.8;
}
100% {
transform: scale(3);
opacity: 0;
}
}
</style>

View File

@@ -4,17 +4,24 @@
<div class="flex flex-col sm:flex-row gap-3"> <div class="flex flex-col sm:flex-row gap-3">
<div class="relative flex-1"> <div class="relative flex-1">
<label for="postcode-input" class="sr-only">Postcode or city</label> <label for="postcode-input" class="sr-only">Postcode or city</label>
<span aria-hidden="true" <button
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 p-4" :disabled="locating"
> aria-label="Use my location"
<iconify-icon icon="lucide:map-pin" style="font-size:1.25rem"></iconify-icon> class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1.5 px-3 py-1.5
</span> bg-primary/85
text-white rounded-sm text-sm font-semibold transition-opacity hover:opacity-80"
type="button"
@click="useMyLocation"
>
<iconify-icon :class="{ 'animate-spin': locating }" :icon="locating ? 'lucide:loader-circle' : 'lucide:map-pin'" style="font-size:1rem"></iconify-icon>
<span class="hidden md:inline-flex">Near me</span>
</button>
<input <input
id="postcode-input" id="postcode-input"
v-model="postcode" v-model="postcode"
type="text" type="text"
placeholder="Enter postcode, e.g. SW1A 1AA" placeholder="Enter postcode, e.g. SW1A 1AA"
class="w-full h-14 pr-12 pl-4 bg-white border border-zinc-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-accent shadow-inner text-base" class="w-full h-14 pr-28 pl-4 bg-white border border-zinc-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-accent shadow-inner text-base"
@keyup.enter="onSearch" @keyup.enter="onSearch"
/> />
</div> </div>
@@ -75,6 +82,28 @@ const postcode = ref('')
const fuelType = ref('e10') const fuelType = ref('e10')
const radius = ref(10) const radius = ref(10)
const sort = ref('price') const sort = ref('price')
const locating = ref(false)
function useMyLocation() {
if (!navigator.geolocation) return
locating.value = true
navigator.geolocation.getCurrentPosition(
async ({ coords }) => {
try {
const res = await fetch(`https://api.postcodes.io/postcodes?lon=${coords.longitude}&lat=${coords.latitude}&limit=1`)
const json = await res.json()
if (json.result?.[0]?.postcode) {
postcode.value = json.result[0].postcode
onSearch()
}
} finally {
locating.value = false
}
},
() => { locating.value = false },
{ timeout: 8000, enableHighAccuracy: false, maximumAge: 30000 },
)
}
function onSearch() { function onSearch() {
if (!postcode.value.trim()) return if (!postcode.value.trim()) return

View File

@@ -118,7 +118,7 @@
<span class="font-medium">No stations found near you. Try a different postcode or increase the radius.</span> <span class="font-medium">No stations found near you. Try a different postcode or increase the radius.</span>
</div> </div>
<template v-else> <template v-else>
<LeafletMap :stations="stations" :default-open="true" /> <LeafletMap :default-open="true" :radius-miles="radiusMiles" :stations="stations" />
<StationList :stations="stations" :current-sort="sort" @sort="onSort" /> <StationList :stations="stations" :current-sort="sort" @sort="onSort" />
</template> </template>
</template> </template>
@@ -423,10 +423,12 @@ const { stations, loading, error, search } = useStations()
const sort = ref('price') const sort = ref('price')
const lastParams = ref(null) const lastParams = ref(null)
const searchAttempted = ref(false) const searchAttempted = ref(false)
const radiusMiles = ref(10)
async function onSearch(params) { async function onSearch(params) {
lastParams.value = params lastParams.value = params
sort.value = params.sort ?? sort.value sort.value = params.sort ?? sort.value
radiusMiles.value = params.radius ?? radiusMiles.value
searchAttempted.value = true searchAttempted.value = true
await search(params) await search(params)
} }