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>
<div class="space-y-2">
<button
@click="toggleMap"
class="flex items-center gap-2 text-sm font-bold text-accent hover:text-accent-content transition-colors"
>
<iconify-icon :icon="isOpen ? 'lucide:chevron-up' : 'lucide:chevron-down'"></iconify-icon>
{{ isOpen ? 'Hide map' : 'Show map' }}
</button>
<div class="space-y-2">
<button
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>
{{ isOpen ? 'Hide map' : 'Show map' }}
</button>
<template v-if="isOpen">
<div
ref="mapContainer"
class="w-full h-72 rounded-2xl overflow-hidden border border-zinc-300 shadow-sm"
></div>
<template v-if="isOpen">
<div
ref="mapContainer"
class="w-full h-72 rounded-2xl overflow-hidden border border-zinc-300 shadow-sm"
></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="inline-block size-3 rounded-full bg-green-500"></span>
Current (&lt;24h)
</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>
Recent (2448h)
</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>
Stale (25 days)
</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>
Outdated (5+ days)
</span>
</div>
</template>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
import {ref, watch, onMounted, onUnmounted, nextTick} from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import {zoom} from "leaflet/src/control/Control.Zoom.js";
const CLASSIFICATION_COLOURS = {
current: '#22c55e',
recent: '#64748b',
stale: '#f59e0b',
outdated: '#ef4444',
current: '#22c55e',
recent: '#64748b',
stale: '#f59e0b',
outdated: '#ef4444',
}
const CLASSIFICATION_BORDER_COLOURS = {
current: '#16a34a',
recent: '#475569',
stale: '#d97706',
outdated: '#dc2626',
current: '#16a34a',
recent: '#475569',
stale: '#d97706',
outdated: '#dc2626',
}
function buildMarkerHtml(station, index, colour, borderColour) {
const isFirst = index === 0
const w = isFirst ? 63 : 59
const h = isFirst ? 58 : 51
const bw = isFirst ? 56 : 52
const bh = isFirst ? 43 : 38
const br = isFirst ? 17 : 15
const tailTop = isFirst ? 45 : 40
const tailW = isFirst ? 9 : 7
const tailH = isFirst ? 11 : 9
const badgeSize = isFirst ? 18 : 16
const badgeFontSize = isFirst ? 10 : 8
const priceFontSize = isFirst ? 12 : 11
const isFirst = index === 0
const w = isFirst ? 63 : 59
const h = isFirst ? 58 : 51
const bw = isFirst ? 56 : 52
const bh = isFirst ? 43 : 38
const br = isFirst ? 17 : 15
const tailTop = isFirst ? 45 : 40
const tailW = isFirst ? 9 : 7
const tailH = isFirst ? 11 : 9
const badgeSize = isFirst ? 18 : 16
const badgeFontSize = isFirst ? 10 : 8
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
? `<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>`
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:-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}
<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;">
@@ -90,53 +91,122 @@ function buildMarkerHtml(station, index, colour, borderColour) {
}
function escHtml(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
const props = defineProps({
stations: { type: Array, required: true },
defaultOpen: { type: Boolean, default: false },
stations: {type: Array, required: true},
defaultOpen: {type: Boolean, default: false},
radiusMiles: {type: Number, default: 10},
})
const mapContainer = ref(null)
const isOpen = ref(false)
let mapInstance = 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() {
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', {
attribution: '© OpenStreetMap contributors',
}).addTo(mapInstance)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
}).addTo(mapInstance)
markersLayer = L.layerGroup().addTo(mapInstance)
markersLayer = L.layerGroup().addTo(mapInstance)
locateUser()
}
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) => {
const colour = CLASSIFICATION_COLOURS[station.price_classification] ?? '#64748b'
const borderColour = CLASSIFICATION_BORDER_COLOURS[station.price_classification] ?? '#475569'
const miles = ((station.distance_km ?? 0) * 0.621371).toFixed(1)
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>'
: ''
props.stations.forEach((station, index) => {
const colour = CLASSIFICATION_COLOURS[station.price_classification] ?? '#64748b'
const borderColour = CLASSIFICATION_BORDER_COLOURS[station.price_classification] ?? '#475569'
const miles = ((station.distance_km ?? 0) * 0.621371).toFixed(1)
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>'
: ''
const popup = `
const popup = `
<div style="min-width:160px">
<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>
@@ -145,61 +215,97 @@ function renderMarkers() {
</div>
`
const isFirst = index === 0
const w = isFirst ? 63 : 59
const h = isFirst ? 58 : 51
const isFirst = index === 0
const w = isFirst ? 63 : 59
const h = isFirst ? 58 : 51
const icon = L.divIcon({
className: '',
iconSize: [w, h],
iconAnchor: [w / 2, h],
html: buildMarkerHtml(station, index, colour, borderColour),
})
const icon = L.divIcon({
className: '',
iconSize: [w, h],
iconAnchor: [w / 2, h],
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)
bounds.push([station.lat, station.lng])
})
markersLayer.addLayer(marker)
bounds.push([station.lat, station.lng])
})
if (bounds.length === 1) {
mapInstance.setView(bounds[0], 14)
} else {
mapInstance.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 })
}
if (bounds.length === 1) {
mapInstance.setView(bounds[0], zoom)
} else {
mapInstance.fitBounds(bounds, {padding: [40, 40], maxZoom: zoom})
}
}
async function toggleMap() {
isOpen.value = !isOpen.value
isOpen.value = !isOpen.value
if (isOpen.value) {
await nextTick()
initMap()
mapInstance.invalidateSize()
renderMarkers()
}
if (isOpen.value) {
await nextTick()
initMap()
mapInstance.invalidateSize()
renderMarkers()
}
}
onMounted(async () => {
if (props.defaultOpen) {
isOpen.value = true
await nextTick()
initMap()
mapInstance.invalidateSize()
renderMarkers()
}
if (props.defaultOpen) {
isOpen.value = true
await nextTick()
initMap()
mapInstance.invalidateSize()
renderMarkers()
}
})
watch(() => props.stations, () => {
if (isOpen.value) {
renderMarkers()
}
if (isOpen.value) {
renderMarkers()
}
})
onUnmounted(() => {
if (mapInstance) {
mapInstance.remove()
mapInstance = null
}
if (mapInstance) {
mapInstance.remove()
mapInstance = null
}
})
</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="relative flex-1">
<label for="postcode-input" class="sr-only">Postcode or city</label>
<span aria-hidden="true"
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 p-4"
>
<iconify-icon icon="lucide:map-pin" style="font-size:1.25rem"></iconify-icon>
</span>
<button
:disabled="locating"
aria-label="Use my location"
class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-1.5 px-3 py-1.5
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
id="postcode-input"
v-model="postcode"
type="text"
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"
/>
</div>
@@ -75,6 +82,28 @@ const postcode = ref('')
const fuelType = ref('e10')
const radius = ref(10)
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() {
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>
</div>
<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" />
</template>
</template>
@@ -423,10 +423,12 @@ const { stations, loading, error, search } = useStations()
const sort = ref('price')
const lastParams = ref(null)
const searchAttempted = ref(false)
const radiusMiles = ref(10)
async function onSearch(params) {
lastParams.value = params
sort.value = params.sort ?? sort.value
radiusMiles.value = params.radius ?? radiusMiles.value
searchAttempted.value = true
await search(params)
}