Compare commits

...

10 Commits

Author SHA1 Message Date
Ovidiu U
1848c070da feat: replace flux:select with dropdown menus using Alpine.js for fuel filters
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
2026-04-08 10:06:55 +01:00
Ovidiu U
7b6aaac661 chore: remove StationSearch, dead Volt SFCs, mobile prototype, and fix homepage CTAs 2026-04-08 09:24:40 +01:00
Ovidiu U
c935903614 feat: strip FuelFinder to layout shell, wire sub-components 2026-04-08 08:48:15 +01:00
Ovidiu U
bce5aa72c8 feat: extract fuel.map component and wire Leaflet to map-update browser event 2026-04-08 08:46:33 +01:00
Ovidiu U
0809a5340b feat: extract fuel.recommendation Livewire component 2026-04-08 08:46:16 +01:00
Ovidiu U
c4f5fd042b fix: remove dead search property, simplify empty state message 2026-04-07 22:04:15 +01:00
Ovidiu U
a576ef6b4a feat: extract fuel.station-list Livewire component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 22:02:08 +01:00
Ovidiu U
266051c52b fix: use required rule for sort property (non-nullable string) 2026-04-07 22:00:38 +01:00
Ovidiu U
0b289c8ec2 feat: extract fuel.search Livewire component with stations-found dispatch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 21:58:01 +01:00
Ovidiu U
f4a958a76c docs: FuelFinder sub-component split design spec 2026-04-07 21:27:56 +01:00
39 changed files with 2394 additions and 1597 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Livewire\Public\Fuel;
use Illuminate\View\View;
use Livewire\Attributes\On;
use Livewire\Component;
final class Map extends Component
{
#[On('stations-found')]
public function handle(array $results, array $meta, int $radius = 5, ?array $prediction = null): void
{
$this->dispatch('map-update', results: $results, meta: $meta, radius: $radius);
}
public function render(): View
{
return view('livewire.public.fuel.map');
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Livewire\Public\Fuel;
use Illuminate\View\View;
use Livewire\Attributes\On;
use Livewire\Component;
final class Recommendation extends Component
{
public ?array $prediction = null;
#[On('stations-found')]
public function handle(array $results, array $meta, int $radius = 5, ?array $prediction = null): void
{
$this->prediction = $prediction;
}
public function render(): View
{
return view('livewire.public.fuel.recommendation');
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Livewire\Public;
namespace App\Livewire\Public\Fuel;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
@@ -8,7 +8,7 @@ use Illuminate\View\View;
use Livewire\Attributes\Validate;
use Livewire\Component;
class StationSearch extends Component
final class Search extends Component
{
#[Validate('required|string', message: 'Please enter a postcode, town or city.')]
public string $search = '';
@@ -19,32 +19,30 @@ class StationSearch extends Component
#[Validate('required|integer|min:1|max:20')]
public int $radius = 5;
#[Validate('nullable|string|in:price,distance,updated,brand,reliable')]
#[Validate('required|string|in:price,distance,updated,brand,reliable')]
public string $sort = 'reliable';
public array $results = [];
public array $meta = [];
public ?string $apiError = null;
public bool $hasSearched = false;
public function updatedFuelType(): void
{
if (! empty($this->meta)) {
$this->findStations();
}
}
public function updatedSort(): void
{
if (! empty($this->meta)) {
if ($this->hasSearched) {
$this->findStations();
}
}
public function updatedRadius(): void
{
if (! empty($this->meta)) {
if ($this->hasSearched) {
$this->findStations();
}
}
public function updatedSort(): void
{
if ($this->hasSearched) {
$this->findStations();
}
}
@@ -53,9 +51,8 @@ class StationSearch extends Component
{
$this->validate();
$this->results = [];
$this->meta = [];
$this->apiError = null;
$this->hasSearched = false;
$radiusKm = round($this->radius * 1.60934, 2);
@@ -88,12 +85,34 @@ class StationSearch extends Component
return;
}
$this->results = $response->json('data', []);
$this->meta = $response->json('meta', []);
$results = $response->json('data', []);
$meta = $response->json('meta', []);
$this->hasSearched = true;
$prediction = null;
try {
$predictionResponse = Http::timeout(10)
->withHeaders(['X-Api-Key' => config('app.api_secret_key')])
->get(url('/api/prediction'));
if ($predictionResponse->successful()) {
$prediction = $predictionResponse->json();
}
} catch (ConnectionException) {
// Prediction failure is silent — stations are more important
}
$this->dispatch('stations-found',
results: $results,
meta: $meta,
prediction: $prediction,
radius: $this->radius,
);
}
public function render(): View
{
return view('livewire.public.station-search');
return view('livewire.public.fuel.search');
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Livewire\Public\Fuel;
use Illuminate\View\View;
use Livewire\Attributes\On;
use Livewire\Component;
final class StationList extends Component
{
public array $results = [];
public array $meta = [];
public bool $hasSearched = false;
public int $radius = 5;
#[On('stations-found')]
public function handle(array $results, array $meta, int $radius = 5, ?array $prediction = null): void
{
// $prediction is handled by fuel.recommendation component
$this->results = $results;
$this->meta = $meta;
$this->radius = $radius;
$this->hasSearched = true;
}
public function render(): View
{
return view('livewire.public.fuel.station-list');
}
}

View File

@@ -2,117 +2,13 @@
namespace App\Livewire\Public;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\View\View;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Layout('layouts.guest')]
class FuelFinder extends Component
#[Layout('layouts.shell')]
final class FuelFinder extends Component
{
#[Validate('required|string', message: 'Please enter a postcode, town or city.')]
public string $search = '';
#[Validate('required|string', message: 'Please select a fuel type.')]
public string $fuelType = 'petrol';
#[Validate('required|integer|min:1|max:20')]
public int $radius = 5;
#[Validate('nullable|string|in:price,distance,updated,brand,reliable')]
public string $sort = 'reliable';
public array $results = [];
public array $meta = [];
public ?array $prediction = null;
public ?string $apiError = null;
public bool $hasSearched = false;
public function updatedFuelType(): void
{
if ($this->hasSearched) {
$this->findStations();
}
}
public function updatedRadius(): void
{
if ($this->hasSearched) {
$this->findStations();
}
}
public function updatedSort(): void
{
if ($this->hasSearched) {
$this->findStations();
}
}
public function findStations(): void
{
$this->validate();
$this->results = [];
$this->meta = [];
$this->prediction = null;
$this->apiError = null;
$this->hasSearched = false;
$radiusKm = round($this->radius * 1.60934, 2);
try {
$response = Http::timeout(10)
->withHeaders(['X-Api-Key' => config('app.api_secret_key')])
->get(url('/api/stations'), [
'postcode' => $this->search,
'fuel_type' => $this->fuelType,
'radius' => $radiusKm,
'sort' => $this->sort,
]);
} catch (ConnectionException) {
$this->apiError = 'Unable to fetch stations. Please try again.';
return;
}
if ($response->status() === 422) {
$errors = $response->json('errors', []);
$this->apiError = collect($errors)->flatten()->first()
?? $response->json('message', 'Validation error.');
return;
}
if (! $response->successful()) {
$this->apiError = 'Unable to fetch stations. Please try again.';
return;
}
$this->results = $response->json('data', []);
$this->meta = $response->json('meta', []);
$this->hasSearched = true;
try {
$predictionResponse = Http::timeout(10)
->withHeaders(['X-Api-Key' => config('app.api_secret_key')])
->get(url('/api/prediction'));
if ($predictionResponse->successful()) {
$this->prediction = $predictionResponse->json();
}
} catch (ConnectionException) {
// Prediction failure is silent — stations are more important
}
}
public function render(): View
{
return view('livewire.public.fuel-finder');

View File

@@ -12,7 +12,7 @@
"laravel/framework": "^13.0",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^3.0",
"livewire/flux": "^2.12.0",
"livewire/flux": "^2.13",
"livewire/livewire": "^4.1"
},
"require-dev": {

4
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "017a8badf2a8b99d8c2de9909475415f",
"content-hash": "789a2e6b542a1e2f263dc8e9c973423b",
"packages": [
{
"name": "bacon/bacon-qr-code",
@@ -11776,7 +11776,7 @@
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": "^8.3"
"php": "^8.4"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,211 @@
# FuelFinder Sub-Component Split
**Date:** 2026-04-07
**Status:** Approved
**Scope:** Split the monolithic `FuelFinder` Livewire component into focused sub-components communicating via browser events.
---
## Motivation
`FuelFinder` currently owns search state, API calls, map data, station results, and prediction — all in one component. Every search re-renders the entire page including the map container. Splitting into focused components allows the map, station list, and recommendation to update independently without re-mounting Leaflet or triggering unnecessary re-renders.
---
## Architecture
```
FuelFinder (shell) — layout only, no state
├── <livewire:fuel.search /> — search form, filters, geolocation, API calls
├── <livewire:fuel.map /> — relays stations-found to Alpine/Leaflet
├── <livewire:fuel.station-list /> — renders station cards
├── <livewire:fuel.recommendation /> — renders prediction card
└── <x-fuel.forecast /> — Blade component, unchanged
```
---
## Components
### FuelFinder (shell)
**Class:** `App\Livewire\Public\FuelFinder`
**View:** `resources/views/livewire/public/fuel-finder.blade.php`
Retains `#[Layout('layouts.shell')]`. Holds no state and no methods. The view contains only the page chrome (header, main scroll area, bottom nav) and the four `<livewire:fuel.*>` tags.
---
### fuel.search
**Class:** `App\Livewire\Public\Fuel\Search`
**View:** `resources/views/livewire/public/fuel/search.blade.php`
Owns all search state and logic migrated from `FuelFinder`:
**Properties:**
- `search: string` — postcode/town input
- `fuelType: string` — default `'petrol'`
- `radius: int` — default `5`
- `sort: string` — default `'reliable'`
- `apiError: ?string`
- `hasSearched: bool`
**Methods:**
- `findStations()` — validates, calls `/api/stations`, calls `/api/prediction`, dispatches `stations-found`
- `updatedFuelType()`, `updatedRadius()`, `updatedSort()` — re-run `findStations()` if `hasSearched`
**Dispatches:**
```php
$this->dispatch('stations-found',
results: $results,
meta: $meta,
prediction: $prediction,
radius: $this->radius, // needed by Map for Leaflet zoom level
);
```
The view contains the search input, geolocation Alpine component, filter selects, and error display — extracted from the current `fuel-finder.blade.php`.
---
### fuel.map
**Class:** `App\Livewire\Public\Fuel\Map`
**View:** `resources/views/livewire/public/fuel/map.blade.php`
Thin relay. Listens for `stations-found` on the PHP side and re-dispatches as a browser event for Alpine/Leaflet to consume. No re-render required.
```php
#[On('stations-found')]
public function handle(array $results, array $meta, int $radius = 5, ?array $prediction = null): void
{
$this->dispatch('map-update', results: $results, meta: $meta, radius: $radius);
}
```
The view contains the `<x-fuel.station-map>` component wrapped in `wire:ignore`. The Alpine `stationMap` function in `station-map.js` is updated to listen for the `map-update` browser event instead of `$watch`-ing an entangled Livewire property.
**station-map.js change:**
```js
init() {
// ...existing Leaflet init...
window.addEventListener('map-update', (e) => {
this.results = e.detail.results;
this.meta = e.detail.meta;
this.radius = e.detail.radius;
this._plotMarkers();
});
}
```
`station-map.blade.php` props change from `@entangle(...)` to static defaults — `Map` holds no state:
```blade
<div x-data="stationMap([], {}, 5)" class="h-56 w-full ..."></div>
```
---
### fuel.station-list
**Class:** `App\Livewire\Public\Fuel\StationList`
**View:** `resources/views/livewire/public/fuel/station-list.blade.php`
Listens for `stations-found`, stores results, re-renders station cards.
**Properties:**
- `results: array`
- `meta: array`
- `hasSearched: bool`
**Methods:**
```php
#[On('stations-found')]
public function handle(array $results, array $meta): void
{
$this->results = $results;
$this->meta = $meta;
$this->hasSearched = true;
}
```
The view contains the "Stations Nearby" heading, result count, `@forelse` station card loop, and the empty-state message — extracted from `fuel-finder.blade.php`.
---
### fuel.recommendation
**Class:** `App\Livewire\Public\Fuel\Recommendation`
**View:** `resources/views/livewire/public/fuel/recommendation.blade.php`
Listens for `stations-found`, stores prediction, re-renders.
**Properties:**
- `prediction: ?array`
**Methods:**
```php
#[On('stations-found')]
public function handle(array $results, array $meta, ?array $prediction = null): void
{
$this->prediction = $prediction;
}
```
The view renders `<x-fuel.recommendation :prediction="$prediction" />` when prediction is set. Currently commented out in the parent view — this component makes it easy to uncomment when the scoring engine is wired.
---
## Event Contract
**Event name:** `stations-found`
**Payload:**
```php
[
'results' => array, // station records from /api/stations
'meta' => array, // lat, lng, count from API response
'prediction' => ?array, // from /api/prediction, nullable
'radius' => int, // user-selected radius in miles, for Leaflet zoom
]
```
All listener components must accept `?array $prediction = null` and `int $radius = 5` as defaults to avoid errors when optional fields are absent.
---
## File Changes
**New files:**
- `app/Livewire/Public/Fuel/Search.php`
- `app/Livewire/Public/Fuel/Map.php`
- `app/Livewire/Public/Fuel/StationList.php`
- `app/Livewire/Public/Fuel/Recommendation.php`
- `resources/views/livewire/public/fuel/search.blade.php`
- `resources/views/livewire/public/fuel/map.blade.php`
- `resources/views/livewire/public/fuel/station-list.blade.php`
- `resources/views/livewire/public/fuel/recommendation.blade.php`
**Modified files:**
- `app/Livewire/Public/FuelFinder.php` — strip all state/methods, keep layout attribute
- `resources/views/livewire/public/fuel-finder.blade.php` — replace inline sections with `<livewire:fuel.*>` tags
- `resources/js/maps/station-map.js` — replace `$watch` with `map-update` event listener
- `resources/views/components/fuel/station-map.blade.php` — remove `@entangle` props
**Moved tests:**
- `FuelFinderTest` search/API logic tests → `SearchTest`
**New tests:**
- `SearchTest` — validates, dispatches `stations-found`, handles API errors
- `StationListTest` — responds to `stations-found`, renders cards, handles empty state
- `RecommendationTest` — responds to `stations-found`, renders when prediction set, hidden when null
---
## What Does Not Change
- `x-fuel.forecast` — remains a Blade component
- `station-map.js` map logic — only the data-binding mechanism changes
- All `fuel/` Blade components (`station-card`, `recommendation`, `forecast`, etc.)
- The visual layout in `fuel-finder.blade.php`
- Route definition

View File

@@ -9,31 +9,70 @@
@custom-variant dark (&:where(.dark, .dark *));
/* Remap Flux's zinc scale to FuelAlert's warm brown neutrals */
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--color-zinc-50: #fafafa;
--color-zinc-100: #f5f5f5;
--color-zinc-200: #e5e5e5;
--color-zinc-300: #d4d4d4;
--color-zinc-400: #a3a3a3;
--color-zinc-500: #737373;
--color-zinc-600: #525252;
--color-zinc-700: #404040;
--color-zinc-800: #262626;
--color-zinc-900: #171717;
--color-zinc-950: #0a0a0a;
--color-zinc-50: #faf6f3; /* surface */
--color-zinc-100: #f5ede5; /* surface-page */
--color-zinc-200: #eeeae5; /* surface-subtle */
--color-zinc-300: #e5ded7; /* border */
--color-zinc-400: #c8b8b0;
--color-zinc-500: #89726c; /* text-muted */
--color-zinc-600: #6b5a55; /* text-dim */
--color-zinc-700: #5a4a45;
--color-zinc-800: #4a3f3b; /* text-base */
--color-zinc-900: #3a302c;
--color-zinc-950: #2a2220;
--color-accent: var(--color-neutral-800);
--color-accent-content: var(--color-neutral-800);
--color-accent-foreground: var(--color-white);
/* Brand accent — burnt sienna */
--color-accent: #bb5b3e;
--color-accent-content: #a34a31;
--color-accent-foreground: #ffffff;
/* Named semantic tokens */
--color-primary: #bb5b3e;
--color-primary-dark: #a34a31;
--color-surface: #faf6f3;
--color-surface-page: #f5ede5;
--color-surface-subtle: #eeeae5;
--color-border: #e5ded7;
--color-text-base: #4a3f3b;
--color-text-muted: #89726c;
--color-text-dim: #6b5a55;
/* Accent palette */
--color-teal: #4a7c7e;
--color-mauve: #8b4860;
--color-tan: #9b8b6b;
/* Status */
--color-status-good: #22c55e;
--color-status-warn: #f59e0b;
--color-status-bad: #ef4444;
/* Display font */
--font-display: 'Manrope', ui-sans-serif, system-ui, sans-serif;
}
@layer theme {
.dark {
--color-accent: var(--color-white);
--color-accent-content: var(--color-white);
--color-accent-foreground: var(--color-neutral-800);
@layer base {
h1, h2, h3, h4 {
font-family: var(--font-display);
letter-spacing: -0.02em;
}
}
@layer utilities {
.hero-gradient {
background:
radial-gradient(circle at top right, color-mix(in oklch, var(--color-primary) 8%, transparent), transparent 50%),
radial-gradient(circle at bottom left, color-mix(in oklch, var(--color-primary) 5%, transparent), transparent 40%);
}
.glass-card {
background: color-mix(in oklch, var(--color-surface) 90%, transparent);
backdrop-filter: blur(12px);
border: 1px solid color-mix(in oklch, var(--color-border) 60%, transparent);
}
}

View File

@@ -65,11 +65,13 @@ export function stationMap(results, meta, radius) {
maxZoom: 19,
}).addTo(this._map);
if (this.results && this.results.length > 0) {
window.addEventListener('map-update', (e) => {
this.results = e.detail.results;
this.meta = e.detail.meta;
this.radius = e.detail.radius;
this._plotMarkers();
}
});
this.$watch('results', () => this._plotMarkers());
this.locateUser();
},

View File

@@ -1,29 +1,23 @@
<div class="relative overflow-hidden rounded-2xl border border-[#e5ded7] bg-[#faf6f3] p-5 shadow-sm">
{{-- Pro badge --}}
<div class="relative overflow-hidden rounded-2xl border border-border bg-surface p-5 shadow-sm">
<div class="mb-3 flex items-center justify-between">
<div>
<p class="text-[10px] font-bold uppercase tracking-widest text-[#89726c]">14-Day Forecast</p>
<h3 class="text-lg font-black text-[#4a3f3b]">Price Trend</h3>
<p class="text-[10px] font-bold uppercase tracking-widest text-text-muted">14-Day Forecast</p>
<h3 class="text-lg font-black text-text-base">Price Trend</h3>
</div>
<span class="rounded-full bg-[#bb5b3e] px-2.5 py-0.5 text-[11px] font-bold uppercase tracking-wide text-white">
Pro
</span>
<flux:badge color="orange">Pro</flux:badge>
</div>
{{-- Decorative squiggle chart --}}
<svg viewBox="0 0 200 60" class="mb-4 h-16 w-full opacity-40" fill="none" stroke="#bb5b3e" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<svg viewBox="0 0 200 60" class="mb-4 h-16 w-full opacity-40"
fill="none" stroke="var(--color-primary)" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="0,45 20,38 40,42 60,30 80,35 100,20 120,25 140,15 160,22 180,12 200,18" />
</svg>
{{-- Blurred overlay --}}
<div class="absolute inset-0 flex flex-col items-center justify-center rounded-2xl bg-[#faf6f3]/80 backdrop-blur-sm">
<iconify-icon icon="lucide:lock" class="mb-2 text-3xl text-[#bb5b3e]"></iconify-icon>
<p class="mb-3 text-sm font-bold text-[#4a3f3b]">14-day forecast is a Pro feature</p>
<a
href="{{ route('register') }}"
class="rounded-xl bg-[#bb5b3e] px-5 py-2.5 text-sm font-bold text-white shadow-md"
>
<div class="absolute inset-0 flex flex-col items-center justify-center rounded-2xl bg-surface/80 backdrop-blur-sm">
<iconify-icon icon="lucide:lock" class="mb-2 text-3xl text-primary"></iconify-icon>
<p class="mb-3 text-sm font-bold text-text-base">14-day forecast is a Pro feature</p>
<flux:button href="{{ route('register') }}" as="a" variant="primary" size="sm">
Unlock Forecast
</a>
</flux:button>
</div>
</div>

View File

@@ -1,10 +1,31 @@
<select
{{ $attributes->whereStartsWith('wire:') }}
class="h-11 w-full rounded-xl border border-[#e5ded7] bg-[#faf6f3] px-3 text-sm font-semibold text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e]"
<div
x-data="{
value: '',
labels: {
1: '1 mile',
2: '2 miles',
5: '5 miles',
10: '10 miles',
20: '20 miles',
},
get label() {
return this.labels[this.value] ?? 'Radius';
},
}"
x-modelable="value"
{{ $attributes->whereStartsWith('wire:model') }}
>
<option value="1">1 mile</option>
<option value="2">2 miles</option>
<option value="5">5 miles</option>
<option value="10">10 miles</option>
<option value="20">20 miles</option>
</select>
<flux:dropdown>
<flux:button size="sm" icon:trailing="chevron-down">
<span x-text="label">Radius</span>
</flux:button>
<flux:menu>
<flux:menu.item @click="value = 1">1 mile</flux:menu.item>
<flux:menu.item @click="value = 2">2 miles</flux:menu.item>
<flux:menu.item @click="value = 5">5 miles</flux:menu.item>
<flux:menu.item @click="value = 10">10 miles</flux:menu.item>
<flux:menu.item @click="value = 20">20 miles</flux:menu.item>
</flux:menu>
</flux:dropdown>
</div>

View File

@@ -19,13 +19,13 @@
};
@endphp
<div class="rounded-2xl border border-[#e5ded7] bg-[#faf6f3] p-5 shadow-sm">
<div class="rounded-2xl border border-border bg-surface p-5 shadow-sm">
<div class="mb-3 flex items-start justify-between">
<div>
<p class="mb-1 text-[10px] font-bold uppercase tracking-widest text-[#89726c]">
<p class="mb-1 text-[10px] font-bold uppercase tracking-widest text-text-muted">
Recommendation
</p>
<h2 class="text-3xl font-black leading-tight text-[#8B4860]">
<h2 class="text-3xl font-black leading-tight text-mauve">
{{ $headline }}
</h2>
</div>
@@ -33,31 +33,30 @@
<div class="flex flex-col items-center gap-1">
<div class="relative h-12 w-12">
<svg class="h-full w-full -rotate-90" viewBox="0 0 48 48">
<circle cx="24" cy="24" r="20" stroke="#eeeae5" stroke-width="4" fill="transparent" />
<circle
cx="24" cy="24" r="20"
stroke="#8B4860" stroke-width="4" fill="transparent"
<circle cx="24" cy="24" r="20"
stroke="var(--color-surface-subtle)"
stroke-width="4" fill="transparent" />
<circle cx="24" cy="24" r="20"
stroke="var(--color-mauve)"
stroke-width="4" fill="transparent"
stroke-dasharray="{{ $circumference }}"
stroke-dashoffset="{{ $offset }}"
stroke-linecap="round"
/>
stroke-linecap="round" />
</svg>
<span class="absolute inset-0 flex items-center justify-center text-[11px] font-black text-[#4a3f3b]">
<span class="absolute inset-0 flex items-center justify-center text-[11px] font-black text-text-base">
{{ (int) $score }}%
</span>
</div>
<span class="text-[9px] font-bold uppercase tracking-wider text-[#89726c]">Confidence</span>
<span class="text-[9px] font-bold uppercase tracking-wider text-text-muted">Confidence</span>
</div>
</div>
<p class="text-sm font-medium leading-relaxed text-[#6b5a55]">
<p class="text-sm font-medium leading-relaxed text-text-dim">
{{ $prediction['reasoning'] ?? '' }}
</p>
<div class="mt-3">
<span class="rounded-full px-2.5 py-0.5 text-[11px] font-bold uppercase tracking-wide {{ $labelColour }}">
{{ ucfirst($label) }} confidence
</span>
<flux:badge size="sm" :class="$labelColour">{{ ucfirst($label) }} confidence</flux:badge>
</div>
</div>
@endif

View File

@@ -1,10 +1,31 @@
<select
{{ $attributes->whereStartsWith('wire:') }}
class="h-11 w-full rounded-xl border border-[#e5ded7] bg-[#faf6f3] px-3 text-sm font-semibold text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e]"
<div
x-data="{
value: '',
labels: {
reliable: 'Best price (reliable)',
price: 'Cheapest first',
distance: 'Nearest first',
updated: 'Recently updated',
brand: 'Brand AZ',
},
get label() {
return this.labels[this.value] ?? 'Sort by';
},
}"
x-modelable="value"
{{ $attributes->whereStartsWith('wire:model') }}
>
<option value="reliable">Best price (reliable)</option>
<option value="price">Cheapest first</option>
<option value="distance">Nearest first</option>
<option value="updated">Recently updated</option>
<option value="brand">Brand AZ</option>
</select>
<flux:dropdown>
<flux:button size="sm" icon:trailing="chevron-down">
<span x-text="label">Sort by</span>
</flux:button>
<flux:menu>
<flux:menu.item @click="value = 'reliable'">Best price (reliable)</flux:menu.item>
<flux:menu.item @click="value = 'price'">Cheapest first</flux:menu.item>
<flux:menu.item @click="value = 'distance'">Nearest first</flux:menu.item>
<flux:menu.item @click="value = 'updated'">Recently updated</flux:menu.item>
<flux:menu.item @click="value = 'brand'">Brand AZ</flux:menu.item>
</flux:menu>
</flux:dropdown>
</div>

View File

@@ -2,36 +2,34 @@
@php
$colourClass = match($station['price_classification'] ?? '') {
'current' => 'text-green-500',
'current' => 'text-status-good',
'recent' => 'text-slate-500',
'stale' => 'text-amber-500',
'outdated' => 'text-red-500',
'stale' => 'text-status-warn',
'outdated' => 'text-status-bad',
default => 'text-zinc-400',
};
$miles = number_format(($station['distance_km'] ?? 0) * 0.621371, 1);
$price = number_format($station['price'] ?? 0, 1);
@endphp
<div class="flex items-center justify-between rounded-xl border border-[#e5ded7] bg-[#faf6f3] px-4 py-3.5">
<div class="flex items-center justify-between rounded-xl border border-border bg-surface px-4 py-3.5">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="truncate text-sm font-bold text-[#4a3f3b]">
<p class="truncate text-sm font-bold text-text-base">
{{ $station['name'] ?? '' }}
</p>
@if (! empty($station['is_supermarket']))
<span class="shrink-0 rounded bg-lime-100 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-lime-700">
Supermarket
</span>
<flux:badge size="sm" color="lime">Supermarket</flux:badge>
@endif
</div>
<p class="truncate text-xs text-[#89726c]">
<p class="truncate text-xs text-text-muted">
{{ $station['address'] ?? '' }}, {{ $station['postcode'] ?? '' }}
</p>
<p class="text-xs text-[#b0a09a]">{{ $miles }} miles away</p>
<p class="text-xs text-text-dim">{{ $miles }} miles away</p>
</div>
<div class="ml-4 shrink-0 text-right">
<p class="text-xl font-black text-[#4a3f3b]">{{ $price }}p</p>
<p class="text-xl font-black text-text-base">{{ $price }}p</p>
<p class="text-[11px] {{ $colourClass }}">
{{ $station['price_classification_label'] ?? '' }}
@if (! empty($station['price_updated_at']))

View File

@@ -1,6 +1,4 @@
@props(['results' => []])
<div
x-data="stationMap(@entangle('results'), @entangle('meta'), @entangle('radius'))"
class="h-56 w-full overflow-hidden border-y border-[#e5ded7] md:h-96"
x-data="stationMap([], {}, 5)"
class="h-56 w-full overflow-hidden border-y border-border md:h-96"
></div>

View File

@@ -1,11 +1,33 @@
<select
{{ $attributes->whereStartsWith('wire:') }}
class="h-11 w-full rounded-xl border border-[#e5ded7] bg-[#faf6f3] px-3 text-sm font-semibold text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e]"
<div
x-data="{
value: '',
labels: {
petrol: 'Petrol (E10)',
e5: 'Super Unleaded (E5)',
diesel: 'Diesel',
b7_premium: 'Premium Diesel',
b10: 'B10 Biodiesel',
hvo: 'HVO',
},
get label() {
return this.labels[this.value] ?? 'Select fuel type';
},
}"
x-modelable="value"
{{ $attributes->whereStartsWith('wire:model') }}
>
<option value="petrol">Petrol (E10)</option>
<option value="e5">Super Unleaded (E5)</option>
<option value="diesel">Diesel</option>
<option value="b7_premium">Premium Diesel</option>
<option value="b10">B10 Biodiesel</option>
<option value="hvo">HVO</option>
</select>
<flux:dropdown>
<flux:button size="sm" icon:trailing="chevron-down">
<span x-text="label">Select fuel type</span>
</flux:button>
<flux:menu>
<flux:menu.item @click="value = 'petrol'">Petrol (E10)</flux:menu.item>
<flux:menu.item @click="value = 'e5'">Super Unleaded (E5)</flux:menu.item>
<flux:menu.item @click="value = 'diesel'">Diesel</flux:menu.item>
<flux:menu.item @click="value = 'b7_premium'">Premium Diesel</flux:menu.item>
<flux:menu.item @click="value = 'b10'">B10 Biodiesel</flux:menu.item>
<flux:menu.item @click="value = 'hvo'">HVO</flux:menu.item>
</flux:menu>
</flux:dropdown>
</div>

View File

@@ -1,26 +0,0 @@
@php
$tabs = [
['label' => 'Prices', 'icon' => 'lucide:fuel', 'route' => 'home'],
['label' => 'Alerts', 'icon' => 'lucide:bell', 'route' => null],
['label' => 'Forecourts', 'icon' => 'lucide:map-pin', 'route' => null],
['label' => 'Trends', 'icon' => 'lucide:trending-up','route' => null],
];
$currentRoute = request()->routeIs('home') ? 'home' : null;
@endphp
<nav class="fixed inset-x-0 bottom-0 z-50 border-t border-[#e5ded7] bg-[#faf6f3] pb-8">
<div class="flex">
@foreach ($tabs as $tab)
@php
$isActive = $tab['route'] && $currentRoute === $tab['route'];
$colour = $isActive ? 'text-[#bb5b3e]' : 'text-[#89726c]';
@endphp
<div class="flex flex-1 flex-col items-center gap-1 pt-3">
<iconify-icon icon="{{ $tab['icon'] }}" class="text-xl {{ $colour }}"></iconify-icon>
<span class="text-[10px] font-bold uppercase tracking-wide {{ $colour }}">
{{ $tab['label'] }}
</span>
</div>
@endforeach
</div>
</nav>

View File

@@ -1,17 +0,0 @@
<header class="fixed inset-x-0 top-0 z-50 shrink-0 border-b border-[#e5ded7] bg-[#faf6f3] px-5 pb-4 pt-14 shadow-sm flex items-center justify-between">
<div class="flex items-center gap-2.5">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-[#bb5b3e] shadow-md">
<iconify-icon icon="lucide:fuel" class="text-xl text-white"></iconify-icon>
</div>
<span class="text-2xl font-black tracking-tighter text-[#bb5b3e]">FuelAlert</span>
</div>
@auth
<a href="{{ route('dashboard') }}" class="flex h-10 w-10 items-center justify-center rounded-full border border-[#e5ded7] bg-[#f5ede5]">
<iconify-icon icon="lucide:user" class="text-lg text-[#89726c]"></iconify-icon>
</a>
@else
<a href="{{ route('login') }}" class="flex h-10 w-10 items-center justify-center rounded-full border border-[#e5ded7] bg-[#f5ede5]">
<iconify-icon icon="lucide:user" class="text-lg text-[#89726c]"></iconify-icon>
</a>
@endauth
</header>

View File

@@ -1,13 +0,0 @@
<?php
use Livewire\Component;
new class extends Component
{
//
};
?>
<div>
{{-- Nothing in life is to be feared, it is only to be understood. Now is the time to understand more, so that we may fear less. - Maria Skłodowska-Curie --}}
</div>

View File

@@ -277,7 +277,7 @@
Join 50,000+ UK drivers using real-time insights to find the cheapest petrol and time their fill-ups perfectly.
</p>
<form action="{{ route('stations.search') }}" method="GET" class="flex flex-col sm:flex-row gap-3 max-w-md">
<form action="{{ route('fuel-finder') }}" method="GET" class="flex flex-col sm:flex-row gap-3 max-w-md">
<div class="relative flex-1">
<iconify-icon icon="lucide:map-pin" class="absolute left-4 top-1/2 -translate-y-1/2 text-[#89726c] text-xl pointer-events-none"></iconify-icon>
<input type="text" name="postcode" placeholder="Enter Postcode" class="w-full h-14 pl-12 pr-4 bg-white border border-[#e5ded7] rounded-xl focus:outline-none focus:ring-2 focus:ring-[#bb5b3e] shadow-inner text-lg text-[#4a3f3b]">
@@ -416,7 +416,7 @@
Historic Price Benchmarking
</li>
</ul>
<a href="{{ route('stations.search') }}" class="inline-flex items-center gap-2 text-[#bb5b3e] font-black text-lg group">
<a href="{{ route('fuel-finder') }}" class="inline-flex items-center gap-2 text-[#bb5b3e] font-black text-lg group">
Find prices near you
<iconify-icon icon="lucide:arrow-right" class="group-hover:translate-x-1 transition-transform"></iconify-icon>
</a>
@@ -558,7 +558,7 @@
<p class="text-xl text-white/80">Sign up 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 href="{{ route('register') }}" class="bg-white text-[#bb5b3e] px-10 py-4 rounded-xl text-lg font-black shadow-2xl hover:bg-gray-100 transition-all">Create Free Account</a>
<a href="{{ route('stations.search') }}" 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">Search Prices Now</a>
<a href="{{ route('fuel-finder') }}" 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">Search Prices Now</a>
</div>
</div>
</section>
@@ -583,7 +583,7 @@
<ul class="space-y-2 text-sm text-[#89726c]">
<li><a href="#pricing" class="hover:text-[#bb5b3e] transition-colors">Pricing</a></li>
<li><a href="#features" class="hover:text-[#bb5b3e] transition-colors">Features</a></li>
<li><a href="{{ route('stations.search') }}" class="hover:text-[#bb5b3e] transition-colors">Find Prices</a></li>
<li><a href="{{ route('fuel-finder') }}" class="hover:text-[#bb5b3e] transition-colors">Find Prices</a></li>
</ul>
</div>

View File

@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('partials.head', ['title' => $title ?? null])
</head>
<body class="bg-surface-page text-text-base font-sans antialiased">
{{ $slot }}
@fluxScripts
</body>
</html>

View File

@@ -1,201 +1,77 @@
<div class="flex h-dvh flex-col bg-[#f5ede5]">
<div class="flex h-dvh flex-col bg-surface-page">
<x-mobile-header />
{{-- HEADER --}}
<header class="shrink-0 z-50 bg-surface border-b border-border shadow-sm
flex items-center justify-between px-5 pb-4 md:px-8"
style="padding-top: max(1rem, env(safe-area-inset-top))">
{{-- Scrollable main content, offset for fixed header (~112px) and footer (~80px) --}}
<main
class="flex-1 overflow-y-auto pt-28 pb-20"
style="-ms-overflow-style:none;scrollbar-width:none;"
>
<a href="{{ route('home') }}" class="flex items-center gap-2.5">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary shadow-md md:h-10 md:w-10">
<iconify-icon icon="lucide:fuel" class="text-lg text-white md:text-xl"></iconify-icon>
</div>
<span class="text-xl font-black tracking-tighter text-primary md:text-2xl">FuelAlert</span>
</a>
{{-- #search --}}
<section class="space-y-3 px-5 pt-5 pb-4">
<form wire:submit="findStations">
<nav class="hidden items-center gap-8 md:flex">
<a href="#" class="text-sm font-semibold text-text-muted transition-colors hover:text-primary">Prices</a>
<a href="#" class="text-sm font-semibold text-text-muted transition-colors hover:text-primary">Alerts</a>
<a href="#" class="text-sm font-semibold text-text-muted transition-colors hover:text-primary">Trends</a>
</nav>
<div
x-data="{
query: @js($search),
locatingUser: false,
_usedIpFallback: false,
async _postcodeFromLatLng(lat, lng) {
const res = await fetch(`https://api.postcodes.io/postcodes?lon=${lng}&lat=${lat}&limit=1&radius=1000`);
const data = await res.json();
return data?.result?.[0]?.postcode ?? null;
},
async locateUser() {
this.locatingUser = true;
this._usedIpFallback = false;
try {
let lat, lng;
try {
const pos = await new Promise((resolve, reject) =>
navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 })
);
lat = pos.coords.latitude;
lng = pos.coords.longitude;
} catch (e) {
const d = await fetch('https://ipapi.co/json/').then(r => r.json());
lat = d.latitude;
lng = d.longitude;
this._usedIpFallback = true;
}
const postcode = await this._postcodeFromLatLng(lat, lng);
if (postcode) {
this.query = postcode;
this.$wire.set('search', postcode);
this.$wire.findStations();
}
} catch (e) {
// silent
} finally {
this.locatingUser = false;
}
}
}"
class="relative mb-3"
>
<iconify-icon
icon="lucide:map-pin"
class="absolute left-4 top-1/2 -translate-y-1/2 text-xl text-[#89726c] pointer-events-none"
></iconify-icon>
<input
wire:model="search"
x-model="query"
x-ref="searchInput"
type="text"
name="search"
@focus="_usedIpFallback = false"
placeholder="Postcode, town or city"
class="h-14 w-full rounded-xl border border-[#e5ded7] bg-[#faf6f3] pl-12 pr-36 text-base font-semibold text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e] focus:border-transparent"
/>
{{-- Right-side controls --}}
<div class="absolute inset-y-0 right-0 flex items-center gap-2 pr-3">
{{-- Clear --}}
<button
x-show="query.length > 0"
x-cloak
type="button"
@click="query = ''; $wire.set('search', ''); _usedIpFallback = false"
class="text-[#89726c] hover:text-[#4a3f3b]"
>
<iconify-icon icon="lucide:x" class="text-base"></iconify-icon>
</button>
{{-- Near me pill --}}
<button
type="button"
@click="locateUser()"
:disabled="locatingUser"
class="flex items-center gap-1.5 rounded-full bg-[#e5ded7] px-3 py-1.5 text-sm font-semibold text-[#4a3f3b] disabled:opacity-40"
>
<iconify-icon x-show="!locatingUser" icon="lucide:locate-fixed" class="text-sm"></iconify-icon>
<iconify-icon x-show="locatingUser" icon="lucide:loader-circle" class="text-sm animate-spin"></iconify-icon>
<span x-text="locatingUser ? 'Finding...' : 'Near me'">Near me</span>
</button>
{{-- Search --}}
<button
type="submit"
wire:loading.attr="disabled"
class="text-[#89726c] disabled:opacity-60"
>
<iconify-icon wire:loading.remove wire:target="findStations" icon="lucide:search" class="text-xl"></iconify-icon>
<iconify-icon wire:loading wire:target="findStations" icon="lucide:loader-circle" class="text-xl animate-spin"></iconify-icon>
</button>
</div>
{{-- IP fallback nudge --}}
<div
x-show="_usedIpFallback"
x-cloak
x-transition:enter="transition ease-out duration-300 delay-500"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-end="opacity-0"
class="absolute top-full left-0 right-0 z-40 mt-2"
>
<div
@click="$refs.searchInput.focus(); _usedIpFallback = false"
class="cursor-pointer rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 transition-colors hover:bg-amber-100"
>
<p class="text-center text-xs text-amber-800">
<span class="font-medium">Showing approximate location.</span>
<span class="underline">Enter your postcode above</span> for exact results.
</p>
</div>
</div>
</div>
@error('search')
<p class="mb-2 text-sm text-red-600">{{ $message }}</p>
@enderror
@auth
<a href="{{ route('dashboard') }}"
class="flex h-9 w-9 items-center justify-center rounded-full border border-border bg-surface-subtle">
<iconify-icon icon="lucide:user" class="text-base text-text-muted"></iconify-icon>
</a>
@else
<a href="{{ route('login') }}"
class="flex h-9 w-9 items-center justify-center rounded-full border border-border bg-surface-subtle">
<iconify-icon icon="lucide:user" class="text-base text-text-muted"></iconify-icon>
</a>
@endauth
</header>
{{-- Filter rows --}}
<div class="space-y-2 mb-3">
<div class="flex gap-2">
<div class="shrink-0">
<x-fuel.type-select wire:model.live="fuelType" />
</div>
<div class="shrink-0">
<x-fuel.sort-select wire:model.live="sort" />
</div>
</div>
<div class="flex">
<div class="shrink-0">
<x-fuel.radius-select wire:model.live="radius" />
</div>
</div>
</div>
{{-- MAIN --}}
<main class="flex-1 overflow-y-auto" style="-ms-overflow-style:none;scrollbar-width:none;">
<div class="md:mx-auto md:max-w-3xl">
</form>
<livewire:public.fuel.search />
<livewire:public.fuel.recommendation />
<livewire:public.fuel.map />
<livewire:public.fuel.station-list />
@if ($apiError)
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ $apiError }}
</div>
@endif
</section>
{{-- #recommendation --}}
{{-- @if ($prediction)
<section class="px-5 pb-5">
<x-fuel.recommendation :prediction="$prediction" />
</section>
@endif --}}
{{-- #map --}}
<section class="mb-4" wire:ignore>
<x-fuel.station-map :results="$results" />
</section>
{{-- #stations --}}
@if ($hasSearched)
<section class="px-5 pb-5">
@if (! empty($meta))
<div class="mb-3 flex items-center justify-between">
<h3 class="text-base font-bold text-[#4a3f3b]">Stations Nearby</h3>
<span class="text-[10px] font-bold uppercase tracking-widest text-[#89726c]">
{{ $meta['count'] ?? 0 }} {{ str('Result')->plural($meta['count'] ?? 0) }}
</span>
</div>
@endif
@forelse ($results as $station)
<div class="mb-2">
<x-fuel.station-card :station="$station" />
</div>
@empty
<p class="text-sm text-[#89726c]">
No stations found within {{ $radius }} {{ str('mile')->plural($radius) }} of "{{ $search }}".
</p>
@endforelse
</section>
@endif
{{-- #forecast --}}
<section class="px-5 pb-8">
<x-fuel.forecast />
</section>
</div>
</main>
<x-mobile-footer />
{{-- BOTTOM TAB BAR --}}
@php
$tabs = [
['label' => 'Prices', 'icon' => 'lucide:fuel', 'route' => 'home'],
['label' => 'Alerts', 'icon' => 'lucide:bell', 'route' => null],
['label' => 'Forecourts', 'icon' => 'lucide:map-pin', 'route' => null],
['label' => 'Trends', 'icon' => 'lucide:trending-up', 'route' => null],
];
@endphp
<nav class="shrink-0 border-t border-border bg-surface md:hidden"
style="padding-bottom: max(0.5rem, env(safe-area-inset-bottom))">
<div class="flex pt-3">
@foreach ($tabs as $tab)
@php $active = $tab['route'] && request()->routeIs($tab['route']); @endphp
<div class="flex flex-1 flex-col items-center gap-1">
<iconify-icon
icon="{{ $tab['icon'] }}"
class="text-xl {{ $active ? 'text-primary' : 'text-text-muted' }}"
></iconify-icon>
<span class="text-[10px] font-bold uppercase tracking-wide {{ $active ? 'text-primary' : 'text-text-muted' }}">
{{ $tab['label'] }}
</span>
</div>
@endforeach
</div>
</nav>
</div>

View File

@@ -0,0 +1,3 @@
<div class="mb-4" wire:ignore>
<x-fuel.station-map />
</div>

View File

@@ -0,0 +1,7 @@
<div>
@if ($prediction)
<div class="px-5 pb-5">
<x-fuel.recommendation :prediction="$prediction" />
</div>
@endif
</div>

View File

@@ -0,0 +1,130 @@
<div class="space-y-3 px-5 pb-4 pt-5">
<form wire:submit="findStations">
<div
x-data="{
query: @js($search),
locatingUser: false,
_usedIpFallback: false,
async _postcodeFromLatLng(lat, lng) {
const res = await fetch(`https://api.postcodes.io/postcodes?lon=${lng}&lat=${lat}&limit=1&radius=1000`);
const data = await res.json();
return data?.result?.[0]?.postcode ?? null;
},
async locateUser() {
this.locatingUser = true;
this._usedIpFallback = false;
try {
let lat, lng;
try {
const pos = await new Promise((resolve, reject) =>
navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 })
);
lat = pos.coords.latitude;
lng = pos.coords.longitude;
} catch (e) {
const d = await fetch('https://ipapi.co/json/').then(r => r.json());
lat = d.latitude;
lng = d.longitude;
this._usedIpFallback = true;
}
const postcode = await this._postcodeFromLatLng(lat, lng);
if (postcode) {
this.query = postcode;
this.$wire.set('search', postcode);
this.$wire.findStations();
}
} catch (e) {
// silent
} finally {
this.locatingUser = false;
}
}
}"
class="relative mb-3"
>
<iconify-icon
icon="lucide:map-pin"
class="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-xl text-text-muted"
></iconify-icon>
<input
wire:model="search"
x-model="query"
x-ref="searchInput"
type="text"
name="search"
@focus="_usedIpFallback = false"
placeholder="Postcode, town or city"
class="h-14 w-full rounded-xl border border-border bg-surface pl-12 pr-36 text-base font-semibold text-text-base focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary"
/>
<div class="absolute inset-y-0 right-0 flex items-center gap-2 pr-3">
<button
x-show="query.length > 0"
x-cloak
type="button"
@click="query = ''; $wire.set('search', ''); _usedIpFallback = false"
class="text-text-muted hover:text-text-base"
>
<iconify-icon icon="lucide:x" class="text-base"></iconify-icon>
</button>
<button
type="button"
@click="locateUser()"
:disabled="locatingUser"
class="flex items-center gap-1.5 rounded-full bg-surface-subtle px-3 py-1.5 text-sm font-semibold text-text-base disabled:opacity-40"
>
<iconify-icon x-show="!locatingUser" icon="lucide:locate-fixed" class="text-sm"></iconify-icon>
<iconify-icon x-show="locatingUser" icon="lucide:loader-circle" class="animate-spin text-sm"></iconify-icon>
<span x-text="locatingUser ? 'Finding...' : 'Near me'">Near me</span>
</button>
<button
type="submit"
wire:loading.attr="disabled"
class="text-text-muted disabled:opacity-60"
>
<iconify-icon wire:loading.remove wire:target="findStations" icon="lucide:search" class="text-xl"></iconify-icon>
<iconify-icon wire:loading wire:target="findStations" icon="lucide:loader-circle" class="animate-spin text-xl"></iconify-icon>
</button>
</div>
{{-- IP fallback nudge --}}
<div
x-show="_usedIpFallback"
x-cloak
x-transition:enter="transition ease-out duration-300 delay-500"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-end="opacity-0"
class="absolute left-0 right-0 top-full z-40 mt-2"
>
<div
@click="$refs.searchInput.focus(); _usedIpFallback = false"
class="cursor-pointer rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 transition-colors hover:bg-amber-100"
>
<p class="text-center text-xs text-amber-800">
<span class="font-medium">Showing approximate location.</span>
<span class="underline">Enter your postcode above</span> for exact results.
</p>
</div>
</div>
</div>
@error('search')
<p class="mb-2 text-sm text-red-600">{{ $message }}</p>
@enderror
<div class="flex gap-2 overflow-x-auto pb-1" style="-ms-overflow-style:none;scrollbar-width:none;">
<div class="shrink-0"><x-fuel.type-select wire:model.live="fuelType" /></div>
<div class="shrink-0"><x-fuel.sort-select wire:model.live="sort" /></div>
<div class="shrink-0"><x-fuel.radius-select wire:model.live="radius" /></div>
</div>
</form>
@if ($apiError)
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ $apiError }}
</div>
@endif
</div>

View File

@@ -0,0 +1,24 @@
<div>
@if ($hasSearched)
<div class="px-5 pb-5">
@if (! empty($meta))
<div class="mb-3 flex items-center justify-between">
<h3 class="text-base font-bold text-text-base">Stations Nearby</h3>
<span class="text-[10px] font-bold uppercase tracking-widest text-text-muted">
{{ $meta['count'] ?? 0 }} {{ str('Result')->plural($meta['count'] ?? 0) }}
</span>
</div>
@endif
@forelse ($results as $station)
<div class="mb-2">
<x-fuel.station-card :station="$station" />
</div>
@empty
<p class="text-sm text-text-muted">
No stations found within {{ $radius }} {{ str('mile')->plural($radius) }}.
</p>
@endforelse
</div>
@endif
</div>

View File

@@ -1,138 +0,0 @@
<div>
<flux:heading size="xl" class="mb-1">Find Cheap Fuel Near You</flux:heading>
<flux:subheading class="mb-6">Search by postcode, town or city</flux:subheading>
<form wire:submit="findStations">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end">
<div class="flex-1">
<flux:input
wire:model="search"
name="search"
label="Location"
placeholder="Postcode, town or city"
/>
@error('search')
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
</div>
<div class="w-full sm:w-48">
<flux:select wire:model.live="fuelType" name="fuelType" label="Fuel type">
<flux:select.option value="">Select fuel type</flux:select.option>
<flux:select.option value="petrol">Petrol (E10)</flux:select.option>
<flux:select.option value="e5">Super Unleaded (E5)</flux:select.option>
<flux:select.option value="diesel">Diesel</flux:select.option>
<flux:select.option value="b7_premium">Premium Diesel</flux:select.option>
<flux:select.option value="b10">B10 Biodiesel</flux:select.option>
<flux:select.option value="hvo">HVO</flux:select.option>
</flux:select>
@error('fuelType')
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p>
@enderror
</div>
<div class="w-full sm:w-36">
<flux:select wire:model.live="radius" name="radius" label="Radius">
<flux:select.option value="1">1 mile</flux:select.option>
<flux:select.option value="2">2 miles</flux:select.option>
<flux:select.option value="5">5 miles</flux:select.option>
<flux:select.option value="10">10 miles</flux:select.option>
<flux:select.option value="20">20 miles</flux:select.option>
</flux:select>
</div>
<div class="w-full sm:w-40">
<flux:select wire:model.live="sort" name="sort" label="Sort by">
<flux:select.option value="reliable">Best price (reliable)</flux:select.option>
<flux:select.option value="price">Cheapest first</flux:select.option>
<flux:select.option value="distance">Nearest first</flux:select.option>
<flux:select.option value="updated">Recently updated</flux:select.option>
<flux:select.option value="brand">Brand AZ</flux:select.option>
</flux:select>
</div>
<div>
<flux:button type="submit" variant="primary" wire:loading.attr="disabled">
<span wire:loading.remove wire:target="findStations">Search</span>
<span wire:loading wire:target="findStations">Searching…</span>
</flux:button>
</div>
</div>
</form>
@if ($apiError)
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950 dark:text-red-400">
{{ $apiError }}
</div>
@endif
@if (! empty($meta))
<div class="mt-6">
@if (! empty($results))
<p class="mb-3 text-sm text-zinc-500 dark:text-zinc-400">
{{ $meta['count'] }} {{ str('station')->plural($meta['count']) }} found
&middot; Cheapest: {{ number_format($meta['lowest_pence'] / 100, 1) }}p
&middot; Average: {{ number_format($meta['avg_pence'] / 100, 1) }}p
</p>
{{-- Map --}}
<div
x-data="stationMap(@entangle('results'))"
class="mb-4 h-72 overflow-hidden rounded-xl border border-neutral-200 sm:h-96 dark:border-neutral-700"
></div>
{{-- Legend --}}
<div class="mb-3 flex flex-wrap gap-3 text-xs text-zinc-500 dark:text-zinc-400">
<span class="flex items-center gap-1"><span class="inline-block size-3 rounded-full bg-green-500"></span> Current (&lt;24h)</span>
<span class="flex items-center gap-1"><span class="inline-block size-3 rounded-full bg-slate-500"></span> Recent (2448h)</span>
<span class="flex items-center gap-1"><span class="inline-block size-3 rounded-full bg-amber-500"></span> Stale (25 days)</span>
<span class="flex items-center gap-1"><span class="inline-block size-3 rounded-full bg-red-500"></span> Outdated (5+ days)</span>
</div>
<div class="space-y-2">
@foreach ($results as $station)
<div class="flex items-center justify-between rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-700">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="truncate font-semibold text-zinc-900 dark:text-zinc-100">
{{ $station['name'] }}
</p>
@if ($station['is_supermarket'])
<flux:badge color="lime" size="sm">Supermarket</flux:badge>
@endif
</div>
<p class="truncate text-sm text-zinc-500 dark:text-zinc-400">
{{ $station['address'] }}, {{ $station['postcode'] }}
</p>
<p class="text-sm text-zinc-400 dark:text-zinc-500">
{{ number_format($station['distance_km'] * 0.621371, 1) }} miles away
</p>
</div>
<div class="ml-4 shrink-0 text-right">
<p class="text-xl font-bold text-zinc-900 dark:text-zinc-100">
{{ number_format($station['price'], 1) }}p
</p>
<p class="text-xs {{ match($station['price_classification']) {
'current' => 'text-green-500 dark:text-green-400',
'recent' => 'text-zinc-400 dark:text-zinc-500',
'stale' => 'text-amber-500 dark:text-amber-400',
'outdated' => 'text-red-500 dark:text-red-400',
default => 'text-zinc-400 dark:text-zinc-500',
} }}">
{{ $station['price_classification_label'] }}
&middot;
{{ $station['price_updated_at'] ? \Carbon\Carbon::parse($station['price_updated_at'])->diffForHumans() : 'Unknown' }}
</p>
</div>
</div>
@endforeach
</div>
@else
<p class="text-sm text-zinc-500 dark:text-zinc-400">
No stations found within {{ $radius }} {{ str('mile')->plural($radius) }} of "{{ $search }}".
</p>
@endif
</div>
@endif
</div>

View File

@@ -1,620 +0,0 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FuelAlert | Stop Overpaying for Fuel</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Manrope:wght@600;700;800;900&display=swap" rel="stylesheet">
@vite(['resources/css/app.css', 'resources/js/app.js'])
<style>
body {
font-family: 'Inter', sans-serif;
color: #4a3f3b;
}
h1, h2, h3, h4 {
font-family: 'Manrope', sans-serif;
letter-spacing: -0.02em;
}
.hero-gradient {
background: radial-gradient(circle at top right, #bb5b3e15, transparent 50%),
radial-gradient(circle at bottom left, #bb5b3e10, transparent 40%);
}
.glass-card {
background: rgba(250, 246, 243, 0.9);
backdrop-filter: blur(12px);
border: 1px solid rgba(229, 222, 215, 0.6);
}
</style>
</head>
<body class="bg-[#f5ede5]">
{{-- Mobile App Layout (hidden on desktop) --}}
<div class="flex flex-col h-dvh md:hidden bg-[#f5ede5]">
{{-- Mobile Header --}}
<header class="shrink-0 pt-14 px-5 pb-4 bg-[#faf6f3] border-b border-[#e5ded7] flex items-center justify-between shadow-sm z-50">
<div class="flex items-center gap-2.5">
<div class="w-10 h-10 rounded-lg bg-[#bb5b3e] flex items-center justify-center shadow-md">
<iconify-icon icon="lucide:fuel" class="text-white text-xl"></iconify-icon>
</div>
<span class="text-2xl font-black tracking-tighter text-[#bb5b3e]">FuelAlert</span>
</div>
<button class="w-10 h-10 rounded-full bg-[#f5ede5] border border-[#e5ded7] flex items-center justify-center">
<iconify-icon icon="lucide:user" class="text-[#89726c] text-lg"></iconify-icon>
</button>
</header>
{{-- Mobile Scrollable Main --}}
<main class="flex-1 overflow-y-auto" style="-ms-overflow-style:none;scrollbar-width:none;">
{{-- Search & Filters --}}
<section class="px-5 pt-5 pb-4 space-y-3">
<div class="relative">
<iconify-icon icon="lucide:map-pin" class="absolute left-4 top-1/2 -translate-y-1/2 text-[#89726c] text-xl pointer-events-none"></iconify-icon>
<input
type="text"
placeholder="Enter postcode (e.g. SW1A)"
value="SW1A 1AA"
class="w-full h-14 pl-12 pr-4 bg-[#faf6f3] border border-[#e5ded7] rounded-xl text-base font-semibold text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e] focus:border-transparent"
>
</div>
<div class="flex gap-2 overflow-x-auto pb-1" style="-ms-overflow-style:none;scrollbar-width:none;">
<button class="flex items-center gap-1.5 px-4 py-2 bg-[#faf6f3] border border-[#e5ded7] rounded-lg shrink-0 text-sm font-semibold text-[#4a3f3b]">
<iconify-icon icon="lucide:droplets" class="text-[#bb5b3e]"></iconify-icon>
Petrol
<iconify-icon icon="lucide:chevron-down" class="text-[#89726c] text-xs"></iconify-icon>
</button>
<button class="flex items-center gap-1.5 px-4 py-2 bg-[#faf6f3] border border-[#e5ded7] rounded-lg shrink-0 text-sm font-semibold text-[#4a3f3b]">
<iconify-icon icon="lucide:maximize" class="text-[#bb5b3e]"></iconify-icon>
5 miles
<iconify-icon icon="lucide:chevron-down" class="text-[#89726c] text-xs"></iconify-icon>
</button>
<button class="flex items-center gap-1.5 px-4 py-2 bg-[#faf6f3] border border-[#e5ded7] rounded-lg shrink-0 text-sm font-semibold text-[#4a3f3b]">
<iconify-icon icon="lucide:arrow-up-down" class="text-[#bb5b3e]"></iconify-icon>
Price
<iconify-icon icon="lucide:chevron-down" class="text-[#89726c] text-xs"></iconify-icon>
</button>
</div>
</section>
{{-- Recommendation Card --}}
<section class="px-5 pb-5">
<div class="bg-[#faf6f3] p-5 rounded-2xl border border-[#e5ded7] shadow-sm">
<div class="flex items-start justify-between mb-3">
<div>
<p class="text-[10px] font-bold uppercase tracking-widest text-[#89726c] mb-1">Recommendation</p>
<h2 class="text-3xl font-black text-[#8B4860] leading-tight">Fill up now</h2>
</div>
<div class="flex flex-col items-center gap-1">
<div class="relative w-12 h-12">
<svg class="w-full h-full -rotate-90" viewBox="0 0 48 48">
<circle cx="24" cy="24" r="20" stroke="#eeeae5" stroke-width="4" fill="transparent"/>
<circle cx="24" cy="24" r="20" stroke="#8B4860" stroke-width="4" fill="transparent"
stroke-dasharray="125.6" stroke-dashoffset="25.1" stroke-linecap="round"/>
</svg>
<span class="absolute inset-0 flex items-center justify-center text-[11px] font-black text-[#4a3f3b]">80%</span>
</div>
<span class="text-[9px] font-bold uppercase tracking-wider text-[#89726c]">Confidence</span>
</div>
</div>
<p class="text-[#6b5a55] text-sm leading-relaxed font-medium">
Local prices are at a 30-day low. Regional wholesale trends indicate a 3p/litre increase starting Monday. Securing fuel today is highly advised.
</p>
</div>
</section>
{{-- Map Section --}}
<section class="relative h-56 w-full bg-[#eeeae5] border-y border-[#e5ded7]">
{{-- Simulated map grid --}}
<div class="absolute inset-0 opacity-10" style="background-image:radial-gradient(#89726c 1px,transparent 1px);background-size:30px 30px;"></div>
<div class="absolute inset-0 opacity-20" style="background-image:linear-gradient(0deg,transparent 24%,rgba(137,114,108,.2) 25%,rgba(137,114,108,.2) 26%,transparent 27%,transparent 74%,rgba(137,114,108,.2) 75%,rgba(137,114,108,.2) 76%,transparent 77%,transparent),linear-gradient(90deg,transparent 24%,rgba(137,114,108,.2) 25%,rgba(137,114,108,.2) 26%,transparent 27%,transparent 74%,rgba(137,114,108,.2) 75%,rgba(137,114,108,.2) 76%,transparent 77%,transparent);background-size:100px 100px;"></div>
{{-- Map Markers --}}
<div class="absolute top-1/4 left-1/4 flex flex-col items-center">
<div class="px-1.5 py-0.5 bg-white border-2 border-[#22c55e] rounded text-[10px] font-bold text-[#4a3f3b] mb-0.5 shadow-sm">142.9p</div>
<iconify-icon icon="mdi:map-marker" class="text-[#22c55e] text-3xl"></iconify-icon>
</div>
<div class="absolute top-1/2 left-3/4 flex flex-col items-center">
<div class="px-1.5 py-0.5 bg-white border-2 border-[#89726c] rounded text-[10px] font-bold text-[#4a3f3b] mb-0.5 shadow-sm">145.7p</div>
<iconify-icon icon="mdi:map-marker" class="text-[#89726c] text-3xl"></iconify-icon>
</div>
<div class="absolute bottom-1/4 left-1/2 flex flex-col items-center">
<div class="px-1.5 py-0.5 bg-white border-2 border-[#f59e0b] rounded text-[10px] font-bold text-[#4a3f3b] mb-0.5 shadow-sm">148.9p</div>
<iconify-icon icon="mdi:map-marker" class="text-[#f59e0b] text-3xl"></iconify-icon>
</div>
{{-- Legend --}}
<div class="absolute bottom-3 left-3 bg-[#faf6f3]/90 backdrop-blur-sm border border-[#e5ded7] rounded-lg px-3 py-2 flex gap-3">
<div class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-[#22c55e] shrink-0"></span>
<span class="text-[9px] font-bold uppercase text-[#4a3f3b]">Current</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-[#89726c] shrink-0"></span>
<span class="text-[9px] font-bold uppercase text-[#4a3f3b]">Recent</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-[#f59e0b] shrink-0"></span>
<span class="text-[9px] font-bold uppercase text-[#4a3f3b]">Stale</span>
</div>
</div>
</section>
{{-- Nearby Stations --}}
<section class="px-5 py-5">
<div class="flex items-center justify-between mb-3">
<h3 class="text-base font-bold text-[#4a3f3b]">Stations Nearby</h3>
<span class="text-[10px] font-bold uppercase tracking-widest text-[#89726c]">26 Results</span>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between bg-[#faf6f3] px-4 py-3.5 rounded-xl border border-[#e5ded7]">
<h4 class="font-bold text-sm text-[#4a3f3b]">Tesco Superstore</h4>
<div class="text-xl font-black text-[#22c55e]">142.9<span class="text-xs font-bold ml-0.5">p</span></div>
</div>
<div class="flex items-center justify-between bg-[#faf6f3] px-4 py-3.5 rounded-xl border border-[#e5ded7]">
<h4 class="font-bold text-sm text-[#4a3f3b]">Sainsbury's Fuel</h4>
<div class="text-xl font-black text-[#22c55e]">143.1<span class="text-xs font-bold ml-0.5">p</span></div>
</div>
<div class="flex items-center justify-between bg-[#faf6f3] px-4 py-3.5 rounded-xl border border-[#e5ded7]">
<h4 class="font-bold text-sm text-[#4a3f3b]">BP Connect</h4>
<div class="text-xl font-black text-[#89726c]">145.7<span class="text-xs font-bold ml-0.5">p</span></div>
</div>
<div class="flex items-center justify-between bg-[#faf6f3] px-4 py-3.5 rounded-xl border border-[#e5ded7]">
<h4 class="font-bold text-sm text-[#4a3f3b]">Shell V-Power</h4>
<div class="text-xl font-black text-[#f59e0b]">148.9<span class="text-xs font-bold ml-0.5">p</span></div>
</div>
<div class="flex items-center justify-between bg-[#faf6f3] px-4 py-3.5 rounded-xl border border-[#e5ded7]">
<h4 class="font-bold text-sm text-[#4a3f3b]">Esso Express</h4>
<div class="text-xl font-black text-[#ef4444]">151.2<span class="text-xs font-bold ml-0.5">p</span></div>
</div>
</div>
</section>
{{-- 14-Day Forecast (Pro) --}}
<section class="px-5 pb-8">
<div class="relative bg-[#faf6f3] border border-[#e5ded7] rounded-2xl overflow-hidden">
<div class="p-5">
<div class="flex items-center justify-between mb-5">
<h3 class="text-base font-bold text-[#4a3f3b]">14-Day Forecast</h3>
<div class="flex items-center gap-1 px-2 py-0.5 bg-[#bb5b3e]/10 text-[#bb5b3e] rounded text-[10px] font-bold uppercase tracking-wide">
<iconify-icon icon="lucide:sparkles"></iconify-icon>
Pro
</div>
</div>
<div class="relative h-20 w-full">
<svg class="w-full h-full" viewBox="0 0 100 40" preserveAspectRatio="none">
<path d="M0 35 Q 10 32, 20 36 T 40 30 T 60 38 T 80 25 T 100 32"
fill="none" stroke="#bb5b3e" stroke-width="2" stroke-linecap="round"/>
</svg>
<div class="absolute inset-y-0 right-0 w-3/5 flex items-center justify-center"
style="background:linear-gradient(to right,transparent,rgba(250,246,243,0.85) 30%,#faf6f3);backdrop-filter:blur(3px);">
<button class="flex items-center gap-1.5 px-4 py-2 bg-[#bb5b3e] text-white rounded-full text-xs font-bold shadow-lg">
<iconify-icon icon="lucide:lock-keyhole"></iconify-icon>
Unlock Forecast
</button>
</div>
</div>
</div>
</div>
</section>
</main>
{{-- Mobile Tab Bar --}}
<footer class="shrink-0 bg-[#faf6f3] border-t border-[#e5ded7] pb-8">
<nav class="flex justify-around items-center pt-3">
<a href="#" class="flex flex-col items-center gap-1 text-[#bb5b3e]">
<iconify-icon icon="lucide:search" class="text-2xl"></iconify-icon>
<span class="text-[9px] font-bold uppercase tracking-wide">Prices</span>
</a>
<a href="#" class="flex flex-col items-center gap-1 text-[#89726c]">
<iconify-icon icon="lucide:bell" class="text-2xl"></iconify-icon>
<span class="text-[9px] font-bold uppercase tracking-wide">Alerts</span>
</a>
<a href="#" class="flex flex-col items-center gap-1 text-[#89726c]">
<iconify-icon icon="lucide:map" class="text-2xl"></iconify-icon>
<span class="text-[9px] font-bold uppercase tracking-wide">Forecourts</span>
</a>
<a href="#" class="flex flex-col items-center gap-1 text-[#89726c]">
<iconify-icon icon="lucide:bar-chart-3" class="text-2xl"></iconify-icon>
<span class="text-[9px] font-bold uppercase tracking-wide">Trends</span>
</a>
</nav>
</footer>
</div>{{-- end mobile layout --}}
{{-- Desktop Layout (hidden on mobile) --}}
<div class="hidden md:block">
{{-- Navigation --}}
<nav class="fixed top-0 w-full z-50 bg-[#faf6f3] border-b border-[#e5ded7] px-6 py-4 md:px-12">
<div class="max-w-7xl mx-auto flex items-center justify-between">
<a href="{{ route('home') }}" class="flex items-center gap-3">
<div class="w-10 h-10 md:w-12 md:h-12 rounded-lg bg-[#bb5b3e] flex items-center justify-center shadow-md">
<iconify-icon icon="lucide:fuel" class="text-white text-xl md:text-2xl"></iconify-icon>
</div>
<span class="text-2xl md:text-3xl font-black tracking-tighter text-[#bb5b3e]">FuelAlert</span>
</a>
<div class="hidden md:flex items-center gap-10">
<a href="#how-it-works" class="text-sm font-semibold text-[#89726c] hover:text-[#bb5b3e] transition-colors">How it Works</a>
<a href="#features" class="text-sm font-semibold text-[#89726c] hover:text-[#bb5b3e] transition-colors">Features</a>
<a href="#pricing" class="text-sm font-semibold text-[#89726c] hover:text-[#bb5b3e] transition-colors">Pricing</a>
</div>
<div class="flex items-center gap-4">
@auth
<a href="{{ route('dashboard') }}" class="text-sm font-bold text-[#89726c] hover:text-[#4a3f3b] transition-colors">Dashboard</a>
@else
<a href="{{ route('login') }}" class="text-sm font-bold text-[#89726c] hover:text-[#4a3f3b] transition-colors">Login</a>
<a href="{{ route('register') }}" class="bg-[#bb5b3e] text-white px-6 py-2.5 rounded-full text-sm font-bold shadow-lg hover:bg-[#a34a31] transition-all transform hover:scale-105 active:scale-95">Get Started</a>
@endauth
</div>
</div>
</nav>
{{-- Hero Section --}}
<section class="relative pt-40 pb-24 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 bg-[#bb5b3e]/10 text-[#bb5b3e] rounded-full text-xs font-bold uppercase tracking-wider">
<iconify-icon icon="lucide:sparkles"></iconify-icon>
Save up to £250/year on fuel
</div>
<h1 class="text-5xl md:text-7xl font-black text-[#4a3f3b] leading-[1.1] tracking-tighter">
Stop Overpaying <br><span class="text-[#bb5b3e]">for Fuel.</span>
</h1>
<p class="text-xl text-[#89726c] 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>
<form action="{{ route('stations.search') }}" method="GET" class="flex flex-col sm:flex-row gap-3 max-w-md">
<div class="relative flex-1">
<iconify-icon icon="lucide:map-pin" class="absolute left-4 top-1/2 -translate-y-1/2 text-[#89726c] text-xl pointer-events-none"></iconify-icon>
<input type="text" name="postcode" placeholder="Enter Postcode" class="w-full h-14 pl-12 pr-4 bg-white border border-[#e5ded7] rounded-xl focus:outline-none focus:ring-2 focus:ring-[#bb5b3e] shadow-inner text-lg text-[#4a3f3b]">
</div>
<button type="submit" class="h-14 px-8 bg-[#bb5b3e] text-white rounded-xl font-bold text-lg shadow-xl hover:bg-[#a34a31] transition-all">Find Prices</button>
</form>
<div class="flex items-center gap-4 pt-4">
<div class="flex -space-x-2">
<div class="w-8 h-8 rounded-full border-2 border-white bg-[#bb5b3e]/20 flex items-center justify-center text-xs font-bold text-[#bb5b3e]">JR</div>
<div class="w-8 h-8 rounded-full border-2 border-white bg-[#4A7C7E]/20 flex items-center justify-center text-xs font-bold text-[#4A7C7E]">SM</div>
<div class="w-8 h-8 rounded-full border-2 border-white bg-[#9B8B6B]/20 flex items-center justify-center text-xs font-bold text-[#9B8B6B]">DK</div>
</div>
<span class="text-sm text-[#89726c] font-medium italic">"Saved me £12 on my first tank!"</span>
</div>
</div>
{{-- Visual Mockup --}}
<div class="relative hidden lg:block">
<div class="absolute -inset-4 bg-[#bb5b3e]/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-[#bb5b3e] flex items-center justify-center">
<iconify-icon icon="lucide:fuel" class="text-white"></iconify-icon>
</div>
<span class="font-black text-[#bb5b3e]">FuelAlert</span>
</div>
<div class="text-xs font-bold text-[#89726c]">SW1A 1AA</div>
</div>
<div class="bg-[#faf6f3] p-4 rounded-xl border border-[#e5ded7] shadow-sm">
<p class="text-[10px] font-bold uppercase tracking-widest text-[#89726c] mb-1">Recommendation</p>
<h3 class="text-2xl font-black text-[#8B4860]">Fill up now</h3>
<div class="mt-2 h-1.5 w-full bg-[#eeeae5] rounded-full overflow-hidden">
<div class="h-full bg-[#8B4860] w-4/5"></div>
</div>
</div>
<div class="space-y-2">
<div class="flex justify-between items-center p-3 bg-white rounded-lg border border-[#eeeae5]">
<span class="font-bold text-sm text-[#4a3f3b]">Tesco Superstore</span>
<span class="font-black text-[#22c55e]">142.9p</span>
</div>
<div class="flex justify-between items-center p-3 bg-white rounded-lg border border-[#eeeae5]">
<span class="font-bold text-sm text-[#4a3f3b]">Sainsbury's Fuel</span>
<span class="font-black text-[#22c55e]">143.1p</span>
</div>
<div class="flex justify-between items-center p-3 bg-white rounded-lg border border-[#eeeae5]">
<span class="font-bold text-sm text-[#4a3f3b]">Shell V-Power</span>
<span class="font-black text-[#89726c]">148.9p</span>
</div>
</div>
</div>
</div>
</div>
</section>
{{-- How It Works --}}
<section id="how-it-works" class="py-24 px-6 bg-[#faf6f3]">
<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 text-[#4a3f3b]">Smart Savings in 3 Steps</h2>
<p class="text-[#89726c] text-lg max-w-2xl mx-auto">Stop guessing when to fill up. Our engine analyses 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-[#bb5b3e]/10 text-[#bb5b3e] 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 text-[#4a3f3b]">1. Search</h3>
<p class="text-[#89726c]">Enter your postcode to find every forecourt within a 520 mile radius instantly.</p>
</div>
<div class="text-center space-y-4">
<div class="w-16 h-16 bg-[#bb5b3e]/10 text-[#bb5b3e] 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 text-[#4a3f3b]">2. Get Advice</h3>
<p class="text-[#89726c]">Our engine 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-[#bb5b3e]/10 text-[#bb5b3e] 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 text-[#4a3f3b]">3. Fill Up Smart</h3>
<p class="text-[#89726c]">Navigate to the cheapest station and fill up with confidence knowing you've secured the best price.</p>
</div>
</div>
</div>
</section>
{{-- Features Section --}}
<section id="features" class="py-24 px-6 bg-[#f5ede5]">
<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-2 gap-6">
<div class="p-6 bg-[#faf6f3] border border-[#e5ded7] rounded-2xl space-y-3">
<iconify-icon icon="lucide:zap" class="text-3xl text-[#bb5b3e]"></iconify-icon>
<h4 class="font-bold text-lg text-[#4a3f3b]">Real-Time Prices</h4>
<p class="text-sm text-[#89726c]">Verified daily prices from thousands of UK forecourts updated every 15 minutes.</p>
</div>
<div class="p-6 bg-[#faf6f3] border border-[#e5ded7] rounded-2xl space-y-3">
<iconify-icon icon="lucide:calendar" class="text-3xl text-[#bb5b3e]"></iconify-icon>
<h4 class="font-bold text-lg text-[#4a3f3b]">Timing Predictions</h4>
<p class="text-sm text-[#89726c]">Proprietary 14-day forecasts for petrol and diesel trends near you.</p>
</div>
<div class="p-6 bg-[#faf6f3] border border-[#e5ded7] rounded-2xl space-y-3">
<iconify-icon icon="lucide:shopping-bag" class="text-3xl text-[#bb5b3e]"></iconify-icon>
<h4 class="font-bold text-lg text-[#4a3f3b]">Supermarket Anchors</h4>
<p class="text-sm text-[#89726c]">Track local supermarkets they set the floor price that independents must follow.</p>
</div>
<div class="p-6 bg-[#faf6f3] border border-[#e5ded7] rounded-2xl space-y-3">
<iconify-icon icon="lucide:bell-ring" class="text-3xl text-[#bb5b3e]"></iconify-icon>
<h4 class="font-bold text-lg text-[#4a3f3b]">Smart Price Alerts</h4>
<p class="text-sm text-[#89726c]">Get notified by email, WhatsApp, or SMS when local prices hit your target.</p>
</div>
</div>
</div>
<div class="order-1 lg:order-2 space-y-8">
<h2 class="text-4xl md:text-5xl font-black text-[#4a3f3b]">The ultimate fuel companion.</h2>
<p class="text-lg text-[#89726c]">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 text-[#4a3f3b]">
<iconify-icon icon="lucide:check-circle-2" class="text-[#bb5b3e] text-xl shrink-0"></iconify-icon>
Coverage for 98% of UK Forecourts
</li>
<li class="flex items-center gap-3 font-bold text-[#4a3f3b]">
<iconify-icon icon="lucide:check-circle-2" class="text-[#bb5b3e] text-xl shrink-0"></iconify-icon>
Hyper-local Map Visualisation
</li>
<li class="flex items-center gap-3 font-bold text-[#4a3f3b]">
<iconify-icon icon="lucide:check-circle-2" class="text-[#bb5b3e] text-xl shrink-0"></iconify-icon>
Historic Price Benchmarking
</li>
</ul>
<a href="{{ route('stations.search') }}" class="inline-flex items-center gap-2 text-[#bb5b3e] font-black text-lg group">
Find prices near you
<iconify-icon icon="lucide:arrow-right" class="group-hover:translate-x-1 transition-transform"></iconify-icon>
</a>
</div>
</div>
</div>
</section>
{{-- Pricing Section --}}
<section id="pricing" class="py-24 px-6 bg-[#faf6f3]">
<div class="max-w-7xl mx-auto">
<div class="text-center mb-16">
<h2 class="text-4xl md:text-5xl font-black text-[#4a3f3b] mb-4">Pricing for every driver</h2>
<p class="text-[#89726c] text-lg">Save hundreds for less than the cost of a coffee.</p>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
{{-- Free --}}
<div class="bg-white border border-[#e5ded7] p-8 rounded-3xl flex flex-col h-full">
<div class="mb-8">
<h4 class="text-xl font-bold mb-2 text-[#4a3f3b]">Free</h4>
<div class="flex items-baseline gap-1">
<span class="text-4xl font-black text-[#4a3f3b]">£0</span>
<span class="text-[#89726c] text-sm">/mo</span>
</div>
</div>
<ul class="space-y-4 mb-8 flex-1 text-[#4a3f3b]">
<li class="text-sm flex gap-2 items-start"><iconify-icon icon="lucide:check" class="text-[#bb5b3e] shrink-0 mt-0.5"></iconify-icon> Nationwide station search</li>
<li class="text-sm flex gap-2 items-start"><iconify-icon icon="lucide:check" class="text-[#bb5b3e] shrink-0 mt-0.5"></iconify-icon> Weekly email digest</li>
<li class="text-sm flex gap-2 items-start text-[#89726c]"><iconify-icon icon="lucide:x" class="text-[#e5ded7] shrink-0 mt-0.5"></iconify-icon> No push / SMS alerts</li>
<li class="text-sm flex gap-2 items-start text-[#89726c]"><iconify-icon icon="lucide:x" class="text-[#e5ded7] shrink-0 mt-0.5"></iconify-icon> No trend forecasts</li>
</ul>
<a href="{{ route('register') }}" class="w-full py-3 px-4 border border-[#e5ded7] rounded-xl text-center font-bold hover:bg-[#faf6f3] transition-colors text-[#4a3f3b]">Get Started</a>
</div>
{{-- Basic --}}
<div class="bg-white border border-[#e5ded7] p-8 rounded-3xl flex flex-col h-full">
<div class="mb-8">
<h4 class="text-xl font-bold mb-2 text-[#4a3f3b]">Basic</h4>
<div class="flex items-baseline gap-1">
<span class="text-4xl font-black text-[#4a3f3b]">£0.99</span>
<span class="text-[#89726c] text-sm">/mo</span>
</div>
</div>
<ul class="space-y-4 mb-8 flex-1 text-[#4a3f3b]">
<li class="text-sm flex gap-2 items-start"><iconify-icon icon="lucide:check" class="text-[#bb5b3e] shrink-0 mt-0.5"></iconify-icon> Daily email + push alerts</li>
<li class="text-sm flex gap-2 items-start"><iconify-icon icon="lucide:check" class="text-[#bb5b3e] shrink-0 mt-0.5"></iconify-icon> WhatsApp price alerts</li>
<li class="text-sm flex gap-2 items-start"><iconify-icon icon="lucide:check" class="text-[#bb5b3e] shrink-0 mt-0.5"></iconify-icon> 14-day trend data</li>
<li class="text-sm flex gap-2 items-start text-[#89726c]"><iconify-icon icon="lucide:x" class="text-[#e5ded7] shrink-0 mt-0.5"></iconify-icon> No SMS alerts</li>
</ul>
<a href="{{ route('register') }}" class="w-full py-3 px-4 border border-[#e5ded7] rounded-xl text-center font-bold hover:bg-[#faf6f3] transition-colors text-[#4a3f3b]">Select Basic</a>
</div>
{{-- Plus (highlighted) --}}
<div class="bg-white border-2 border-[#bb5b3e] p-8 rounded-3xl flex flex-col h-full relative">
<div class="absolute -top-4 left-1/2 -translate-x-1/2 bg-[#bb5b3e] text-white px-4 py-1 rounded-full text-[10px] font-black uppercase tracking-widest whitespace-nowrap">Most Popular</div>
<div class="mb-8">
<h4 class="text-xl font-bold mb-2 text-[#4a3f3b]">Plus</h4>
<div class="flex items-baseline gap-1">
<span class="text-4xl font-black text-[#bb5b3e]">£2.49</span>
<span class="text-[#89726c] text-sm">/mo</span>
</div>
</div>
<ul class="space-y-4 mb-8 flex-1 text-[#4a3f3b]">
<li class="text-sm flex gap-2 items-start font-bold"><iconify-icon icon="lucide:check" class="text-[#bb5b3e] shrink-0 mt-0.5"></iconify-icon> Everything in Basic</li>
<li class="text-sm flex gap-2 items-start"><iconify-icon icon="lucide:check" class="text-[#bb5b3e] shrink-0 mt-0.5"></iconify-icon> SMS alerts (up to 1/day)</li>
<li class="text-sm flex gap-2 items-start"><iconify-icon icon="lucide:check" class="text-[#bb5b3e] shrink-0 mt-0.5"></iconify-icon> Supermarket anchor tracking</li>
<li class="text-sm flex gap-2 items-start"><iconify-icon icon="lucide:check" class="text-[#bb5b3e] shrink-0 mt-0.5"></iconify-icon> Multi-location tracking</li>
</ul>
<a href="{{ route('register') }}" class="w-full py-3 px-4 bg-[#bb5b3e] text-white rounded-xl text-center font-bold shadow-lg hover:bg-[#a34a31] transition-all">Join Plus</a>
</div>
{{-- Pro --}}
<div class="bg-[#4a3f3b] border border-[#4a3f3b] p-8 rounded-3xl flex flex-col h-full text-white">
<div class="mb-8">
<h4 class="text-xl font-bold mb-2">Pro</h4>
<div class="flex items-baseline gap-1">
<span class="text-4xl font-black">£3.99</span>
<span class="text-white/60 text-sm">/mo</span>
</div>
</div>
<ul class="space-y-4 mb-8 flex-1">
<li class="text-sm flex gap-2 items-start font-bold"><iconify-icon icon="lucide:check" class="text-[#bb5b3e] shrink-0 mt-0.5"></iconify-icon> Everything in Plus</li>
<li class="text-sm flex gap-2 items-start"><iconify-icon icon="lucide:sparkles" class="text-[#bb5b3e] shrink-0 mt-0.5"></iconify-icon> AI price predictions</li>
<li class="text-sm flex gap-2 items-start"><iconify-icon icon="lucide:check" class="text-[#bb5b3e] shrink-0 mt-0.5"></iconify-icon> SMS alerts (up to 3/day)</li>
<li class="text-sm flex gap-2 items-start"><iconify-icon icon="lucide:check" class="text-[#bb5b3e] shrink-0 mt-0.5"></iconify-icon> Exportable price history</li>
</ul>
<a href="{{ route('register') }}" class="w-full py-3 px-4 bg-white text-[#4a3f3b] rounded-xl text-center font-bold hover:bg-gray-100 transition-colors">Go Pro</a>
</div>
</div>
</div>
</section>
{{-- Social Proof --}}
<section class="py-24 px-6 bg-[#f5ede5]">
<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 text-[#4a3f3b] mb-4">Loved by commuters.</h2>
<div class="flex items-center gap-1 text-[#f59e0b] 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-[#89726c]">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-[#faf6f3] border border-[#e5ded7] rounded-2xl shadow-sm">
<p class="italic text-[#4a3f3b]">"I used to just go to the nearest station. Now I check FuelAlert and there's always a station 2 miles away that's 5p cheaper. Over a month it adds up to a free tank per year."</p>
<div class="mt-4 flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-[#bb5b3e]/20 flex items-center justify-center text-sm font-bold text-[#bb5b3e]">JR</div>
<div>
<p class="font-bold text-sm text-[#4a3f3b]">James R.</p>
<p class="text-[10px] text-[#89726c] uppercase font-bold tracking-widest">Daily Commuter</p>
</div>
</div>
</div>
<div class="p-6 bg-[#faf6f3] border border-[#e5ded7] rounded-2xl shadow-sm">
<p class="italic text-[#4a3f3b]">"The predictions are eerily accurate. I was going to fill up Friday, FuelAlert said wait until Monday. Sure enough, my local Tesco dropped 3p. Brilliant."</p>
<div class="mt-4 flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-[#4A7C7E]/20 flex items-center justify-center text-sm font-bold text-[#4A7C7E]">SM</div>
<div>
<p class="font-bold text-sm text-[#4a3f3b]">Sarah M.</p>
<p class="text-[10px] text-[#89726c] uppercase font-bold tracking-widest">Delivery Driver</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{{-- CTA Banner --}}
<section class="py-24 px-6 bg-[#bb5b3e] text-white text-center">
<div class="max-w-3xl mx-auto space-y-8">
<h2 class="text-4xl md:text-5xl font-black leading-tight">Ready to outsmart the pumps?</h2>
<p class="text-xl text-white/80">Sign up 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 href="{{ route('register') }}" class="bg-white text-[#bb5b3e] px-10 py-4 rounded-xl text-lg font-black shadow-2xl hover:bg-gray-100 transition-all">Create Free Account</a>
<a href="{{ route('stations.search') }}" 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">Search Prices Now</a>
</div>
</div>
</section>
{{-- Footer --}}
<footer class="bg-[#faf6f3] border-t border-[#e5ded7] 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">
<a href="{{ route('home') }}" class="flex items-center gap-2">
<div class="w-8 h-8 rounded bg-[#bb5b3e] flex items-center justify-center">
<iconify-icon icon="lucide:fuel" class="text-white"></iconify-icon>
</div>
<span class="text-xl font-black tracking-tighter text-[#bb5b3e]">FuelAlert</span>
</a>
<p class="text-sm text-[#89726c] leading-relaxed">
Helping UK drivers save money at the pump. Real-time data, smarter choices.
</p>
</div>
<div class="space-y-4">
<h5 class="font-black uppercase text-xs text-[#4a3f3b] tracking-widest">Product</h5>
<ul class="space-y-2 text-sm text-[#89726c]">
<li><a href="#pricing" class="hover:text-[#bb5b3e] transition-colors">Pricing</a></li>
<li><a href="#features" class="hover:text-[#bb5b3e] transition-colors">Features</a></li>
<li><a href="{{ route('stations.search') }}" class="hover:text-[#bb5b3e] transition-colors">Find Prices</a></li>
</ul>
</div>
<div class="space-y-4">
<h5 class="font-black uppercase text-xs text-[#4a3f3b] tracking-widest">Account</h5>
<ul class="space-y-2 text-sm text-[#89726c]">
<li><a href="{{ route('register') }}" class="hover:text-[#bb5b3e] transition-colors">Sign Up Free</a></li>
<li><a href="{{ route('login') }}" class="hover:text-[#bb5b3e] transition-colors">Login</a></li>
@auth
<li><a href="{{ route('dashboard') }}" class="hover:text-[#bb5b3e] transition-colors">Dashboard</a></li>
@endauth
</ul>
</div>
<div class="space-y-4">
<h5 class="font-black uppercase text-xs text-[#4a3f3b] tracking-widest">Legal</h5>
<ul class="space-y-2 text-sm text-[#89726c]">
<li><span class="cursor-default">Privacy Policy</span></li>
<li><span class="cursor-default">Terms of Service</span></li>
<li><span class="cursor-default">Cookie Settings</span></li>
</ul>
</div>
</div>
<div class="max-w-7xl mx-auto pt-8 border-t border-[#e5ded7] flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] font-bold uppercase tracking-widest text-[#89726c]">
<p>© {{ date('Y') }} FuelAlert. All Rights Reserved.</p>
<p>Data sourced from the UK Fuel Finder transparency scheme.</p>
</div>
</footer>
</div>{{-- end desktop layout --}}
</body>
</html>

View File

@@ -10,7 +10,7 @@
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600|inter:400,500,600|manrope:600,700,800,900" rel="stylesheet" />
@vite(['resources/css/app.css', 'resources/js/app.js'])
@fluxAppearance

View File

@@ -1,17 +1,12 @@
<?php
use App\Livewire\Public\FuelFinder;
use App\Livewire\Public\StationSearch;
use Illuminate\Support\Facades\Route;
//Route::get('/', FuelFinder::class)->name('home');
Route::view('/', 'homepage')->name('home');
Route::get('/fuel-finder', FuelFinder::class)->name('fuel-finder');
Route::get('/stations', StationSearch::class)->name('stations.search');
Route::middleware(['auth', 'verified'])->group(function () {
Route::view('dashboard', 'dashboard')->name('dashboard');
});

View File

@@ -0,0 +1,33 @@
<?php
use App\Livewire\Public\Fuel\Map;
use Livewire\Livewire;
it('renders the map component', function () {
Livewire::test(Map::class)
->assertStatus(200);
});
it('dispatches map-update browser event when stations-found is received', function () {
Livewire::test(Map::class)
->dispatch('stations-found',
results: [['name' => 'BP Garage']],
meta: ['lat' => 51.5, 'lng' => -0.1, 'count' => 1],
radius: 5,
prediction: null
)
->assertDispatched('map-update');
});
it('passes radius in map-update payload', function () {
Livewire::test(Map::class)
->dispatch('stations-found',
results: [],
meta: ['lat' => 51.5, 'lng' => -0.1, 'count' => 0],
radius: 10,
prediction: null
)
->assertDispatched('map-update', fn ($event, $params) =>
$params['radius'] === 10
);
});

View File

@@ -0,0 +1,52 @@
<?php
use App\Livewire\Public\Fuel\Recommendation;
use Livewire\Livewire;
it('renders nothing before stations-found fires', function () {
Livewire::test(Recommendation::class)
->assertStatus(200)
->assertSet('prediction', null)
->assertDontSee('Recommendation');
});
it('shows recommendation card when stations-found includes a prediction', function () {
$prediction = [
'action' => 'fill_now',
'confidence_score' => 80.0,
'confidence_label' => 'high',
'reasoning' => 'Prices are rising sharply.',
'predicted_direction' => 'up',
'predicted_change_pence' => 3.5,
];
Livewire::test(Recommendation::class)
->dispatch('stations-found', results: [], meta: [], prediction: $prediction, radius: 5)
->assertSet('prediction', $prediction)
->assertSee('Recommendation')
->assertSee('Fill up now');
});
it('shows nothing when stations-found has null prediction', function () {
Livewire::test(Recommendation::class)
->dispatch('stations-found', results: [], meta: [], prediction: null, radius: 5)
->assertSet('prediction', null)
->assertDontSee('Recommendation');
});
it('clears previous prediction when new stations-found fires with null prediction', function () {
$prediction = [
'action' => 'fill_now',
'confidence_score' => 80.0,
'confidence_label' => 'high',
'reasoning' => 'Prices rising.',
'predicted_direction' => 'up',
'predicted_change_pence' => 3.5,
];
Livewire::test(Recommendation::class)
->dispatch('stations-found', results: [], meta: [], prediction: $prediction, radius: 5)
->assertSee('Recommendation')
->dispatch('stations-found', results: [], meta: [], prediction: null, radius: 5)
->assertDontSee('Recommendation');
});

View File

@@ -0,0 +1,217 @@
<?php
use App\Livewire\Public\Fuel\Search;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('renders the search component', function () {
Livewire::test(Search::class)
->assertStatus(200)
->assertSeeHtml('name="search"');
});
it('has default property values', function () {
Livewire::test(Search::class)
->assertSet('search', '')
->assertSet('fuelType', 'petrol')
->assertSet('radius', 5)
->assertSet('sort', 'reliable')
->assertSet('apiError', null)
->assertSet('hasSearched', false);
});
it('validates search is required', function () {
Livewire::test(Search::class)
->call('findStations')
->assertHasErrors(['search' => 'required']);
});
it('validates fuelType is required', function () {
Livewire::test(Search::class)
->set('search', 'SW1A 1AA')
->set('fuelType', '')
->call('findStations')
->assertHasErrors(['fuelType' => 'required']);
});
it('dispatches stations-found with results, meta, prediction and radius on successful search', function () {
Http::fake([
'*/api/stations*' => Http::response([
'data' => [
[
'station_id' => 'abc123',
'name' => 'BP Garage',
'brand' => 'BP',
'is_supermarket' => false,
'address' => '1 High Street',
'postcode' => 'SW1A 1AA',
'lat' => 51.5074,
'lng' => -0.1278,
'distance_km' => 1.5,
'fuel_type' => 'e10',
'price_pence' => 14390,
'price' => 143.9,
'price_updated_at' => '2026-04-05T08:00:00.000Z',
'price_classification' => 'current',
'price_classification_label' => 'Current',
],
],
'meta' => ['count' => 1, 'lowest_pence' => 14390, 'avg_pence' => 14390.0],
], 200),
'*/api/prediction*' => Http::response([
'action' => 'fill_now',
'confidence_score' => 80.0,
'confidence_label' => 'high',
'reasoning' => 'Prices rising.',
'predicted_direction' => 'up',
'predicted_change_pence' => 3.5,
], 200),
]);
Livewire::test(Search::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->call('findStations')
->assertSet('hasSearched', true)
->assertSet('apiError', null)
->assertDispatched('stations-found', fn ($event, $params) =>
count($params['results']) === 1
&& $params['results'][0]['name'] === 'BP Garage'
&& $params['meta']['count'] === 1
&& $params['prediction']['action'] === 'fill_now'
&& $params['radius'] === 5
);
});
it('sets apiError from 422 station response and does not dispatch stations-found', function () {
Http::fake([
'*/api/stations*' => Http::response([
'errors' => ['postcode' => ['Postcode not found.']],
], 422),
]);
Livewire::test(Search::class)
->set('search', 'ZZ99 9ZZ')
->set('fuelType', 'petrol')
->call('findStations')
->assertSet('hasSearched', false)
->assertSet('apiError', 'Postcode not found.')
->assertNotDispatched('stations-found');
});
it('sets generic apiError on server error', function () {
Http::fake([
'*/api/stations*' => Http::response([], 500),
]);
Livewire::test(Search::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->call('findStations')
->assertSet('apiError', 'Unable to fetch stations. Please try again.');
});
it('converts radius from miles to km in the outgoing stations request', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
]);
Livewire::test(Search::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->set('radius', 5)
->call('findStations');
Http::assertSent(function ($request) {
if (! str_contains($request->url(), 'api/stations')) {
return false;
}
$data = $request->data();
return isset($data['radius']) && abs((float) $data['radius'] - 8.05) < 0.01;
});
});
it('resets apiError before each new search', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
]);
Livewire::test(Search::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->set('apiError', 'Old error')
->call('findStations')
->assertSet('apiError', null);
});
it('does not call findStations on updatedFuelType if not yet searched', function () {
Http::fake();
Livewire::test(Search::class)
->set('fuelType', 'diesel');
Http::assertNothingSent();
});
it('re-runs findStations on updatedFuelType when already searched', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
]);
Livewire::test(Search::class)
->set('hasSearched', true)
->set('search', 'SW1A 1AA')
->set('fuelType', 'diesel');
Http::assertSentCount(2);
});
it('re-runs findStations on updatedRadius when already searched', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
]);
Livewire::test(Search::class)
->set('hasSearched', true)
->set('search', 'SW1A 1AA')
->set('radius', 10);
Http::assertSentCount(2);
});
it('re-runs findStations on updatedSort when already searched', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
]);
Livewire::test(Search::class)
->set('hasSearched', true)
->set('search', 'SW1A 1AA')
->set('sort', 'price');
Http::assertSentCount(2);
});
it('prediction is null in stations-found payload when prediction api fails', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response([], 500),
]);
Livewire::test(Search::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->call('findStations')
->assertSet('hasSearched', true)
->assertDispatched('stations-found', fn ($event, $params) =>
$params['prediction'] === null
);
});

View File

@@ -0,0 +1,72 @@
<?php
use App\Livewire\Public\Fuel\StationList;
use Livewire\Livewire;
it('renders empty state before any search', function () {
Livewire::test(StationList::class)
->assertStatus(200)
->assertSet('hasSearched', false)
->assertDontSee('Stations Nearby');
});
it('shows station cards after stations-found event', function () {
$station = [
'station_id' => 'abc123',
'name' => 'BP Garage',
'brand' => 'BP',
'is_supermarket' => false,
'address' => '1 High Street',
'postcode' => 'SW1A 1AA',
'lat' => 51.5074,
'lng' => -0.1278,
'distance_km' => 1.5,
'fuel_type' => 'e10',
'price_pence' => 14390,
'price' => 143.9,
'price_updated_at' => '2026-04-05T08:00:00.000Z',
'price_classification' => 'current',
'price_classification_label' => 'Current',
];
$meta = ['count' => 1, 'lowest_pence' => 14390, 'avg_pence' => 14390.0];
Livewire::test(StationList::class)
->dispatch('stations-found', results: [$station], meta: $meta, prediction: null, radius: 5)
->assertSet('hasSearched', true)
->assertSee('Stations Nearby')
->assertSee('BP Garage')
->assertSee('1 Result');
});
it('shows empty state message when stations-found has no results', function () {
Livewire::test(StationList::class)
->dispatch('stations-found', results: [], meta: ['count' => 0], prediction: null, radius: 5)
->assertSet('hasSearched', true)
->assertSee('No stations found');
});
it('updates results when stations-found fires again', function () {
$station = [
'station_id' => 'abc123',
'name' => 'BP Garage',
'brand' => 'BP',
'is_supermarket' => false,
'address' => '1 High Street',
'postcode' => 'SW1A 1AA',
'lat' => 51.5074,
'lng' => -0.1278,
'distance_km' => 1.5,
'fuel_type' => 'e10',
'price_pence' => 14390,
'price' => 143.9,
'price_updated_at' => '2026-04-05T08:00:00.000Z',
'price_classification' => 'current',
'price_classification_label' => 'Current',
];
Livewire::test(StationList::class)
->dispatch('stations-found', results: [$station], meta: ['count' => 1], prediction: null, radius: 5)
->assertSee('BP Garage')
->dispatch('stations-found', results: [], meta: ['count' => 0], prediction: null, radius: 5)
->assertDontSee('BP Garage');
});

View File

@@ -1,223 +1,9 @@
<?php
use App\Livewire\Public\FuelFinder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('renders the fuel finder page', function () {
it('renders the fuel finder shell', function () {
Livewire::test(FuelFinder::class)
->assertStatus(200)
->assertSeeHtml('name="search"');
});
it('has default property values', function () {
Livewire::test(FuelFinder::class)
->assertSet('search', '')
->assertSet('fuelType', 'petrol')
->assertSet('radius', 5)
->assertSet('sort', 'reliable')
->assertSet('results', [])
->assertSet('meta', [])
->assertSet('prediction', null)
->assertSet('apiError', null)
->assertSet('hasSearched', false);
});
it('validates search is required', function () {
Livewire::test(FuelFinder::class)
->call('findStations')
->assertHasErrors(['search' => 'required']);
});
it('validates fuelType is required', function () {
Livewire::test(FuelFinder::class)
->set('search', 'SW1A 1AA')
->set('fuelType', '')
->call('findStations')
->assertHasErrors(['fuelType' => 'required']);
});
it('populates results, meta, and prediction on successful search', function () {
Http::fake([
'*/api/stations*' => Http::response([
'data' => [
[
'station_id' => 'abc123',
'name' => 'BP Garage',
'brand' => 'BP',
'is_supermarket' => false,
'address' => '1 High Street',
'postcode' => 'SW1A 1AA',
'lat' => 51.5074,
'lng' => -0.1278,
'distance_km' => 1.5,
'fuel_type' => 'e10',
'price_pence' => 14390,
'price' => 143.9,
'price_updated_at' => '2026-04-05T08:00:00.000Z',
'price_classification' => 'current',
'price_classification_label' => 'Current',
],
],
'meta' => [
'count' => 1,
'lowest_pence' => 14390,
'avg_pence' => 14390.0,
],
], 200),
'*/api/prediction*' => Http::response([
'action' => 'fill_now',
'confidence_score' => 80.0,
'confidence_label' => 'high',
'reasoning' => 'Prices rising.',
'predicted_direction' => 'up',
'predicted_change_pence' => 3.5,
], 200),
]);
Livewire::test(FuelFinder::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->call('findStations')
->assertSet('hasSearched', true)
->assertSet('apiError', null)
->assertSet('results', fn (array $r) => count($r) === 1 && $r[0]['name'] === 'BP Garage')
->assertSet('meta', fn (array $m) => $m['count'] === 1)
->assertSet('prediction', fn (?array $p) => $p !== null && $p['action'] === 'fill_now');
});
it('sets apiError from 422 station response and leaves prediction null', function () {
Http::fake([
'*/api/stations*' => Http::response([
'errors' => ['postcode' => ['Postcode not found.']],
], 422),
]);
Livewire::test(FuelFinder::class)
->set('search', 'ZZ99 9ZZ')
->set('fuelType', 'petrol')
->call('findStations')
->assertSet('results', [])
->assertSet('meta', [])
->assertSet('prediction', null)
->assertSet('hasSearched', false)
->assertSet('apiError', 'Postcode not found.');
});
it('sets generic apiError on server error', function () {
Http::fake([
'*/api/stations*' => Http::response([], 500),
]);
Livewire::test(FuelFinder::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->call('findStations')
->assertSet('apiError', 'Unable to fetch stations. Please try again.');
});
it('converts radius from miles to km in the outgoing stations request', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
]);
Livewire::test(FuelFinder::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->set('radius', 5)
->call('findStations');
Http::assertSent(function ($request) {
if (! str_contains($request->url(), 'api/stations')) {
return false;
}
$data = $request->data();
return isset($data['radius']) && abs((float) $data['radius'] - 8.05) < 0.01;
});
});
it('resets state before each new search', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
]);
Livewire::test(FuelFinder::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->set('results', [['name' => 'Old Result']])
->set('apiError', 'Old error')
->call('findStations')
->assertSet('apiError', null)
->assertSet('results', []);
});
it('does not call findStations on updatedFuelType if not yet searched', function () {
Http::fake();
Livewire::test(FuelFinder::class)
->set('fuelType', 'diesel');
Http::assertNothingSent();
});
it('re-runs findStations on updatedFuelType when already searched', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
]);
Livewire::test(FuelFinder::class)
->set('hasSearched', true)
->set('search', 'SW1A 1AA')
->set('fuelType', 'diesel');
Http::assertSentCount(2); // stations + prediction
});
it('re-runs findStations on updatedRadius when already searched', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
]);
Livewire::test(FuelFinder::class)
->set('hasSearched', true)
->set('search', 'SW1A 1AA')
->set('radius', 10);
Http::assertSentCount(2);
});
it('re-runs findStations on updatedSort when already searched', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
]);
Livewire::test(FuelFinder::class)
->set('hasSearched', true)
->set('search', 'SW1A 1AA')
->set('sort', 'price');
Http::assertSentCount(2);
});
it('prediction remains null when prediction api fails', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
'*/api/prediction*' => Http::response([], 500),
]);
Livewire::test(FuelFinder::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->call('findStations')
->assertSet('hasSearched', true)
->assertSet('prediction', null);
->assertStatus(200);
});

View File

@@ -1,140 +0,0 @@
<?php
use App\Livewire\Public\StationSearch;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('renders the station search form', function () {
Livewire::test(StationSearch::class)
->assertStatus(200)
->assertSeeHtml('name="search"')
->assertSeeHtml('name="fuelType"')
->assertSeeHtml('name="radius"');
});
it('validates search is required', function () {
Livewire::test(StationSearch::class)
->call('findStations')
->assertHasErrors(['search' => 'required']);
});
it('validates fuelType is required', function () {
Livewire::test(StationSearch::class)
->set('search', 'SW1A 1AA')
->set('fuelType', '')
->call('findStations')
->assertHasErrors(['fuelType' => 'required']);
});
it('populates results and meta on successful search', function () {
Http::fake([
'*/api/stations*' => Http::response([
'data' => [
[
'station_id' => 'abc123',
'name' => 'BP Garage',
'brand' => 'BP',
'is_supermarket' => false,
'address' => '1 High Street, London',
'postcode' => 'SW1A 1AA',
'lat' => 51.5074,
'lng' => -0.1278,
'distance_km' => 1.5,
'fuel_type' => 'e10',
'price_pence' => 14390,
'price' => 143.9,
'price_updated_at' => '2026-04-05T08:00:00.000Z',
'price_classification' => 'current',
'price_classification_label' => 'Current',
],
],
'meta' => [
'count' => 1,
'fuel_type' => 'e10',
'radius_km' => 8.05,
'lat' => 51.5010,
'lng' => -0.1415,
'lowest_pence' => 14390,
'highest_pence' => 14390,
'cheapest_price_pence' => 14390,
'avg_pence' => 14390.0,
],
], 200),
]);
Livewire::test(StationSearch::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->set('radius', 5)
->call('findStations')
->assertSet('apiError', null)
->assertSet('results', fn (array $r) => count($r) === 1 && $r[0]['name'] === 'BP Garage')
->assertSet('meta', fn (array $m) => $m['count'] === 1);
});
it('sets apiError from 422 postcode validation response', function () {
Http::fake([
'*/api/stations*' => Http::response([
'errors' => ['postcode' => ['Postcode not found.']],
], 422),
]);
Livewire::test(StationSearch::class)
->set('search', 'ZZ99 9ZZ')
->set('fuelType', 'petrol')
->call('findStations')
->assertSet('results', [])
->assertSet('meta', [])
->assertSet('apiError', 'Postcode not found.');
});
it('sets generic apiError on server error', function () {
Http::fake([
'*/api/stations*' => Http::response([], 500),
]);
Livewire::test(StationSearch::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->call('findStations')
->assertSet('results', [])
->assertSet('meta', [])
->assertSet('apiError', 'Unable to fetch stations. Please try again.');
});
it('converts radius from miles to km in the outgoing API request', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
]);
Livewire::test(StationSearch::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->set('radius', 5)
->call('findStations');
Http::assertSent(function ($request) {
$data = $request->data();
return isset($data['radius']) && abs((float) $data['radius'] - 8.05) < 0.01
&& isset($data['fuel_type']) && $data['fuel_type'] === 'petrol';
});
});
it('resets results and error before each new search', function () {
Http::fake([
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
]);
Livewire::test(StationSearch::class)
->set('search', 'SW1A 1AA')
->set('fuelType', 'petrol')
->set('results', [['name' => 'Old Result']])
->set('apiError', 'Old error')
->call('findStations')
->assertSet('apiError', null)
->assertSet('results', []);
});