feat: redesign homepage with responsive hero, verdict card preview, and modular landing components
- Extract LandingNav, LiveTicker, StatsRow, VerdictCard, and HeroSearch into reusable landing components - Implement responsive two-layout strategy: mobile stacked (hero search + verdict card + CTA) vs desktop inline pill input with verdict card sidebar - Add serif/mono font tokens and live-dot pulse animation to CSS - Move verdict card above search input on mobile, to right sidebar on desktop - Replace hero "fill up now" mockup with dynamic VerdictCard showing top stations, pricing, and recommendation - Simplify navigation with uppercase tracking, add Fleet anchor, and gate CTA by auth state - Lazy-load LeafletMap with defineAsyncComponent to reduce initial bundle - Relocate SearchBar below hero on search attempt for persistent filter UI - Add meta description for SEO
This commit is contained in:
@@ -51,6 +51,10 @@
|
||||
|
||||
/* Display font */
|
||||
--font-display: 'Manrope', ui-sans-serif, system-ui, sans-serif;
|
||||
|
||||
/* Hero type pairing — swap these tokens to upgrade to Instrument Serif / Geist Mono later */
|
||||
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +70,15 @@
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid color-mix(in oklch, var(--color-border) 60%, transparent);
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
animation: live-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes live-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
127
resources/js/components/landing/HeroSearch.vue
Normal file
127
resources/js/components/landing/HeroSearch.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div class="w-full max-w-xl">
|
||||
<!-- Mobile layout: stacked input + full-width geolocation CTA -->
|
||||
<div class="md:hidden space-y-3">
|
||||
<label class="relative block">
|
||||
<span class="sr-only">Postcode</span>
|
||||
<iconify-icon
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500"
|
||||
icon="lucide:map-pin"
|
||||
style="font-size:18px;"
|
||||
></iconify-icon>
|
||||
<input
|
||||
v-model="postcode"
|
||||
class="w-full h-[52px] pl-11 pr-4 bg-white border border-zinc-300 rounded-xl text-base text-zinc-800 placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-accent/40 focus:border-accent"
|
||||
placeholder="Enter a UK postcode"
|
||||
type="text"
|
||||
@keyup.enter="submitPostcode"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
:disabled="locating"
|
||||
class="w-full h-14 bg-accent text-white rounded-xl font-semibold text-base flex items-center justify-center gap-2 shadow-lg hover:bg-primary-dark transition-all disabled:opacity-70"
|
||||
type="button"
|
||||
@click="useMyLocation"
|
||||
>
|
||||
<iconify-icon :class="{ 'animate-spin': locating }" :icon="locating ? 'lucide:loader-circle' : 'lucide:locate-fixed'" style="font-size:20px;"></iconify-icon>
|
||||
{{ locating ? 'Getting location…' : 'Use my location' }}
|
||||
</button>
|
||||
<p class="font-mono text-[11px] text-zinc-500 text-center">Free · no signup to try</p>
|
||||
</div>
|
||||
|
||||
<!-- Desktop layout: inline postcode pill + geo link below -->
|
||||
<div class="hidden md:block">
|
||||
<div class="flex items-stretch bg-white border border-zinc-300 rounded-full h-[60px] pl-5 pr-1.5 shadow-sm focus-within:ring-2 focus-within:ring-accent/40 focus-within:border-accent">
|
||||
<iconify-icon
|
||||
class="self-center text-zinc-500 mr-3"
|
||||
icon="lucide:map-pin"
|
||||
style="font-size:18px;"
|
||||
></iconify-icon>
|
||||
<input
|
||||
v-model="postcode"
|
||||
class="flex-1 bg-transparent text-base text-zinc-800 placeholder:text-zinc-500 focus:outline-none"
|
||||
placeholder="Enter a UK postcode"
|
||||
type="text"
|
||||
@keyup.enter="submitPostcode"
|
||||
/>
|
||||
<button
|
||||
:disabled="!postcode.trim()"
|
||||
class="my-1.5 px-6 bg-accent text-white rounded-full font-semibold text-sm hover:bg-primary-dark transition-all disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
type="button"
|
||||
@click="submitPostcode"
|
||||
>
|
||||
Check prices
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 mt-3 text-[13px]">
|
||||
<button
|
||||
:disabled="locating"
|
||||
class="inline-flex items-center gap-1.5 font-medium text-zinc-700 hover:text-accent transition-colors disabled:opacity-70"
|
||||
type="button"
|
||||
@click="useMyLocation"
|
||||
>
|
||||
<iconify-icon :class="{ 'animate-spin': locating }" :icon="locating ? 'lucide:loader-circle' : 'lucide:locate-fixed'" style="font-size:14px;"></iconify-icon>
|
||||
{{ locating ? 'Getting location…' : 'Use my location' }}
|
||||
</button>
|
||||
<span aria-hidden="true" class="text-zinc-400">·</span>
|
||||
<span class="font-mono text-zinc-500">Try SW1A 1AA · M1 1AD · EH1 1YZ</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
initial: { type: Object, default: () => ({}) },
|
||||
fuelType: { type: String, default: 'e10' },
|
||||
radius: { type: Number, default: 10 },
|
||||
sort: { type: String, default: 'reliable' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['search'])
|
||||
|
||||
const postcode = ref('')
|
||||
const locating = ref(false)
|
||||
|
||||
watch(() => props.initial, (v) => {
|
||||
if (!v) return
|
||||
if (typeof v.postcode === 'string') postcode.value = v.postcode
|
||||
}, { immediate: true, deep: true })
|
||||
|
||||
function submitPostcode() {
|
||||
const trimmed = postcode.value.trim()
|
||||
if (!trimmed) return
|
||||
emit('search', {
|
||||
postcode: trimmed,
|
||||
lat: null,
|
||||
lng: null,
|
||||
fuelType: props.fuelType,
|
||||
radius: props.radius,
|
||||
sort: props.sort,
|
||||
})
|
||||
}
|
||||
|
||||
function useMyLocation() {
|
||||
if (!navigator.geolocation) return
|
||||
locating.value = true
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
({ coords }) => {
|
||||
locating.value = false
|
||||
postcode.value = ''
|
||||
emit('search', {
|
||||
postcode: null,
|
||||
lat: coords.latitude,
|
||||
lng: coords.longitude,
|
||||
fuelType: props.fuelType,
|
||||
radius: props.radius,
|
||||
sort: props.sort,
|
||||
})
|
||||
},
|
||||
() => { locating.value = false },
|
||||
{ timeout: 8000, enableHighAccuracy: false, maximumAge: 30000 },
|
||||
)
|
||||
}
|
||||
</script>
|
||||
46
resources/js/components/landing/LandingNav.vue
Normal file
46
resources/js/components/landing/LandingNav.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<nav class="fixed top-0 w-full z-50 bg-zinc-50/90 backdrop-blur-sm border-b border-zinc-300 px-6 py-4 md:px-12">
|
||||
<div class="max-w-7xl mx-auto flex items-center justify-between gap-6">
|
||||
<RouterLink class="flex items-center gap-3 shrink-0" to="/">
|
||||
<div class="w-9 h-9 md:w-10 md:h-10 rounded-lg bg-accent flex items-center justify-center shadow-md">
|
||||
<iconify-icon class="text-white text-xl" icon="lucide:fuel"></iconify-icon>
|
||||
</div>
|
||||
<span class="text-xl md:text-2xl font-black font-display tracking-tighter text-accent">FuelAlert</span>
|
||||
</RouterLink>
|
||||
|
||||
<div class="hidden lg:flex items-center gap-8 font-mono text-[11px] uppercase tracking-widest text-zinc-600">
|
||||
<a class="hover:text-accent transition-colors" href="#how-it-works">How it works</a>
|
||||
<a class="hover:text-accent transition-colors" href="#features">Why it works</a>
|
||||
<a class="hover:text-accent transition-colors" href="#pricing">Pricing</a>
|
||||
<a class="hover:text-accent transition-colors" href="#fleet">Fleet</a>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 md:gap-5">
|
||||
<template v-if="isAuthenticated">
|
||||
<RouterLink
|
||||
class="bg-accent text-white px-5 py-2 rounded-full text-sm font-bold shadow-md hover:bg-primary-dark transition-all"
|
||||
to="/dashboard"
|
||||
>
|
||||
Dashboard
|
||||
</RouterLink>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a class="text-sm font-semibold text-zinc-600 hover:text-zinc-900 transition-colors" href="/login">Login</a>
|
||||
<a
|
||||
class="hidden sm:inline-flex bg-accent text-white px-5 py-2 rounded-full text-sm font-bold shadow-md hover:bg-primary-dark transition-all"
|
||||
href="/register"
|
||||
>
|
||||
Get started
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useAuth } from '../../composables/useAuth.js'
|
||||
|
||||
const { isAuthenticated } = useAuth()
|
||||
</script>
|
||||
45
resources/js/components/landing/LiveTicker.vue
Normal file
45
resources/js/components/landing/LiveTicker.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.15em] text-zinc-600">
|
||||
<span class="size-1.5 rounded-full bg-status-good live-dot"></span>
|
||||
<span class="font-medium">Live</span>
|
||||
<span v-if="stationCount" aria-hidden="true">·</span>
|
||||
<span v-if="stationCount">{{ formattedStationCount }} UK stations</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>updated {{ updatedAgo || '…' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
stationCount: { type: Number, default: null },
|
||||
latestPriceAt: { type: String, default: null },
|
||||
})
|
||||
|
||||
const now = ref(Date.now())
|
||||
let ticker = null
|
||||
|
||||
const formattedStationCount = computed(() => {
|
||||
return props.stationCount == null ? '' : props.stationCount.toLocaleString('en-GB')
|
||||
})
|
||||
|
||||
const updatedAgo = computed(() => {
|
||||
if (!props.latestPriceAt) return ''
|
||||
const diffMin = Math.floor((now.value - new Date(props.latestPriceAt).getTime()) / 60000)
|
||||
if (diffMin < 1) return 'just now'
|
||||
if (diffMin < 60) return `${diffMin} min ago`
|
||||
const hours = Math.floor(diffMin / 60)
|
||||
if (hours < 24) return `${hours} hr ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days} day${days === 1 ? '' : 's'} ago`
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
ticker = setInterval(() => { now.value = Date.now() }, 60000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (ticker) clearInterval(ticker)
|
||||
})
|
||||
</script>
|
||||
28
resources/js/components/landing/StatsRow.vue
Normal file
28
resources/js/components/landing/StatsRow.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<dl class="hidden md:flex items-center gap-8 divide-x divide-zinc-300">
|
||||
<div v-for="(stat, idx) in stats" :key="stat.label" :class="idx === 0 ? '' : 'pl-8'">
|
||||
<dt class="sr-only">{{ stat.label }}</dt>
|
||||
<dd>
|
||||
<span class="block font-serif text-2xl text-zinc-900 leading-none">{{ stat.value }}</span>
|
||||
<span class="block font-mono text-[11px] uppercase tracking-widest text-zinc-500 mt-1.5">{{ stat.label }}</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
stationCount: { type: Number, default: null },
|
||||
})
|
||||
|
||||
const stats = computed(() => [
|
||||
{
|
||||
value: props.stationCount ? props.stationCount.toLocaleString('en-GB') : '11,482',
|
||||
label: 'Stations tracked',
|
||||
},
|
||||
{ value: '£273', label: 'Median saving / yr' },
|
||||
{ value: '84%', label: 'Forecast accuracy' },
|
||||
])
|
||||
</script>
|
||||
73
resources/js/components/landing/VerdictCard.vue
Normal file
73
resources/js/components/landing/VerdictCard.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div
|
||||
:class="{ 'max-w-md mx-auto': variant === 'full' }"
|
||||
class="bg-zinc-50 border border-zinc-300 rounded-3xl p-5 md:p-6 shadow-[0_20px_40px_-20px_rgba(74,63,59,0.25)]"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between gap-3 mb-5">
|
||||
<div class="min-w-0">
|
||||
<p class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">
|
||||
Today near {{ postcode }}
|
||||
</p>
|
||||
<h3
|
||||
:class="variant === 'compact' ? 'text-3xl' : 'text-4xl'"
|
||||
class="font-serif text-accent leading-none"
|
||||
>
|
||||
{{ verdict }}
|
||||
</h3>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1.5 shrink-0 font-mono text-[10px] uppercase tracking-widest text-zinc-600 bg-white border border-zinc-300 rounded-full px-2.5 py-1">
|
||||
<span class="size-1.5 rounded-full bg-status-good live-dot"></span>
|
||||
Live
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Station rows -->
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="(station, idx) in stations"
|
||||
:key="station.name + idx"
|
||||
class="flex items-center gap-3 bg-white border border-zinc-300 rounded-xl px-3 py-2.5"
|
||||
>
|
||||
<span class="font-mono text-xs text-zinc-500 tabular-nums">{{ String(idx + 1).padStart(2, '0') }}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-semibold text-sm text-zinc-800 truncate">{{ station.name }}</p>
|
||||
<p class="font-mono text-[11px] text-zinc-500 mt-0.5">{{ station.distance }}</p>
|
||||
</div>
|
||||
<span
|
||||
v-if="station.tag"
|
||||
class="font-mono text-[9px] uppercase tracking-widest bg-accent text-white rounded-full px-2 py-0.5"
|
||||
>
|
||||
{{ station.tag }}
|
||||
</span>
|
||||
<span class="font-mono font-medium text-sm text-zinc-900 tabular-nums">{{ station.price }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Footer -->
|
||||
<p v-if="moreCount" class="font-mono text-[11px] text-zinc-500 mt-4 text-center">
|
||||
+ {{ moreCount }} more stations within {{ radiusMiles }} miles
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
postcode: { type: String, default: 'SW1A 1AA' },
|
||||
verdict: { type: String, default: 'Fill up today' },
|
||||
stations: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
{ name: 'Tesco Victoria', distance: '0.4 mi', price: '142.9p', tag: 'Cheapest' },
|
||||
{ name: "Sainsbury's Nine Elms", distance: '1.4 mi', price: '143.9p', tag: null },
|
||||
],
|
||||
},
|
||||
moreCount: { type: Number, default: 21 },
|
||||
radiusMiles: { type: Number, default: 5 },
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'full',
|
||||
validator: (v) => ['full', 'compact'].includes(v),
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,98 +1,31 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-zinc-100">
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="fixed top-0 w-full z-50 bg-zinc-50 border-b border-zinc-300 px-6 py-4 md:px-12">
|
||||
<div class="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<RouterLink class="flex items-center gap-3" to="/">
|
||||
<div class="w-10 h-10 md:w-12 md:h-12 rounded-lg bg-accent flex items-center justify-center shadow-md">
|
||||
<iconify-icon class="text-white text-xl md:text-2xl" icon="lucide:fuel"></iconify-icon>
|
||||
</div>
|
||||
<span class="text-2xl md:text-3xl font-black font-display tracking-tighter text-accent">FuelAlert</span>
|
||||
</RouterLink>
|
||||
|
||||
<div class="hidden md:flex items-center gap-10">
|
||||
<a class="text-sm font-semibold text-zinc-500 hover:text-accent transition-colors" href="#how-it-works">How it Works</a>
|
||||
<a class="text-sm font-semibold text-zinc-500 hover:text-accent transition-colors" href="#features">Features</a>
|
||||
<a class="text-sm font-semibold text-zinc-500 hover:text-accent transition-colors" href="#pricing">Pricing</a>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<template v-if="isAuthenticated">
|
||||
<RouterLink class="text-sm font-bold text-zinc-500 hover:text-zinc-800" to="/dashboard">Dashboard</RouterLink>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a class="text-sm font-bold text-zinc-500 hover:text-zinc-800" href="/login">Login</a>
|
||||
<a class="bg-accent text-white px-6 py-2.5 rounded-full text-sm font-bold shadow-lg hover:bg-primary-dark transition-all transform hover:scale-105 active:scale-95" href="/register">Get Started</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<LandingNav />
|
||||
|
||||
<!-- Hero -->
|
||||
<section id="hero" class="relative pt-24 md:pt-40 pb-6 md:pb-10 px-6 hero-gradient overflow-hidden">
|
||||
<div class="max-w-7xl mx-auto grid lg:grid-cols-2 gap-16 items-center">
|
||||
<div class="space-y-8">
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1 text-accent text-xs tracking-wider">
|
||||
<span class="inline-flex items-center gap-1.5 font-bold uppercase">
|
||||
<span class="size-1.5 rounded-full bg-status-good animate-pulse"></span>
|
||||
Live
|
||||
</span>
|
||||
<span v-if="liveStats.stationCount">· {{ formattedStationCount }} UK stations</span>
|
||||
<span>· updated {{ updatedAgo || '…' }}</span>
|
||||
</div>
|
||||
<h1 class="text-4xl sm:text-5xl md:text-7xl font-black font-display text-zinc-800 leading-[1.1] tracking-tighter">
|
||||
Know exactly <br class="hidden sm:block"><span class="text-accent">when</span> to fuel.
|
||||
<section id="hero" class="relative pt-24 md:pt-36 pb-10 md:pb-16 px-6 hero-gradient 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>
|
||||
<p class="text-xl text-zinc-500 max-w-lg leading-relaxed">
|
||||
Join 50,000+ UK drivers using real-time insights to find the cheapest petrol and time their fill-ups perfectly.
|
||||
</p>
|
||||
|
||||
<SearchBar :initial="searchInitial" @search="onSearch" />
|
||||
<!-- Mobile verdict card: between headline and input -->
|
||||
<div class="lg:hidden">
|
||||
<VerdictCard variant="compact" />
|
||||
</div>
|
||||
|
||||
<!-- <div class="flex items-center gap-4 pt-4">
|
||||
<div class="flex -space-x-2">
|
||||
<img alt="User" class="w-8 h-8 rounded-full border-2 border-white" src="https://api.dicebear.com/7.x/avataaars/svg?seed=1">
|
||||
<img alt="User" class="w-8 h-8 rounded-full border-2 border-white" src="https://api.dicebear.com/7.x/avataaars/svg?seed=2">
|
||||
<img alt="User" class="w-8 h-8 rounded-full border-2 border-white" src="https://api.dicebear.com/7.x/avataaars/svg?seed=3">
|
||||
</div>
|
||||
<span class="text-sm text-zinc-500 font-medium italic">"Saved me £12 on my first tank!"</span>
|
||||
</div>-->
|
||||
<HeroSearch :fuel-type="searchInitial.fuelType" :initial="searchInitial" :radius="searchInitial.radius" :sort="searchInitial.sort" @search="onSearch" />
|
||||
|
||||
<StatsRow :station-count="liveStats.stationCount" />
|
||||
</div>
|
||||
|
||||
<!-- Visual mockup card -->
|
||||
<div class="relative hidden lg:block">
|
||||
<div class="absolute -inset-4 bg-accent/5 rounded-[2.5rem] blur-2xl"></div>
|
||||
<div class="relative glass-card p-6 rounded-[2rem] shadow-2xl space-y-4 max-w-md mx-auto transform rotate-2">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<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="font-black text-accent">FuelAlert</span>
|
||||
</div>
|
||||
<div class="text-xs font-bold text-zinc-500">SW1A 1AA</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-zinc-50 p-4 rounded-xl border border-zinc-300 shadow-sm">
|
||||
<p class="text-[10px] font-bold uppercase tracking-widest text-zinc-500 mb-1">Recommendation</p>
|
||||
<h3 class="text-2xl font-black font-display text-mauve">Fill up now</h3>
|
||||
<div class="mt-2 h-1.5 w-full bg-zinc-200 rounded-full overflow-hidden">
|
||||
<div class="h-full bg-mauve w-[80%]"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center p-3 bg-white rounded-lg border border-zinc-200">
|
||||
<span class="font-bold text-sm">Tesco Superstore</span>
|
||||
<span class="font-black text-status-good">142.9p</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center p-3 bg-white rounded-lg border border-zinc-200">
|
||||
<span class="font-bold text-sm">Shell V-Power</span>
|
||||
<span class="font-black text-zinc-500">148.9p</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Desktop verdict card -->
|
||||
<div class="hidden lg:block">
|
||||
<VerdictCard variant="full" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -101,6 +34,11 @@
|
||||
<section v-if="searchAttempted" class="px-6 py-10 bg-zinc-100">
|
||||
<div class="max-w-7xl mx-auto space-y-6">
|
||||
|
||||
<!-- Post-search filter bar -->
|
||||
<div class="bg-white border border-zinc-300 rounded-2xl p-4 md:p-5 shadow-sm">
|
||||
<SearchBar :initial="searchInitial" @search="onSearch" />
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-16">
|
||||
<div class="flex items-center gap-3 text-zinc-500">
|
||||
@@ -431,37 +369,24 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, watch, onMounted, 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 SearchBar from '../components/SearchBar.vue'
|
||||
import LeafletMap from '../components/LeafletMap.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 })
|
||||
const now = ref(Date.now())
|
||||
let nowTicker = null
|
||||
|
||||
const formattedStationCount = computed(() => {
|
||||
const n = liveStats.value.stationCount
|
||||
return n == null ? '' : n.toLocaleString('en-GB')
|
||||
})
|
||||
|
||||
const updatedAgo = computed(() => {
|
||||
const iso = liveStats.value.latestPriceAt
|
||||
if (!iso) return ''
|
||||
const diffMin = Math.floor((now.value - new Date(iso).getTime()) / 60000)
|
||||
if (diffMin < 1) return 'just now'
|
||||
if (diffMin < 60) return `${diffMin} min ago`
|
||||
const hours = Math.floor(diffMin / 60)
|
||||
if (hours < 24) return `${hours} hr ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days} day${days === 1 ? '' : 's'} ago`
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -471,16 +396,8 @@ onMounted(async () => {
|
||||
latestPriceAt: data.latest_price_at,
|
||||
}
|
||||
} catch {
|
||||
// leave defaults; hero line degrades to "Live" only
|
||||
// leave defaults; ticker degrades to "Live · updated …" only
|
||||
}
|
||||
|
||||
nowTicker = setInterval(() => {
|
||||
now.value = Date.now()
|
||||
}, 60000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (nowTicker) clearInterval(nowTicker)
|
||||
})
|
||||
|
||||
const cadence = ref('monthly')
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ __('Welcome') }} - {{ config('app.name', 'Laravel') }}</title>
|
||||
<meta name="description" content="Live UK fuel prices across 11,000+ stations. See whether to fill up today or wait, based on local trends.">
|
||||
<script>
|
||||
window['FUEL_TYPES'] = @json(
|
||||
collect(App\Enums\FuelType::cases())
|
||||
|
||||
Reference in New Issue
Block a user