Redesign search UI with unified input, expandable filters, and integrated map controls
- Consolidate HeroSearch into single responsive form with inline geolocation button and submit actions - Transform SearchBar into pill-based filter bar with visual state indicators (active filters highlighted) - Move map toggle from separate component into SearchBar with open/close state management - Redesign StationList sort controls as pills with icons, move brand filter inline, add result count - Expand LeafletMap to full-width panel (96 viewport height) controlled by parent open state - Remove nested mobile/desktop layouts in HeroSearch in favor of single adaptive form - Add "Refine" and "Sort" labels to filter groups, implement clear-all filters button - Show verdict card only before first search on mobile, hide after results load - Position StatsRow within hero gradient, move results section into same gradient container - Update map initialization to only occur when panel is open, destroy on close - Add accessibility labels (aria-expanded, aria-controls) to map toggle button
This commit is contained in:
@@ -1,38 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-2">
|
<div v-if="isOpen" id="leaflet-map-panel" class="space-y-2">
|
||||||
<button
|
<div
|
||||||
class="flex items-center gap-2 text-sm font-bold text-accent hover:text-accent-content transition-colors"
|
ref="mapContainer"
|
||||||
@click="toggleMap"
|
class="w-full h-96 md:h-160 rounded-2xl overflow-hidden border border-zinc-300 shadow-sm"
|
||||||
>
|
></div>
|
||||||
<iconify-icon :icon="isOpen ? 'lucide:chevron-up' : 'lucide:chevron-down'"></iconify-icon>
|
|
||||||
{{ isOpen ? 'Hide map' : 'Show map' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<template v-if="isOpen">
|
<div class="flex flex-wrap gap-3 text-xs text-zinc-500">
|
||||||
<div
|
<span class="flex items-center gap-1.5">
|
||||||
ref="mapContainer"
|
<span class="inline-block size-3 rounded-full bg-green-500"></span>
|
||||||
class="w-full h-72 rounded-2xl overflow-hidden border border-zinc-300 shadow-sm"
|
Current (<24h)
|
||||||
></div>
|
</span>
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
<div class="flex flex-wrap gap-3 text-xs text-zinc-500">
|
<span class="inline-block size-3 rounded-full bg-slate-500"></span>
|
||||||
<span class="flex items-center gap-1.5">
|
Recent (24–48h)
|
||||||
<span class="inline-block size-3 rounded-full bg-green-500"></span>
|
</span>
|
||||||
Current (<24h)
|
<span class="flex items-center gap-1.5">
|
||||||
</span>
|
<span class="inline-block size-3 rounded-full bg-amber-500"></span>
|
||||||
<span class="flex items-center gap-1.5">
|
Stale (2–5 days)
|
||||||
<span class="inline-block size-3 rounded-full bg-slate-500"></span>
|
</span>
|
||||||
Recent (24–48h)
|
<span class="flex items-center gap-1.5">
|
||||||
</span>
|
<span class="inline-block size-3 rounded-full bg-red-500"></span>
|
||||||
<span class="flex items-center gap-1.5">
|
Outdated (5+ days)
|
||||||
<span class="inline-block size-3 rounded-full bg-amber-500"></span>
|
</span>
|
||||||
Stale (2–5 days)
|
</div>
|
||||||
</span>
|
|
||||||
<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>
|
</template>
|
||||||
|
|
||||||
@@ -87,13 +77,12 @@ function escHtml(str) {
|
|||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
stations: {type: Array, required: true},
|
stations: {type: Array, required: true},
|
||||||
defaultOpen: {type: Boolean, default: false},
|
isOpen: {type: Boolean, default: true},
|
||||||
radiusMiles: {type: Number, default: 10},
|
radiusMiles: {type: Number, default: 10},
|
||||||
origin: {type: Object, default: null},
|
origin: {type: Object, default: null},
|
||||||
})
|
})
|
||||||
|
|
||||||
const mapContainer = ref(null)
|
const mapContainer = ref(null)
|
||||||
const isOpen = ref(false)
|
|
||||||
let mapInstance = null
|
let mapInstance = null
|
||||||
let markersLayer = null
|
let markersLayer = null
|
||||||
let userMarker = null
|
let userMarker = null
|
||||||
@@ -102,8 +91,9 @@ function getZoomForRadius(radiusMiles) {
|
|||||||
if (radiusMiles <= 1) return 16
|
if (radiusMiles <= 1) return 16
|
||||||
if (radiusMiles <= 2) return 15
|
if (radiusMiles <= 2) return 15
|
||||||
if (radiusMiles <= 5) return 14
|
if (radiusMiles <= 5) return 14
|
||||||
if (radiusMiles <= 10) return 13
|
if (radiusMiles <= 10) return 12
|
||||||
if (radiusMiles <= 15) return 11
|
if (radiusMiles <= 15) return 12
|
||||||
|
if (radiusMiles <= 20) return 10
|
||||||
if (radiusMiles <= 25) return 10
|
if (radiusMiles <= 25) return 10
|
||||||
if (radiusMiles <= 50) return 9
|
if (radiusMiles <= 50) return 9
|
||||||
return 8
|
return 8
|
||||||
@@ -174,6 +164,10 @@ function initMap() {
|
|||||||
|
|
||||||
markersLayer = L.layerGroup().addTo(mapInstance)
|
markersLayer = L.layerGroup().addTo(mapInstance)
|
||||||
|
|
||||||
|
mapInstance.on('zoomend', () => {
|
||||||
|
console.log('Map zoom:', mapInstance.getZoom())
|
||||||
|
})
|
||||||
|
|
||||||
locateUser()
|
locateUser()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,47 +232,45 @@ function renderMarkers() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const zoom = getZoomForRadius(props.radiusMiles)
|
const zoom = getZoomForRadius(props.radiusMiles)
|
||||||
|
const center = props.origin?.lat != null && props.origin?.lng != null
|
||||||
|
? [props.origin.lat, props.origin.lng]
|
||||||
|
: bounds[0]
|
||||||
|
|
||||||
if (bounds.length === 1) {
|
mapInstance.setView(center, zoom)
|
||||||
mapInstance.setView(bounds[0], zoom)
|
|
||||||
} else {
|
|
||||||
mapInstance.fitBounds(bounds, {padding: [40, 40], maxZoom: zoom})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleMap() {
|
function destroyMap() {
|
||||||
isOpen.value = !isOpen.value
|
|
||||||
|
|
||||||
if (isOpen.value) {
|
|
||||||
await nextTick()
|
|
||||||
initMap()
|
|
||||||
mapInstance.invalidateSize()
|
|
||||||
renderMarkers()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (props.defaultOpen) {
|
|
||||||
isOpen.value = true
|
|
||||||
await nextTick()
|
|
||||||
initMap()
|
|
||||||
mapInstance.invalidateSize()
|
|
||||||
renderMarkers()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => props.stations, () => {
|
|
||||||
if (isOpen.value) {
|
|
||||||
renderMarkers()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (mapInstance) {
|
if (mapInstance) {
|
||||||
mapInstance.remove()
|
mapInstance.remove()
|
||||||
mapInstance = null
|
mapInstance = null
|
||||||
|
markersLayer = null
|
||||||
|
userMarker = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMap() {
|
||||||
|
await nextTick()
|
||||||
|
initMap()
|
||||||
|
mapInstance?.invalidateSize()
|
||||||
|
renderMarkers()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.isOpen) openMap()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.isOpen, (open) => {
|
||||||
|
if (open) openMap()
|
||||||
|
else destroyMap()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.stations, () => {
|
||||||
|
if (props.isOpen) {
|
||||||
|
renderMarkers()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(destroyMap)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -1,91 +1,114 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-3 max-w-md w-full">
|
<div class="flex flex-wrap items-center gap-2 md:gap-2.5 py-3 border-b border-zinc-200">
|
||||||
<!-- Row 1: postcode + button -->
|
<!-- Leading label -->
|
||||||
<div class="flex flex-col sm:flex-row gap-3">
|
<span class="hidden md:inline text-xs font-mono uppercase tracking-widest text-zinc-400 mr-1">
|
||||||
<div class="relative flex-1">
|
Refine
|
||||||
<label for="postcode-input" class="sr-only">Postcode or city</label>
|
</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 icon="lucide:locate-fixed" style="font-size:16px;"></iconify-icon>
|
|
||||||
<span class="hidden sr-only">Near me</span>
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
id="postcode-input"
|
|
||||||
v-model="postcode"
|
|
||||||
type="text"
|
|
||||||
:placeholder="coords ? 'Using your current location' : 'Enter postcode, e.g. SW1A 1AA'"
|
|
||||||
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>
|
|
||||||
<button
|
|
||||||
@click="onSearch"
|
|
||||||
:disabled="!postcode.trim() && !coords"
|
|
||||||
class="h-14 px-8 bg-primary text-white rounded-xl font-bold text-base shadow-xl hover:bg-primary-dark transition-all disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Find Prices
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Row 2: fuel type + radius + sort -->
|
<!-- Fuel type -->
|
||||||
<div class="grid grid-cols-3 gap-2">
|
<label
|
||||||
|
:class="[
|
||||||
|
'relative group inline-flex items-center gap-2 h-10 pl-3 pr-2 rounded-full border transition-colors cursor-pointer',
|
||||||
|
fuelType !== DEFAULTS.fuelType
|
||||||
|
? 'bg-primary/10 border-primary text-primary'
|
||||||
|
: 'bg-white border-zinc-200 hover:border-zinc-300',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<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
|
<select
|
||||||
v-model="fuelType"
|
v-model="fuelType"
|
||||||
aria-label="Fuel type"
|
aria-label="Fuel type"
|
||||||
class="min-w-0 h-10 px-2 bg-white border border-zinc-300 rounded-xl text-sm font-medium text-zinc-700 focus:outline-none focus:ring-2 focus:ring-accent truncate"
|
class="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
>
|
>
|
||||||
<option v-for="fuel in FUEL_TYPES" :key="fuel.value" :value="fuel.value">
|
<option v-for="fuel in FUEL_TYPES" :key="fuel.value" :value="fuel.value">{{ fuel.label }}</option>
|
||||||
{{ fuel.label }}
|
|
||||||
</option>
|
|
||||||
</select>
|
</select>
|
||||||
<select
|
</label>
|
||||||
v-model="radius"
|
|
||||||
aria-label="Search radius"
|
|
||||||
class="min-w-0 h-10 px-2 bg-white border border-zinc-300 rounded-xl text-sm font-medium text-zinc-700 focus:outline-none focus:ring-2 focus:ring-accent"
|
|
||||||
>
|
|
||||||
<!-- <option :value="2">2 miles</option> -->
|
|
||||||
|
|
||||||
|
<!-- Radius -->
|
||||||
|
<label
|
||||||
|
:class="[
|
||||||
|
'relative group inline-flex items-center gap-2 h-10 pl-3 pr-2 rounded-full border transition-colors cursor-pointer',
|
||||||
|
radius !== DEFAULTS.radius
|
||||||
|
? 'bg-primary/10 border-primary text-primary'
|
||||||
|
: 'bg-white border-zinc-200 hover:border-zinc-300',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
<option :value="5">5 miles</option>
|
<option :value="5">5 miles</option>
|
||||||
<option :value="10">10 miles</option>
|
<option :value="10">10 miles</option>
|
||||||
<option :value="20">20 miles</option>
|
<option :value="20">20 miles</option>
|
||||||
</select>
|
</select>
|
||||||
<select
|
</label>
|
||||||
v-model="sort"
|
|
||||||
aria-label="Sort by"
|
<!-- Show / hide map -->
|
||||||
class="min-w-0 h-10 px-2 bg-white border border-zinc-300 rounded-xl text-sm font-medium text-zinc-700 focus:outline-none focus:ring-2 focus:ring-accent"
|
<button
|
||||||
|
:aria-expanded="mapOpen"
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center gap-2 h-10 pl-3 pr-2 rounded-full border transition-colors cursor-pointer',
|
||||||
|
mapOpen
|
||||||
|
? 'bg-primary/10 border-primary text-primary'
|
||||||
|
: 'bg-white border-zinc-200 text-zinc-700 hover:border-zinc-300',
|
||||||
|
]"
|
||||||
|
aria-controls="leaflet-map-panel"
|
||||||
|
type="button"
|
||||||
|
@click="emit('toggle-map')"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Divider + clear (only when any active) -->
|
||||||
|
<template v-if="hasActive">
|
||||||
|
<span class="hidden md:inline h-5 w-px bg-zinc-200 mx-1"></span>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-1 h-10 px-3 text-sm text-zinc-500 hover:text-zinc-900 transition-colors"
|
||||||
|
type="button"
|
||||||
|
@click="resetFilters"
|
||||||
>
|
>
|
||||||
<option value="reliable">Reliable</option>
|
<iconify-icon class="text-sm" icon="lucide:x"></iconify-icon>
|
||||||
<option value="price">Price</option>
|
Clear
|
||||||
<option value="distance">Distance</option>
|
</button>
|
||||||
<option value="updated">Updated</option>
|
</template>
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, nextTick } from 'vue'
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
import { FUEL_TYPES } from '../constants/fuelTypes.js'
|
import { FUEL_TYPES } from '../constants/fuelTypes.js'
|
||||||
|
|
||||||
|
const DEFAULTS = Object.freeze({
|
||||||
|
fuelType: 'e10',
|
||||||
|
radius: 10,
|
||||||
|
sort: 'reliable',
|
||||||
|
})
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
initial: { type: Object, default: () => ({}) },
|
initial: { type: Object, default: () => ({}) },
|
||||||
|
resultCount: { type: Number, default: null },
|
||||||
|
mapOpen: { type: Boolean, default: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['search'])
|
const emit = defineEmits(['search', 'toggle-map'])
|
||||||
|
|
||||||
const postcode = ref('')
|
const postcode = ref('')
|
||||||
const coords = ref(null)
|
const coords = ref(null)
|
||||||
const fuelType = ref('e10')
|
const fuelType = ref(DEFAULTS.fuelType)
|
||||||
const radius = ref(10)
|
const radius = ref(DEFAULTS.radius)
|
||||||
const sort = ref('reliable')
|
const sort = ref(DEFAULTS.sort)
|
||||||
const locating = ref(false)
|
|
||||||
|
|
||||||
let hydrating = false
|
let hydrating = false
|
||||||
|
|
||||||
@@ -100,29 +123,28 @@ watch(() => props.initial, (v) => {
|
|||||||
nextTick(() => { hydrating = false })
|
nextTick(() => { hydrating = false })
|
||||||
}, { immediate: true, deep: true })
|
}, { immediate: true, deep: true })
|
||||||
|
|
||||||
watch(postcode, () => { coords.value = null })
|
|
||||||
|
|
||||||
watch([fuelType, radius, sort], () => {
|
watch([fuelType, radius, sort], () => {
|
||||||
if (hydrating) return
|
if (hydrating) return
|
||||||
if (postcode.value.trim() || coords.value) onSearch()
|
if (postcode.value.trim() || coords.value) emitSearch()
|
||||||
})
|
})
|
||||||
|
|
||||||
function useMyLocation() {
|
const fuelLabel = computed(() => {
|
||||||
if (!navigator.geolocation) return
|
return FUEL_TYPES.find(f => f.value === fuelType.value)?.label ?? 'Fuel'
|
||||||
locating.value = true
|
})
|
||||||
navigator.geolocation.getCurrentPosition(
|
|
||||||
({ coords: c }) => {
|
const hasActive = computed(() => (
|
||||||
coords.value = { lat: c.latitude, lng: c.longitude }
|
fuelType.value !== DEFAULTS.fuelType
|
||||||
postcode.value = ''
|
|| radius.value !== DEFAULTS.radius
|
||||||
locating.value = false
|
|| sort.value !== DEFAULTS.sort
|
||||||
onSearch()
|
))
|
||||||
},
|
|
||||||
() => { locating.value = false },
|
function resetFilters() {
|
||||||
{ timeout: 8000, enableHighAccuracy: false, maximumAge: 30000 },
|
fuelType.value = DEFAULTS.fuelType
|
||||||
)
|
radius.value = DEFAULTS.radius
|
||||||
|
sort.value = DEFAULTS.sort
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSearch() {
|
function emitSearch() {
|
||||||
const hasPostcode = postcode.value.trim().length > 0
|
const hasPostcode = postcode.value.trim().length > 0
|
||||||
const hasCoords = coords.value !== null
|
const hasCoords = coords.value !== null
|
||||||
if (!hasPostcode && !hasCoords) return
|
if (!hasPostcode && !hasCoords) return
|
||||||
|
|||||||
@@ -1,38 +1,54 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<!-- Sort tabs + brand filter -->
|
<!-- Sort tabs -->
|
||||||
<div class="flex gap-2 flex-wrap items-center">
|
<div class="flex flex-wrap items-center gap-2 md:gap-2.5 py-3 border-b border-zinc-200">
|
||||||
|
<span class="hidden md:inline text-xs font-mono uppercase tracking-widest text-zinc-400 mr-1">
|
||||||
|
Sort
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
v-for="option in sortOptions"
|
v-for="option in sortOptions"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
@click="emit('sort', option.value)"
|
|
||||||
:class="[
|
:class="[
|
||||||
'h-10 px-4 rounded-xl text-sm font-bold transition-colors',
|
'inline-flex items-center gap-2 h-10 px-3 rounded-full border transition-colors cursor-pointer',
|
||||||
currentSort === option.value
|
currentSort === option.value
|
||||||
? 'bg-accent text-white'
|
? 'bg-primary/10 border-primary text-primary'
|
||||||
: 'bg-white border border-zinc-300 text-zinc-500 hover:border-accent'
|
: 'bg-white border-zinc-200 text-zinc-700 hover:border-zinc-300',
|
||||||
]"
|
]"
|
||||||
|
@click="emit('sort', option.value)"
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
{{ option.label }}
|
<iconify-icon :icon="option.icon" class="text-sm opacity-70"></iconify-icon>
|
||||||
|
<span class="text-sm font-medium">{{ option.label }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<select
|
<!-- Brand filter -->
|
||||||
v-if="availableBrands.length > 1"
|
<label
|
||||||
v-model="brandFilter"
|
v-if="brands.length > 1"
|
||||||
aria-label="Filter by brand"
|
:class="[
|
||||||
class="min-w-0 h-10 px-2 bg-white border border-zinc-300 rounded-xl text-sm font-medium text-zinc-700 focus:outline-none focus:ring-2 focus:ring-accent"
|
'relative group inline-flex items-center gap-2 h-10 pl-3 pr-2 rounded-full border transition-colors cursor-pointer',
|
||||||
|
brandFilter
|
||||||
|
? 'bg-primary/10 border-primary text-primary'
|
||||||
|
: 'bg-white border-zinc-200 text-zinc-700 hover:border-zinc-300',
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<option value="">All brands</option>
|
<iconify-icon class="text-sm opacity-70" icon="lucide:tag"></iconify-icon>
|
||||||
<option v-for="brand in availableBrands" :key="brand" :value="brand">{{ brand }}</option>
|
<span class="text-sm font-medium">{{ brandFilter || 'All brands' }}</span>
|
||||||
</select>
|
<iconify-icon class="text-sm opacity-50 group-hover:opacity-100" icon="lucide:chevron-down"></iconify-icon>
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<!-- Count -->
|
<span class="ml-auto text-sm text-zinc-500 font-medium">
|
||||||
<p class="text-sm text-zinc-500 font-medium">
|
{{ stations.length }} station{{ stations.length !== 1 ? 's' : '' }} found
|
||||||
{{ filteredStations.length }} station{{ filteredStations.length !== 1 ? 's' : '' }}
|
</span>
|
||||||
<span v-if="brandFilter">matching <strong>{{ brandFilter }}</strong></span>
|
</div>
|
||||||
<span v-else>found</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Grouped results when sorting by reliability -->
|
<!-- Grouped results when sorting by reliability -->
|
||||||
<template v-if="currentSort === 'reliable'">
|
<template v-if="currentSort === 'reliable'">
|
||||||
@@ -94,7 +110,7 @@
|
|||||||
<!-- Flat list for other sort modes -->
|
<!-- Flat list for other sort modes -->
|
||||||
<div v-else class="space-y-2">
|
<div v-else class="space-y-2">
|
||||||
<StationCard
|
<StationCard
|
||||||
v-for="station in filteredStations"
|
v-for="station in stations"
|
||||||
:key="station.station_id"
|
:key="station.station_id"
|
||||||
:avg-pence="avgPence"
|
:avg-pence="avgPence"
|
||||||
:lowest-price="lowestPrice"
|
:lowest-price="lowestPrice"
|
||||||
@@ -106,51 +122,38 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed } from 'vue'
|
||||||
import StationCard from './StationCard.vue'
|
import StationCard from './StationCard.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
stations: { type: Array, required: true },
|
stations: { type: Array, required: true },
|
||||||
currentSort: { type: String, default: 'reliable' },
|
currentSort: { type: String, default: 'reliable' },
|
||||||
origin: { type: Object, default: null },
|
origin: { type: Object, default: null },
|
||||||
|
brands: { type: Array, default: () => [] },
|
||||||
|
brandFilter: { type: String, default: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['sort'])
|
const emit = defineEmits(['sort', 'update:brandFilter'])
|
||||||
|
|
||||||
const brandFilter = ref('')
|
|
||||||
|
|
||||||
const sortOptions = [
|
const sortOptions = [
|
||||||
{ label: 'Reliable', value: 'reliable' },
|
{ label: 'Reliable', value: 'reliable', icon: 'lucide:shield-check' },
|
||||||
{ label: 'Price', value: 'price' },
|
{ label: 'Price', value: 'price', icon: 'lucide:pound-sterling' },
|
||||||
{ label: 'Distance', value: 'distance' },
|
{ label: 'Distance', value: 'distance', icon: 'lucide:map-pin' },
|
||||||
{ label: 'Updated', value: 'updated' },
|
{ label: 'Updated', value: 'updated', icon: 'lucide:clock' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const availableBrands = computed(() => {
|
const reliable = computed(() => props.stations.filter(s => s.reliability === 'reliable'))
|
||||||
const brands = new Set()
|
const stale = computed(() => props.stations.filter(s => s.reliability === 'stale'))
|
||||||
props.stations.forEach(s => {
|
const outdated = computed(() => props.stations.filter(s => s.reliability === 'outdated'))
|
||||||
if (s.brand) brands.add(s.brand)
|
|
||||||
})
|
|
||||||
return [...brands].sort((a, b) => a.localeCompare(b))
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredStations = computed(() => {
|
|
||||||
if (!brandFilter.value) return props.stations
|
|
||||||
return props.stations.filter(s => s.brand === brandFilter.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
const reliable = computed(() => filteredStations.value.filter(s => s.reliability === 'reliable'))
|
|
||||||
const stale = computed(() => filteredStations.value.filter(s => s.reliability === 'stale'))
|
|
||||||
const outdated = computed(() => filteredStations.value.filter(s => s.reliability === 'outdated'))
|
|
||||||
|
|
||||||
const lowestPrice = computed(() => {
|
const lowestPrice = computed(() => {
|
||||||
if (!reliable.value.length && !filteredStations.value.length) return null
|
if (!reliable.value.length && !props.stations.length) return null
|
||||||
const pool = reliable.value.length ? reliable.value : filteredStations.value
|
const pool = reliable.value.length ? reliable.value : props.stations
|
||||||
return Math.min(...pool.map(s => s.price_pence))
|
return Math.min(...pool.map(s => s.price_pence))
|
||||||
})
|
})
|
||||||
|
|
||||||
const avgPence = computed(() => {
|
const avgPence = computed(() => {
|
||||||
const prices = filteredStations.value.map(s => s.price_pence).filter(p => typeof p === 'number')
|
const prices = props.stations.map(s => s.price_pence).filter(p => typeof p === 'number')
|
||||||
if (!prices.length) return null
|
if (!prices.length) return null
|
||||||
return prices.reduce((a, b) => a + b, 0) / prices.length
|
return prices.reduce((a, b) => a + b, 0) / prices.length
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,74 +1,50 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full max-w-xl">
|
<form class="w-full max-w-xl" @submit.prevent="submitPostcode">
|
||||||
<!-- Mobile layout: stacked input + full-width geolocation CTA -->
|
<label class="flex items-center gap-2 h-14 md:h-15 pl-3.5 md:pl-4 pr-1.5 md:pr-2 bg-white md:bg-surface border border-zinc-200 rounded-2xl focus-within:border-primary transition-colors md:shadow-[0_20px_40px_-20px_rgba(0,0,0,0.12)]">
|
||||||
<div class="md:hidden space-y-3">
|
<iconify-icon class="text-zinc-400 text-lg shrink-0" icon="lucide:map-pin"></iconify-icon>
|
||||||
<label class="relative block">
|
<input
|
||||||
<span class="sr-only">Postcode</span>
|
v-model="postcode"
|
||||||
<iconify-icon
|
autocomplete="postal-code"
|
||||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500"
|
class="flex-1 min-w-0 bg-transparent outline-none text-[15px] md:text-base placeholder:text-zinc-400"
|
||||||
icon="lucide:map-pin"
|
placeholder="Postcode"
|
||||||
style="font-size:18px;"
|
type="text"
|
||||||
></iconify-icon>
|
/>
|
||||||
<input
|
|
||||||
v-model="postcode"
|
<!-- Geolocation icon-button — visible on mobile AND desktop -->
|
||||||
class="w-full h-[52px] pl-11 pr-4 bg-white border border-zinc-300 rounded-xl text-base text-zinc-800 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-accent/40 focus:border-accent"
|
|
||||||
placeholder="Enter a UK postcode"
|
|
||||||
type="text"
|
|
||||||
@keyup.enter="submitPostcode"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button
|
<button
|
||||||
:disabled="locating"
|
:disabled="locating"
|
||||||
class="w-full h-14 bg-accent text-white rounded-xl font-semibold text-base flex items-center justify-center gap-2 shadow-lg hover:bg-primary-dark transition-all disabled:opacity-70"
|
aria-label="Use my location"
|
||||||
|
class="w-11 h-11 rounded-[10px] bg-zinc-100 text-primary inline-flex items-center justify-center shrink-0 hover:bg-zinc-200 transition-colors disabled:opacity-70"
|
||||||
type="button"
|
type="button"
|
||||||
@click="useMyLocation"
|
@click="useMyLocation"
|
||||||
>
|
>
|
||||||
<iconify-icon :class="{ 'animate-spin': locating }" :icon="locating ? 'lucide:loader-circle' : 'lucide:locate-fixed'" style="font-size:20px;"></iconify-icon>
|
<iconify-icon :class="{ 'animate-spin': locating }" :icon="locating ? 'lucide:loader-circle' : 'lucide:locate-fixed'" class="text-lg"></iconify-icon>
|
||||||
{{ locating ? 'Getting location…' : 'Use my location' }}
|
|
||||||
</button>
|
</button>
|
||||||
<p class="font-mono text-[11px] text-zinc-500 text-center">Free · no signup to try</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Desktop layout: inline postcode pill + geo link below -->
|
<!-- Desktop-only inline submit -->
|
||||||
<div class="hidden md:block">
|
<button
|
||||||
<div class="flex items-stretch bg-white border border-zinc-300 rounded-full h-[60px] pl-5 pr-1.5 shadow-sm focus-within:ring-2 focus-within:ring-accent/40 focus-within:border-accent">
|
class="hidden md:inline-flex h-12 px-5 ml-1 rounded-xl bg-primary text-white font-medium text-[15px] items-center gap-2 hover:opacity-90 transition"
|
||||||
<iconify-icon
|
type="submit"
|
||||||
class="self-center text-zinc-500 mr-3"
|
>
|
||||||
icon="lucide:map-pin"
|
Check prices
|
||||||
style="font-size:18px;"
|
<iconify-icon icon="lucide:arrow-right"></iconify-icon>
|
||||||
></iconify-icon>
|
</button>
|
||||||
<input
|
</label>
|
||||||
v-model="postcode"
|
|
||||||
class="flex-1 bg-transparent text-base text-zinc-800 placeholder:text-zinc-500 focus:outline-none"
|
|
||||||
placeholder="Enter a UK postcode"
|
|
||||||
type="text"
|
|
||||||
@keyup.enter="submitPostcode"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
:disabled="!postcode.trim()"
|
|
||||||
class="my-1.5 px-6 bg-accent text-white rounded-full font-semibold text-sm hover:bg-primary-dark transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
|
||||||
type="button"
|
|
||||||
@click="submitPostcode"
|
|
||||||
>
|
|
||||||
Check prices
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-3 mt-3 text-[13px]">
|
<!-- Mobile-only full-width submit -->
|
||||||
<button
|
<button
|
||||||
:disabled="locating"
|
class="md:hidden w-full mt-2.5 h-14 rounded-2xl bg-primary text-white font-medium text-base inline-flex items-center justify-center gap-2 shadow-lg hover:opacity-90 transition"
|
||||||
class="inline-flex items-center gap-1.5 font-medium text-zinc-700 hover:text-accent transition-colors disabled:opacity-70"
|
type="submit"
|
||||||
type="button"
|
>
|
||||||
@click="useMyLocation"
|
Check prices
|
||||||
>
|
<iconify-icon class="text-lg" icon="lucide:arrow-right"></iconify-icon>
|
||||||
<iconify-icon :class="{ 'animate-spin': locating }" :icon="locating ? 'lucide:loader-circle' : 'lucide:locate-fixed'" style="font-size:14px;"></iconify-icon>
|
</button>
|
||||||
{{ locating ? 'Getting location…' : 'Use my location' }}
|
|
||||||
</button>
|
<div class="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-400 justify-center md:justify-start">
|
||||||
<span aria-hidden="true" class="text-zinc-400">·</span>
|
<span class="hidden md:inline text-zinc-300">·</span>
|
||||||
<span class="font-mono text-zinc-500">Try SW1A 1AA · M1 1AD · EH1 1YZ</span>
|
<span class="hidden md:inline font-mono">Try SW1A 1AA · M1 1AD · EH1 1YZ</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<dl class="hidden md:flex items-center gap-8 divide-x divide-zinc-300">
|
<dl class="hidden md:flex items-center divide-x divide-zinc-300">
|
||||||
<div v-for="(stat, idx) in stats" :key="stat.label" :class="idx === 0 ? '' : 'pl-8'">
|
<div v-for="stat in stats" :key="stat.label" class="px-8 first:pl-0 last:pr-0">
|
||||||
<dt class="sr-only">{{ stat.label }}</dt>
|
<dt class="sr-only">{{ stat.label }}</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<span class="block font-serif text-2xl text-zinc-900 leading-none">{{ stat.value }}</span>
|
<span class="block font-serif text-2xl text-zinc-900 leading-none">{{ stat.value }}</span>
|
||||||
|
|||||||
@@ -3,41 +3,47 @@
|
|||||||
|
|
||||||
<LandingNav />
|
<LandingNav />
|
||||||
|
|
||||||
<!-- Hero -->
|
<div class="hero-gradient">
|
||||||
<section id="hero" class="relative pt-24 md:pt-36 pb-10 md:pb-16 px-6 hero-gradient overflow-hidden">
|
<!-- Hero -->
|
||||||
<div class="max-w-7xl mx-auto grid lg:grid-cols-[1.1fr_1fr] gap-10 lg:gap-16 items-center">
|
<section id="hero" class="relative pt-24 md:pt-36 pb-4 md:pb-8 px-6 overflow-hidden">
|
||||||
<div class="space-y-6 md:space-y-8">
|
<div class="max-w-7xl mx-auto grid lg:grid-cols-[1.1fr_1fr] gap-10 lg:gap-16 items-center">
|
||||||
<LiveTicker :latest-price-at="liveStats.latestPriceAt" :station-count="liveStats.stationCount" />
|
<div class="space-y-6 md:space-y-8">
|
||||||
|
<LiveTicker :latest-price-at="liveStats.latestPriceAt" :station-count="liveStats.stationCount" />
|
||||||
|
|
||||||
<h1 class="font-serif text-zinc-900 text-[40px] leading-[0.98] md:text-6xl lg:text-[88px] lg:leading-[0.95] max-w-[560px]">
|
<h1 class="font-serif text-zinc-900 text-[40px] leading-[0.98] md:text-6xl lg:text-[88px] lg:leading-[0.95] max-w-[560px]">
|
||||||
Know <span class="text-accent">exactly</span> when to fill up.
|
Know <span class="text-accent">exactly</span> when to fill up.
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<!-- Mobile verdict card: between headline and input -->
|
<HeroSearch :fuel-type="searchInitial.fuelType" :initial="searchInitial" :radius="searchInitial.radius" :sort="searchInitial.sort" @search="onSearch" />
|
||||||
<div class="lg:hidden">
|
|
||||||
<VerdictCard variant="compact" />
|
<!-- Mobile verdict card: below search, only before first search -->
|
||||||
|
<div v-if="!searchAttempted" class="lg:hidden">
|
||||||
|
<VerdictCard variant="compact" />
|
||||||
|
</div>
|
||||||
|
<div id="stats-row">
|
||||||
|
<StatsRow :station-count="liveStats.stationCount" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<HeroSearch :fuel-type="searchInitial.fuelType" :initial="searchInitial" :radius="searchInitial.radius" :sort="searchInitial.sort" @search="onSearch" />
|
<!-- Desktop verdict card -->
|
||||||
|
<div class="hidden lg:block">
|
||||||
<StatsRow :station-count="liveStats.stationCount" />
|
<VerdictCard variant="full" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Desktop verdict card -->
|
<!-- Search Results -->
|
||||||
<div class="hidden lg:block">
|
<section v-if="searchAttempted" id="searchAttempted" class="px-6">
|
||||||
<VerdictCard variant="full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Search Results -->
|
|
||||||
<section v-if="searchAttempted" class="px-6 py-10 bg-zinc-100">
|
|
||||||
<div class="max-w-7xl mx-auto space-y-6">
|
<div class="max-w-7xl mx-auto space-y-6">
|
||||||
|
|
||||||
<!-- Post-search filter bar -->
|
<!-- Post-search filter bar -->
|
||||||
<div class="bg-white border border-zinc-300 rounded-2xl p-4 md:p-5 shadow-sm">
|
<SearchBar
|
||||||
<SearchBar :initial="searchInitial" @search="onSearch" />
|
:initial="searchInitial"
|
||||||
</div>
|
:map-open="mapOpen"
|
||||||
|
:result-count="filteredStations.length"
|
||||||
|
@search="onSearch"
|
||||||
|
@toggle-map="mapOpen = !mapOpen"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<div v-if="loading" class="flex items-center justify-center py-16">
|
<div v-if="loading" class="flex items-center justify-center py-16">
|
||||||
@@ -60,13 +66,26 @@
|
|||||||
<span class="font-medium">No stations found near you. Try a different postcode or increase the radius.</span>
|
<span class="font-medium">No stations found near you. Try a different postcode or increase the radius.</span>
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<LeafletMap :default-open="true" :origin="searchOrigin" :radius-miles="radiusMiles" :stations="stations" />
|
<LeafletMap
|
||||||
<StationList :current-sort="sort" :origin="searchOrigin" :stations="stations" @sort="onSort" />
|
:is-open="mapOpen"
|
||||||
|
:origin="searchOrigin"
|
||||||
|
:radius-miles="radiusMiles"
|
||||||
|
:stations="filteredStations"
|
||||||
|
/>
|
||||||
|
<StationList
|
||||||
|
v-model:brand-filter="brandFilter"
|
||||||
|
:brands="availableBrands"
|
||||||
|
:current-sort="sort"
|
||||||
|
:origin="searchOrigin"
|
||||||
|
:stations="filteredStations"
|
||||||
|
@sort="onSort"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- How It Works -->
|
<!-- How It Works -->
|
||||||
<section id="how-it-works" class="py-12 md:py-24 px-6 bg-zinc-50">
|
<section id="how-it-works" class="py-12 md:py-24 px-6 bg-zinc-50">
|
||||||
@@ -369,7 +388,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted, defineAsyncComponent } from 'vue'
|
import { ref, computed, watch, onMounted, nextTick, defineAsyncComponent } from 'vue'
|
||||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuth } from '../composables/useAuth.js'
|
import { useAuth } from '../composables/useAuth.js'
|
||||||
import { useStations } from '../composables/useStations.js'
|
import { useStations } from '../composables/useStations.js'
|
||||||
@@ -436,6 +455,13 @@ const PRICES = {
|
|||||||
const PRICE_SUFFIX = { monthly: '/mo', annual: '/yr' }
|
const PRICE_SUFFIX = { monthly: '/mo', annual: '/yr' }
|
||||||
const { stations, meta, loading, error, search } = useStations()
|
const { stations, meta, loading, error, search } = useStations()
|
||||||
|
|
||||||
|
watch(loading, (isLoading) => {
|
||||||
|
if (!isLoading) return
|
||||||
|
nextTick(() => {
|
||||||
|
window.scrollBy({ top: 40, behavior: 'smooth' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const searchOrigin = computed(() => {
|
const searchOrigin = computed(() => {
|
||||||
if (meta.value?.lat != null && meta.value?.lng != null) {
|
if (meta.value?.lat != null && meta.value?.lng != null) {
|
||||||
return { lat: meta.value.lat, lng: meta.value.lng }
|
return { lat: meta.value.lat, lng: meta.value.lng }
|
||||||
@@ -450,6 +476,48 @@ const sort = ref('reliable')
|
|||||||
const lastParams = ref(null)
|
const lastParams = ref(null)
|
||||||
const searchAttempted = ref(false)
|
const searchAttempted = ref(false)
|
||||||
const radiusMiles = ref(10)
|
const radiusMiles = ref(10)
|
||||||
|
const brandFilter = ref('')
|
||||||
|
|
||||||
|
const MAP_STORAGE_KEY = 'fuel-price:map-open'
|
||||||
|
|
||||||
|
function readSavedMapOpen() {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem(MAP_STORAGE_KEY)
|
||||||
|
if (v === null) return true
|
||||||
|
return v === '1'
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapOpen = ref(readSavedMapOpen())
|
||||||
|
|
||||||
|
watch(mapOpen, (v) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(MAP_STORAGE_KEY, v ? '1' : '0')
|
||||||
|
} catch {
|
||||||
|
// ignore quota / privacy-mode errors
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const availableBrands = computed(() => {
|
||||||
|
const brands = new Set()
|
||||||
|
stations.value.forEach(s => {
|
||||||
|
if (s.brand) brands.add(s.brand)
|
||||||
|
})
|
||||||
|
return [...brands].sort((a, b) => a.localeCompare(b))
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredStations = computed(() => {
|
||||||
|
if (!brandFilter.value) return stations.value
|
||||||
|
return stations.value.filter(s => s.brand === brandFilter.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => stations.value, () => {
|
||||||
|
if (brandFilter.value && !availableBrands.value.includes(brandFilter.value)) {
|
||||||
|
brandFilter.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const searchInitial = computed(() => ({
|
const searchInitial = computed(() => ({
|
||||||
postcode: route.query.postcode ?? '',
|
postcode: route.query.postcode ?? '',
|
||||||
|
|||||||
Reference in New Issue
Block a user