feat(ui): consolidate map filters and rework station selection
- replace inline filter pills with a single "Filters" popover containing small pill buttons for fuel/radius/sort/brand (no native <select>s) - map polish: Carto Positron tiles, hidden zoom buttons, locate-me floating button + accuracy ring, smooth flyTo transitions, slim ⓘ attribution - map markers no longer open Leaflet popups; clicking a marker selects the station and surfaces the existing StationCard inline over the map, with swipe-down-to-close and a small overlay × button - price colour now reflects deal quality (cheap / average / expensive vs search avg ± 3p) on both list and map — stable across sort/filter - promote the "X ago" timestamp into the card header so it stays visible in the expanded state Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,26 +1,31 @@
|
||||
<template>
|
||||
<div v-if="isOpen" id="leaflet-map-panel" class="space-y-2">
|
||||
<div
|
||||
ref="mapContainer"
|
||||
class="w-full h-96 md:h-160 rounded-2xl overflow-hidden border border-zinc-300 shadow-sm"
|
||||
></div>
|
||||
<div class="relative w-full h-96 md:h-160 rounded-2xl overflow-hidden border border-zinc-300 shadow-sm">
|
||||
<div ref="mapContainer" class="absolute inset-0"></div>
|
||||
<!-- map-polish:4 — Locate-me floating button -->
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Show my location"
|
||||
class="absolute bottom-4 right-4 z-[900] inline-flex items-center justify-center w-10 h-10 rounded-full bg-white border border-zinc-200 text-zinc-700 shadow-md hover:bg-zinc-50 active:scale-95 transition cursor-pointer"
|
||||
@click="onLocateClick"
|
||||
>
|
||||
<iconify-icon icon="lucide:locate-fixed" class="text-lg"></iconify-icon>
|
||||
</button>
|
||||
<slot name="overlay" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3 text-xs text-zinc-500">
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1 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 (<24h)
|
||||
Below average
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="inline-block size-3 rounded-full bg-slate-500"></span>
|
||||
Recent (24–48h)
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="inline-block size-3 rounded-full bg-amber-500"></span>
|
||||
Stale (2–5 days)
|
||||
Around average
|
||||
</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="inline-block size-3 rounded-full bg-red-500"></span>
|
||||
Outdated (5+ days)
|
||||
Above average
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,18 +36,18 @@ import {ref, watch, onMounted, onUnmounted, nextTick} from 'vue'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
|
||||
const CLASSIFICATION_COLOURS = {
|
||||
current: '#22c55e',
|
||||
recent: '#64748b',
|
||||
stale: '#f59e0b',
|
||||
outdated: '#ef4444',
|
||||
const MARKER_FOCUS_ZOOM = 13 // map-polish:6 — minimum zoom when focusing a marker
|
||||
|
||||
const DEAL_COLOURS = {
|
||||
cheap: '#22c55e', // green — below market
|
||||
average: '#64748b', // slate — around market
|
||||
expensive: '#dc2626', // red — above market
|
||||
}
|
||||
|
||||
const CLASSIFICATION_BORDER_COLOURS = {
|
||||
current: '#16a34a',
|
||||
recent: '#475569',
|
||||
stale: '#d97706',
|
||||
outdated: '#dc2626',
|
||||
const DEAL_BORDER_COLOURS = {
|
||||
cheap: '#16a34a',
|
||||
average: '#475569',
|
||||
expensive: '#991b1b',
|
||||
}
|
||||
|
||||
function buildDirectionsUrl(station, origin) {
|
||||
@@ -53,7 +58,7 @@ function buildDirectionsUrl(station, origin) {
|
||||
return base
|
||||
}
|
||||
|
||||
function buildMarkerHtml(station, index, colour, borderColour) {
|
||||
function buildMarkerHtml(station, index, colour, borderColour, isSelected = false) {
|
||||
const isFirst = index === 0
|
||||
const w = isFirst ? 46 : 40
|
||||
const h = isFirst ? 20 : 18
|
||||
@@ -62,11 +67,29 @@ function buildMarkerHtml(station, index, colour, borderColour) {
|
||||
? `<span style="margin-right:2px;color:#facc15;font-size:10px;line-height:1;">★</span>`
|
||||
: ''
|
||||
|
||||
return `<div style="display:inline-flex;align-items:center;justify-content:center;width:${w}px;height:${h}px;padding:0 5px;background:${colour};color:#fff;font-weight:700;font-size:${fontSize}px;line-height:1;letter-spacing:-0.2px;border-radius:10px;border:1.5px solid ${borderColour};box-shadow:0 1px 3px rgba(0,0,0,0.25);white-space:nowrap;">
|
||||
const ringStyle = isSelected
|
||||
? 'box-shadow:0 0 0 3px rgba(187,91,62,0.35),0 1px 3px rgba(0,0,0,0.25);transform:scale(1.12);'
|
||||
: 'box-shadow:0 1px 3px rgba(0,0,0,0.25);'
|
||||
|
||||
return `<div style="display:inline-flex;align-items:center;justify-content:center;width:${w}px;height:${h}px;padding:0 5px;background:${colour};color:#fff;font-weight:700;font-size:${fontSize}px;line-height:1;letter-spacing:-0.2px;border-radius:10px;border:1.5px solid ${borderColour};${ringStyle}white-space:nowrap;transition:transform .15s ease,box-shadow .15s ease;">
|
||||
${star}${Number(station.price).toFixed(1)}
|
||||
</div>`
|
||||
}
|
||||
|
||||
function buildStationIcon(station, index, isSelected) {
|
||||
const isFirst = index === 0
|
||||
const w = isFirst ? 46 : 40
|
||||
const h = isFirst ? 20 : 18
|
||||
const colour = DEAL_COLOURS[station.deal_quality] ?? DEAL_COLOURS.average
|
||||
const borderColour = DEAL_BORDER_COLOURS[station.deal_quality] ?? DEAL_BORDER_COLOURS.average
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
iconSize: [w, h],
|
||||
iconAnchor: [w / 2, h / 2],
|
||||
html: buildMarkerHtml(station, index, colour, borderColour, isSelected),
|
||||
})
|
||||
}
|
||||
|
||||
function escHtml(str) {
|
||||
return String(str ?? '')
|
||||
.replace(/&/g, '&')
|
||||
@@ -80,12 +103,18 @@ const props = defineProps({
|
||||
isOpen: {type: Boolean, default: true},
|
||||
radiusMiles: {type: Number, default: 10},
|
||||
origin: {type: Object, default: null},
|
||||
selectedStationId: {type: String, default: null},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['station-select'])
|
||||
|
||||
const mapContainer = ref(null)
|
||||
let mapInstance = null
|
||||
let markersLayer = null
|
||||
let userMarker = null
|
||||
let userAccuracyCircle = null // map-polish:4
|
||||
let hasInitialView = false // map-polish:6 — flyTo needs an existing view
|
||||
const markerByStationId = new Map()
|
||||
|
||||
function getZoomForRadius(radiusMiles) {
|
||||
if (radiusMiles <= 1) return 16
|
||||
@@ -100,73 +129,103 @@ function getZoomForRadius(radiusMiles) {
|
||||
}
|
||||
|
||||
|
||||
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>'
|
||||
;
|
||||
// map-polish:4 — accuracy ring rendered alongside the user dot
|
||||
function addUserMarker(lat, lng, accuracyMeters = null) {
|
||||
if (userMarker) userMarker.remove()
|
||||
if (userAccuracyCircle) userAccuracyCircle.remove()
|
||||
|
||||
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]
|
||||
<div class="fa-user-loc">
|
||||
<div class="fa-user-loc-pulse"></div>
|
||||
<div class="fa-user-loc-dot"></div>
|
||||
</div>
|
||||
`,
|
||||
className: 'fa-user-loc-icon',
|
||||
iconSize: [22, 22],
|
||||
iconAnchor: [11, 11],
|
||||
})
|
||||
|
||||
userMarker = L.marker([lat, lng], {icon, zIndexOffset: -100})
|
||||
.bindPopup('Your location')
|
||||
userMarker = L.marker([lat, lng], {icon, zIndexOffset: -100, interactive: false})
|
||||
.addTo(mapInstance)
|
||||
|
||||
if (accuracyMeters && accuracyMeters > 0) {
|
||||
userAccuracyCircle = L.circle([lat, lng], {
|
||||
radius: accuracyMeters,
|
||||
color: '#3b82f6',
|
||||
weight: 1,
|
||||
opacity: 0.4,
|
||||
fillColor: '#3b82f6',
|
||||
fillOpacity: 0.08,
|
||||
interactive: false,
|
||||
}).addTo(mapInstance)
|
||||
}
|
||||
}
|
||||
|
||||
function locateUser() {
|
||||
function locateUser({pan = false} = {}) {
|
||||
if (!navigator.geolocation) return
|
||||
|
||||
const onSuccess = (pos) => {
|
||||
const {latitude, longitude, accuracy} = pos.coords
|
||||
addUserMarker(latitude, longitude, accuracy)
|
||||
// map-polish:6 — smooth flyTo when explicitly locating the user
|
||||
if (pan && mapInstance) {
|
||||
const zoom = hasInitialView ? Math.max(mapInstance.getZoom(), 13) : 13
|
||||
if (hasInitialView) {
|
||||
mapInstance.flyTo([latitude, longitude], zoom, {duration: 0.8})
|
||||
} else {
|
||||
mapInstance.setView([latitude, longitude], zoom)
|
||||
hasInitialView = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ipFallback = () => {
|
||||
fetch('https://ipapi.co/json/')
|
||||
.then(r => r.json())
|
||||
.then(d => d.latitude && d.longitude && addUserMarker(d.latitude, d.longitude))
|
||||
.catch(() => {
|
||||
})
|
||||
.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},
|
||||
)
|
||||
},
|
||||
onSuccess,
|
||||
() => ipFallback(),
|
||||
{enableHighAccuracy: false, timeout: 5000, maximumAge: 60000},
|
||||
{enableHighAccuracy: true, timeout: 10000, maximumAge: 30000},
|
||||
)
|
||||
}
|
||||
|
||||
function onLocateClick() {
|
||||
locateUser({pan: true})
|
||||
}
|
||||
|
||||
function initMap() {
|
||||
if (mapInstance || !mapContainer.value) return
|
||||
|
||||
mapInstance = L.map(mapContainer.value)
|
||||
// map-polish:7 — replace default attribution control with custom ⓘ button
|
||||
mapInstance = L.map(mapContainer.value, {zoomControl: false, attributionControl: false})
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
// map-polish:5 — Carto Positron tile (cleaner than raw OSM)
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||
subdomains: 'abcd',
|
||||
maxZoom: 19,
|
||||
attribution: '© OpenStreetMap contributors © CARTO',
|
||||
}).addTo(mapInstance)
|
||||
|
||||
markersLayer = L.layerGroup().addTo(mapInstance)
|
||||
// map-polish:7 — small ⓘ control with attribution on click/hover
|
||||
const attribCtl = L.control({position: 'bottomleft'})
|
||||
attribCtl.onAdd = () => {
|
||||
const wrap = L.DomUtil.create('div', 'fa-attrib')
|
||||
wrap.innerHTML = `
|
||||
<button type="button" class="fa-attrib-btn" aria-label="Map attribution">i</button>
|
||||
<div class="fa-attrib-popover" role="tooltip">© OpenStreetMap contributors © CARTO</div>
|
||||
`
|
||||
L.DomEvent.disableClickPropagation(wrap)
|
||||
L.DomEvent.disableScrollPropagation(wrap)
|
||||
return wrap
|
||||
}
|
||||
attribCtl.addTo(mapInstance)
|
||||
|
||||
mapInstance.on('zoomend', () => {
|
||||
console.log('Map zoom:', mapInstance.getZoom())
|
||||
})
|
||||
markersLayer = L.layerGroup().addTo(mapInstance)
|
||||
|
||||
locateUser()
|
||||
}
|
||||
@@ -175,59 +234,31 @@ function renderMarkers() {
|
||||
if (!mapInstance || !markersLayer) return
|
||||
|
||||
markersLayer.clearLayers()
|
||||
markerByStationId.clear()
|
||||
|
||||
if (!props.stations.length) return
|
||||
|
||||
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>'
|
||||
: ''
|
||||
|
||||
const directionsUrl = escHtml(buildDirectionsUrl(station, props.origin))
|
||||
|
||||
const popup = `
|
||||
<div style="min-width:180px">
|
||||
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:8px;">
|
||||
<div style="min-width:0;flex:1;">
|
||||
<strong style="font-size:13px">${escHtml(station.name)}</strong>${supermarketTag}
|
||||
</div>
|
||||
<a data-directions href="${directionsUrl}" target="_blank" rel="noopener" aria-label="Directions" style="display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;flex-shrink:0;border-radius:8px;;color:black;text-decoration:none;"><iconify-icon icon="lucide:navigation" style="font-size:16px;"></iconify-icon></a>
|
||||
</div>
|
||||
<div style="margin-top:4px;"><span style="font-size:20px;font-weight:700;color:${escHtml(colour)}">${Number(station.price).toFixed(1)}p</span></div>
|
||||
<span style="font-size:12px;color:#6b7280">${escHtml(miles)} miles away</span><br>
|
||||
<span style="font-size:11px;color:#9ca3af">${escHtml(station.address)}, ${escHtml(station.postcode)}</span>
|
||||
</div>
|
||||
`
|
||||
|
||||
const isFirst = index === 0
|
||||
const w = isFirst ? 46 : 40
|
||||
const h = isFirst ? 20 : 18
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
iconSize: [w, h],
|
||||
iconAnchor: [w / 2, h / 2],
|
||||
html: buildMarkerHtml(station, index, colour, borderColour),
|
||||
})
|
||||
|
||||
const marker = L.marker([station.lat, station.lng], {icon}).bindPopup(popup)
|
||||
const isSelected = props.selectedStationId === station.station_id
|
||||
const icon = buildStationIcon(station, index, isSelected)
|
||||
const marker = L.marker([station.lat, station.lng], {icon, _stationIndex: index})
|
||||
|
||||
marker.on('click', () => {
|
||||
const target = Math.max(mapInstance.getZoom(), 16)
|
||||
mapInstance.setView([station.lat, station.lng], target, {animate: true})
|
||||
})
|
||||
|
||||
marker.on('popupopen', (e) => {
|
||||
const link = e.popup.getElement()?.querySelector('a[data-directions]')
|
||||
if (link) L.DomEvent.disableClickPropagation(link)
|
||||
emit('station-select', station.station_id)
|
||||
const target = Math.max(mapInstance.getZoom(), MARKER_FOCUS_ZOOM)
|
||||
// map-polish:6 — flyTo for smooth marker focus (view always set by now)
|
||||
if (hasInitialView) {
|
||||
mapInstance.flyTo([station.lat, station.lng], target, {duration: 0.6})
|
||||
} else {
|
||||
mapInstance.setView([station.lat, station.lng], target)
|
||||
hasInitialView = true
|
||||
}
|
||||
})
|
||||
|
||||
markersLayer.addLayer(marker)
|
||||
markerByStationId.set(station.station_id, {marker, station, index})
|
||||
bounds.push([station.lat, station.lng])
|
||||
})
|
||||
|
||||
@@ -236,7 +267,13 @@ function renderMarkers() {
|
||||
? [props.origin.lat, props.origin.lng]
|
||||
: bounds[0]
|
||||
|
||||
mapInstance.setView(center, zoom)
|
||||
// map-polish:6 — flyTo only after the initial view exists
|
||||
if (hasInitialView) {
|
||||
mapInstance.flyTo(center, zoom, {duration: 0.6})
|
||||
} else {
|
||||
mapInstance.setView(center, zoom)
|
||||
hasInitialView = true
|
||||
}
|
||||
}
|
||||
|
||||
function destroyMap() {
|
||||
@@ -245,6 +282,9 @@ function destroyMap() {
|
||||
mapInstance = null
|
||||
markersLayer = null
|
||||
userMarker = null
|
||||
userAccuracyCircle = null
|
||||
hasInitialView = false
|
||||
markerByStationId.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,41 +310,93 @@ watch(() => props.stations, () => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.selectedStationId, (newId, oldId) => {
|
||||
if (!mapInstance) return
|
||||
|
||||
if (oldId) {
|
||||
const prev = markerByStationId.get(oldId)
|
||||
if (prev) prev.marker.setIcon(buildStationIcon(prev.station, prev.index, false))
|
||||
}
|
||||
if (newId) {
|
||||
const next = markerByStationId.get(newId)
|
||||
if (next) {
|
||||
next.marker.setIcon(buildStationIcon(next.station, next.index, true))
|
||||
if (hasInitialView) {
|
||||
const ll = next.marker.getLatLng()
|
||||
mapInstance.flyTo(ll, Math.max(mapInstance.getZoom(), MARKER_FOCUS_ZOOM), {duration: 0.5})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(destroyMap)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.fuelalert-user-marker {
|
||||
/* map-polish:4 — user-location dot + pulse */
|
||||
.fa-user-loc-icon { background: transparent; border: none; }
|
||||
.fa-user-loc {
|
||||
position: relative;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.fuelalert-user-dot {
|
||||
.fa-user-loc-dot {
|
||||
position: absolute;
|
||||
inset: 3px;
|
||||
background: #3b82f6;
|
||||
inset: 6px;
|
||||
background: #2563eb;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.fuelalert-user-ring {
|
||||
.fa-user-loc-pulse {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(59, 130, 246, 0.25);
|
||||
animation: fuelalert-pulse 2s ease-out infinite;
|
||||
background: rgba(37, 99, 235, 0.25);
|
||||
animation: fa-user-pulse 2s ease-out infinite;
|
||||
}
|
||||
@keyframes fa-user-pulse {
|
||||
0% { transform: scale(0.6); opacity: 0.8; }
|
||||
100% { transform: scale(2.2); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes fuelalert-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(3);
|
||||
opacity: 0;
|
||||
}
|
||||
/* map-polish:7 — discrete attribution control */
|
||||
.fa-attrib { position: relative; }
|
||||
.fa-attrib-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
color: #6b7280;
|
||||
font: 600 11px/1 ui-sans-serif, system-ui, sans-serif;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.fa-attrib-btn:hover { color: #111827; }
|
||||
.fa-attrib-popover {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(100% + 6px);
|
||||
white-space: nowrap;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #4b5563;
|
||||
font-size: 10px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(2px);
|
||||
transition: opacity .15s ease, transform .15s ease;
|
||||
}
|
||||
.fa-attrib:hover .fa-attrib-popover,
|
||||
.fa-attrib:focus-within .fa-attrib-popover {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,114 +1,164 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-2 md:gap-2.5 py-3 border-b border-zinc-200">
|
||||
<!-- Refine group -->
|
||||
<span class="hidden md:inline text-xs font-mono uppercase tracking-widest text-zinc-400 mr-1">
|
||||
Refine
|
||||
</span>
|
||||
|
||||
<label :class="{ 'is-active': fuelType !== DEFAULTS.fuelType }" class="pill group">
|
||||
<iconify-icon class="text-sm opacity-70" icon="lucide:fuel"></iconify-icon>
|
||||
<span class="text-sm font-medium">{{ fuelLabel }}</span>
|
||||
<iconify-icon class="text-sm opacity-50 group-hover:opacity-100" icon="lucide:chevron-down"></iconify-icon>
|
||||
<select
|
||||
v-model="fuelType"
|
||||
aria-label="Fuel type"
|
||||
class="absolute inset-0 opacity-0 cursor-pointer"
|
||||
name="fuelType"
|
||||
<div ref="popoverRoot">
|
||||
<div class="flex flex-wrap items-center gap-2 md:gap-2.5 py-3 border-b border-zinc-200">
|
||||
<button
|
||||
:aria-expanded="open"
|
||||
:class="{ 'is-active': activeCount > 0 || open }"
|
||||
aria-controls="post-search-filters-panel"
|
||||
aria-haspopup="dialog"
|
||||
class="pill"
|
||||
type="button"
|
||||
@click="open = !open"
|
||||
>
|
||||
<option v-for="fuel in FUEL_TYPES" :key="fuel.value" :value="fuel.value">{{ fuel.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<iconify-icon class="text-sm opacity-70" icon="lucide:sliders-horizontal"></iconify-icon>
|
||||
<span class="text-sm font-medium">Filters</span>
|
||||
<span
|
||||
v-if="activeCount > 0"
|
||||
class="inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded-full bg-accent text-accent-foreground text-xs font-semibold"
|
||||
aria-label="Active filter count"
|
||||
>
|
||||
{{ activeCount }}
|
||||
</span>
|
||||
<iconify-icon
|
||||
:class="open ? 'rotate-180' : ''"
|
||||
class="text-sm opacity-50 transition-transform duration-200"
|
||||
icon="lucide:chevron-down"
|
||||
></iconify-icon>
|
||||
</button>
|
||||
|
||||
<label :class="{ 'is-active': radius !== DEFAULTS.radius }" class="pill group">
|
||||
<iconify-icon class="text-sm opacity-70" icon="lucide:circle-dot"></iconify-icon>
|
||||
<span class="text-sm font-medium">{{ radius }} miles</span>
|
||||
<iconify-icon class="text-sm opacity-50 group-hover:opacity-100" icon="lucide:chevron-down"></iconify-icon>
|
||||
<select
|
||||
v-model.number="radius"
|
||||
aria-label="Search radius"
|
||||
class="absolute inset-0 opacity-0 cursor-pointer"
|
||||
name="radius"
|
||||
<button
|
||||
:aria-expanded="mapOpen"
|
||||
:class="{ 'is-active': mapOpen }"
|
||||
aria-controls="leaflet-map-panel"
|
||||
class="pill"
|
||||
type="button"
|
||||
@click="emit('toggle-map')"
|
||||
>
|
||||
<option :value="5">5 miles</option>
|
||||
<option :value="10">10 miles</option>
|
||||
<option :value="20">20 miles</option>
|
||||
</select>
|
||||
</label>
|
||||
<iconify-icon :icon="mapOpen ? 'lucide:map' : 'lucide:map-off'" class="text-sm opacity-70"></iconify-icon>
|
||||
<span class="text-sm font-medium">{{ mapOpen ? 'Hide map' : 'Show map' }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
:aria-expanded="mapOpen"
|
||||
:class="{ 'is-active': mapOpen }"
|
||||
aria-controls="leaflet-map-panel"
|
||||
class="pill"
|
||||
type="button"
|
||||
@click="emit('toggle-map')"
|
||||
<span class="ml-auto text-sm text-zinc-500 font-medium">
|
||||
{{ stationCount }} station{{ stationCount !== 1 ? 's' : '' }} found
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="open"
|
||||
id="post-search-filters-panel"
|
||||
role="dialog"
|
||||
aria-label="Filters"
|
||||
class="mt-3 rounded-2xl border border-zinc-200 bg-white shadow-sm p-4 space-y-4 max-h-[70vh] overflow-y-auto"
|
||||
>
|
||||
<iconify-icon :icon="mapOpen ? 'lucide:map' : 'lucide:map-off'" class="text-sm opacity-70"></iconify-icon>
|
||||
<span class="text-sm font-medium">{{ mapOpen ? 'Hide map' : 'Show map' }}</span>
|
||||
<iconify-icon
|
||||
:class="mapOpen ? 'rotate-180' : ''"
|
||||
class="text-sm opacity-60 transition-transform duration-200"
|
||||
icon="lucide:chevron-down"
|
||||
></iconify-icon>
|
||||
</button>
|
||||
<div>
|
||||
<span class="block text-[10px] font-mono uppercase tracking-widest text-zinc-500 mb-2">Fuel</span>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2" role="radiogroup" aria-label="Fuel type">
|
||||
<button
|
||||
v-for="fuel in FUEL_TYPES"
|
||||
:key="fuel.value"
|
||||
:aria-checked="fuelType === fuel.value"
|
||||
:class="{ 'is-active': fuelType === fuel.value }"
|
||||
class="pill-sm"
|
||||
role="radio"
|
||||
type="button"
|
||||
@click="fuelType = fuel.value"
|
||||
>
|
||||
{{ fuel.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="hasActive"
|
||||
class="inline-flex items-center gap-1 h-10 px-3 text-sm text-zinc-500 hover:text-zinc-900 transition-colors cursor-pointer"
|
||||
type="button"
|
||||
@click="resetFilters"
|
||||
>
|
||||
<iconify-icon class="text-sm" icon="lucide:x"></iconify-icon>
|
||||
Clear
|
||||
</button>
|
||||
<div>
|
||||
<span class="block text-[10px] font-mono uppercase tracking-widest text-zinc-500 mb-2">Radius</span>
|
||||
<div class="grid grid-cols-3 gap-2" role="radiogroup" aria-label="Search radius">
|
||||
<button
|
||||
v-for="option in [5, 10, 20]"
|
||||
:key="option"
|
||||
:aria-checked="radius === option"
|
||||
:class="{ 'is-active': radius === option }"
|
||||
class="pill-sm"
|
||||
role="radio"
|
||||
type="button"
|
||||
@click="radius = option"
|
||||
>
|
||||
{{ option }} miles
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Force Sort to a new line on mobile only -->
|
||||
<div aria-hidden="true" class="basis-full md:hidden"></div>
|
||||
<div>
|
||||
<span class="block text-[10px] font-mono uppercase tracking-widest text-zinc-500 mb-2">Sort by</span>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2" role="radiogroup" aria-label="Sort by">
|
||||
<button
|
||||
v-for="option in sortOptions"
|
||||
:key="option.value"
|
||||
:aria-checked="sort === option.value"
|
||||
:class="{ 'is-active': sort === option.value }"
|
||||
class="pill-sm"
|
||||
role="radio"
|
||||
type="button"
|
||||
@click="sort = option.value"
|
||||
>
|
||||
<iconify-icon :icon="option.icon" class="text-sm opacity-70"></iconify-icon>
|
||||
<span>{{ option.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sort group -->
|
||||
<span class="hidden md:inline text-xs font-mono uppercase tracking-widest text-zinc-400 mx-1">
|
||||
Sort
|
||||
</span>
|
||||
<div v-if="brands.length > 1">
|
||||
<span class="block text-[10px] font-mono uppercase tracking-widest text-zinc-500 mb-2">Brand</span>
|
||||
<div class="flex flex-wrap gap-2" role="radiogroup" aria-label="Filter by brand">
|
||||
<button
|
||||
:aria-checked="!brandFilter"
|
||||
:class="{ 'is-active': !brandFilter }"
|
||||
class="pill-sm"
|
||||
role="radio"
|
||||
type="button"
|
||||
@click="emit('update:brandFilter', '')"
|
||||
>
|
||||
All brands
|
||||
</button>
|
||||
<button
|
||||
v-for="brand in brands"
|
||||
:key="brand"
|
||||
:aria-checked="brandFilter === brand"
|
||||
:class="{ 'is-active': brandFilter === brand }"
|
||||
class="pill-sm"
|
||||
role="radio"
|
||||
type="button"
|
||||
@click="emit('update:brandFilter', brand)"
|
||||
>
|
||||
{{ brand }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="option in sortOptions"
|
||||
:key="option.value"
|
||||
:class="{ 'is-active': sort === option.value }"
|
||||
class="pill"
|
||||
type="button"
|
||||
@click="sort = option.value"
|
||||
>
|
||||
<iconify-icon :icon="option.icon" class="text-sm opacity-70"></iconify-icon>
|
||||
<span class="text-sm font-medium">{{ option.label }}</span>
|
||||
</button>
|
||||
<div class="flex items-center justify-between pt-2 border-t border-zinc-200">
|
||||
<button
|
||||
v-if="hasActive"
|
||||
class="inline-flex items-center gap-1 text-sm text-zinc-500 hover:text-zinc-900 transition-colors cursor-pointer"
|
||||
type="button"
|
||||
@click="resetFilters"
|
||||
>
|
||||
<iconify-icon class="text-sm" icon="lucide:x"></iconify-icon>
|
||||
Clear all
|
||||
</button>
|
||||
<span v-else class="text-sm text-zinc-400">No filters applied</span>
|
||||
|
||||
<label
|
||||
v-if="brands.length > 1"
|
||||
:class="{ 'is-active': brandFilter }"
|
||||
class="pill group"
|
||||
>
|
||||
<iconify-icon class="text-sm opacity-70" icon="lucide:tag"></iconify-icon>
|
||||
<span class="text-sm font-medium">{{ brandFilter || 'All brands' }}</span>
|
||||
<iconify-icon class="text-sm opacity-50 group-hover:opacity-100" icon="lucide:chevron-down"></iconify-icon>
|
||||
<select
|
||||
:value="brandFilter"
|
||||
aria-label="Filter by brand"
|
||||
class="absolute inset-0 opacity-0 cursor-pointer"
|
||||
@change="emit('update:brandFilter', $event.target.value)"
|
||||
>
|
||||
<option value="">All brands</option>
|
||||
<option v-for="brand in brands" :key="brand" :value="brand">{{ brand }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<span class="ml-auto text-sm text-zinc-500 font-medium">
|
||||
{{ stationCount }} station{{ stationCount !== 1 ? 's' : '' }} found
|
||||
</span>
|
||||
<button
|
||||
class="inline-flex items-center h-9 px-4 rounded-full bg-accent text-accent-foreground text-sm font-medium hover:bg-accent-content transition-colors cursor-pointer"
|
||||
type="button"
|
||||
@click="open = false"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { FUEL_TYPES } from '../constants/fuelTypes.js'
|
||||
|
||||
const DEFAULTS = Object.freeze({
|
||||
@@ -140,6 +190,9 @@ const fuelType = ref(DEFAULTS.fuelType)
|
||||
const radius = ref(DEFAULTS.radius)
|
||||
const sort = ref(DEFAULTS.sort)
|
||||
|
||||
const open = ref(false)
|
||||
const popoverRoot = ref(null)
|
||||
|
||||
let hydrating = false
|
||||
|
||||
watch(() => props.initial, (v) => {
|
||||
@@ -158,10 +211,6 @@ watch([fuelType, radius, sort], () => {
|
||||
if (postcode.value.trim() || coords.value) emitSearch()
|
||||
})
|
||||
|
||||
const fuelLabel = computed(() => {
|
||||
return FUEL_TYPES.find(f => f.value === fuelType.value)?.label ?? 'Fuel'
|
||||
})
|
||||
|
||||
const hasActive = computed(() => (
|
||||
fuelType.value !== DEFAULTS.fuelType
|
||||
|| radius.value !== DEFAULTS.radius
|
||||
@@ -169,6 +218,15 @@ const hasActive = computed(() => (
|
||||
|| Boolean(props.brandFilter)
|
||||
))
|
||||
|
||||
const activeCount = computed(() => {
|
||||
let count = 0
|
||||
if (fuelType.value !== DEFAULTS.fuelType) count++
|
||||
if (radius.value !== DEFAULTS.radius) count++
|
||||
if (sort.value !== DEFAULTS.sort) count++
|
||||
if (props.brandFilter) count++
|
||||
return count
|
||||
})
|
||||
|
||||
function resetFilters() {
|
||||
fuelType.value = DEFAULTS.fuelType
|
||||
radius.value = DEFAULTS.radius
|
||||
@@ -190,4 +248,27 @@ function emitSearch() {
|
||||
sort: sort.value,
|
||||
})
|
||||
}
|
||||
|
||||
function onDocumentPointerDown(event) {
|
||||
if (!open.value) return
|
||||
if (popoverRoot.value && !popoverRoot.value.contains(event.target)) {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onDocumentKeydown(event) {
|
||||
if (event.key === 'Escape' && open.value) {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown)
|
||||
document.addEventListener('keydown', onDocumentKeydown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onDocumentPointerDown)
|
||||
document.removeEventListener('keydown', onDocumentKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,29 +1,48 @@
|
||||
<template>
|
||||
<div
|
||||
:aria-expanded="expanded"
|
||||
:aria-expanded="isExpanded"
|
||||
:class="[
|
||||
'bg-zinc-50 p-4 rounded-xl border shadow-sm flex flex-col gap-4 cursor-pointer transition-colors',
|
||||
expanded ? 'border-accent' : 'border-zinc-300 hover:border-zinc-400',
|
||||
'relative bg-zinc-50 rounded-xl border shadow-sm flex flex-col gap-4 transition-colors',
|
||||
dismissible ? 'pt-7 px-4 pb-4' : 'p-4',
|
||||
isExpanded ? 'border-accent' : 'border-zinc-300 hover:border-zinc-400',
|
||||
isControlled ? '' : 'cursor-pointer',
|
||||
]"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:role="isControlled ? null : 'button'"
|
||||
:style="swipeStyle"
|
||||
:tabindex="isControlled ? null : 0"
|
||||
@click="toggle"
|
||||
@keydown.enter.prevent="toggle"
|
||||
@keydown.space.prevent="toggle"
|
||||
@touchcancel="onTouchEnd"
|
||||
@touchend="onTouchEnd"
|
||||
@touchmove="onTouchMove"
|
||||
@touchstart.passive="onTouchStart"
|
||||
>
|
||||
<button
|
||||
v-if="dismissible"
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
class="absolute top-2 right-2 z-10 inline-flex w-6 h-6 items-center justify-center rounded-full bg-white/90 text-zinc-500 border border-zinc-200 shadow-sm hover:bg-white hover:text-zinc-800"
|
||||
@click.stop="emit('dismiss')"
|
||||
>
|
||||
<iconify-icon class="text-xs" icon="lucide:x"></iconify-icon>
|
||||
</button>
|
||||
<div
|
||||
v-if="dismissible"
|
||||
aria-hidden="true"
|
||||
class="absolute top-2 left-1/2 -translate-x-1/2 w-9 h-1 rounded-full bg-zinc-300"
|
||||
></div>
|
||||
<div class="flex justify-between items-start gap-3">
|
||||
<div class="space-y-0.5 min-w-0 flex-1">
|
||||
<h4 class="font-semibold text-sm text-zinc-800 truncate">{{ displayName }}</h4>
|
||||
<template v-if="!expanded">
|
||||
<p class="text-xs text-zinc-500 flex items-center gap-1">
|
||||
<iconify-icon class="text-xs" icon="lucide:map-pin"></iconify-icon>
|
||||
<span>{{ distanceMiles }} mi</span>
|
||||
</p>
|
||||
<p v-if="updatedAgo" :class="[priceColor, 'text-xs flex items-center gap-1']">
|
||||
<iconify-icon class="text-xs" icon="lucide:clock"></iconify-icon>
|
||||
<span>{{ updatedAgo }}</span>
|
||||
</p>
|
||||
</template>
|
||||
<p v-if="!isExpanded" class="text-xs text-zinc-500 flex items-center gap-1">
|
||||
<iconify-icon class="text-xs" icon="lucide:map-pin"></iconify-icon>
|
||||
<span>{{ distanceMiles }} mi</span>
|
||||
</p>
|
||||
<p v-if="updatedAgo" class="text-xs text-zinc-500 flex items-center gap-1">
|
||||
<iconify-icon class="text-xs" icon="lucide:clock"></iconify-icon>
|
||||
<span>{{ updatedAgo }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
:href="directionsUrl"
|
||||
@@ -56,7 +75,7 @@
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div v-if="expanded" class="border-t border-zinc-200 pt-3 space-y-3">
|
||||
<div v-if="isExpanded" class="border-t border-zinc-200 pt-3 space-y-3">
|
||||
<p v-if="brandLabel" class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">
|
||||
{{ brandLabel }}
|
||||
</p>
|
||||
@@ -111,7 +130,7 @@
|
||||
</div>
|
||||
|
||||
<p class="text-[11px] text-zinc-400">
|
||||
{{ fullAddress }}<span v-if="updatedAgo"> · Updated {{ updatedAgo }}</span>
|
||||
{{ fullAddress }}
|
||||
</p>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -159,22 +178,89 @@ const props = defineProps({
|
||||
avgPence: { type: Number, default: null },
|
||||
removable: { type: Boolean, default: false },
|
||||
origin: { type: Object, default: null },
|
||||
expanded: { type: Boolean, default: null },
|
||||
dismissible: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['remove'])
|
||||
const emit = defineEmits(['remove', 'dismiss', 'update:expanded'])
|
||||
|
||||
const expanded = ref(false)
|
||||
const internalExpanded = ref(false)
|
||||
const isControlled = computed(() => props.expanded !== null)
|
||||
const isExpanded = computed(() => (isControlled.value ? props.expanded : internalExpanded.value))
|
||||
|
||||
function toggle() {
|
||||
expanded.value = !expanded.value
|
||||
if (isControlled.value) return
|
||||
internalExpanded.value = !internalExpanded.value
|
||||
emit('update:expanded', internalExpanded.value)
|
||||
}
|
||||
|
||||
const SWIPE_DISMISS_THRESHOLD_PX = 80
|
||||
const dragOffset = ref(0)
|
||||
const dragging = ref(false)
|
||||
let touchStartX = 0
|
||||
let touchStartY = 0
|
||||
let lockedAxis = null
|
||||
|
||||
function onTouchStart(e) {
|
||||
if (!props.dismissible || e.touches.length !== 1) return
|
||||
touchStartX = e.touches[0].clientX
|
||||
touchStartY = e.touches[0].clientY
|
||||
lockedAxis = null
|
||||
dragging.value = false
|
||||
dragOffset.value = 0
|
||||
}
|
||||
|
||||
function onTouchMove(e) {
|
||||
if (!props.dismissible || e.touches.length !== 1) return
|
||||
const dy = e.touches[0].clientY - touchStartY
|
||||
const dx = e.touches[0].clientX - touchStartX
|
||||
|
||||
if (lockedAxis === null) {
|
||||
if (Math.abs(dy) < 6 && Math.abs(dx) < 6) return
|
||||
lockedAxis = Math.abs(dy) > Math.abs(dx) ? 'y' : 'x'
|
||||
}
|
||||
|
||||
if (lockedAxis === 'y' && dy > 0) {
|
||||
dragging.value = true
|
||||
dragOffset.value = dy
|
||||
if (e.cancelable) e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
if (!props.dismissible) return
|
||||
if (dragging.value && dragOffset.value > SWIPE_DISMISS_THRESHOLD_PX) {
|
||||
emit('dismiss')
|
||||
}
|
||||
dragging.value = false
|
||||
dragOffset.value = 0
|
||||
lockedAxis = null
|
||||
}
|
||||
|
||||
const swipeStyle = computed(() => {
|
||||
if (!props.dismissible) return null
|
||||
if (dragOffset.value === 0 && !dragging.value) {
|
||||
return { transition: 'transform .2s ease, opacity .2s ease' }
|
||||
}
|
||||
return {
|
||||
transform: `translateY(${dragOffset.value}px)`,
|
||||
opacity: String(Math.max(1 - dragOffset.value / 400, 0.4)),
|
||||
transition: dragging.value ? 'none' : 'transform .2s ease, opacity .2s ease',
|
||||
}
|
||||
})
|
||||
|
||||
const RELIABILITY_MAP = {
|
||||
reliable: { label: 'Current', color: 'text-status-good' },
|
||||
stale: { label: 'Stale', color: 'text-status-warn' },
|
||||
outdated: { label: 'Outdated', color: 'text-status-bad' },
|
||||
}
|
||||
|
||||
const DEAL_COLOR_MAP = {
|
||||
cheap: 'text-status-good',
|
||||
average: 'text-zinc-800',
|
||||
expensive: 'text-status-bad',
|
||||
}
|
||||
|
||||
const AMENITY_META = {
|
||||
customer_toilets: { icon: 'lucide:toilet', label: 'WC' },
|
||||
car_wash: { icon: 'lucide:spray-can', label: 'Wash' },
|
||||
@@ -197,12 +283,7 @@ const AMENITY_ORDER = [
|
||||
|
||||
const reliabilityInfo = computed(() => RELIABILITY_MAP[props.station.reliability] ?? RELIABILITY_MAP.reliable)
|
||||
|
||||
const priceColor = computed(() => {
|
||||
if (props.lowestPrice && props.station.price_pence === props.lowestPrice) {
|
||||
return 'text-status-good'
|
||||
}
|
||||
return reliabilityInfo.value.color
|
||||
})
|
||||
const priceColor = computed(() => DEAL_COLOR_MAP[props.station.deal_quality] ?? 'text-zinc-800')
|
||||
|
||||
const statusLabel = computed(() => reliabilityInfo.value.label)
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<h3 class="font-black text-zinc-800">Older prices</h3>
|
||||
<span class="text-xs text-zinc-500 font-medium">3–7 days old — verify before driving</span>
|
||||
</header>
|
||||
<div class="opacity-80">
|
||||
<div>
|
||||
<StationCard
|
||||
v-for="station in stale"
|
||||
:key="station.station_id"
|
||||
@@ -53,7 +53,7 @@
|
||||
icon="lucide:chevron-down"
|
||||
></iconify-icon>
|
||||
</button>
|
||||
<div v-if="outdatedOpen" class="opacity-60">
|
||||
<div v-if="outdatedOpen">
|
||||
<StationCard
|
||||
v-for="station in outdated"
|
||||
:key="station.station_id"
|
||||
|
||||
Reference in New Issue
Block a user