Rename SearchBar to PostSearchFilters, add sort controls and brand filter, relocate station count display
- 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:
@@ -80,7 +80,7 @@ for that `(station_id, fuel_type)` combination. Avoids row explosion on unchange
|
||||
```
|
||||
FUEL_FINDER_CLIENT_ID=
|
||||
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
|
||||
|
||||
@@ -45,7 +45,8 @@ class FuelPriceService
|
||||
{
|
||||
return Cache::remember(self::TOKEN_CACHE_KEY, 3540, function (): string {
|
||||
$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, [
|
||||
'client_id' => config('services.fuel_finder.client_id'),
|
||||
'client_secret' => config('services.fuel_finder.client_secret'),
|
||||
|
||||
@@ -91,8 +91,8 @@ function getZoomForRadius(radiusMiles) {
|
||||
if (radiusMiles <= 1) return 16
|
||||
if (radiusMiles <= 2) return 15
|
||||
if (radiusMiles <= 5) return 14
|
||||
if (radiusMiles <= 10) return 12
|
||||
if (radiusMiles <= 15) return 12
|
||||
if (radiusMiles <= 10) return 11
|
||||
if (radiusMiles <= 15) return 11
|
||||
if (radiusMiles <= 20) return 10
|
||||
if (radiusMiles <= 25) return 10
|
||||
if (radiusMiles <= 50) return 9
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-2 md:gap-2.5">
|
||||
<!-- Leading label -->
|
||||
<div class="flex flex-wrap items-center gap-2 md:gap-2.5 py-3 border-b border-zinc-200">
|
||||
<!-- Refine group -->
|
||||
<span class="hidden md:inline text-xs font-mono uppercase tracking-widest text-zinc-400 mr-1">
|
||||
Refine
|
||||
</span>
|
||||
|
||||
<!-- Fuel type -->
|
||||
<label :class="{ 'is-active': fuelType !== DEFAULTS.fuelType }" class="pill group">
|
||||
<iconify-icon class="text-sm opacity-70" icon="lucide:fuel"></iconify-icon>
|
||||
<span class="text-sm font-medium">{{ fuelLabel }}</span>
|
||||
@@ -14,13 +13,12 @@
|
||||
v-model="fuelType"
|
||||
aria-label="Fuel type"
|
||||
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>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<!-- Radius -->
|
||||
<label :class="{ 'is-active': radius !== DEFAULTS.radius }" class="pill group">
|
||||
<iconify-icon class="text-sm opacity-70" icon="lucide:circle-dot"></iconify-icon>
|
||||
<span class="text-sm font-medium">{{ radius }} miles</span>
|
||||
@@ -29,7 +27,7 @@
|
||||
v-model.number="radius"
|
||||
aria-label="Search radius"
|
||||
class="absolute inset-0 opacity-0 cursor-pointer"
|
||||
name="radius"
|
||||
name="radius"
|
||||
>
|
||||
<option :value="5">5 miles</option>
|
||||
<option :value="10">10 miles</option>
|
||||
@@ -37,12 +35,11 @@
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<!-- Show / hide map -->
|
||||
<button
|
||||
:class="{ 'is-active': mapOpen }"
|
||||
class="pill"
|
||||
:aria-expanded="mapOpen"
|
||||
:class="{ 'is-active': mapOpen }"
|
||||
aria-controls="leaflet-map-panel"
|
||||
class="pill"
|
||||
type="button"
|
||||
@click="emit('toggle-map')"
|
||||
>
|
||||
@@ -55,18 +52,58 @@
|
||||
></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"
|
||||
<button
|
||||
v-if="hasActive"
|
||||
class="inline-flex items-center gap-1 h-10 px-3 text-sm text-zinc-500 hover:text-zinc-900 transition-colors cursor-pointer"
|
||||
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>
|
||||
Clear
|
||||
</button>
|
||||
</template>
|
||||
<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">
|
||||
{{ stationCount }} station{{ stationCount !== 1 ? 's' : '' }} found
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -80,13 +117,22 @@ const DEFAULTS = Object.freeze({
|
||||
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({
|
||||
initial: { type: Object, default: () => ({}) },
|
||||
resultCount: { type: Number, default: null },
|
||||
brands: { type: Array, default: () => [] },
|
||||
brandFilter: { type: String, default: '' },
|
||||
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 coords = ref(null)
|
||||
@@ -120,12 +166,14 @@ const hasActive = computed(() => (
|
||||
fuelType.value !== DEFAULTS.fuelType
|
||||
|| radius.value !== DEFAULTS.radius
|
||||
|| sort.value !== DEFAULTS.sort
|
||||
|| Boolean(props.brandFilter)
|
||||
))
|
||||
|
||||
function resetFilters() {
|
||||
fuelType.value = DEFAULTS.fuelType
|
||||
radius.value = DEFAULTS.radius
|
||||
sort.value = DEFAULTS.sort
|
||||
if (props.brandFilter) emit('update:brandFilter', '')
|
||||
}
|
||||
|
||||
function emitSearch() {
|
||||
@@ -1,47 +1,5 @@
|
||||
<template>
|
||||
<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 -->
|
||||
<template v-if="currentSort === 'reliable'">
|
||||
<section v-if="reliable.length" class="space-y-2">
|
||||
@@ -121,19 +79,8 @@ 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', '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 stale = computed(() => props.stations.filter(s => s.reliability === 'stale'))
|
||||
const outdated = computed(() => props.stations.filter(s => s.reliability === 'outdated'))
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -37,10 +37,12 @@
|
||||
<div class="max-w-7xl mx-auto space-y-6">
|
||||
|
||||
<!-- Post-search filter bar -->
|
||||
<SearchBar
|
||||
<PostSearchFilters
|
||||
v-model:brand-filter="brandFilter"
|
||||
:brands="availableBrands"
|
||||
:initial="searchInitial"
|
||||
:map-open="mapOpen"
|
||||
:result-count="filteredStations.length"
|
||||
:station-count="filteredStations.length"
|
||||
@search="onSearch"
|
||||
@toggle-map="mapOpen = !mapOpen"
|
||||
/>
|
||||
@@ -73,12 +75,9 @@
|
||||
:stations="filteredStations"
|
||||
/>
|
||||
<StationList
|
||||
v-model:brand-filter="brandFilter"
|
||||
:brands="availableBrands"
|
||||
:current-sort="sort"
|
||||
:origin="searchOrigin"
|
||||
:stations="filteredStations"
|
||||
@sort="onSort"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
@@ -393,7 +392,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { useAuth } from '../composables/useAuth.js'
|
||||
import { useStations } from '../composables/useStations.js'
|
||||
import api from '../axios.js'
|
||||
import SearchBar from '../components/SearchBar.vue'
|
||||
import PostSearchFilters from '../components/PostSearchFilters.vue'
|
||||
import StationList from '../components/StationList.vue'
|
||||
|
||||
const LeafletMap = defineAsyncComponent(() => import('../components/LeafletMap.vue'))
|
||||
@@ -453,7 +452,7 @@ const PRICES = {
|
||||
annual: { basic: '£9.90', plus: '£24.90', pro: '£39.90' },
|
||||
}
|
||||
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) => {
|
||||
if (!isLoading) return
|
||||
@@ -571,16 +570,15 @@ async function onSearch(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) => {
|
||||
const params = paramsFromQuery(query)
|
||||
if (!params) return
|
||||
if (!params) {
|
||||
searchAttempted.value = false
|
||||
lastParams.value = null
|
||||
brandFilter.value = ''
|
||||
reset()
|
||||
return
|
||||
}
|
||||
const sameAsLast = lastParams.value
|
||||
&& JSON.stringify(queryFromParams(lastParams.value)) === JSON.stringify(queryFromParams(params))
|
||||
if (sameAsLast) return
|
||||
|
||||
Reference in New Issue
Block a user