feat(ui): mobile-first redesign — compact hero, inline submit button, map-first with collapsible list
- Hero: remove full-width mobile submit, add inline "Go" button next to locate
- Prediction cards: tighter mobile padding (px-3 py-3)
- Search filters: right-aligned toolbar, remove "X stations found" count and map toggle
- Map: initialize view immediately to avoid tile wiggle, skip recenter on fresh init
- Station list: hidden by default, toggled via "Stations {count}" pill above map
- Typography: hide desktop h1 on mobile, scale down section headings and spacing
- Footer: remove uppercase styling from headings and copyright line
- Filter popover: auto-close on fuel/radius/sort/brand selection
fix(llm): retry submit_overlay when events_cited is missing, extend Fuel Finder timeout with retries
- LlmOverlayService: add `minItems: 1` to events_cited schema, detect missing citations
in submit response, inject tool_result error and retry once with explicit prompt
- Log full raw_result context when no verified citations, capturing direction/confidence/reasoning
- FuelPriceService: add 3×1s retry with 60s timeout to batch price requests (was 30s no retry)
- Tests: cover successful retry recovery and rejection when retry also omits citations
This commit is contained in:
@@ -62,7 +62,13 @@ final class LlmOverlayService
|
||||
|
||||
$verifiedEvents = $this->verifyCitedUrls($rawResult['events_cited'] ?? []);
|
||||
if ($verifiedEvents === []) {
|
||||
Log::warning('LlmOverlayService: no verified citations, rejecting overlay');
|
||||
Log::warning('LlmOverlayService: no verified citations, rejecting overlay', [
|
||||
'events_cited_count' => count($rawResult['events_cited'] ?? []),
|
||||
'direction' => $rawResult['direction'] ?? null,
|
||||
'confidence' => $rawResult['confidence'] ?? null,
|
||||
'reasoning_short' => $rawResult['reasoning_short'] ?? null,
|
||||
'raw_result' => $rawResult,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -131,7 +137,10 @@ final class LlmOverlayService
|
||||
$messages = [['role' => 'user', 'content' => $this->prompt($context)]];
|
||||
|
||||
try {
|
||||
// Phase 1: web search loop
|
||||
// Phase 1: web search loop. Append the assistant turn after every
|
||||
// successful response, then decide whether to keep looping —
|
||||
// this guarantees the messages array stays well-formed regardless
|
||||
// of whether we exit via `break` or by exhausting iterations.
|
||||
for ($i = 0, $response = null; $i < 5; $i++) {
|
||||
$response = $this->apiLogger->send('anthropic', 'POST', self::URL, fn () => Http::timeout(45)
|
||||
->withHeaders($this->headers())
|
||||
@@ -148,14 +157,13 @@ final class LlmOverlayService
|
||||
return null;
|
||||
}
|
||||
|
||||
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
||||
|
||||
if ($response->json('stop_reason') !== 'pause_turn') {
|
||||
break;
|
||||
}
|
||||
|
||||
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
||||
}
|
||||
|
||||
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
||||
$messages[] = ['role' => 'user', 'content' => 'Now submit your overlay using the submit_overlay tool. Cite at least one event with a URL.'];
|
||||
|
||||
// Phase 2: forced structured output
|
||||
@@ -175,7 +183,18 @@ final class LlmOverlayService
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->extractToolInput($submitResponse->json('content') ?? []);
|
||||
$submitContent = $submitResponse->json('content') ?? [];
|
||||
$rawResult = $this->extractToolInput($submitContent);
|
||||
|
||||
// Haiku sometimes calls submit_overlay without `events_cited` even
|
||||
// though the schema marks it required. Confirmed in laravel.log on
|
||||
// 2026-05-12: tool_use input had only direction/confidence/reasoning.
|
||||
// Retry once with an explicit tool_result error.
|
||||
if ($this->citationsMissing($rawResult)) {
|
||||
$rawResult = $this->retrySubmitWithCitationError($messages, $submitContent) ?? $rawResult;
|
||||
}
|
||||
|
||||
return $rawResult;
|
||||
} catch (Throwable $e) {
|
||||
Log::error('LlmOverlayService: callAnthropic failed', ['error' => $e->getMessage()]);
|
||||
|
||||
@@ -183,7 +202,12 @@ final class LlmOverlayService
|
||||
}
|
||||
}
|
||||
|
||||
private const string VERIFICATION_USER_AGENT = 'Mozilla/5.0 (compatible; FuelPriceBot/1.0; +https://fuel-price.test/bot)';
|
||||
private function verificationUserAgent(): string
|
||||
{
|
||||
$appUrl = rtrim((string) config('app.url'), '/');
|
||||
|
||||
return "Mozilla/5.0 (compatible; FuelPriceBot/1.0; +{$appUrl}/bot)";
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify each cited URL is reachable. Major news sites (Reuters, FT,
|
||||
@@ -235,7 +259,7 @@ final class LlmOverlayService
|
||||
/** @return array{0: bool, 1: string} [reachable, diagnostic_string] */
|
||||
private function urlReachable(string $url): array
|
||||
{
|
||||
$headers = ['User-Agent' => self::VERIFICATION_USER_AGENT];
|
||||
$headers = ['User-Agent' => $this->verificationUserAgent()];
|
||||
$headStatus = 'no-attempt';
|
||||
|
||||
try {
|
||||
@@ -342,6 +366,7 @@ final class LlmOverlayService
|
||||
'reasoning_short' => ['type' => 'string', 'description' => '1–2 sentences.'],
|
||||
'events_cited' => [
|
||||
'type' => 'array',
|
||||
'minItems' => 1,
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
@@ -371,4 +396,57 @@ final class LlmOverlayService
|
||||
|
||||
return $block['input'] ?? null;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed>|null $rawResult */
|
||||
private function citationsMissing(?array $rawResult): bool
|
||||
{
|
||||
return $rawResult === null
|
||||
|| ! isset($rawResult['events_cited'])
|
||||
|| ! is_array($rawResult['events_cited'])
|
||||
|| $rawResult['events_cited'] === [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $messages
|
||||
* @param array<int, mixed> $failedSubmitContent
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function retrySubmitWithCitationError(array $messages, array $failedSubmitContent): ?array
|
||||
{
|
||||
$toolUseId = collect($failedSubmitContent)->firstWhere('type', 'tool_use')['id'] ?? null;
|
||||
|
||||
if ($toolUseId === null) {
|
||||
Log::warning('LlmOverlayService: cannot retry — no tool_use id in failed submit');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Log::info('LlmOverlayService: retrying submit with citation error', ['tool_use_id' => $toolUseId]);
|
||||
|
||||
$messages[] = ['role' => 'assistant', 'content' => $failedSubmitContent];
|
||||
$messages[] = ['role' => 'user', 'content' => [[
|
||||
'type' => 'tool_result',
|
||||
'tool_use_id' => $toolUseId,
|
||||
'content' => 'events_cited was missing or empty. Resubmit submit_overlay with at least one event from your earlier web search results, including its real URL, headline, source, and impact.',
|
||||
'is_error' => true,
|
||||
]]];
|
||||
|
||||
$retryResponse = $this->apiLogger->send('anthropic', 'POST', self::URL, fn () => Http::timeout(20)
|
||||
->withHeaders($this->headers())
|
||||
->post(self::URL, [
|
||||
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
|
||||
'max_tokens' => 512,
|
||||
'tools' => [$this->submitOverlayTool()],
|
||||
'tool_choice' => ['type' => 'tool', 'name' => 'submit_overlay'],
|
||||
'messages' => $messages,
|
||||
]));
|
||||
|
||||
if (! $retryResponse->successful()) {
|
||||
Log::error('LlmOverlayService: retry submit failed', ['status' => $retryResponse->status()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->extractToolInput($retryResponse->json('content') ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,8 @@ class FuelPriceService
|
||||
}
|
||||
|
||||
$logUrl = $baseUrl.'?'.http_build_query($params);
|
||||
$response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::timeout(30)
|
||||
$response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::retry(3, 1000)
|
||||
->timeout(60)
|
||||
->withToken($token)
|
||||
->get($baseUrl, $params));
|
||||
|
||||
|
||||
@@ -204,6 +204,20 @@ function initMap() {
|
||||
// map-polish:7 — replace default attribution control with custom ⓘ button
|
||||
mapInstance = L.map(mapContainer.value, {zoomControl: false, attributionControl: false})
|
||||
|
||||
// Set the initial view immediately so tiles load at the correct spot from
|
||||
// frame 1 — avoids the "wiggle" caused by setView running after the
|
||||
// container is already laid out and tiles are mid-load.
|
||||
const initialZoom = getZoomForRadius(props.radiusMiles)
|
||||
const initialCenter = props.origin?.lat != null && props.origin?.lng != null
|
||||
? [props.origin.lat, props.origin.lng]
|
||||
: props.stations.length
|
||||
? [props.stations[0].lat, props.stations[0].lng]
|
||||
: null
|
||||
if (initialCenter) {
|
||||
mapInstance.setView(initialCenter, initialZoom)
|
||||
hasInitialView = true
|
||||
}
|
||||
|
||||
// map-polish:5 — Carto Positron tile (cleaner than raw OSM)
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||
subdomains: 'abcd',
|
||||
@@ -230,7 +244,7 @@ function initMap() {
|
||||
locateUser()
|
||||
}
|
||||
|
||||
function renderMarkers() {
|
||||
function renderMarkers({skipRecenter = false} = {}) {
|
||||
if (!mapInstance || !markersLayer) return
|
||||
|
||||
markersLayer.clearLayers()
|
||||
@@ -262,6 +276,8 @@ function renderMarkers() {
|
||||
bounds.push([station.lat, station.lng])
|
||||
})
|
||||
|
||||
if (skipRecenter) return
|
||||
|
||||
const zoom = getZoomForRadius(props.radiusMiles)
|
||||
const center = props.origin?.lat != null && props.origin?.lng != null
|
||||
? [props.origin.lat, props.origin.lng]
|
||||
@@ -290,9 +306,10 @@ function destroyMap() {
|
||||
|
||||
async function openMap() {
|
||||
await nextTick()
|
||||
const wasFreshInit = !mapInstance
|
||||
initMap()
|
||||
mapInstance?.invalidateSize()
|
||||
renderMarkers()
|
||||
renderMarkers({skipRecenter: wasFreshInit})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div ref="popoverRoot">
|
||||
<div class="flex flex-wrap items-center gap-2 md:gap-2.5 py-3 border-b border-zinc-200">
|
||||
<div class="flex flex-wrap items-center justify-end gap-2 md:gap-2.5 py-2 border-b border-zinc-200">
|
||||
<button
|
||||
:aria-expanded="open"
|
||||
:class="{ 'is-active': activeCount > 0 || open }"
|
||||
aria-controls="post-search-filters-panel"
|
||||
aria-haspopup="dialog"
|
||||
class="pill"
|
||||
class="pill !rounded-xl"
|
||||
type="button"
|
||||
@click="open = !open"
|
||||
>
|
||||
@@ -26,21 +26,6 @@
|
||||
></iconify-icon>
|
||||
</button>
|
||||
|
||||
<button
|
||||
:aria-expanded="mapOpen"
|
||||
:class="{ 'is-active': mapOpen }"
|
||||
aria-controls="leaflet-map-panel"
|
||||
class="pill"
|
||||
type="button"
|
||||
@click="emit('toggle-map')"
|
||||
>
|
||||
<iconify-icon :icon="mapOpen ? 'lucide:map' : 'lucide:map-off'" class="text-sm opacity-70"></iconify-icon>
|
||||
<span class="text-sm font-medium">{{ mapOpen ? 'Hide map' : 'Show map' }}</span>
|
||||
</button>
|
||||
|
||||
<span class="ml-auto text-sm text-zinc-500 font-medium">
|
||||
{{ stationCount }} station{{ stationCount !== 1 ? 's' : '' }} found
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -114,7 +99,7 @@
|
||||
class="pill-sm"
|
||||
role="radio"
|
||||
type="button"
|
||||
@click="emit('update:brandFilter', '')"
|
||||
@click="selectBrand('')"
|
||||
>
|
||||
All brands
|
||||
</button>
|
||||
@@ -126,7 +111,7 @@
|
||||
class="pill-sm"
|
||||
role="radio"
|
||||
type="button"
|
||||
@click="emit('update:brandFilter', brand)"
|
||||
@click="selectBrand(brand)"
|
||||
>
|
||||
{{ brand }}
|
||||
</button>
|
||||
@@ -178,11 +163,9 @@ const props = defineProps({
|
||||
initial: { type: Object, default: () => ({}) },
|
||||
brands: { type: Array, default: () => [] },
|
||||
brandFilter: { type: String, default: '' },
|
||||
mapOpen: { type: Boolean, default: true },
|
||||
stationCount: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['search', 'toggle-map', 'update:brandFilter'])
|
||||
const emit = defineEmits(['search', 'update:brandFilter'])
|
||||
|
||||
const postcode = ref('')
|
||||
const coords = ref(null)
|
||||
@@ -208,6 +191,7 @@ watch(() => props.initial, (v) => {
|
||||
|
||||
watch([fuelType, radius, sort], () => {
|
||||
if (hydrating) return
|
||||
open.value = false
|
||||
if (postcode.value.trim() || coords.value) emitSearch()
|
||||
})
|
||||
|
||||
@@ -232,6 +216,12 @@ function resetFilters() {
|
||||
radius.value = DEFAULTS.radius
|
||||
sort.value = DEFAULTS.sort
|
||||
if (props.brandFilter) emit('update:brandFilter', '')
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function selectBrand(brand) {
|
||||
emit('update:brandFilter', brand)
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function emitSearch() {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<!-- Free / guest: compact one-liner -->
|
||||
<div
|
||||
v-else-if="!isPaidTier"
|
||||
class="flex items-center gap-3 px-4 py-3 bg-white rounded-2xl border border-zinc-300"
|
||||
class="flex items-center gap-3 px-3 py-3 bg-white rounded-2xl border border-zinc-300"
|
||||
>
|
||||
<div :class="['shrink-0 w-10 h-10 rounded-full flex items-center justify-center', accentBg]">
|
||||
<iconify-icon :icon="genericIcon" class="text-xl text-white"></iconify-icon>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="prediction"
|
||||
class="p-4 sm:p-5 bg-white rounded-2xl border border-zinc-300"
|
||||
class="p-2 sm:p-5 bg-white rounded-2xl border border-zinc-300"
|
||||
>
|
||||
<div class="grid gap-4 lg:grid-cols-2 lg:gap-5">
|
||||
<div class="space-y-1.5">
|
||||
|
||||
@@ -21,6 +21,14 @@
|
||||
<iconify-icon :class="{ 'animate-spin': locating }" :icon="locating ? 'lucide:loader-circle' : 'lucide:locate-fixed'" class="text-lg"></iconify-icon>
|
||||
</button>
|
||||
|
||||
<!-- Mobile-only inline "Go" submit — sits next to the locate button -->
|
||||
<button
|
||||
class="md:hidden h-11 px-4 -ml-1 rounded-[10px] bg-primary text-white font-medium text-sm inline-flex items-center justify-center shrink-0 hover:opacity-90 transition"
|
||||
type="submit"
|
||||
>
|
||||
Go
|
||||
</button>
|
||||
|
||||
<!-- Desktop-only inline submit -->
|
||||
<button
|
||||
class="hidden md:inline-flex h-12 px-5 ml-1 rounded-xl bg-primary text-white font-medium text-[15px] items-center gap-2 hover:opacity-90 transition"
|
||||
@@ -31,15 +39,6 @@
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<!-- Mobile-only full-width submit -->
|
||||
<button
|
||||
class="md:hidden w-full mt-2.5 h-14 rounded-2xl bg-primary text-white font-medium text-base inline-flex items-center justify-center gap-2 shadow-lg hover:opacity-90 transition"
|
||||
type="submit"
|
||||
>
|
||||
Check prices
|
||||
<iconify-icon class="text-lg" icon="lucide:arrow-right"></iconify-icon>
|
||||
</button>
|
||||
|
||||
<div class="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-400 justify-center md:justify-start">
|
||||
<span class="hidden md:inline text-zinc-300">·</span>
|
||||
<span class="hidden md:inline font-mono">Try SW1A 1AA · M1 1AD · EH1 1YZ</span>
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
|
||||
<div class="hero-gradient">
|
||||
<!-- Hero -->
|
||||
<section id="hero" class="relative pt-24 md:pt-36 pb-4 md:pb-8 px-6 overflow-hidden">
|
||||
<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-6 md:space-y-8">
|
||||
<div class="space-y-3 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]">
|
||||
<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>
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
</section>
|
||||
|
||||
<!-- Search Results -->
|
||||
<section v-if="searchAttempted" id="searchAttempted" class="px-6">
|
||||
<div class="max-w-7xl mx-auto space-y-6">
|
||||
<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
|
||||
@@ -48,10 +48,7 @@
|
||||
v-model:brand-filter="brandFilter"
|
||||
:brands="availableBrands"
|
||||
:initial="searchInitial"
|
||||
:map-open="mapOpen"
|
||||
:station-count="filteredStations.length"
|
||||
@search="onSearch"
|
||||
@toggle-map="mapOpen = !mapOpen"
|
||||
/>
|
||||
|
||||
<!-- Loading -->
|
||||
@@ -76,7 +73,6 @@
|
||||
</div>
|
||||
<template v-else>
|
||||
<LeafletMap
|
||||
:is-open="mapOpen"
|
||||
:origin="searchOrigin"
|
||||
:radius-miles="radiusMiles"
|
||||
:selected-station-id="selectedStationId"
|
||||
@@ -111,11 +107,28 @@
|
||||
</template>
|
||||
</LeafletMap>
|
||||
<UpsellBanner :station-count="liveStats.stationCount" />
|
||||
<StationList
|
||||
:current-sort="sort"
|
||||
:origin="searchOrigin"
|
||||
:stations="filteredStations"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -124,41 +137,41 @@
|
||||
</div>
|
||||
|
||||
<!-- How It Works -->
|
||||
<section id="how-it-works" class="py-12 md:py-24 px-6 bg-zinc-50">
|
||||
<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-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 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-4">
|
||||
<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-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>
|
||||
<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 5–20 mile radius instantly.</p>
|
||||
</div>
|
||||
<div class="text-center space-y-4">
|
||||
<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-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>
|
||||
<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-4">
|
||||
<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-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>
|
||||
<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-12 md:py-24 px-6">
|
||||
<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">
|
||||
@@ -187,8 +200,8 @@
|
||||
</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>
|
||||
<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>
|
||||
@@ -213,7 +226,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Pricing -->
|
||||
<section id="pricing" class="py-12 md:py-24 px-6 bg-zinc-50">
|
||||
<section id="pricing" class="py-4 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>
|
||||
@@ -367,7 +380,7 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h5 class="font-black uppercase text-xs text-zinc-800 tracking-widest">Product</h5>
|
||||
<h5 class="font-black 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>
|
||||
@@ -377,7 +390,7 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h5 class="font-black uppercase text-xs text-zinc-800 tracking-widest">Resources</h5>
|
||||
<h5 class="font-black 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>
|
||||
@@ -387,7 +400,7 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h5 class="font-black uppercase text-xs text-zinc-800 tracking-widest">Legal</h5>
|
||||
<h5 class="font-black 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>
|
||||
@@ -396,7 +409,7 @@
|
||||
</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">
|
||||
<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] 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>
|
||||
<p>Postcode data from <a class="underline hover:text-accent" href="https://geoportal.statistics.gov.uk/datasets/ons::onspd-online-latest-centroids-1/about" rel="noopener" target="_blank">ONS Postcode Directory</a>: contains OS data © Crown copyright & database right, Royal Mail data © Royal Mail copyright & database right, and National Statistics data © Crown copyright & database right.</p>
|
||||
@@ -501,23 +514,23 @@ const searchAttempted = ref(false)
|
||||
const radiusMiles = ref(10)
|
||||
const brandFilter = ref('')
|
||||
|
||||
const MAP_STORAGE_KEY = 'fuel-price:map-open'
|
||||
const LIST_STORAGE_KEY = 'fuelalert:list-open'
|
||||
|
||||
function readSavedMapOpen() {
|
||||
function readSavedListOpen() {
|
||||
try {
|
||||
const v = localStorage.getItem(MAP_STORAGE_KEY)
|
||||
if (v === null) return true
|
||||
const v = localStorage.getItem(LIST_STORAGE_KEY)
|
||||
if (v === null) return false
|
||||
return v === '1'
|
||||
} catch {
|
||||
return true
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const mapOpen = ref(readSavedMapOpen())
|
||||
const listOpen = ref(readSavedListOpen())
|
||||
|
||||
watch(mapOpen, (v) => {
|
||||
watch(listOpen, (v) => {
|
||||
try {
|
||||
localStorage.setItem(MAP_STORAGE_KEY, v ? '1' : '0')
|
||||
localStorage.setItem(LIST_STORAGE_KEY, v ? '1' : '0')
|
||||
} catch {
|
||||
// ignore quota / privacy-mode errors
|
||||
}
|
||||
|
||||
@@ -189,6 +189,91 @@ it('persists an overlay row with verified citations and capped confidence', func
|
||||
->and($row->events_json)->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('retries the submit when the model omits events_cited', function (): void {
|
||||
Http::fake([
|
||||
'*api.anthropic.com/*' => Http::sequence()
|
||||
->push([
|
||||
'stop_reason' => 'end_turn',
|
||||
'content' => [['type' => 'text', 'text' => 'Search done.']],
|
||||
])
|
||||
->push([
|
||||
'stop_reason' => 'tool_use',
|
||||
'content' => [[
|
||||
'type' => 'tool_use',
|
||||
'id' => 'toolu_first',
|
||||
'name' => 'submit_overlay',
|
||||
'input' => [
|
||||
'direction' => 'rising',
|
||||
'confidence' => 70,
|
||||
'reasoning_short' => 'Forgot citations.',
|
||||
// events_cited omitted entirely — the bug we are guarding against
|
||||
],
|
||||
]],
|
||||
])
|
||||
->push([
|
||||
'stop_reason' => 'tool_use',
|
||||
'content' => [[
|
||||
'type' => 'tool_use',
|
||||
'id' => 'toolu_retry',
|
||||
'name' => 'submit_overlay',
|
||||
'input' => [
|
||||
'direction' => 'rising',
|
||||
'confidence' => 70,
|
||||
'reasoning_short' => 'With citations now.',
|
||||
'events_cited' => [
|
||||
['headline' => 'OPEC', 'source' => 'Reuters', 'url' => 'https://reuters.com/opec', 'impact' => 'rising'],
|
||||
],
|
||||
'agrees_with_ridge' => true,
|
||||
'major_impact_event' => false,
|
||||
],
|
||||
]],
|
||||
]),
|
||||
'*' => Http::response('', 200),
|
||||
]);
|
||||
|
||||
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
|
||||
|
||||
$row = $service->run();
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and($row->events_json)->toHaveCount(1)
|
||||
->and(LlmOverlay::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('rejects when the retry also omits events_cited', function (): void {
|
||||
Http::fake([
|
||||
'*api.anthropic.com/*' => Http::sequence()
|
||||
->push([
|
||||
'stop_reason' => 'end_turn',
|
||||
'content' => [['type' => 'text', 'text' => 'Search done.']],
|
||||
])
|
||||
->push([
|
||||
'stop_reason' => 'tool_use',
|
||||
'content' => [[
|
||||
'type' => 'tool_use',
|
||||
'id' => 'toolu_first',
|
||||
'name' => 'submit_overlay',
|
||||
'input' => ['direction' => 'rising', 'confidence' => 70, 'reasoning_short' => 'No cites.'],
|
||||
]],
|
||||
])
|
||||
->push([
|
||||
'stop_reason' => 'tool_use',
|
||||
'content' => [[
|
||||
'type' => 'tool_use',
|
||||
'id' => 'toolu_retry',
|
||||
'name' => 'submit_overlay',
|
||||
'input' => ['direction' => 'rising', 'confidence' => 70, 'reasoning_short' => 'Still none.'],
|
||||
]],
|
||||
]),
|
||||
'*' => Http::response('', 200),
|
||||
]);
|
||||
|
||||
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
|
||||
|
||||
expect($service->run())->toBeNull()
|
||||
->and(LlmOverlay::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('honors the 4-hour cooldown for event-driven calls', function (): void {
|
||||
Carbon::setTestNow('2026-05-01 10:00:00');
|
||||
DB::table('llm_overlays')->insert([
|
||||
|
||||
@@ -342,6 +342,38 @@ it('does not cache the poll timestamp when a batch errors', function (): void {
|
||||
expect(Cache::has('fuel_finder_last_price_poll_at'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('retries a failing batch and recovers when the API responds successfully', function (): void {
|
||||
Cache::put('fuel_finder_access_token', 'tok', 3540);
|
||||
Station::factory()->create(['node_id' => 'sta1']);
|
||||
|
||||
Http::fake([
|
||||
'*/pfs/fuel-prices*' => Http::sequence()
|
||||
->push([], 500)
|
||||
->push([], 500)
|
||||
->push([
|
||||
[
|
||||
'node_id' => 'sta1',
|
||||
'fuel_prices' => [
|
||||
[
|
||||
'fuel_type' => 'E10',
|
||||
'price' => 142.9,
|
||||
'price_last_updated' => '2026-04-04T10:00:00.000Z',
|
||||
'price_change_effective_timestamp' => '2026-04-04T10:00:00.000Z',
|
||||
],
|
||||
],
|
||||
],
|
||||
])
|
||||
->push([]),
|
||||
]);
|
||||
|
||||
$inserted = $this->service->pollPrices();
|
||||
|
||||
expect($inserted)->toBe(1)
|
||||
->and(StationPrice::count())->toBe(1);
|
||||
|
||||
Http::assertSentCount(4);
|
||||
});
|
||||
|
||||
it('skips price rows for stations not present in the stations table', function (): void {
|
||||
Cache::put('fuel_finder_access_token', 'tok', 3540);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user