- 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
588 lines
31 KiB
Vue
588 lines
31 KiB
Vue
<template>
|
||
<div class="min-h-screen bg-zinc-100">
|
||
|
||
<LandingNav />
|
||
|
||
<div class="hero-gradient">
|
||
<!-- Hero -->
|
||
<section id="hero" class="relative pt-24 md:pt-36 pb-4 md:pb-8 px-6 overflow-hidden">
|
||
<div class="max-w-7xl mx-auto grid lg:grid-cols-[1.1fr_1fr] gap-10 lg:gap-16 items-center">
|
||
<div class="space-y-6 md:space-y-8">
|
||
<LiveTicker :latest-price-at="liveStats.latestPriceAt" :station-count="liveStats.stationCount" />
|
||
|
||
<h1 class="font-serif text-zinc-900 text-[40px] leading-[0.98] md:text-6xl lg:text-[88px] lg:leading-[0.95] max-w-[560px]">
|
||
Know <span class="text-accent">exactly</span> when to fill up.
|
||
</h1>
|
||
|
||
<HeroSearch :fuel-type="searchInitial.fuelType" :initial="searchInitial" :radius="searchInitial.radius" :sort="searchInitial.sort" @search="onSearch" />
|
||
|
||
<!-- Mobile verdict card: below search, only before first search -->
|
||
<div v-if="!searchAttempted" class="lg:hidden">
|
||
<VerdictCard variant="compact" />
|
||
</div>
|
||
<div id="stats-row">
|
||
<StatsRow :station-count="liveStats.stationCount" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Desktop verdict card -->
|
||
<div class="hidden lg:block">
|
||
<VerdictCard variant="full" />
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Search Results -->
|
||
<section v-if="searchAttempted" id="searchAttempted" class="px-6">
|
||
<div class="max-w-7xl mx-auto space-y-6">
|
||
|
||
<!-- Post-search filter bar -->
|
||
<PostSearchFilters
|
||
v-model:brand-filter="brandFilter"
|
||
:brands="availableBrands"
|
||
:initial="searchInitial"
|
||
:map-open="mapOpen"
|
||
:station-count="filteredStations.length"
|
||
@search="onSearch"
|
||
@toggle-map="mapOpen = !mapOpen"
|
||
/>
|
||
|
||
<!-- Loading -->
|
||
<div v-if="loading" class="flex items-center justify-center py-16">
|
||
<div class="flex items-center gap-3 text-zinc-500">
|
||
<iconify-icon icon="lucide:loader-circle" class="animate-spin text-2xl text-accent"></iconify-icon>
|
||
<span class="font-medium">Finding stations near you…</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Error -->
|
||
<div v-else-if="error" class="flex items-center gap-3 p-4 bg-white border border-zinc-300 rounded-xl text-status-bad">
|
||
<iconify-icon icon="lucide:circle-alert" style="font-size:1.25rem"></iconify-icon>
|
||
<span class="font-medium">{{ Object.values(error).flat()[0] ?? 'Unable to load stations. Please try again.' }}</span>
|
||
</div>
|
||
|
||
<!-- Results -->
|
||
<template v-else>
|
||
<div v-if="!stations.length" class="flex items-center gap-3 p-4 bg-white border border-zinc-300 rounded-xl text-zinc-500">
|
||
<iconify-icon icon="lucide:map-pin-off" style="font-size:1.25rem"></iconify-icon>
|
||
<span class="font-medium">No stations found near you. Try a different postcode or increase the radius.</span>
|
||
</div>
|
||
<template v-else>
|
||
<LeafletMap
|
||
:is-open="mapOpen"
|
||
:origin="searchOrigin"
|
||
:radius-miles="radiusMiles"
|
||
:stations="filteredStations"
|
||
/>
|
||
<StationList
|
||
:current-sort="sort"
|
||
:origin="searchOrigin"
|
||
:stations="filteredStations"
|
||
/>
|
||
</template>
|
||
</template>
|
||
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<!-- How It Works -->
|
||
<section id="how-it-works" class="py-12 md:py-24 px-6 bg-zinc-50">
|
||
<div class="max-w-7xl mx-auto">
|
||
<div class="text-center mb-16 space-y-4">
|
||
<h2 class="text-4xl md:text-5xl font-black font-display text-zinc-800">Smart Savings in 3 Steps</h2>
|
||
<p class="text-zinc-500 text-lg max-w-2xl mx-auto">Stop guessing when to fill up. Our engine analyzes thousands of data points daily to save you money.</p>
|
||
</div>
|
||
|
||
<div class="grid md:grid-cols-3 gap-12">
|
||
<div class="text-center space-y-4">
|
||
<div class="w-16 h-16 bg-accent/10 text-accent rounded-2xl flex items-center justify-center mx-auto text-3xl">
|
||
<iconify-icon icon="lucide:search"></iconify-icon>
|
||
</div>
|
||
<h3 class="text-2xl font-bold font-display">1. Search</h3>
|
||
<p class="text-zinc-500">Enter your postcode or location to find every forecourt within a 5–20 mile radius instantly.</p>
|
||
</div>
|
||
<div class="text-center space-y-4">
|
||
<div class="w-16 h-16 bg-accent/10 text-accent rounded-2xl flex items-center justify-center mx-auto text-3xl">
|
||
<iconify-icon icon="lucide:trending-up"></iconify-icon>
|
||
</div>
|
||
<h3 class="text-2xl font-bold font-display">2. Get Advice</h3>
|
||
<p class="text-zinc-500">Our AI compares local prices against national wholesale trends to give you a Fill Up / Wait recommendation.</p>
|
||
</div>
|
||
<div class="text-center space-y-4">
|
||
<div class="w-16 h-16 bg-accent/10 text-accent rounded-2xl flex items-center justify-center mx-auto text-3xl">
|
||
<iconify-icon icon="lucide:wallet"></iconify-icon>
|
||
</div>
|
||
<h3 class="text-2xl font-bold font-display">3. Fill Up Smart</h3>
|
||
<p class="text-zinc-500">Navigate to the cheapest station and fill up with confidence knowing you've secured the best price.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Features -->
|
||
<section id="features" class="py-12 md:py-24 px-6">
|
||
<div class="max-w-7xl mx-auto">
|
||
<div class="grid lg:grid-cols-2 gap-20 items-center">
|
||
<div class="order-2 lg:order-1">
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
<div class="p-6 bg-zinc-50 border border-zinc-300 rounded-2xl space-y-3">
|
||
<iconify-icon class="text-3xl text-accent" icon="lucide:zap"></iconify-icon>
|
||
<h4 class="font-bold text-lg font-display">Real-Time Prices</h4>
|
||
<p class="text-sm text-zinc-500">Verified daily prices from thousands of UK forecourts.</p>
|
||
</div>
|
||
<div class="p-6 bg-zinc-50 border border-zinc-300 rounded-2xl space-y-3">
|
||
<iconify-icon class="text-3xl text-accent" icon="lucide:calendar"></iconify-icon>
|
||
<h4 class="font-bold text-lg font-display">Timing Predictions</h4>
|
||
<p class="text-sm text-zinc-500">Proprietary 14-day forecasts for petrol and diesel trends.</p>
|
||
</div>
|
||
<div class="p-6 bg-zinc-50 border border-zinc-300 rounded-2xl space-y-3">
|
||
<iconify-icon class="text-3xl text-accent" icon="lucide:shopping-bag"></iconify-icon>
|
||
<h4 class="font-bold text-lg font-display">Supermarket Anchors</h4>
|
||
<p class="text-sm text-zinc-500">Track local supermarkets to find the absolute lowest base price.</p>
|
||
</div>
|
||
<div class="p-6 bg-zinc-50 border border-zinc-300 rounded-2xl space-y-3">
|
||
<iconify-icon class="text-3xl text-accent" icon="lucide:bell-ring"></iconify-icon>
|
||
<h4 class="font-bold text-lg font-display">Smart Price Alerts</h4>
|
||
<p class="text-sm text-zinc-500">Get notified when local prices drop below your set target.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="order-1 lg:order-2 space-y-8">
|
||
<h2 class="text-4xl md:text-5xl font-black font-display text-zinc-800">The ultimate fuel companion.</h2>
|
||
<p class="text-lg text-zinc-500">Whether you're a daily commuter, a delivery professional, or just planning a weekend road trip, FuelAlert gives you the edge at the pump.</p>
|
||
<ul class="space-y-4">
|
||
<li class="flex items-center gap-3 font-bold">
|
||
<iconify-icon class="text-accent" icon="lucide:check-circle-2"></iconify-icon>
|
||
Coverage for 98% of UK Forecourts
|
||
</li>
|
||
<li class="flex items-center gap-3 font-bold">
|
||
<iconify-icon class="text-accent" icon="lucide:check-circle-2"></iconify-icon>
|
||
Hyper-local Map Visualization
|
||
</li>
|
||
<li class="flex items-center gap-3 font-bold">
|
||
<iconify-icon class="text-accent" icon="lucide:check-circle-2"></iconify-icon>
|
||
Historic Price Benchmarking
|
||
</li>
|
||
</ul>
|
||
<button class="inline-flex items-center gap-2 text-accent font-black text-lg group">
|
||
Explore all features
|
||
<iconify-icon class="group-hover:translate-x-1 transition-transform" icon="lucide:arrow-right"></iconify-icon>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Pricing -->
|
||
<section id="pricing" class="py-12 md:py-24 px-6 bg-zinc-50">
|
||
<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 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">
|
||
<!-- Free -->
|
||
<div class="bg-white border border-zinc-300 p-8 rounded-3xl flex flex-col h-full">
|
||
<div class="mb-8">
|
||
<h4 class="text-xl font-bold font-display mb-2">Free</h4>
|
||
<div class="flex items-baseline gap-1">
|
||
<span class="text-4xl font-black">£0</span>
|
||
<span class="text-zinc-500 text-sm">/mo</span>
|
||
</div>
|
||
</div>
|
||
<ul class="space-y-4 mb-8 flex-1">
|
||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Basic Search</li>
|
||
<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 :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>
|
||
|
||
<!-- Daily (backend: basic) -->
|
||
<div class="bg-white border border-zinc-300 p-8 rounded-3xl flex flex-col h-full">
|
||
<div class="mb-8">
|
||
<h4 class="text-xl font-bold font-display mb-2">Daily</h4>
|
||
<div class="flex items-baseline gap-1">
|
||
<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">
|
||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Ad-free Experience</li>
|
||
<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 :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>
|
||
|
||
<!-- Smart (backend: plus) -->
|
||
<div class="bg-white border-2 border-accent p-8 rounded-3xl flex flex-col h-full relative">
|
||
<div class="absolute -top-4 left-1/2 -translate-x-1/2 bg-accent text-white px-4 py-1 rounded-full text-[10px] font-black uppercase tracking-widest whitespace-nowrap">Most pick this</div>
|
||
<div class="mb-8">
|
||
<h4 class="text-xl font-bold font-display mb-2">Smart</h4>
|
||
<div class="flex items-baseline gap-1">
|
||
<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">
|
||
<li class="text-sm flex gap-2 font-bold"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Supermarket Anchor</li>
|
||
<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 :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 -->
|
||
<div class="bg-zinc-800 border border-zinc-800 p-8 rounded-3xl flex flex-col h-full text-white">
|
||
<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">{{ 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">
|
||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:sparkles"></iconify-icon> AI Price Predictions</li>
|
||
<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 :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>
|
||
</section>
|
||
|
||
<!-- Testimonials -->
|
||
<section class="py-12 md:py-24 px-6">
|
||
<div class="max-w-7xl mx-auto">
|
||
<div class="flex flex-col md:flex-row gap-12 items-center">
|
||
<div class="md:w-1/3">
|
||
<h2 class="text-4xl font-black font-display text-zinc-800 mb-4">Loved by commuters.</h2>
|
||
<div class="flex items-center gap-1 text-status-warn mb-4 text-xl">
|
||
<iconify-icon icon="lucide:star"></iconify-icon>
|
||
<iconify-icon icon="lucide:star"></iconify-icon>
|
||
<iconify-icon icon="lucide:star"></iconify-icon>
|
||
<iconify-icon icon="lucide:star"></iconify-icon>
|
||
<iconify-icon icon="lucide:star"></iconify-icon>
|
||
</div>
|
||
<p class="text-zinc-500">Join thousands of UK drivers saving every single month.</p>
|
||
</div>
|
||
<div class="md:w-2/3 grid sm:grid-cols-2 gap-6">
|
||
<div class="p-6 bg-zinc-50 border border-zinc-300 rounded-2xl shadow-sm italic text-zinc-800">
|
||
"I used to just go to the station on my way home. Now I check FuelAlert and realise there's a station 2 miles away that's 5p cheaper! Over a month, it adds up to a free tank per year."
|
||
<div class="mt-4 flex items-center gap-3 not-italic">
|
||
<img alt="James R." class="w-10 h-10 rounded-full" src="https://api.dicebear.com/7.x/avataaars/svg?seed=John">
|
||
<div>
|
||
<p class="font-bold text-sm">James R.</p>
|
||
<p class="text-[10px] text-zinc-500 uppercase font-bold tracking-widest">Daily Commuter</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="p-6 bg-zinc-50 border border-zinc-300 rounded-2xl shadow-sm italic text-zinc-800">
|
||
"The predictions are eerily accurate. I was going to fill up Friday, but FuelAlert said 'Hold on' for Monday. Sure enough, prices dropped at my local Tesco by 3p. Brilliant."
|
||
<div class="mt-4 flex items-center gap-3 not-italic">
|
||
<img alt="Sarah M." class="w-10 h-10 rounded-full" src="https://api.dicebear.com/7.x/avataaars/svg?seed=Sarah">
|
||
<div>
|
||
<p class="font-bold text-sm">Sarah M.</p>
|
||
<p class="text-[10px] text-zinc-500 uppercase font-bold tracking-widest">Delivery Driver</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- CTA -->
|
||
<section class="py-12 md:py-24 px-6 bg-accent text-white text-center">
|
||
<div class="max-w-3xl mx-auto space-y-8">
|
||
<h2 class="text-4xl md:text-5xl font-black font-display leading-tight">Ready to outsmart the pumps?</h2>
|
||
<p class="text-xl text-white/80">Sign up for free today and never pay over the odds for fuel again.</p>
|
||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||
<a class="bg-white text-accent px-10 py-4 rounded-xl text-lg font-black shadow-2xl hover:bg-zinc-100 transition-all" href="/register">Create Free Account</a>
|
||
<a class="bg-transparent border-2 border-white/30 text-white px-10 py-4 rounded-xl text-lg font-bold hover:bg-white/10 transition-all" href="#">Watch Demo Video</a>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Footer -->
|
||
<footer class="bg-zinc-50 border-t border-zinc-300 pt-16 pb-8 px-6">
|
||
<div class="max-w-7xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-12 mb-12">
|
||
<div class="col-span-2 md:col-span-1 space-y-4">
|
||
<RouterLink class="flex items-center gap-2" to="/">
|
||
<div class="w-8 h-8 rounded bg-accent flex items-center justify-center">
|
||
<iconify-icon class="text-white" icon="lucide:fuel"></iconify-icon>
|
||
</div>
|
||
<span class="text-xl font-black font-display tracking-tighter text-accent">FuelAlert</span>
|
||
</RouterLink>
|
||
<p class="text-sm text-zinc-500 leading-relaxed">
|
||
Helping UK drivers save money at the pump since 2021. Real-time data, smarter choices.
|
||
</p>
|
||
<div class="flex gap-4">
|
||
<iconify-icon class="text-2xl text-zinc-500 hover:text-accent cursor-pointer transition-colors" icon="mdi:twitter"></iconify-icon>
|
||
<iconify-icon class="text-2xl text-zinc-500 hover:text-accent cursor-pointer transition-colors" icon="mdi:facebook"></iconify-icon>
|
||
<iconify-icon class="text-2xl text-zinc-500 hover:text-accent cursor-pointer transition-colors" icon="mdi:instagram"></iconify-icon>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="space-y-4">
|
||
<h5 class="font-black uppercase text-xs text-zinc-800 tracking-widest">Product</h5>
|
||
<ul class="space-y-2 text-sm text-zinc-500">
|
||
<li><a class="hover:text-accent transition-colors" href="#pricing">Pricing</a></li>
|
||
<li><a class="hover:text-accent transition-colors" href="#features">Features</a></li>
|
||
<li><a class="hover:text-accent transition-colors" href="#">FuelAlert Pro</a></li>
|
||
<li><a class="hover:text-accent transition-colors" href="#">Enterprise API</a></li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="space-y-4">
|
||
<h5 class="font-black uppercase text-xs text-zinc-800 tracking-widest">Resources</h5>
|
||
<ul class="space-y-2 text-sm text-zinc-500">
|
||
<li><a class="hover:text-accent transition-colors" href="#">Market Insights</a></li>
|
||
<li><a class="hover:text-accent transition-colors" href="#">How We Track</a></li>
|
||
<li><a class="hover:text-accent transition-colors" href="#">Help Center</a></li>
|
||
<li><a class="hover:text-accent transition-colors" href="#">Driver Safety</a></li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="space-y-4">
|
||
<h5 class="font-black uppercase text-xs text-zinc-800 tracking-widest">Legal</h5>
|
||
<ul class="space-y-2 text-sm text-zinc-500">
|
||
<li><a class="hover:text-accent transition-colors" href="#">Privacy Policy</a></li>
|
||
<li><a class="hover:text-accent transition-colors" href="#">Terms of Service</a></li>
|
||
<li><a class="hover:text-accent transition-colors" href="#">Cookie Settings</a></li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="max-w-7xl mx-auto pt-8 border-t border-zinc-300 flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] font-bold uppercase tracking-widest text-zinc-500">
|
||
<p>© 2024 FuelAlert UK Limited. All Rights Reserved.</p>
|
||
<p>Data provided by official UK retail price transparency schemes.</p>
|
||
</div>
|
||
</footer>
|
||
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, watch, onMounted, nextTick, defineAsyncComponent } from 'vue'
|
||
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 PostSearchFilters from '../components/PostSearchFilters.vue'
|
||
import StationList from '../components/StationList.vue'
|
||
|
||
const LeafletMap = defineAsyncComponent(() => import('../components/LeafletMap.vue'))
|
||
import LandingNav from '../components/landing/LandingNav.vue'
|
||
import LiveTicker from '../components/landing/LiveTicker.vue'
|
||
import VerdictCard from '../components/landing/VerdictCard.vue'
|
||
import HeroSearch from '../components/landing/HeroSearch.vue'
|
||
import StatsRow from '../components/landing/StatsRow.vue'
|
||
|
||
const { isAuthenticated, userTier } = useAuth()
|
||
|
||
const liveStats = ref({ stationCount: null, latestPriceAt: null })
|
||
|
||
onMounted(async () => {
|
||
try {
|
||
const { data } = await api.get('/stats/live')
|
||
liveStats.value = {
|
||
stationCount: data.station_count,
|
||
latestPriceAt: data.latest_price_at,
|
||
}
|
||
} catch {
|
||
// leave defaults; ticker degrades to "Live · updated …" only
|
||
}
|
||
})
|
||
|
||
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' : 'Start free'
|
||
}
|
||
if (isAuthenticated.value && userTier.value === tier) {
|
||
return 'Manage subscription'
|
||
}
|
||
return {
|
||
basic: 'Choose Daily',
|
||
plus: 'Choose Smart',
|
||
pro: 'Choose 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, meta, loading, error, search, reset } = useStations()
|
||
|
||
watch(loading, (isLoading) => {
|
||
if (!isLoading) return
|
||
nextTick(() => {
|
||
window.scrollBy({ top: 40, behavior: 'smooth' })
|
||
})
|
||
})
|
||
|
||
const searchOrigin = computed(() => {
|
||
if (meta.value?.lat != null && meta.value?.lng != null) {
|
||
return { lat: meta.value.lat, lng: meta.value.lng }
|
||
}
|
||
return null
|
||
})
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
|
||
const sort = ref('reliable')
|
||
const lastParams = ref(null)
|
||
const searchAttempted = ref(false)
|
||
const radiusMiles = ref(10)
|
||
const brandFilter = ref('')
|
||
|
||
const MAP_STORAGE_KEY = 'fuel-price:map-open'
|
||
|
||
function readSavedMapOpen() {
|
||
try {
|
||
const v = localStorage.getItem(MAP_STORAGE_KEY)
|
||
if (v === null) return true
|
||
return v === '1'
|
||
} catch {
|
||
return true
|
||
}
|
||
}
|
||
|
||
const mapOpen = ref(readSavedMapOpen())
|
||
|
||
watch(mapOpen, (v) => {
|
||
try {
|
||
localStorage.setItem(MAP_STORAGE_KEY, v ? '1' : '0')
|
||
} catch {
|
||
// ignore quota / privacy-mode errors
|
||
}
|
||
})
|
||
|
||
const availableBrands = computed(() => {
|
||
const brands = new Set()
|
||
stations.value.forEach(s => {
|
||
if (s.brand) brands.add(s.brand)
|
||
})
|
||
return [...brands].sort((a, b) => a.localeCompare(b))
|
||
})
|
||
|
||
const filteredStations = computed(() => {
|
||
if (!brandFilter.value) return stations.value
|
||
return stations.value.filter(s => s.brand === brandFilter.value)
|
||
})
|
||
|
||
watch(() => stations.value, () => {
|
||
if (brandFilter.value && !availableBrands.value.includes(brandFilter.value)) {
|
||
brandFilter.value = ''
|
||
}
|
||
})
|
||
|
||
const searchInitial = computed(() => ({
|
||
postcode: route.query.postcode ?? '',
|
||
lat: route.query.lat ? Number(route.query.lat) : null,
|
||
lng: route.query.lng ? Number(route.query.lng) : null,
|
||
fuelType: route.query.fuel_type ?? 'e10',
|
||
radius: route.query.radius ? Number(route.query.radius) : 10,
|
||
sort: route.query.sort ?? 'reliable',
|
||
}))
|
||
|
||
function paramsFromQuery(query) {
|
||
const hasPostcode = typeof query.postcode === 'string' && query.postcode.trim().length > 0
|
||
const hasCoords = query.lat && query.lng
|
||
if (!hasPostcode && !hasCoords) return null
|
||
|
||
return {
|
||
postcode: hasPostcode ? query.postcode.trim() : null,
|
||
lat: hasCoords ? Number(query.lat) : null,
|
||
lng: hasCoords ? Number(query.lng) : null,
|
||
fuelType: query.fuel_type ?? 'e10',
|
||
radius: query.radius ? Number(query.radius) : 10,
|
||
sort: query.sort ?? 'reliable',
|
||
}
|
||
}
|
||
|
||
function queryFromParams(params) {
|
||
const q = {
|
||
fuel_type: params.fuelType,
|
||
radius: String(params.radius),
|
||
sort: params.sort,
|
||
}
|
||
if (params.postcode) {
|
||
q.postcode = params.postcode
|
||
} else if (params.lat && params.lng) {
|
||
q.lat = String(params.lat)
|
||
q.lng = String(params.lng)
|
||
}
|
||
return q
|
||
}
|
||
|
||
async function runSearch(params) {
|
||
lastParams.value = params
|
||
sort.value = params.sort ?? sort.value
|
||
radiusMiles.value = params.radius ?? radiusMiles.value
|
||
searchAttempted.value = true
|
||
await search(params)
|
||
}
|
||
|
||
async function onSearch(params) {
|
||
await router.push({ query: queryFromParams(params) })
|
||
await runSearch(params)
|
||
}
|
||
|
||
watch(() => route.query, (query) => {
|
||
const params = paramsFromQuery(query)
|
||
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
|
||
runSearch(params)
|
||
}, { immediate: true })
|
||
</script>
|