Files
fuel-price/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

33 KiB
Raw Blame History

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

php artisan make:test --pest Livewire/FuelFinderTest
  • Step 2: Replace the generated file with these tests
<?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
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

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

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:

<div>
    <input type="text" name="search" wire:model="search" />
</div>
  • Step 4: Run the tests
php artisan test --compact tests/Feature/Livewire/FuelFinderTest.php

Expected: All tests PASS.

  • Step 5: Commit
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
<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
<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
<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
@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
@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
@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
<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
php artisan test --compact tests/Feature/Livewire/FuelFinderTest.php

Expected: All PASS.

  • Step 9: Commit
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

<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
@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
php artisan test --compact tests/Feature/Livewire/FuelFinderTest.php

Expected: All PASS.

  • Step 4: Commit
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

<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
php artisan test --compact tests/Feature/Livewire/FuelFinderTest.php

Expected: All PASS.

  • Step 3: Commit
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:

Route::view('/', 'homepage')->name('home');

With:

Route::get('/', \App\Livewire\Public\FuelFinder::class)->name('home');
  • Step 2: Run all tests
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
vendor/bin/pint --dirty --format agent
  • Step 4: Re-run tests after Pint
php artisan test --compact

Expected: All PASS.

  • Step 5: Commit
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)