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:
Ovidiu U
2026-05-05 12:16:13 +01:00
parent 8dad223d06
commit 11a3b433ff
7 changed files with 587 additions and 256 deletions

View File

@@ -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 (&lt;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 (2448h)
</span>
<span class="flex items-center gap-1.5">
<span class="inline-block size-3 rounded-full bg-amber-500"></span>
Stale (25 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, '&amp;')
@@ -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: '&copy; OpenStreetMap contributors &copy; 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">&copy; OpenStreetMap contributors &copy; 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>

View File

@@ -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>

View File

@@ -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)

View File

@@ -24,7 +24,7 @@
<h3 class="font-black text-zinc-800">Older prices</h3>
<span class="text-xs text-zinc-500 font-medium">37 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"