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_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
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
||||||
@@ -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'))
|
||||||
|
|||||||
@@ -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">
|
<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
|
||||||
|
|||||||
Reference in New Issue
Block a user