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:
@@ -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 ?? '',
|
||||
|
||||
Reference in New Issue
Block a user