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

@@ -3,41 +3,47 @@
<LandingNav />
<!-- Hero -->
<section id="hero" class="relative pt-24 md:pt-36 pb-10 md:pb-16 px-6 hero-gradient overflow-hidden">
<div class="max-w-7xl mx-auto grid lg:grid-cols-[1.1fr_1fr] gap-10 lg:gap-16 items-center">
<div class="space-y-6 md:space-y-8">
<LiveTicker :latest-price-at="liveStats.latestPriceAt" :station-count="liveStats.stationCount" />
<div class="hero-gradient">
<!-- Hero -->
<section id="hero" class="relative pt-24 md:pt-36 pb-4 md:pb-8 px-6 overflow-hidden">
<div class="max-w-7xl mx-auto grid lg:grid-cols-[1.1fr_1fr] gap-10 lg:gap-16 items-center">
<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]">
Know <span class="text-accent">exactly</span> when to fill up.
</h1>
<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.
</h1>
<!-- Mobile verdict card: between headline and input -->
<div class="lg:hidden">
<VerdictCard variant="compact" />
<HeroSearch :fuel-type="searchInitial.fuelType" :initial="searchInitial" :radius="searchInitial.radius" :sort="searchInitial.sort" @search="onSearch" />
<!-- 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>
<HeroSearch :fuel-type="searchInitial.fuelType" :initial="searchInitial" :radius="searchInitial.radius" :sort="searchInitial.sort" @search="onSearch" />
<StatsRow :station-count="liveStats.stationCount" />
<!-- Desktop verdict card -->
<div class="hidden lg:block">
<VerdictCard variant="full" />
</div>
</div>
</section>
<!-- Desktop verdict card -->
<div class="hidden lg:block">
<VerdictCard variant="full" />
</div>
</div>
</section>
<!-- Search Results -->
<section v-if="searchAttempted" class="px-6 py-10 bg-zinc-100">
<!-- Search Results -->
<section v-if="searchAttempted" id="searchAttempted" class="px-6">
<div class="max-w-7xl mx-auto space-y-6">
<!-- Post-search filter bar -->
<div class="bg-white border border-zinc-300 rounded-2xl p-4 md:p-5 shadow-sm">
<SearchBar :initial="searchInitial" @search="onSearch" />
</div>
<SearchBar
:initial="searchInitial"
:map-open="mapOpen"
:result-count="filteredStations.length"
@search="onSearch"
@toggle-map="mapOpen = !mapOpen"
/>
<!-- Loading -->
<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>
</div>
<template v-else>
<LeafletMap :default-open="true" :origin="searchOrigin" :radius-miles="radiusMiles" :stations="stations" />
<StationList :current-sort="sort" :origin="searchOrigin" :stations="stations" @sort="onSort" />
<LeafletMap
: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>
</div>
</section>
</div>
<!-- How It Works -->
<section id="how-it-works" class="py-12 md:py-24 px-6 bg-zinc-50">
@@ -369,7 +388,7 @@
</template>
<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 { useAuth } from '../composables/useAuth.js'
import { useStations } from '../composables/useStations.js'
@@ -436,6 +455,13 @@ const PRICES = {
const PRICE_SUFFIX = { monthly: '/mo', annual: '/yr' }
const { stations, meta, loading, error, search } = useStations()
watch(loading, (isLoading) => {
if (!isLoading) return
nextTick(() => {
window.scrollBy({ top: 40, behavior: 'smooth' })
})
})
const searchOrigin = computed(() => {
if (meta.value?.lat != null && meta.value?.lng != null) {
return { lat: meta.value.lat, lng: meta.value.lng }
@@ -450,6 +476,48 @@ const sort = ref('reliable')
const lastParams = ref(null)
const searchAttempted = ref(false)
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(() => ({
postcode: route.query.postcode ?? '',