Rename SearchBar to PostSearchFilters, add sort controls and brand filter, relocate station count display
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

- Move SearchBar.vue to PostSearchFilters.vue and expand to include sort buttons, brand filter dropdown, and station count
- Integrate sort controls (Reliable/Price/Distance/Updated) with icons into filter bar
- Add brand filter dropdown with dynamic brand list from parent, emit update events
- Move station count from StationList to PostSearchFilters, display as "X station(s) found"
- Remove sort tabs and brand filter from StationList component
- Add force-new-line div for mobile layout between Refine and Sort groups
- Include brand filter in hasActive check and resetFilters function
- Update Home.vue to pass brands/brandFilter props and handle brandFilter updates
- Add reset() method to useStations composable to clear state on empty query
- Clear search state when route query is empty instead of attempting search
- Update Fuel Finder API base URL to include /api/v1 path
- Adjust map zoom levels for 10-15 mile radius range
- Update API token request to use retry and increase timeout to 60s
This commit is contained in:
Ovidiu U
2026-04-22 11:50:59 +01:00
parent 8335f49fd6
commit b4bd78ab4c
7 changed files with 96 additions and 95 deletions

View File

@@ -80,7 +80,7 @@ for that `(station_id, fuel_type)` combination. Avoids row explosion on unchange
``` ```
FUEL_FINDER_CLIENT_ID= FUEL_FINDER_CLIENT_ID=
FUEL_FINDER_CLIENT_SECRET= FUEL_FINDER_CLIENT_SECRET=
FUEL_FINDER_BASE_URL=https://api.fuel-finder.service.gov.uk FUEL_FINDER_BASE_URL=https://www.fuel-finder.service.gov.uk/api/v1
``` ```
## Postcodes.io — location resolution ## Postcodes.io — location resolution

View File

@@ -45,7 +45,8 @@ class FuelPriceService
{ {
return Cache::remember(self::TOKEN_CACHE_KEY, 3540, function (): string { return Cache::remember(self::TOKEN_CACHE_KEY, 3540, function (): string {
$url = config('services.fuel_finder.base_url').'/oauth/generate_access_token'; $url = config('services.fuel_finder.base_url').'/oauth/generate_access_token';
$response = $this->apiLogger->send('fuel_finder', 'POST', $url, fn () => Http::timeout(10) $response = $this->apiLogger->send('fuel_finder', 'POST', $url, fn () => Http::retry(3, 500)
->timeout(60)
->post($url, [ ->post($url, [
'client_id' => config('services.fuel_finder.client_id'), 'client_id' => config('services.fuel_finder.client_id'),
'client_secret' => config('services.fuel_finder.client_secret'), 'client_secret' => config('services.fuel_finder.client_secret'),

View File

@@ -91,8 +91,8 @@ 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 12 if (radiusMiles <= 10) return 11
if (radiusMiles <= 15) return 12 if (radiusMiles <= 15) return 11
if (radiusMiles <= 20) return 10 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

View File

@@ -1,11 +1,10 @@
<template> <template>
<div class="flex flex-wrap items-center gap-2 md:gap-2.5"> <div class="flex flex-wrap items-center gap-2 md:gap-2.5 py-3 border-b border-zinc-200">
<!-- Leading label --> <!-- Refine group -->
<span class="hidden md:inline text-xs font-mono uppercase tracking-widest text-zinc-400 mr-1"> <span class="hidden md:inline text-xs font-mono uppercase tracking-widest text-zinc-400 mr-1">
Refine Refine
</span> </span>
<!-- Fuel type -->
<label :class="{ 'is-active': fuelType !== DEFAULTS.fuelType }" class="pill group"> <label :class="{ 'is-active': fuelType !== DEFAULTS.fuelType }" class="pill group">
<iconify-icon class="text-sm opacity-70" icon="lucide:fuel"></iconify-icon> <iconify-icon class="text-sm opacity-70" icon="lucide:fuel"></iconify-icon>
<span class="text-sm font-medium">{{ fuelLabel }}</span> <span class="text-sm font-medium">{{ fuelLabel }}</span>
@@ -14,13 +13,12 @@
v-model="fuelType" v-model="fuelType"
aria-label="Fuel type" aria-label="Fuel type"
class="absolute inset-0 opacity-0 cursor-pointer" class="absolute inset-0 opacity-0 cursor-pointer"
name="fuelType" name="fuelType"
> >
<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>
</label> </label>
<!-- Radius -->
<label :class="{ 'is-active': radius !== DEFAULTS.radius }" class="pill group"> <label :class="{ 'is-active': radius !== DEFAULTS.radius }" class="pill group">
<iconify-icon class="text-sm opacity-70" icon="lucide:circle-dot"></iconify-icon> <iconify-icon class="text-sm opacity-70" icon="lucide:circle-dot"></iconify-icon>
<span class="text-sm font-medium">{{ radius }} miles</span> <span class="text-sm font-medium">{{ radius }} miles</span>
@@ -29,7 +27,7 @@
v-model.number="radius" v-model.number="radius"
aria-label="Search radius" aria-label="Search radius"
class="absolute inset-0 opacity-0 cursor-pointer" class="absolute inset-0 opacity-0 cursor-pointer"
name="radius" name="radius"
> >
<option :value="5">5 miles</option> <option :value="5">5 miles</option>
<option :value="10">10 miles</option> <option :value="10">10 miles</option>
@@ -37,12 +35,11 @@
</select> </select>
</label> </label>
<!-- Show / hide map -->
<button <button
:class="{ 'is-active': mapOpen }"
class="pill"
:aria-expanded="mapOpen" :aria-expanded="mapOpen"
:class="{ 'is-active': mapOpen }"
aria-controls="leaflet-map-panel" aria-controls="leaflet-map-panel"
class="pill"
type="button" type="button"
@click="emit('toggle-map')" @click="emit('toggle-map')"
> >
@@ -55,18 +52,58 @@
></iconify-icon> ></iconify-icon>
</button> </button>
<!-- Divider + clear (only when any active) --> <button
<template v-if="hasActive"> v-if="hasActive"
<span class="hidden md:inline h-5 w-px bg-zinc-200 mx-1"></span> class="inline-flex items-center gap-1 h-10 px-3 text-sm text-zinc-500 hover:text-zinc-900 transition-colors cursor-pointer"
<button type="button"
class="inline-flex items-center gap-1 h-10 px-3 text-sm text-zinc-500 hover:text-zinc-900 transition-colors" @click="resetFilters"
type="button" >
@click="resetFilters" <iconify-icon class="text-sm" icon="lucide:x"></iconify-icon>
Clear
</button>
<!-- Force Sort to a new line on mobile only -->
<div aria-hidden="true" class="basis-full md:hidden"></div>
<!-- Sort group -->
<span class="hidden md:inline text-xs font-mono uppercase tracking-widest text-zinc-400 mx-1">
Sort
</span>
<button
v-for="option in sortOptions"
:key="option.value"
:class="{ 'is-active': sort === option.value }"
class="pill"
type="button"
@click="sort = option.value"
>
<iconify-icon :icon="option.icon" class="text-sm opacity-70"></iconify-icon>
<span class="text-sm font-medium">{{ option.label }}</span>
</button>
<label
v-if="brands.length > 1"
:class="{ 'is-active': brandFilter }"
class="pill group"
>
<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)"
> >
<iconify-icon class="text-sm" icon="lucide:x"></iconify-icon> <option value="">All brands</option>
Clear <option v-for="brand in brands" :key="brand" :value="brand">{{ brand }}</option>
</button> </select>
</template> </label>
<span class="ml-auto text-sm text-zinc-500 font-medium">
{{ stationCount }} station{{ stationCount !== 1 ? 's' : '' }} found
</span>
</div> </div>
</template> </template>
@@ -80,13 +117,22 @@ const DEFAULTS = Object.freeze({
sort: 'reliable', sort: 'reliable',
}) })
const sortOptions = [
{ 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 props = defineProps({ const props = defineProps({
initial: { type: Object, default: () => ({}) }, initial: { type: Object, default: () => ({}) },
resultCount: { type: Number, default: null }, brands: { type: Array, default: () => [] },
brandFilter: { type: String, default: '' },
mapOpen: { type: Boolean, default: true }, mapOpen: { type: Boolean, default: true },
stationCount: { type: Number, default: 0 },
}) })
const emit = defineEmits(['search', 'toggle-map']) const emit = defineEmits(['search', 'toggle-map', 'update:brandFilter'])
const postcode = ref('') const postcode = ref('')
const coords = ref(null) const coords = ref(null)
@@ -120,12 +166,14 @@ const hasActive = computed(() => (
fuelType.value !== DEFAULTS.fuelType fuelType.value !== DEFAULTS.fuelType
|| radius.value !== DEFAULTS.radius || radius.value !== DEFAULTS.radius
|| sort.value !== DEFAULTS.sort || sort.value !== DEFAULTS.sort
|| Boolean(props.brandFilter)
)) ))
function resetFilters() { function resetFilters() {
fuelType.value = DEFAULTS.fuelType fuelType.value = DEFAULTS.fuelType
radius.value = DEFAULTS.radius radius.value = DEFAULTS.radius
sort.value = DEFAULTS.sort sort.value = DEFAULTS.sort
if (props.brandFilter) emit('update:brandFilter', '')
} }
function emitSearch() { function emitSearch() {

View File

@@ -1,47 +1,5 @@
<template> <template>
<div class="space-y-3"> <div class="space-y-3">
<!-- 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"
:class="{ 'is-active': currentSort === option.value }"
class="pill"
type="button"
@click="emit('sort', option.value)"
>
<iconify-icon :icon="option.icon" class="text-sm opacity-70"></iconify-icon>
<span class="text-sm font-medium">{{ option.label }}</span>
</button>
<!-- Brand filter -->
<label
v-if="brands.length > 1"
:class="{ 'is-active': brandFilter }"
class="pill group"
>
<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>
<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 --> <!-- Grouped results when sorting by reliability -->
<template v-if="currentSort === 'reliable'"> <template v-if="currentSort === 'reliable'">
<section v-if="reliable.length" class="space-y-2"> <section v-if="reliable.length" class="space-y-2">
@@ -121,19 +79,8 @@ 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', 'update:brandFilter'])
const sortOptions = [
{ 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 reliable = computed(() => props.stations.filter(s => s.reliability === 'reliable')) const reliable = computed(() => props.stations.filter(s => s.reliability === 'reliable'))
const stale = computed(() => props.stations.filter(s => s.reliability === 'stale')) const stale = computed(() => props.stations.filter(s => s.reliability === 'stale'))
const outdated = computed(() => props.stations.filter(s => s.reliability === 'outdated')) const outdated = computed(() => props.stations.filter(s => s.reliability === 'outdated'))

View File

@@ -34,5 +34,12 @@ export function useStations() {
} }
} }
return { stations, meta, loading, error, search } function reset() {
stations.value = []
meta.value = null
error.value = null
loading.value = false
}
return { stations, meta, loading, error, search, reset }
} }

View File

@@ -37,10 +37,12 @@
<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 -->
<SearchBar <PostSearchFilters
v-model:brand-filter="brandFilter"
:brands="availableBrands"
:initial="searchInitial" :initial="searchInitial"
:map-open="mapOpen" :map-open="mapOpen"
:result-count="filteredStations.length" :station-count="filteredStations.length"
@search="onSearch" @search="onSearch"
@toggle-map="mapOpen = !mapOpen" @toggle-map="mapOpen = !mapOpen"
/> />
@@ -73,12 +75,9 @@
:stations="filteredStations" :stations="filteredStations"
/> />
<StationList <StationList
v-model:brand-filter="brandFilter"
:brands="availableBrands"
:current-sort="sort" :current-sort="sort"
:origin="searchOrigin" :origin="searchOrigin"
:stations="filteredStations" :stations="filteredStations"
@sort="onSort"
/> />
</template> </template>
</template> </template>
@@ -393,7 +392,7 @@ 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'
import api from '../axios.js' import api from '../axios.js'
import SearchBar from '../components/SearchBar.vue' import PostSearchFilters from '../components/PostSearchFilters.vue'
import StationList from '../components/StationList.vue' import StationList from '../components/StationList.vue'
const LeafletMap = defineAsyncComponent(() => import('../components/LeafletMap.vue')) const LeafletMap = defineAsyncComponent(() => import('../components/LeafletMap.vue'))
@@ -453,7 +452,7 @@ const PRICES = {
annual: { basic: '£9.90', plus: '£24.90', pro: '£39.90' }, annual: { basic: '£9.90', plus: '£24.90', pro: '£39.90' },
} }
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, reset } = useStations()
watch(loading, (isLoading) => { watch(loading, (isLoading) => {
if (!isLoading) return if (!isLoading) return
@@ -571,16 +570,15 @@ async function onSearch(params) {
await runSearch(params) await runSearch(params)
} }
async function onSort(newSort) {
if (!lastParams.value) return
const next = { ...lastParams.value, sort: newSort }
await router.push({ query: queryFromParams(next) })
await runSearch(next)
}
watch(() => route.query, (query) => { watch(() => route.query, (query) => {
const params = paramsFromQuery(query) const params = paramsFromQuery(query)
if (!params) return if (!params) {
searchAttempted.value = false
lastParams.value = null
brandFilter.value = ''
reset()
return
}
const sameAsLast = lastParams.value const sameAsLast = lastParams.value
&& JSON.stringify(queryFromParams(lastParams.value)) === JSON.stringify(queryFromParams(params)) && JSON.stringify(queryFromParams(lastParams.value)) === JSON.stringify(queryFromParams(params))
if (sameAsLast) return if (sameAsLast) return