docs: add FuelFinder mobile landing implementation plan
This commit is contained in:
963
docs/superpowers/plans/2026-04-07-fuelfinder-mobile-landing.md
Normal file
963
docs/superpowers/plans/2026-04-07-fuelfinder-mobile-landing.md
Normal file
@@ -0,0 +1,963 @@
|
|||||||
|
# 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 A–Z</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']))
|
||||||
|
· {{ \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) |
|
||||||
13
resources/views/components/public/⚡fuel-finder.blade.php
Normal file
13
resources/views/components/public/⚡fuel-finder.blade.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?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>
|
||||||
Reference in New Issue
Block a user