Redesign search UI with unified input, expandable filters, and integrated map controls
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

- 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:
Ovidiu U
2026-04-22 09:38:23 +01:00
parent afe459f248
commit dd9bd95657
6 changed files with 352 additions and 291 deletions

View File

@@ -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 (&lt;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 (2448h)
<span class="inline-block size-3 rounded-full bg-green-500"></span> </span>
Current (&lt;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 (25 days)
<span class="inline-block size-3 rounded-full bg-slate-500"></span> </span>
Recent (2448h) <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 (25 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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ?? '',