Remove obsolete Livewire fuel search components and consolidate pricing tiers
- Delete unused Livewire Search test and fuel type select Blade component - Move subscription webhook listener from EventServiceProvider to AppServiceProvider - Add FUEL_TYPES global config to app layout for client-side use - Add Billable trait to User model and include email_verified_at in fillable - Implement monthly/annual cadence toggle with pricing display and smart CTA routing on homepage - Update VerifyApiKeyMiddlewareTest to use e10 instead of petrol - Refactor PollFuelPrices to auto-refresh stale stations based on last_seen_at - Add incremental polling with cached timestamp and effective-start-timestamp param to FuelPriceService - Normalize amenities/fuel_types from API objects to flat arrays, skip stations missing required fields - Log response body on API failures in ApiLogger - Default homepage sort to 'reliable' instead of 'price'
This commit is contained in:
@@ -41,12 +41,9 @@
|
||||
aria-label="Fuel type"
|
||||
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 truncate"
|
||||
>
|
||||
<option value="e10">Petrol (E10)</option>
|
||||
<option value="e5">Premium (E5)</option>
|
||||
<option value="b7_standard">Diesel (B7)</option>
|
||||
<option value="b7_premium">Prem Diesel</option>
|
||||
<option value="b10">Diesel (B10)</option>
|
||||
<option value="hvo">HVO</option>
|
||||
<option v-for="fuel in FUEL_TYPES" :key="fuel.value" :value="fuel.value">
|
||||
{{ fuel.label }}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
v-model="radius"
|
||||
@@ -63,11 +60,11 @@
|
||||
aria-label="Sort by"
|
||||
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="reliable">Reliable</option>
|
||||
<option value="price">Price</option>
|
||||
<option value="distance">Distance</option>
|
||||
<option value="updated">Updated</option>
|
||||
<option value="brand">Brand</option>
|
||||
<option value="reliable">Reliable</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,13 +72,14 @@
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { FUEL_TYPES } from '../constants/fuelTypes.js'
|
||||
|
||||
const emit = defineEmits(['search'])
|
||||
|
||||
const postcode = ref('')
|
||||
const fuelType = ref('e10')
|
||||
const radius = ref(10)
|
||||
const sort = ref('price')
|
||||
const sort = ref('reliable')
|
||||
const locating = ref(false)
|
||||
|
||||
function useMyLocation() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<!-- Sort tabs -->
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button
|
||||
v-for="option in sortOptions"
|
||||
:key="option.value"
|
||||
@@ -22,8 +22,59 @@
|
||||
{{ stations.length }} station{{ stations.length !== 1 ? 's' : '' }} found
|
||||
</p>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="space-y-2">
|
||||
<!-- 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"
|
||||
:lowest-price="lowestPrice"
|
||||
: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">3–7 days old — verify before driving</span>
|
||||
</header>
|
||||
<div class="opacity-80">
|
||||
<StationCard
|
||||
v-for="station in stale"
|
||||
:key="station.station_id"
|
||||
:lowest-price="lowestPrice"
|
||||
: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"
|
||||
:lowest-price="lowestPrice"
|
||||
: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 stations"
|
||||
:key="station.station_id"
|
||||
@@ -40,21 +91,26 @@ import StationCard from './StationCard.vue'
|
||||
|
||||
const props = defineProps({
|
||||
stations: { type: Array, required: true },
|
||||
currentSort: { type: String, default: 'price' },
|
||||
currentSort: { type: String, default: 'reliable' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['sort'])
|
||||
|
||||
const sortOptions = [
|
||||
{ label: 'Reliable', value: 'reliable' },
|
||||
{ label: 'Price', value: 'price' },
|
||||
{ label: 'Distance', value: 'distance' },
|
||||
{ label: 'Updated', value: 'updated' },
|
||||
{ label: 'Brand', value: 'brand' },
|
||||
{ label: 'Reliable', value: 'reliable' },
|
||||
]
|
||||
|
||||
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'))
|
||||
|
||||
const lowestPrice = computed(() => {
|
||||
if (!props.stations.length) return null
|
||||
return Math.min(...props.stations.map(s => s.price_pence))
|
||||
if (!reliable.value.length && !props.stations.length) return null
|
||||
const pool = reliable.value.length ? reliable.value : props.stations
|
||||
return Math.min(...pool.map(s => s.price_pence))
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -220,7 +220,25 @@
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-4xl md:text-5xl font-black font-display text-zinc-800 mb-4">Pricing for every driver</h2>
|
||||
<p class="text-zinc-500 text-lg">Save hundreds for less than the cost of a coffee.</p>
|
||||
<p class="text-zinc-500 text-lg mb-8">Save hundreds for less than the cost of a coffee.</p>
|
||||
<div class="inline-flex items-center gap-1 p-1 bg-white border border-zinc-300 rounded-full">
|
||||
<button
|
||||
:class="cadence === 'monthly' ? 'bg-accent text-white' : 'text-zinc-500'"
|
||||
class="px-5 py-2 rounded-full text-sm font-bold transition-colors"
|
||||
type="button"
|
||||
@click="cadence = 'monthly'"
|
||||
>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
:class="cadence === 'annual' ? 'bg-accent text-white' : 'text-zinc-500'"
|
||||
class="px-5 py-2 rounded-full text-sm font-bold transition-colors"
|
||||
type="button"
|
||||
@click="cadence = 'annual'"
|
||||
>
|
||||
Annual <span class="text-[10px] opacity-80">(save 17%)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
@@ -238,7 +256,7 @@
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Daily Updates</li>
|
||||
<li class="text-sm flex gap-2 text-zinc-500"><iconify-icon class="text-zinc-300" icon="lucide:x"></iconify-icon> No Alerts</li>
|
||||
</ul>
|
||||
<a class="w-full py-3 px-4 border border-zinc-300 rounded-xl text-center font-bold hover:bg-zinc-100 transition-colors" href="/register">Get Started</a>
|
||||
<a :href="ctaHref('free')" class="w-full py-3 px-4 border border-zinc-300 rounded-xl text-center font-bold hover:bg-zinc-100 transition-colors">{{ ctaLabel('free') }}</a>
|
||||
</div>
|
||||
|
||||
<!-- Basic -->
|
||||
@@ -246,8 +264,8 @@
|
||||
<div class="mb-8">
|
||||
<h4 class="text-xl font-bold font-display mb-2">Basic</h4>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-4xl font-black">£0.99</span>
|
||||
<span class="text-zinc-500 text-sm">/mo</span>
|
||||
<span class="text-4xl font-black">{{ PRICES[cadence].basic }}</span>
|
||||
<span class="text-zinc-500 text-sm">{{ PRICE_SUFFIX[cadence] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="space-y-4 mb-8 flex-1">
|
||||
@@ -255,7 +273,7 @@
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> 14-day Trend Data</li>
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> 3 Daily Price Alerts</li>
|
||||
</ul>
|
||||
<a class="w-full py-3 px-4 border border-zinc-300 rounded-xl text-center font-bold hover:bg-zinc-100 transition-colors" href="/register">Select Basic</a>
|
||||
<a :href="ctaHref('basic')" class="w-full py-3 px-4 border border-zinc-300 rounded-xl text-center font-bold hover:bg-zinc-100 transition-colors">{{ ctaLabel('basic') }}</a>
|
||||
</div>
|
||||
|
||||
<!-- Plus -->
|
||||
@@ -264,8 +282,8 @@
|
||||
<div class="mb-8">
|
||||
<h4 class="text-xl font-bold font-display mb-2">Plus</h4>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-4xl font-black text-accent">£2.49</span>
|
||||
<span class="text-zinc-500 text-sm">/mo</span>
|
||||
<span class="text-4xl font-black text-accent">{{ PRICES[cadence].plus }}</span>
|
||||
<span class="text-zinc-500 text-sm">{{ PRICE_SUFFIX[cadence] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="space-y-4 mb-8 flex-1">
|
||||
@@ -273,7 +291,7 @@
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Priority Price Alerts</li>
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Multi-location tracking</li>
|
||||
</ul>
|
||||
<a class="w-full py-3 px-4 bg-accent text-white rounded-xl text-center font-bold shadow-lg hover:bg-primary-dark transition-all" href="/register">Join Plus</a>
|
||||
<a :href="ctaHref('plus')" class="w-full py-3 px-4 bg-accent text-white rounded-xl text-center font-bold shadow-lg hover:bg-primary-dark transition-all">{{ ctaLabel('plus') }}</a>
|
||||
</div>
|
||||
|
||||
<!-- Pro -->
|
||||
@@ -281,8 +299,8 @@
|
||||
<div class="mb-8">
|
||||
<h4 class="text-xl font-bold font-display mb-2">Pro</h4>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-4xl font-black">£3.99</span>
|
||||
<span class="text-zinc-400 text-sm">/mo</span>
|
||||
<span class="text-4xl font-black">{{ PRICES[cadence].pro }}</span>
|
||||
<span class="text-zinc-400 text-sm">{{ PRICE_SUFFIX[cadence] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="space-y-4 mb-8 flex-1">
|
||||
@@ -290,7 +308,7 @@
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Multi-Vehicle Fleet</li>
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Exportable Price History</li>
|
||||
</ul>
|
||||
<a class="w-full py-3 px-4 bg-white text-zinc-800 rounded-xl text-center font-bold hover:bg-zinc-100 transition-colors" href="/register">Go Pro</a>
|
||||
<a :href="ctaHref('pro')" class="w-full py-3 px-4 bg-white text-zinc-800 rounded-xl text-center font-bold hover:bg-zinc-100 transition-colors">{{ ctaLabel('pro') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -417,10 +435,45 @@ import SearchBar from '../components/SearchBar.vue'
|
||||
import LeafletMap from '../components/LeafletMap.vue'
|
||||
import StationList from '../components/StationList.vue'
|
||||
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { isAuthenticated, userTier } = useAuth()
|
||||
|
||||
const cadence = ref('monthly')
|
||||
|
||||
function ctaHref(tier) {
|
||||
if (tier === 'free') {
|
||||
return isAuthenticated.value ? '/dashboard' : '/register'
|
||||
}
|
||||
if (!isAuthenticated.value) {
|
||||
return '/register?tier=' + tier + '&cadence=' + cadence.value
|
||||
}
|
||||
if (userTier.value === tier) {
|
||||
return '/billing/portal'
|
||||
}
|
||||
return '/billing/checkout/' + tier + '/' + cadence.value
|
||||
}
|
||||
|
||||
function ctaLabel(tier) {
|
||||
if (tier === 'free') {
|
||||
return isAuthenticated.value ? 'Go to dashboard' : 'Get started'
|
||||
}
|
||||
if (isAuthenticated.value && userTier.value === tier) {
|
||||
return 'Manage subscription'
|
||||
}
|
||||
return {
|
||||
basic: 'Select Basic',
|
||||
plus: 'Join Plus',
|
||||
pro: 'Go Pro',
|
||||
}[tier]
|
||||
}
|
||||
|
||||
const PRICES = {
|
||||
monthly: { basic: '£0.99', plus: '£2.49', pro: '£3.99' },
|
||||
annual: { basic: '£9.90', plus: '£24.90', pro: '£39.90' },
|
||||
}
|
||||
const PRICE_SUFFIX = { monthly: '/mo', annual: '/yr' }
|
||||
const { stations, loading, error, search } = useStations()
|
||||
|
||||
const sort = ref('price')
|
||||
const sort = ref('reliable')
|
||||
const lastParams = ref(null)
|
||||
const searchAttempted = ref(false)
|
||||
const radiusMiles = ref(10)
|
||||
|
||||
@@ -9,12 +9,9 @@
|
||||
v-model="form.preferred_fuel_type"
|
||||
class="w-full h-12 px-4 bg-zinc-50 border border-zinc-300 rounded-xl font-medium text-zinc-800 focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
>
|
||||
<option value="petrol">Petrol (E10)</option>
|
||||
<option value="diesel">Diesel (B7)</option>
|
||||
<option value="e5">Premium Unleaded (E5)</option>
|
||||
<option value="b7_premium">Premium Diesel</option>
|
||||
<option value="b10">B10 Biodiesel</option>
|
||||
<option value="hvo">HVO</option>
|
||||
<option v-for="fuel in FUEL_TYPES" :key="fuel.value" :value="fuel.value">
|
||||
{{ fuel.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -46,14 +43,15 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '../../axios.js'
|
||||
import { FUEL_TYPES } from '../../constants/fuelTypes.js'
|
||||
|
||||
const form = ref({ preferred_fuel_type: 'petrol', postcode: '' })
|
||||
const form = ref({ preferred_fuel_type: 'e10', postcode: '' })
|
||||
const saving = ref(false)
|
||||
const saved = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
const response = await api.get('/user/preferences')
|
||||
form.value.preferred_fuel_type = response.data.preferred_fuel_type ?? 'petrol'
|
||||
form.value.preferred_fuel_type = response.data.preferred_fuel_type ?? 'e10'
|
||||
form.value.postcode = response.data.postcode ?? ''
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>FuelAlert</title>
|
||||
<script>
|
||||
window['FUEL_TYPES'] = @json(
|
||||
collect(App\Enums\FuelType::cases())
|
||||
->map(fn ($case) => ['value' => $case->value, 'label' => $case->label()])
|
||||
->values()
|
||||
);
|
||||
</script>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body class="bg-[#f5ede5]">
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<div
|
||||
x-data="{
|
||||
value: '',
|
||||
labels: {
|
||||
petrol: 'Petrol (E10)',
|
||||
e5: 'Super Unleaded (E5)',
|
||||
diesel: 'Diesel',
|
||||
b7_premium: 'Premium Diesel',
|
||||
b10: 'B10 Biodiesel',
|
||||
hvo: 'HVO',
|
||||
},
|
||||
get label() {
|
||||
return this.labels[this.value] ?? 'Select fuel type';
|
||||
},
|
||||
}"
|
||||
x-modelable="value"
|
||||
{{ $attributes->whereStartsWith('wire:model') }}
|
||||
>
|
||||
<flux:dropdown>
|
||||
<flux:button size="sm" icon:trailing="chevron-down">
|
||||
<span x-text="label">Select fuel type</span>
|
||||
</flux:button>
|
||||
|
||||
<flux:menu>
|
||||
<flux:menu.item @click="value = 'petrol'">Petrol (E10)</flux:menu.item>
|
||||
<flux:menu.item @click="value = 'e5'">Super Unleaded (E5)</flux:menu.item>
|
||||
<flux:menu.item @click="value = 'diesel'">Diesel</flux:menu.item>
|
||||
<flux:menu.item @click="value = 'b7_premium'">Premium Diesel</flux:menu.item>
|
||||
<flux:menu.item @click="value = 'b10'">B10 Biodiesel</flux:menu.item>
|
||||
<flux:menu.item @click="value = 'hvo'">HVO</flux:menu.item>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
</div>
|
||||
Reference in New Issue
Block a user