203 lines
6.6 KiB
Markdown
203 lines
6.6 KiB
Markdown
# FuelFinder Mobile Landing Page — Design Spec
|
||
|
||
**Date:** 2026-04-07
|
||
**Scope:** Replace static mobile homepage with a fully functional Livewire-powered landing page, backed by reusable Blade components shared with the desktop search.
|
||
|
||
---
|
||
|
||
## Goals
|
||
|
||
- Mobile landing page (`/`) becomes the working app — search, recommendation, map, stations list, forecast
|
||
- All interactive sections are driven by a single new Livewire component (`FuelFinder`)
|
||
- Presentation elements extracted into reusable Blade components consumed by both `FuelFinder` and the existing `StationSearch`
|
||
- As lean as possible — no duplicated logic, one station repeater, one map component
|
||
|
||
---
|
||
|
||
## Route Change
|
||
|
||
```php
|
||
// routes/web.php
|
||
Route::get('/', FuelFinder::class)->name('home');
|
||
```
|
||
|
||
`/stations` (StationSearch) is kept as-is for now. Migration to shared components happens separately.
|
||
|
||
---
|
||
|
||
## Livewire Component: `FuelFinder`
|
||
|
||
**File:** `app/Livewire/Public/FuelFinder.php`
|
||
**View:** `resources/views/livewire/public/fuel-finder.blade.php`
|
||
|
||
### Properties
|
||
|
||
| Property | Type | Default | Notes |
|
||
|---|---|---|---|
|
||
| `$search` | `string` | `''` | Validated: required |
|
||
| `$fuelType` | `string` | `'petrol'` | Validated: required |
|
||
| `$radius` | `int` | `5` | Validated: 1–20 |
|
||
| `$sort` | `string` | `'reliable'` | Validated: in allowed list |
|
||
| `$results` | `array` | `[]` | Populated from `/api/stations` |
|
||
| `$meta` | `array` | `[]` | Count, lowest/avg price |
|
||
| `$prediction` | `?array` | `null` | Populated from `/api/prediction` |
|
||
| `$apiError` | `?string` | `null` | Surface API/connection errors |
|
||
| `$hasSearched` | `bool` | `false` | Controls section visibility |
|
||
|
||
### Methods
|
||
|
||
**`findStations()`**
|
||
1. Validate all properties
|
||
2. Reset results, meta, prediction, apiError, hasSearched
|
||
3. Call `GET /api/stations` with postcode, fuel_type, radius (km), sort
|
||
4. On success: populate `$results`, `$meta`, set `$hasSearched = true`
|
||
5. Call `GET /api/prediction` with `fuel_type` (no lat/lng — national fallback)
|
||
6. On success: populate `$prediction`
|
||
7. On any failure: set `$apiError`
|
||
|
||
**`updatedFuelType()` / `updatedRadius()` / `updatedSort()`**
|
||
Re-run `findStations()` if `$hasSearched` is true (live filter refresh).
|
||
|
||
---
|
||
|
||
## Blade Components
|
||
|
||
All components live under `resources/views/components/fuel/` and are namespaced as `x-fuel.*`.
|
||
|
||
### `x-fuel.type-select`
|
||
Fuel type `<select>`. Accepts `wire:model` passthrough via `$attributes->whereStartsWith('wire:')`.
|
||
Options: Petrol (E10), Super Unleaded (E5), Diesel, Premium Diesel, B10 Biodiesel, HVO.
|
||
Reused in: `FuelFinder`, `StationSearch`.
|
||
|
||
### `x-fuel.radius-select`
|
||
Radius `<select>`. Options: 1, 2, 5, 10, 20 miles.
|
||
Accepts `wire:model` passthrough.
|
||
|
||
### `x-fuel.sort-select`
|
||
Sort `<select>`. Options: Best price (reliable), Cheapest first, Nearest first, Recently updated, Brand A–Z.
|
||
Accepts `wire:model` passthrough.
|
||
|
||
### `x-fuel.station-card :station`
|
||
Single station row. Props from `/api/stations` response:
|
||
- Name, address, postcode, distance (km → miles)
|
||
- Price in pence (formatted to 1dp)
|
||
- Price colour: `current` → green, `recent` → slate, `stale` → amber, `outdated` → red
|
||
- `is_supermarket` → shows "Supermarket" badge
|
||
|
||
### `x-fuel.station-map :results`
|
||
Leaflet map wrapper using Alpine `x-data="stationMap(@entangle('results'))"` pattern.
|
||
- Default centre: UK (`54.0, -2.0`), zoom 6 — shown before search
|
||
- After search: re-centres to fit result bounds automatically (handled in `station-map.js`)
|
||
- Height: `h-56` mobile / `h-96` desktop
|
||
|
||
### `x-fuel.recommendation :prediction`
|
||
Recommendation card driven by `/api/prediction` response.
|
||
- Shows `action` as headline: `fill_now` → "Fill up now", `wait` → "Wait", else "No signal"
|
||
- Confidence ring (SVG) from `confidence_score` (0–100)
|
||
- `reasoning` text below
|
||
- Confidence label badge (`confidence_label`: low / medium / high)
|
||
- Hidden when `$prediction` is null
|
||
|
||
### `x-fuel.forecast`
|
||
Static 14-day forecast Pro upsell card.
|
||
- SVG squiggle line chart (decorative)
|
||
- Blurred/locked overlay with "Unlock Forecast" CTA button
|
||
- "Pro" badge
|
||
|
||
### `x-mobile-header`
|
||
Mobile app header: FuelAlert logo + user icon button (links to login/dashboard based on auth state).
|
||
`pt-14` for safe area, fixed positioning.
|
||
|
||
### `x-mobile-footer`
|
||
Mobile tab bar: Prices, Alerts, Forecourts, Trends. Sticky bottom, `pb-8` for safe area.
|
||
Active tab highlight for current route.
|
||
|
||
---
|
||
|
||
## View: `fuel-finder.blade.php`
|
||
|
||
Mobile layout only (desktop layout handled by existing homepage / StationSearch).
|
||
Structure:
|
||
|
||
```
|
||
<x-mobile-header />
|
||
|
||
<main> {{-- scrollable --}}
|
||
|
||
{{-- #search --}}
|
||
Search input + x-fuel.type-select + x-fuel.radius-select + x-fuel.sort-select
|
||
Displayed as pill-style filter buttons (scroll horizontally on mobile)
|
||
Submit triggers findStations()
|
||
|
||
{{-- #recommendation --}}
|
||
<x-fuel.recommendation :prediction="$prediction" />
|
||
Hidden (@if $prediction) until searched
|
||
|
||
{{-- #map --}}
|
||
<x-fuel.station-map :results="$results" />
|
||
Always visible — UK centre until search
|
||
|
||
{{-- #stations --}}
|
||
@if $hasSearched
|
||
@forelse $results as $station
|
||
<x-fuel.station-card :station="$station" />
|
||
@empty
|
||
"No stations found" message
|
||
@endforelse
|
||
@endif
|
||
|
||
{{-- #forecast --}}
|
||
<x-fuel.forecast />
|
||
|
||
</main>
|
||
|
||
<x-mobile-footer />
|
||
```
|
||
|
||
---
|
||
|
||
## API Contracts
|
||
|
||
### `/api/stations`
|
||
| Param | Type | Notes |
|
||
|---|---|---|
|
||
| `postcode` | string | Full postcode, outcode, or place name |
|
||
| `fuel_type` | string | e.g. `petrol`, `diesel`, `e5` |
|
||
| `radius` | float | km (convert from miles: × 1.60934) |
|
||
| `sort` | string | `reliable`, `price`, `distance`, `updated`, `brand` |
|
||
|
||
Response shape: `{ data: Station[], meta: { count, lowest_pence, avg_pence } }`
|
||
|
||
### `/api/prediction`
|
||
| Param | Type | Notes |
|
||
|---|---|---|
|
||
| `lat` | float? | Optional — falls back to national |
|
||
| `lng` | float? | Optional |
|
||
|
||
Key response fields used: `action`, `confidence_score`, `confidence_label`, `reasoning`, `predicted_direction`, `predicted_change_pence`.
|
||
|
||
---
|
||
|
||
## Data Flow
|
||
|
||
```
|
||
User types postcode → submits → findStations()
|
||
→ GET /api/stations → $results, $meta, $hasSearched=true
|
||
→ GET /api/prediction → $prediction
|
||
→ blade re-renders:
|
||
#recommendation shows with prediction data
|
||
#map re-centres to results
|
||
#stations lists results
|
||
```
|
||
|
||
Filter change (fuel type / radius / sort) → `updated*()` → `findStations()` if `$hasSearched`
|
||
|
||
---
|
||
|
||
## Not in scope
|
||
|
||
- Desktop layout changes (StationSearch at `/stations` unchanged)
|
||
- Migrating StationSearch to use shared Blade components (follow-up)
|
||
- lat/lng resolution for `/api/prediction` (uses national fallback for now)
|
||
- Tab bar navigation routes (Alerts, Forecourts, Trends pages don't exist yet)
|