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

@@ -79,8 +79,37 @@
:is-open="mapOpen"
:origin="searchOrigin"
:radius-miles="radiusMiles"
:selected-station-id="selectedStationId"
:stations="filteredStations"
/>
@station-select="selectedStationId = $event"
>
<template #overlay>
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 translate-y-3"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0 translate-y-3"
>
<div
v-if="selectedStation"
class="absolute left-3 right-3 bottom-3 md:right-auto md:max-w-sm z-[1000] pointer-events-auto"
>
<StationCard
:avg-pence="avgPenceForCard"
:dismissible="true"
:expanded="true"
:lowest-price="lowestPriceForCard"
:origin="searchOrigin"
:station="selectedStation"
class="shadow-xl"
@dismiss="selectedStationId = null"
/>
</div>
</Transition>
</template>
</LeafletMap>
<UpsellBanner :station-count="liveStats.stationCount" />
<StationList
:current-sort="sort"
@@ -387,6 +416,7 @@ import PostSearchFilters from '../components/PostSearchFilters.vue'
import PredictionCard from '../components/PredictionCard.vue'
import StationList from '../components/StationList.vue'
import UpsellBanner from '../components/UpsellBanner.vue'
import StationCard from '../components/StationCard.vue'
const LeafletMap = defineAsyncComponent(() => import('../components/LeafletMap.vue'))
import LandingNav from '../components/landing/LandingNav.vue'
@@ -512,6 +542,32 @@ watch(() => stations.value, () => {
}
})
const selectedStationId = ref(null)
const selectedStation = computed(() =>
selectedStationId.value
? filteredStations.value.find(s => s.station_id === selectedStationId.value) ?? null
: null,
)
const lowestPriceForCard = computed(() => {
const reliable = filteredStations.value.filter(s => s.reliability === 'reliable')
const pool = reliable.length ? reliable : filteredStations.value
if (!pool.length) return null
return Math.min(...pool.map(s => s.price_pence))
})
const avgPenceForCard = computed(() => {
const prices = filteredStations.value.map(s => s.price_pence).filter(p => typeof p === 'number')
if (!prices.length) return null
return prices.reduce((a, b) => a + b, 0) / prices.length
})
watch(filteredStations, (next) => {
if (selectedStationId.value && !next.find(s => s.station_id === selectedStationId.value)) {
selectedStationId.value = null
}
})
const searchInitial = computed(() => ({
postcode: route.query.postcode ?? '',
lat: route.query.lat ? Number(route.query.lat) : null,