Files
fuel-alert/docs/superpowers/plans/2026-04-07-fuelfinder-mobile-landing.md
Ovidiu U 6da626347b
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
docs: add FuelFinder mobile landing implementation plan
2026-04-07 14:43:03 +01:00

964 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# FuelFinder Mobile Landing Page Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the static homepage with a Livewire-powered `FuelFinder` component that provides search, map, recommendation, stations list, and forecast sections for mobile.
**Architecture:** A single `FuelFinder` Livewire component drives all page state — it calls `/api/stations` and `/api/prediction`, then delegates rendering to reusable `x-fuel.*` and layout Blade components. The existing `StationSearch` at `/stations` is untouched.
**Tech Stack:** Livewire 4, Alpine.js, Tailwind CSS v4, Flux UI v2, Leaflet (existing `station-map.js`), iconify-icon (existing).
---
## File Map
| Action | Path | Responsibility |
|---|---|---|
| Create | `app/Livewire/Public/FuelFinder.php` | Component class: properties, `findStations()`, updated* hooks |
| Create | `resources/views/livewire/public/fuel-finder.blade.php` | Full mobile page template |
| Create | `resources/views/components/fuel/type-select.blade.php` | Fuel type `<select>` with `wire:model` passthrough |
| Create | `resources/views/components/fuel/radius-select.blade.php` | Radius `<select>` with `wire:model` passthrough |
| Create | `resources/views/components/fuel/sort-select.blade.php` | Sort `<select>` with `wire:model` passthrough |
| Create | `resources/views/components/fuel/station-card.blade.php` | Single station row: name, address, price, classification colour |
| Create | `resources/views/components/fuel/station-map.blade.php` | Leaflet map wrapper using Alpine `stationMap()` |
| Create | `resources/views/components/fuel/recommendation.blade.php` | Prediction card: action headline, confidence ring, reasoning, label badge |
| Create | `resources/views/components/fuel/forecast.blade.php` | Static Pro upsell card with blurred overlay |
| Create | `resources/views/components/mobile-header.blade.php` | Fixed app header: logo + user icon |
| Create | `resources/views/components/mobile-footer.blade.php` | Sticky tab bar: Prices, Alerts, Forecourts, Trends |
| Create | `tests/Feature/Livewire/FuelFinderTest.php` | All component behaviour tests |
| Modify | `routes/web.php` | `Route::get('/', FuelFinder::class)->name('home')` |
---
## Task 1: Write Failing Tests for FuelFinder
**Files:**
- Create: `tests/Feature/Livewire/FuelFinderTest.php`
- [ ] **Step 1: Create the test file**
```bash
php artisan make:test --pest Livewire/FuelFinderTest
```
- [ ] **Step 2: Replace the generated file with these tests**
```php
<?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 () {
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);
});
```
- [ ] **Step 3: Run the tests to confirm they fail**
```bash
php artisan test --compact tests/Feature/Livewire/FuelFinderTest.php
```
Expected: FAIL — "Class App\Livewire\Public\FuelFinder not found"
---
## Task 2: Create the FuelFinder Component Class
**Files:**
- Create: `app/Livewire/Public/FuelFinder.php`
- Create: `resources/views/livewire/public/fuel-finder.blade.php` (stub only)
- [ ] **Step 1: Generate the Livewire component**
```bash
php artisan make:livewire --no-interaction Public/FuelFinder
```
This creates `app/Livewire/Public/FuelFinder.php` and `resources/views/livewire/public/fuel-finder.blade.php`.
- [ ] **Step 2: Replace the generated class with the full implementation**
```php
<?php
namespace App\Livewire\Public;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\View\View;
use Livewire\Attributes\Validate;
use Livewire\Component;
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');
}
}
```
- [ ] **Step 3: Add a minimal stub to the generated view so the component renders**
Replace the content of `resources/views/livewire/public/fuel-finder.blade.php` with:
```blade
<div>
<input type="text" name="search" wire:model="search" />
</div>
```
- [ ] **Step 4: Run the tests**
```bash
php artisan test --compact tests/Feature/Livewire/FuelFinderTest.php
```
Expected: All tests PASS.
- [ ] **Step 5: Commit**
```bash
git add app/Livewire/Public/FuelFinder.php resources/views/livewire/public/fuel-finder.blade.php tests/Feature/Livewire/FuelFinderTest.php
git commit -m "feat: add FuelFinder Livewire component with tests"
```
---
## Task 3: Create `x-fuel.*` Blade Components
**Files:**
- Create: `resources/views/components/fuel/type-select.blade.php`
- Create: `resources/views/components/fuel/radius-select.blade.php`
- Create: `resources/views/components/fuel/sort-select.blade.php`
- Create: `resources/views/components/fuel/station-card.blade.php`
- Create: `resources/views/components/fuel/station-map.blade.php`
- Create: `resources/views/components/fuel/recommendation.blade.php`
- Create: `resources/views/components/fuel/forecast.blade.php`
These are anonymous Blade components (no class file needed). Create the `fuel/` directory, then create each file.
- [ ] **Step 1: Create `fuel/type-select.blade.php`**
```blade
<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]"
>
<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>
```
- [ ] **Step 2: Create `fuel/radius-select.blade.php`**
```blade
<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]"
>
<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>
```
- [ ] **Step 3: Create `fuel/sort-select.blade.php`**
```blade
<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]"
>
<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>
```
- [ ] **Step 4: Create `fuel/station-card.blade.php`**
```blade
@props(['station'])
@php
$colourClass = match($station['price_classification'] ?? '') {
'current' => 'text-green-500',
'recent' => 'text-slate-500',
'stale' => 'text-amber-500',
'outdated' => 'text-red-500',
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="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="truncate text-sm font-bold text-[#4a3f3b]">
{{ $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>
@endif
</div>
<p class="truncate text-xs text-[#89726c]">
{{ $station['address'] ?? '' }}, {{ $station['postcode'] ?? '' }}
</p>
<p class="text-xs text-[#b0a09a]">{{ $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-[11px] {{ $colourClass }}">
{{ $station['price_classification_label'] ?? '' }}
@if (! empty($station['price_updated_at']))
&middot; {{ \Carbon\Carbon::parse($station['price_updated_at'])->diffForHumans() }}
@endif
</p>
</div>
</div>
```
- [ ] **Step 5: Create `fuel/station-map.blade.php`**
```blade
@props(['results' => []])
<div
x-data="stationMap(@entangle('results'))"
class="h-56 w-full overflow-hidden border-y border-[#e5ded7] md:h-96"
></div>
```
- [ ] **Step 6: Create `fuel/recommendation.blade.php`**
```blade
@props(['prediction'])
@if ($prediction)
@php
$action = $prediction['action'] ?? 'no_signal';
$headline = match ($action) {
'fill_now' => 'Fill up now',
'wait' => 'Wait',
default => 'No signal',
};
$score = (float) ($prediction['confidence_score'] ?? 0);
$circumference = 125.6; // 2π × 20
$offset = round($circumference * (1 - $score / 100), 1);
$label = $prediction['confidence_label'] ?? 'low';
$labelColour = match ($label) {
'high' => 'bg-green-100 text-green-700',
'medium' => 'bg-amber-100 text-amber-700',
default => 'bg-zinc-100 text-zinc-500',
};
@endphp
<div class="rounded-2xl border border-[#e5ded7] bg-[#faf6f3] 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]">
Recommendation
</p>
<h2 class="text-3xl font-black leading-tight text-[#8B4860]">
{{ $headline }}
</h2>
</div>
<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"
stroke-dasharray="{{ $circumference }}"
stroke-dashoffset="{{ $offset }}"
stroke-linecap="round"
/>
</svg>
<span class="absolute inset-0 flex items-center justify-center text-[11px] font-black text-[#4a3f3b]">
{{ (int) $score }}%
</span>
</div>
<span class="text-[9px] font-bold uppercase tracking-wider text-[#89726c]">Confidence</span>
</div>
</div>
<p class="text-sm font-medium leading-relaxed text-[#6b5a55]">
{{ $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>
</div>
</div>
@endif
```
- [ ] **Step 7: Create `fuel/forecast.blade.php`**
```blade
<div class="relative overflow-hidden rounded-2xl border border-[#e5ded7] bg-[#faf6f3] p-5 shadow-sm">
{{-- Pro badge --}}
<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>
</div>
<span class="rounded-full bg-[#bb5b3e] px-2.5 py-0.5 text-[11px] font-bold uppercase tracking-wide text-white">
Pro
</span>
</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">
<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"
>
Unlock Forecast
</a>
</div>
</div>
```
- [ ] **Step 8: Run tests to confirm they still pass**
```bash
php artisan test --compact tests/Feature/Livewire/FuelFinderTest.php
```
Expected: All PASS.
- [ ] **Step 9: Commit**
```bash
git add resources/views/components/fuel/
git commit -m "feat: add x-fuel.* blade components for FuelFinder"
```
---
## Task 4: Create Mobile Layout Components
**Files:**
- Create: `resources/views/components/mobile-header.blade.php`
- Create: `resources/views/components/mobile-footer.blade.php`
- [ ] **Step 1: Create `mobile-header.blade.php`**
```blade
<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>
```
- [ ] **Step 2: Create `mobile-footer.blade.php`**
```blade
@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>
```
- [ ] **Step 3: Run tests**
```bash
php artisan test --compact tests/Feature/Livewire/FuelFinderTest.php
```
Expected: All PASS.
- [ ] **Step 4: Commit**
```bash
git add resources/views/components/mobile-header.blade.php resources/views/components/mobile-footer.blade.php
git commit -m "feat: add x-mobile-header and x-mobile-footer components"
```
---
## Task 5: Build the FuelFinder View Template
**Files:**
- Modify: `resources/views/livewire/public/fuel-finder.blade.php`
- [ ] **Step 1: Replace the stub with the full mobile template**
```blade
<div class="flex h-dvh flex-col bg-[#f5ede5]">
<x-mobile-header />
{{-- Scrollable main content, offset for fixed header (~80px) and footer (~80px) --}}
<main
class="flex-1 overflow-y-auto pt-[80px] pb-[80px]"
style="-ms-overflow-style:none;scrollbar-width:none;"
>
{{-- #search --}}
<section class="space-y-3 px-5 pt-5 pb-4">
<form wire:submit="findStations">
<div 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"
type="text"
name="search"
placeholder="Postcode, town or city"
class="h-14 w-full rounded-xl border border-[#e5ded7] bg-[#faf6f3] pl-12 pr-4 text-base font-semibold text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e] focus:border-transparent"
/>
</div>
@error('search')
<p class="mb-2 text-sm text-red-600">{{ $message }}</p>
@enderror
{{-- Filter pills (scrollable row) --}}
<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.radius-select wire:model.live="radius" />
</div>
<div class="shrink-0">
<x-fuel.sort-select wire:model.live="sort" />
</div>
</div>
<button
type="submit"
wire:loading.attr="disabled"
class="mt-3 w-full rounded-xl bg-[#bb5b3e] py-3.5 text-sm font-bold text-white shadow-md disabled:opacity-60"
>
<span wire:loading.remove wire:target="findStations">Find Stations</span>
<span wire:loading wire:target="findStations">Searching…</span>
</button>
</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
</section>
{{-- #recommendation --}}
@if ($prediction)
<section class="px-5 pb-5">
<x-fuel.recommendation :prediction="$prediction" />
</section>
@endif
{{-- #map --}}
<section class="mb-4">
<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>
</main>
<x-mobile-footer />
</div>
```
- [ ] **Step 2: Run tests**
```bash
php artisan test --compact tests/Feature/Livewire/FuelFinderTest.php
```
Expected: All PASS.
- [ ] **Step 3: Commit**
```bash
git add resources/views/livewire/public/fuel-finder.blade.php
git commit -m "feat: build fuel-finder view with mobile layout"
```
---
## Task 6: Update the Route and Run Final Checks
**Files:**
- Modify: `routes/web.php`
- [ ] **Step 1: Update the home route**
In `routes/web.php`, replace:
```php
Route::view('/', 'homepage')->name('home');
```
With:
```php
Route::get('/', \App\Livewire\Public\FuelFinder::class)->name('home');
```
- [ ] **Step 2: Run all tests**
```bash
php artisan test --compact
```
Expected: Full suite PASS. (The old homepage tests in `ExampleTest.php` and `DashboardTest.php` should still pass since they don't hit `/` directly.)
- [ ] **Step 3: Run Pint on all modified PHP files**
```bash
vendor/bin/pint --dirty --format agent
```
- [ ] **Step 4: Re-run tests after Pint**
```bash
php artisan test --compact
```
Expected: All PASS.
- [ ] **Step 5: Commit**
```bash
git add routes/web.php
git commit -m "feat: wire FuelFinder to home route, replacing static homepage"
```
---
## Self-Review Against Spec
| Spec Requirement | Covered by |
|---|---|
| `Route::get('/', FuelFinder::class)->name('home')` | Task 6 Step 1 |
| All FuelFinder properties | Task 2 Step 2 |
| `findStations()` — validate, reset, call `/api/stations`, populate results/meta, set `$hasSearched` | Task 2 Step 2 |
| `findStations()` — call `/api/prediction` | Task 2 Step 2 |
| `updatedFuelType/Radius/Sort` re-run if `$hasSearched` | Task 2 Step 2 |
| `x-fuel.type-select` with wire passthrough | Task 3 Step 1 |
| `x-fuel.radius-select` with wire passthrough | Task 3 Step 2 |
| `x-fuel.sort-select` with wire passthrough | Task 3 Step 3 |
| `x-fuel.station-card` — name, address, distance, price, classification colour, supermarket badge | Task 3 Step 4 |
| `x-fuel.station-map` — UK centre default, re-centres after search | Task 3 Step 5 (JS already handles this in `station-map.js`) |
| `x-fuel.recommendation` — action headline, confidence ring, reasoning, label badge | Task 3 Step 6 |
| `x-fuel.forecast` — SVG chart, blur overlay, Pro badge, Unlock CTA | Task 3 Step 7 |
| `x-mobile-header` — logo + user icon (auth-aware) | Task 4 Step 1 |
| `x-mobile-footer` — 4 tabs, active highlight | Task 4 Step 2 |
| View structure — search, recommendation, map, stations, forecast | Task 5 Step 1 |
| Recommendation hidden until `$prediction` set | Task 5 Step 1 (`@if $prediction`) |
| Map always visible | Task 5 Step 1 (outside any conditional) |
| Stations list under `$hasSearched` guard | Task 5 Step 1 (`@if $hasSearched`) |
| Forecast always shown | Task 5 Step 1 (always rendered) |