Files
fuel-price/resources/js/components/StationList.vue
Ovidiu U 831637380c
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
feat: expand station cards with detailed information and add live statistics endpoint
- Add `/stats/live` endpoint returning station count and latest price timestamp with 5-minute cache
- Transform StationCard into expandable component with click/keyboard interaction showing full details
- Display brand label, badges (24h/Supermarket/Motorway), fuel types, amenities, opening hours, and price delta vs average
- Add brand filter dropdown to StationList with dynamic brand extraction from results
- Calculate and display price comparison against filtered stations average
- Redesign map markers to simpler price display; move directions link to popup alongside station details
- Add "locate-me" button to SearchBar for geolocation trigger
- Show "Live" indicator with station count and last-update time on homepage hero
- Remove standalone directions link from marker HTML; consolidate in popup with click propagation handling
- Persist `avgPence` calculation across StationList and pass to cards for delta display
- Add `@iconify-json/lucide` dev dependency and register collection on app mount
- Stop click propagation on card action buttons (directions, remove)
2026-04-20 18:58:13 +01:00

158 lines
6.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="space-y-3">
<!-- Sort tabs + brand filter -->
<div class="flex gap-2 flex-wrap items-center">
<button
v-for="option in sortOptions"
:key="option.value"
@click="emit('sort', option.value)"
:class="[
'h-10 px-4 rounded-xl text-sm font-bold transition-colors',
currentSort === option.value
? 'bg-accent text-white'
: 'bg-white border border-zinc-300 text-zinc-500 hover:border-accent'
]"
>
{{ option.label }}
</button>
<select
v-if="availableBrands.length > 1"
v-model="brandFilter"
aria-label="Filter by brand"
class="min-w-0 h-10 px-2 bg-white border border-zinc-300 rounded-xl text-sm font-medium text-zinc-700 focus:outline-none focus:ring-2 focus:ring-accent"
>
<option value="">All brands</option>
<option v-for="brand in availableBrands" :key="brand" :value="brand">{{ brand }}</option>
</select>
</div>
<!-- Count -->
<p class="text-sm text-zinc-500 font-medium">
{{ filteredStations.length }} station{{ filteredStations.length !== 1 ? 's' : '' }}
<span v-if="brandFilter">matching <strong>{{ brandFilter }}</strong></span>
<span v-else>found</span>
</p>
<!-- Grouped results when sorting by reliability -->
<template v-if="currentSort === 'reliable'">
<section v-if="reliable.length" class="space-y-2">
<header class="flex items-center gap-2 pt-2">
<iconify-icon class="text-status-good text-lg" icon="lucide:shield-check"></iconify-icon>
<h3 class="font-black text-zinc-800">Reliable</h3>
<span class="text-xs text-zinc-500 font-medium">Updated in the last 3 days</span>
</header>
<StationCard
v-for="station in reliable"
:key="station.station_id"
:avg-pence="avgPence"
:lowest-price="lowestPrice"
:origin="origin"
:station="station"
/>
</section>
<section v-if="stale.length" class="space-y-2 pt-4">
<header class="flex items-center gap-2">
<iconify-icon class="text-status-warn text-lg" icon="lucide:clock"></iconify-icon>
<h3 class="font-black text-zinc-800">Older prices</h3>
<span class="text-xs text-zinc-500 font-medium">37 days old verify before driving</span>
</header>
<div class="opacity-80">
<StationCard
v-for="station in stale"
:key="station.station_id"
:avg-pence="avgPence"
:lowest-price="lowestPrice"
:origin="origin"
:station="station"
class="mb-2"
/>
</div>
</section>
<section v-if="outdated.length" class="space-y-2 pt-4">
<header class="flex items-center gap-2">
<iconify-icon class="text-status-bad text-lg" icon="lucide:triangle-alert"></iconify-icon>
<h3 class="font-black text-zinc-800">Outdated</h3>
<span class="text-xs text-zinc-500 font-medium">Over 7 days old likely inaccurate</span>
</header>
<div class="opacity-60">
<StationCard
v-for="station in outdated"
:key="station.station_id"
:avg-pence="avgPence"
:lowest-price="lowestPrice"
:origin="origin"
:station="station"
class="mb-2"
/>
</div>
</section>
</template>
<!-- Flat list for other sort modes -->
<div v-else class="space-y-2">
<StationCard
v-for="station in filteredStations"
:key="station.station_id"
:avg-pence="avgPence"
:lowest-price="lowestPrice"
:origin="origin"
:station="station"
/>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import StationCard from './StationCard.vue'
const props = defineProps({
stations: { type: Array, required: true },
currentSort: { type: String, default: 'reliable' },
origin: { type: Object, default: null },
})
const emit = defineEmits(['sort'])
const brandFilter = ref('')
const sortOptions = [
{ label: 'Reliable', value: 'reliable' },
{ label: 'Price', value: 'price' },
{ label: 'Distance', value: 'distance' },
{ label: 'Updated', value: 'updated' },
]
const availableBrands = computed(() => {
const brands = new Set()
props.stations.forEach(s => {
if (s.brand) brands.add(s.brand)
})
return [...brands].sort((a, b) => a.localeCompare(b))
})
const filteredStations = computed(() => {
if (!brandFilter.value) return props.stations
return props.stations.filter(s => s.brand === brandFilter.value)
})
const reliable = computed(() => filteredStations.value.filter(s => s.reliability === 'reliable'))
const stale = computed(() => filteredStations.value.filter(s => s.reliability === 'stale'))
const outdated = computed(() => filteredStations.value.filter(s => s.reliability === 'outdated'))
const lowestPrice = computed(() => {
if (!reliable.value.length && !filteredStations.value.length) return null
const pool = reliable.value.length ? reliable.value : filteredStations.value
return Math.min(...pool.map(s => s.price_pence))
})
const avgPence = computed(() => {
const prices = filteredStations.value.map(s => s.price_pence).filter(p => typeof p === 'number')
if (!prices.length) return null
return prices.reduce((a, b) => a + b, 0) / prices.length
})
</script>