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,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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user