Files
fuel-alert/resources/js/views/Home.vue
Ovidiu U a841a7c900 Add dedicated /pricing page and lock launch tiers to Free/Daily/Smart
Consolidate pricing onto a single source. Pro is deferred from launch
(left dormant: no Stripe price, no card), so the offered set is 3 tiers.

- Extract the pricing grid and footer into shared components
  (PricingGrid.vue, landing/SiteFooter.vue); add a /pricing route
  rendering Pricing.vue; remove the pricing section from Home
- Repoint every upgrade link to the /pricing route (LandingNav and
  SiteFooter via RouterLink, UpsellBanner CTA) — no more #pricing anchors
- Bump Smart (plus) SMS daily limit 1 -> 3 (PlanSeeder + PlanFactory),
  update PlanFeaturesTest assertion
- Rewrite /pricing card bullets to match real entitlements (drop
  unbuilt promises: multi-location tracking, 14-day trend, supermarket anchor)
- Fix stale "1/day" SMS references in notifications.md, tiers.md, docs/tiers.md
- Delete unused resources/views/components/pricing-card.blade.php

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:22:19 +01:00

487 lines
23 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="min-h-screen bg-zinc-100">
<LandingNav />
<div class="hero-gradient">
<!-- Hero -->
<section id="hero" class="relative pt-24 md:pt-36 pb-2 md:pb-8 px-3 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-3 md:space-y-8">
<LiveTicker :latest-price-at="liveStats.latestPriceAt" :station-count="liveStats.stationCount" />
<h1 class="hidden lg:block font-serif text-zinc-900 text-[40px] leading-[0.98] md:text-6xl lg:text-[88px] lg:leading-[0.95] max-w-140">
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-3">
<div class="max-w-7xl mx-auto space-y-3">
<!-- Prediction box (sits above filter results) -->
<PredictionCard
:is-paid-tier="showFullPrediction"
:loading="loading"
:prediction="prediction"
/>
<!-- Post-search filter bar -->
<PostSearchFilters
v-model:brand-filter="brandFilter"
:brands="availableBrands"
:initial="searchInitial"
@search="onSearch"
/>
<!-- 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
:origin="searchOrigin"
:radius-miles="radiusMiles"
:selected-station-id="selectedStationId"
:stations="filteredStations"
@station-select="selectedStationId = $event"
>
<template #overlay>
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 translate-y-3"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0 translate-y-3"
>
<div
v-if="selectedStation"
class="absolute left-3 right-3 bottom-3 md:right-auto md:max-w-sm z-[1000] pointer-events-auto"
>
<StationCard
:avg-pence="avgPenceForCard"
:dismissible="true"
:expanded="true"
:lowest-price="lowestPriceForCard"
:origin="searchOrigin"
:station="selectedStation"
class="shadow-xl"
@dismiss="selectedStationId = null"
/>
</div>
</Transition>
</template>
</LeafletMap>
<UpsellBanner :station-count="liveStats.stationCount" />
<button
:aria-expanded="listOpen"
:class="listOpen ? '' : 'mb-6'"
aria-controls="station-list-panel"
class="pill w-full justify-center"
type="button"
@click="listOpen = !listOpen"
>
<iconify-icon :icon="listOpen ? 'lucide:chevron-up' : 'lucide:list'" class="text-sm opacity-70"></iconify-icon>
<span class="text-sm font-medium">
{{ listOpen ? 'Hide list' : `Stations ${filteredStations.length}` }}
</span>
</button>
<div v-if="listOpen" id="station-list-panel">
<StationList
:current-sort="sort"
:origin="searchOrigin"
:stations="filteredStations"
/>
</div>
</template>
</template>
</div>
</section>
</div>
<!-- How It Works -->
<section id="how-it-works" class="py-4 md:py-24 px-3 bg-zinc-50">
<div class="max-w-7xl mx-auto">
<div class="text-center mb-4 md:mb-8 space-y-2 md:space-y-4">
<h2 class="text-xl md:text-5xl font-black font-display text-zinc-800">Smart Savings in 3 Steps</h2>
<p class="text-zinc-500 text-sm md: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-2 md: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-xl md:text-5xl font-bold font-display">1. Search</h3>
<p class="text-zinc-500 text-sm md:text-lg">Enter your postcode or location to find every forecourt within a 520 mile radius instantly.</p>
</div>
<div class="text-center space-y-2 md: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-sm md:text-lg font-bold font-display">2. Get Advice</h3>
<p class="text-zinc-500 text-sm md:text-lg">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-2 md: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-sm md:text-lg font-bold font-display">3. Fill Up Smart</h3>
<p class="text-zinc-500 text-sm md:text-lg">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-4 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-xl md:text-5xl font-black font-display text-zinc-800">The ultimate fuel companion.</h2>
<p class="text-sm md: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>
<!-- 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" v-if="!isAuthenticated">
<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>
</div>
</div>
</section>
<!-- Footer -->
<SiteFooter />
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, nextTick, defineAsyncComponent } from 'vue'
import { 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 PredictionCard from '../components/PredictionCard.vue'
import StationList from '../components/StationList.vue'
import UpsellBanner from '../components/UpsellBanner.vue'
import StationCard from '../components/StationCard.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'
import SiteFooter from '../components/landing/SiteFooter.vue'
const { isAuthenticated } = 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 { stations, meta, prediction, loading, error, search, reset } = useStations()
const showFullPrediction = computed(() => Boolean(prediction.value) && !prediction.value.tier_locked)
// 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 LIST_STORAGE_KEY = 'fuelalert:list-open'
function readSavedListOpen() {
try {
const v = localStorage.getItem(LIST_STORAGE_KEY)
if (v === null) return false
return v === '1'
} catch {
return false
}
}
const listOpen = ref(readSavedListOpen())
watch(listOpen, (v) => {
try {
localStorage.setItem(LIST_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 selectedStationId = ref(null)
const selectedStation = computed(() =>
selectedStationId.value
? filteredStations.value.find(s => s.station_id === selectedStationId.value) ?? null
: null,
)
const lowestPriceForCard = computed(() => {
const reliable = filteredStations.value.filter(s => s.reliability === 'reliable')
const pool = reliable.length ? reliable : filteredStations.value
if (!pool.length) return null
return Math.min(...pool.map(s => s.price_pence))
})
const avgPenceForCard = 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
})
watch(filteredStations, (next) => {
if (selectedStationId.value && !next.find(s => s.station_id === selectedStationId.value)) {
selectedStationId.value = null
}
})
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) {
// Single dedup choke point. A user search calls onSearch, which both pushes to
// the URL (synchronously firing the route.query watcher → runSearch) and then
// calls runSearch directly — two triggers for one intent. Guarding here, where
// both paths funnel through, collapses them into one request for a given query.
if (lastParams.value
&& JSON.stringify(queryFromParams(lastParams.value)) === JSON.stringify(queryFromParams(params))) {
return
}
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
}
runSearch(params)
}, { immediate: true })
</script>