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>
|
||||
<div class="space-y-2">
|
||||
<button
|
||||
class="flex items-center gap-2 text-sm font-bold text-accent hover:text-accent-content transition-colors"
|
||||
@click="toggleMap"
|
||||
>
|
||||
<iconify-icon :icon="isOpen ? 'lucide:chevron-up' : 'lucide:chevron-down'"></iconify-icon>
|
||||
{{ isOpen ? 'Hide map' : 'Show map' }}
|
||||
</button>
|
||||
<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>
|
||||
|
||||
<template v-if="isOpen">
|
||||
<div
|
||||
ref="mapContainer"
|
||||
class="w-full h-72 rounded-2xl overflow-hidden border border-zinc-300 shadow-sm"
|
||||
></div>
|
||||
|
||||
<div class="flex flex-wrap gap-3 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)
|
||||
</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)
|
||||
</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 class="flex flex-wrap gap-3 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)
|
||||
</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)
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -87,13 +77,12 @@ function escHtml(str) {
|
||||
|
||||
const props = defineProps({
|
||||
stations: {type: Array, required: true},
|
||||
defaultOpen: {type: Boolean, default: false},
|
||||
isOpen: {type: Boolean, default: true},
|
||||
radiusMiles: {type: Number, default: 10},
|
||||
origin: {type: Object, default: null},
|
||||
})
|
||||
|
||||
const mapContainer = ref(null)
|
||||
const isOpen = ref(false)
|
||||
let mapInstance = null
|
||||
let markersLayer = null
|
||||
let userMarker = null
|
||||
@@ -102,8 +91,9 @@ function getZoomForRadius(radiusMiles) {
|
||||
if (radiusMiles <= 1) return 16
|
||||
if (radiusMiles <= 2) return 15
|
||||
if (radiusMiles <= 5) return 14
|
||||
if (radiusMiles <= 10) return 13
|
||||
if (radiusMiles <= 15) return 11
|
||||
if (radiusMiles <= 10) return 12
|
||||
if (radiusMiles <= 15) return 12
|
||||
if (radiusMiles <= 20) return 10
|
||||
if (radiusMiles <= 25) return 10
|
||||
if (radiusMiles <= 50) return 9
|
||||
return 8
|
||||
@@ -174,6 +164,10 @@ function initMap() {
|
||||
|
||||
markersLayer = L.layerGroup().addTo(mapInstance)
|
||||
|
||||
mapInstance.on('zoomend', () => {
|
||||
console.log('Map zoom:', mapInstance.getZoom())
|
||||
})
|
||||
|
||||
locateUser()
|
||||
}
|
||||
|
||||
@@ -238,47 +232,45 @@ function renderMarkers() {
|
||||
})
|
||||
|
||||
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(bounds[0], zoom)
|
||||
} else {
|
||||
mapInstance.fitBounds(bounds, {padding: [40, 40], maxZoom: zoom})
|
||||
}
|
||||
mapInstance.setView(center, zoom)
|
||||
}
|
||||
|
||||
async function toggleMap() {
|
||||
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(() => {
|
||||
function destroyMap() {
|
||||
if (mapInstance) {
|
||||
mapInstance.remove()
|
||||
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>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,91 +1,114 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 max-w-md w-full">
|
||||
<!-- Row 1: postcode + button -->
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<div class="relative flex-1">
|
||||
<label for="postcode-input" class="sr-only">Postcode or city</label>
|
||||
<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>
|
||||
<div class="flex flex-wrap items-center gap-2 md:gap-2.5 py-3 border-b border-zinc-200">
|
||||
<!-- Leading label -->
|
||||
<span class="hidden md:inline text-xs font-mono uppercase tracking-widest text-zinc-400 mr-1">
|
||||
Refine
|
||||
</span>
|
||||
|
||||
<!-- Row 2: fuel type + radius + sort -->
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<!-- Fuel type -->
|
||||
<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
|
||||
v-model="fuelType"
|
||||
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">
|
||||
{{ fuel.label }}
|
||||
</option>
|
||||
<option v-for="fuel in FUEL_TYPES" :key="fuel.value" :value="fuel.value">{{ fuel.label }}</option>
|
||||
</select>
|
||||
<select
|
||||
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> -->
|
||||
</label>
|
||||
|
||||
<!-- 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="10">10 miles</option>
|
||||
<option :value="20">20 miles</option>
|
||||
</select>
|
||||
<select
|
||||
v-model="sort"
|
||||
aria-label="Sort by"
|
||||
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"
|
||||
</label>
|
||||
|
||||
<!-- Show / hide map -->
|
||||
<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>
|
||||
<option value="price">Price</option>
|
||||
<option value="distance">Distance</option>
|
||||
<option value="updated">Updated</option>
|
||||
</select>
|
||||
</div>
|
||||
<iconify-icon class="text-sm" icon="lucide:x"></iconify-icon>
|
||||
Clear
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { FUEL_TYPES } from '../constants/fuelTypes.js'
|
||||
|
||||
const DEFAULTS = Object.freeze({
|
||||
fuelType: 'e10',
|
||||
radius: 10,
|
||||
sort: 'reliable',
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
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 coords = ref(null)
|
||||
const fuelType = ref('e10')
|
||||
const radius = ref(10)
|
||||
const sort = ref('reliable')
|
||||
const locating = ref(false)
|
||||
const fuelType = ref(DEFAULTS.fuelType)
|
||||
const radius = ref(DEFAULTS.radius)
|
||||
const sort = ref(DEFAULTS.sort)
|
||||
|
||||
let hydrating = false
|
||||
|
||||
@@ -100,29 +123,28 @@ watch(() => props.initial, (v) => {
|
||||
nextTick(() => { hydrating = false })
|
||||
}, { immediate: true, deep: true })
|
||||
|
||||
watch(postcode, () => { coords.value = null })
|
||||
|
||||
watch([fuelType, radius, sort], () => {
|
||||
if (hydrating) return
|
||||
if (postcode.value.trim() || coords.value) onSearch()
|
||||
if (postcode.value.trim() || coords.value) emitSearch()
|
||||
})
|
||||
|
||||
function useMyLocation() {
|
||||
if (!navigator.geolocation) return
|
||||
locating.value = true
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
({ coords: c }) => {
|
||||
coords.value = { lat: c.latitude, lng: c.longitude }
|
||||
postcode.value = ''
|
||||
locating.value = false
|
||||
onSearch()
|
||||
},
|
||||
() => { locating.value = false },
|
||||
{ timeout: 8000, enableHighAccuracy: false, maximumAge: 30000 },
|
||||
)
|
||||
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
|
||||
|| sort.value !== DEFAULTS.sort
|
||||
))
|
||||
|
||||
function resetFilters() {
|
||||
fuelType.value = DEFAULTS.fuelType
|
||||
radius.value = DEFAULTS.radius
|
||||
sort.value = DEFAULTS.sort
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
function emitSearch() {
|
||||
const hasPostcode = postcode.value.trim().length > 0
|
||||
const hasCoords = coords.value !== null
|
||||
if (!hasPostcode && !hasCoords) return
|
||||
|
||||
@@ -1,38 +1,54 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<!-- Sort tabs + brand filter -->
|
||||
<div class="flex gap-2 flex-wrap items-center">
|
||||
<!-- Sort tabs -->
|
||||
<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
|
||||
v-for="option in sortOptions"
|
||||
:key="option.value"
|
||||
@click="emit('sort', option.value)"
|
||||
: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
|
||||
? 'bg-accent text-white'
|
||||
: 'bg-white border border-zinc-300 text-zinc-500 hover:border-accent'
|
||||
? 'bg-primary/10 border-primary text-primary'
|
||||
: '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>
|
||||
|
||||
<select
|
||||
v-if="availableBrands.length > 1"
|
||||
v-model="brandFilter"
|
||||
aria-label="Filter by brand"
|
||||
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"
|
||||
<!-- Brand filter -->
|
||||
<label
|
||||
v-if="brands.length > 1"
|
||||
:class="[
|
||||
'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>
|
||||
<option v-for="brand in availableBrands" :key="brand" :value="brand">{{ brand }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- Count -->
|
||||
<p class="text-sm text-zinc-500 font-medium">
|
||||
{{ filteredStations.length }} station{{ filteredStations.length !== 1 ? 's' : '' }}
|
||||
<span v-if="brandFilter">matching <strong>{{ brandFilter }}</strong></span>
|
||||
<span v-else>found</span>
|
||||
</p>
|
||||
<span class="ml-auto text-sm text-zinc-500 font-medium">
|
||||
{{ stations.length }} station{{ stations.length !== 1 ? 's' : '' }} found
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Grouped results when sorting by reliability -->
|
||||
<template v-if="currentSort === 'reliable'">
|
||||
@@ -94,7 +110,7 @@
|
||||
<!-- Flat list for other sort modes -->
|
||||
<div v-else class="space-y-2">
|
||||
<StationCard
|
||||
v-for="station in filteredStations"
|
||||
v-for="station in stations"
|
||||
:key="station.station_id"
|
||||
:avg-pence="avgPence"
|
||||
:lowest-price="lowestPrice"
|
||||
@@ -106,51 +122,38 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import StationCard from './StationCard.vue'
|
||||
|
||||
const props = defineProps({
|
||||
stations: { type: Array, required: true },
|
||||
currentSort: { type: String, default: 'reliable' },
|
||||
origin: { type: Object, default: null },
|
||||
brands: { type: Array, default: () => [] },
|
||||
brandFilter: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['sort'])
|
||||
|
||||
const brandFilter = ref('')
|
||||
const emit = defineEmits(['sort', 'update:brandFilter'])
|
||||
|
||||
const sortOptions = [
|
||||
{ label: 'Reliable', value: 'reliable' },
|
||||
{ label: 'Price', value: 'price' },
|
||||
{ label: 'Distance', value: 'distance' },
|
||||
{ label: 'Updated', value: 'updated' },
|
||||
{ label: 'Reliable', value: 'reliable', icon: 'lucide:shield-check' },
|
||||
{ label: 'Price', value: 'price', icon: 'lucide:pound-sterling' },
|
||||
{ label: 'Distance', value: 'distance', icon: 'lucide:map-pin' },
|
||||
{ label: 'Updated', value: 'updated', icon: 'lucide:clock' },
|
||||
]
|
||||
|
||||
const availableBrands = computed(() => {
|
||||
const brands = new Set()
|
||||
props.stations.forEach(s => {
|
||||
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 reliable = computed(() => props.stations.filter(s => s.reliability === 'reliable'))
|
||||
const stale = computed(() => props.stations.filter(s => s.reliability === 'stale'))
|
||||
const outdated = computed(() => props.stations.filter(s => s.reliability === 'outdated'))
|
||||
|
||||
const lowestPrice = computed(() => {
|
||||
if (!reliable.value.length && !filteredStations.value.length) return null
|
||||
const pool = reliable.value.length ? reliable.value : filteredStations.value
|
||||
if (!reliable.value.length && !props.stations.length) return null
|
||||
const pool = reliable.value.length ? reliable.value : props.stations
|
||||
return Math.min(...pool.map(s => s.price_pence))
|
||||
})
|
||||
|
||||
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
|
||||
return prices.reduce((a, b) => a + b, 0) / prices.length
|
||||
})
|
||||
|
||||
@@ -1,74 +1,50 @@
|
||||
<template>
|
||||
<div class="w-full max-w-xl">
|
||||
<!-- Mobile layout: stacked input + full-width geolocation CTA -->
|
||||
<div class="md:hidden space-y-3">
|
||||
<label class="relative block">
|
||||
<span class="sr-only">Postcode</span>
|
||||
<iconify-icon
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500"
|
||||
icon="lucide:map-pin"
|
||||
style="font-size:18px;"
|
||||
></iconify-icon>
|
||||
<input
|
||||
v-model="postcode"
|
||||
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>
|
||||
<form class="w-full max-w-xl" @submit.prevent="submitPostcode">
|
||||
<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)]">
|
||||
<iconify-icon class="text-zinc-400 text-lg shrink-0" icon="lucide:map-pin"></iconify-icon>
|
||||
<input
|
||||
v-model="postcode"
|
||||
autocomplete="postal-code"
|
||||
class="flex-1 min-w-0 bg-transparent outline-none text-[15px] md:text-base placeholder:text-zinc-400"
|
||||
placeholder="Postcode"
|
||||
type="text"
|
||||
/>
|
||||
|
||||
<!-- Geolocation icon-button — visible on mobile AND desktop -->
|
||||
<button
|
||||
: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"
|
||||
@click="useMyLocation"
|
||||
>
|
||||
<iconify-icon :class="{ 'animate-spin': locating }" :icon="locating ? 'lucide:loader-circle' : 'lucide:locate-fixed'" style="font-size:20px;"></iconify-icon>
|
||||
{{ locating ? 'Getting location…' : 'Use my location' }}
|
||||
<iconify-icon :class="{ 'animate-spin': locating }" :icon="locating ? 'lucide:loader-circle' : 'lucide:locate-fixed'" class="text-lg"></iconify-icon>
|
||||
</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 -->
|
||||
<div class="hidden md:block">
|
||||
<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">
|
||||
<iconify-icon
|
||||
class="self-center text-zinc-500 mr-3"
|
||||
icon="lucide:map-pin"
|
||||
style="font-size:18px;"
|
||||
></iconify-icon>
|
||||
<input
|
||||
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>
|
||||
<!-- Desktop-only inline submit -->
|
||||
<button
|
||||
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"
|
||||
type="submit"
|
||||
>
|
||||
Check prices
|
||||
<iconify-icon icon="lucide:arrow-right"></iconify-icon>
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<div class="flex items-center gap-3 mt-3 text-[13px]">
|
||||
<button
|
||||
:disabled="locating"
|
||||
class="inline-flex items-center gap-1.5 font-medium text-zinc-700 hover:text-accent transition-colors disabled:opacity-70"
|
||||
type="button"
|
||||
@click="useMyLocation"
|
||||
>
|
||||
<iconify-icon :class="{ 'animate-spin': locating }" :icon="locating ? 'lucide:loader-circle' : 'lucide:locate-fixed'" style="font-size:14px;"></iconify-icon>
|
||||
{{ locating ? 'Getting location…' : 'Use my location' }}
|
||||
</button>
|
||||
<span aria-hidden="true" class="text-zinc-400">·</span>
|
||||
<span class="font-mono text-zinc-500">Try SW1A 1AA · M1 1AD · EH1 1YZ</span>
|
||||
</div>
|
||||
<!-- Mobile-only full-width submit -->
|
||||
<button
|
||||
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"
|
||||
type="submit"
|
||||
>
|
||||
Check prices
|
||||
<iconify-icon class="text-lg" icon="lucide:arrow-right"></iconify-icon>
|
||||
</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 class="hidden md:inline text-zinc-300">·</span>
|
||||
<span class="hidden md:inline font-mono">Try SW1A 1AA · M1 1AD · EH1 1YZ</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<dl class="hidden md:flex items-center gap-8 divide-x divide-zinc-300">
|
||||
<div v-for="(stat, idx) in stats" :key="stat.label" :class="idx === 0 ? '' : 'pl-8'">
|
||||
<dl class="hidden md:flex items-center divide-x divide-zinc-300">
|
||||
<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>
|
||||
<dd>
|
||||
<span class="block font-serif text-2xl text-zinc-900 leading-none">{{ stat.value }}</span>
|
||||
|
||||
Reference in New Issue
Block a user