1250 lines
40 KiB
Markdown
1250 lines
40 KiB
Markdown
# FuelFinder Sub-Component Split 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:** Split the monolithic `FuelFinder` Livewire component into four focused sub-components (`Search`, `Map`, `StationList`, `Recommendation`) communicating via a `stations-found` browser event, so each part re-renders independently without re-mounting Leaflet.
|
||
|
||
**Architecture:** `FuelFinder` becomes a layout-only shell. `Search` owns all API calls and dispatches `stations-found` with results, meta, prediction, and radius. `Map` relays the event to Alpine/Leaflet via `map-update`. `StationList` and `Recommendation` each listen for `stations-found` and re-render their slice of the UI. `Forecast` is unchanged.
|
||
|
||
**Tech Stack:** Laravel 13, Livewire 4, Alpine.js, Leaflet.js, Pest 4
|
||
|
||
---
|
||
|
||
## File Map
|
||
|
||
**New PHP classes:**
|
||
- `app/Livewire/Public/Fuel/Search.php` — search state, API calls, dispatcher
|
||
- `app/Livewire/Public/Fuel/Map.php` — event relay, no state
|
||
- `app/Livewire/Public/Fuel/StationList.php` — station card list
|
||
- `app/Livewire/Public/Fuel/Recommendation.php` — prediction card
|
||
|
||
**New Blade views:**
|
||
- `resources/views/livewire/public/fuel/search.blade.php`
|
||
- `resources/views/livewire/public/fuel/map.blade.php`
|
||
- `resources/views/livewire/public/fuel/station-list.blade.php`
|
||
- `resources/views/livewire/public/fuel/recommendation.blade.php`
|
||
|
||
**New tests:**
|
||
- `tests/Feature/Livewire/Fuel/SearchTest.php`
|
||
- `tests/Feature/Livewire/Fuel/StationListTest.php`
|
||
- `tests/Feature/Livewire/Fuel/RecommendationTest.php`
|
||
- `tests/Feature/Livewire/Fuel/MapTest.php`
|
||
|
||
**Modified files:**
|
||
- `app/Livewire/Public/FuelFinder.php` — strip all state and methods
|
||
- `resources/views/livewire/public/fuel-finder.blade.php` — replace sections with `<livewire:fuel.*>` tags
|
||
- `resources/js/maps/station-map.js` — replace `$watch` with `map-update` window event listener
|
||
- `resources/views/components/fuel/station-map.blade.php` — remove `@entangle` props
|
||
- `tests/Feature/Livewire/FuelFinderTest.php` — strip to render-only test
|
||
|
||
---
|
||
|
||
## Task 1: fuel.search component
|
||
|
||
**Files:**
|
||
- Create: `app/Livewire/Public/Fuel/Search.php`
|
||
- Create: `resources/views/livewire/public/fuel/search.blade.php`
|
||
- Create: `tests/Feature/Livewire/Fuel/SearchTest.php`
|
||
|
||
- [ ] **Step 1: Scaffold the component and test**
|
||
|
||
```bash
|
||
php artisan make:livewire Public/Fuel/Search --no-interaction
|
||
php artisan make:test Feature/Livewire/Fuel/SearchTest --pest --no-interaction
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing tests**
|
||
|
||
Replace the contents of `tests/Feature/Livewire/Fuel/SearchTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
use App\Livewire\Public\Fuel\Search;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Livewire\Livewire;
|
||
|
||
uses(RefreshDatabase::class);
|
||
|
||
it('renders the search component', function () {
|
||
Livewire::test(Search::class)
|
||
->assertStatus(200)
|
||
->assertSeeHtml('name="search"');
|
||
});
|
||
|
||
it('has default property values', function () {
|
||
Livewire::test(Search::class)
|
||
->assertSet('search', '')
|
||
->assertSet('fuelType', 'petrol')
|
||
->assertSet('radius', 5)
|
||
->assertSet('sort', 'reliable')
|
||
->assertSet('apiError', null)
|
||
->assertSet('hasSearched', false);
|
||
});
|
||
|
||
it('validates search is required', function () {
|
||
Livewire::test(Search::class)
|
||
->call('findStations')
|
||
->assertHasErrors(['search' => 'required']);
|
||
});
|
||
|
||
it('validates fuelType is required', function () {
|
||
Livewire::test(Search::class)
|
||
->set('search', 'SW1A 1AA')
|
||
->set('fuelType', '')
|
||
->call('findStations')
|
||
->assertHasErrors(['fuelType' => 'required']);
|
||
});
|
||
|
||
it('dispatches stations-found with results, meta, prediction and radius on successful search', function () {
|
||
Http::fake([
|
||
'*/api/stations*' => Http::response([
|
||
'data' => [
|
||
[
|
||
'station_id' => 'abc123',
|
||
'name' => 'BP Garage',
|
||
'brand' => 'BP',
|
||
'is_supermarket' => false,
|
||
'address' => '1 High Street',
|
||
'postcode' => 'SW1A 1AA',
|
||
'lat' => 51.5074,
|
||
'lng' => -0.1278,
|
||
'distance_km' => 1.5,
|
||
'fuel_type' => 'e10',
|
||
'price_pence' => 14390,
|
||
'price' => 143.9,
|
||
'price_updated_at' => '2026-04-05T08:00:00.000Z',
|
||
'price_classification' => 'current',
|
||
'price_classification_label' => 'Current',
|
||
],
|
||
],
|
||
'meta' => ['count' => 1, 'lowest_pence' => 14390, 'avg_pence' => 14390.0],
|
||
], 200),
|
||
'*/api/prediction*' => Http::response([
|
||
'action' => 'fill_now',
|
||
'confidence_score' => 80.0,
|
||
'confidence_label' => 'high',
|
||
'reasoning' => 'Prices rising.',
|
||
'predicted_direction' => 'up',
|
||
'predicted_change_pence' => 3.5,
|
||
], 200),
|
||
]);
|
||
|
||
Livewire::test(Search::class)
|
||
->set('search', 'SW1A 1AA')
|
||
->set('fuelType', 'petrol')
|
||
->call('findStations')
|
||
->assertSet('hasSearched', true)
|
||
->assertSet('apiError', null)
|
||
->assertDispatched('stations-found', fn ($event, $params) =>
|
||
count($params['results']) === 1
|
||
&& $params['results'][0]['name'] === 'BP Garage'
|
||
&& $params['meta']['count'] === 1
|
||
&& $params['prediction']['action'] === 'fill_now'
|
||
&& $params['radius'] === 5
|
||
);
|
||
});
|
||
|
||
it('sets apiError from 422 station response and does not dispatch stations-found', function () {
|
||
Http::fake([
|
||
'*/api/stations*' => Http::response([
|
||
'errors' => ['postcode' => ['Postcode not found.']],
|
||
], 422),
|
||
]);
|
||
|
||
Livewire::test(Search::class)
|
||
->set('search', 'ZZ99 9ZZ')
|
||
->set('fuelType', 'petrol')
|
||
->call('findStations')
|
||
->assertSet('hasSearched', false)
|
||
->assertSet('apiError', 'Postcode not found.')
|
||
->assertNotDispatched('stations-found');
|
||
});
|
||
|
||
it('sets generic apiError on server error', function () {
|
||
Http::fake([
|
||
'*/api/stations*' => Http::response([], 500),
|
||
]);
|
||
|
||
Livewire::test(Search::class)
|
||
->set('search', 'SW1A 1AA')
|
||
->set('fuelType', 'petrol')
|
||
->call('findStations')
|
||
->assertSet('apiError', 'Unable to fetch stations. Please try again.');
|
||
});
|
||
|
||
it('converts radius from miles to km in the outgoing stations request', function () {
|
||
Http::fake([
|
||
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
|
||
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
|
||
]);
|
||
|
||
Livewire::test(Search::class)
|
||
->set('search', 'SW1A 1AA')
|
||
->set('fuelType', 'petrol')
|
||
->set('radius', 5)
|
||
->call('findStations');
|
||
|
||
Http::assertSent(function ($request) {
|
||
if (! str_contains($request->url(), 'api/stations')) {
|
||
return false;
|
||
}
|
||
$data = $request->data();
|
||
return isset($data['radius']) && abs((float) $data['radius'] - 8.05) < 0.01;
|
||
});
|
||
});
|
||
|
||
it('resets apiError before each new search', function () {
|
||
Http::fake([
|
||
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
|
||
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
|
||
]);
|
||
|
||
Livewire::test(Search::class)
|
||
->set('search', 'SW1A 1AA')
|
||
->set('fuelType', 'petrol')
|
||
->set('apiError', 'Old error')
|
||
->call('findStations')
|
||
->assertSet('apiError', null);
|
||
});
|
||
|
||
it('does not call findStations on updatedFuelType if not yet searched', function () {
|
||
Http::fake();
|
||
|
||
Livewire::test(Search::class)
|
||
->set('fuelType', 'diesel');
|
||
|
||
Http::assertNothingSent();
|
||
});
|
||
|
||
it('re-runs findStations on updatedFuelType when already searched', function () {
|
||
Http::fake([
|
||
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
|
||
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
|
||
]);
|
||
|
||
Livewire::test(Search::class)
|
||
->set('hasSearched', true)
|
||
->set('search', 'SW1A 1AA')
|
||
->set('fuelType', 'diesel');
|
||
|
||
Http::assertSentCount(2);
|
||
});
|
||
|
||
it('re-runs findStations on updatedRadius when already searched', function () {
|
||
Http::fake([
|
||
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
|
||
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
|
||
]);
|
||
|
||
Livewire::test(Search::class)
|
||
->set('hasSearched', true)
|
||
->set('search', 'SW1A 1AA')
|
||
->set('radius', 10);
|
||
|
||
Http::assertSentCount(2);
|
||
});
|
||
|
||
it('re-runs findStations on updatedSort when already searched', function () {
|
||
Http::fake([
|
||
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
|
||
'*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200),
|
||
]);
|
||
|
||
Livewire::test(Search::class)
|
||
->set('hasSearched', true)
|
||
->set('search', 'SW1A 1AA')
|
||
->set('sort', 'price');
|
||
|
||
Http::assertSentCount(2);
|
||
});
|
||
|
||
it('prediction is null in stations-found payload when prediction api fails', function () {
|
||
Http::fake([
|
||
'*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200),
|
||
'*/api/prediction*' => Http::response([], 500),
|
||
]);
|
||
|
||
Livewire::test(Search::class)
|
||
->set('search', 'SW1A 1AA')
|
||
->set('fuelType', 'petrol')
|
||
->call('findStations')
|
||
->assertSet('hasSearched', true)
|
||
->assertDispatched('stations-found', fn ($event, $params) =>
|
||
$params['prediction'] === null
|
||
);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run tests to confirm they fail**
|
||
|
||
```bash
|
||
php artisan test --compact --filter="SearchTest" --timeout=10
|
||
```
|
||
|
||
Expected: all fail with `Class "App\Livewire\Public\Fuel\Search" not found` or similar.
|
||
|
||
- [ ] **Step 4: Implement Search.php**
|
||
|
||
Replace the contents of `app/Livewire/Public/Fuel/Search.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Livewire\Public\Fuel;
|
||
|
||
use Illuminate\Http\Client\ConnectionException;
|
||
use Illuminate\Support\Facades\Http;
|
||
use Illuminate\View\View;
|
||
use Livewire\Attributes\Validate;
|
||
use Livewire\Component;
|
||
|
||
final class Search 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 ?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->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;
|
||
}
|
||
|
||
$results = $response->json('data', []);
|
||
$meta = $response->json('meta', []);
|
||
$this->hasSearched = true;
|
||
|
||
$prediction = null;
|
||
|
||
try {
|
||
$predictionResponse = Http::timeout(10)
|
||
->withHeaders(['X-Api-Key' => config('app.api_secret_key')])
|
||
->get(url('/api/prediction'));
|
||
|
||
if ($predictionResponse->successful()) {
|
||
$prediction = $predictionResponse->json();
|
||
}
|
||
} catch (ConnectionException) {
|
||
// Prediction failure is silent — stations are more important
|
||
}
|
||
|
||
$this->dispatch('stations-found',
|
||
results: $results,
|
||
meta: $meta,
|
||
prediction: $prediction,
|
||
radius: $this->radius,
|
||
);
|
||
}
|
||
|
||
public function render(): View
|
||
{
|
||
return view('livewire.public.fuel.search');
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Implement search.blade.php**
|
||
|
||
Replace the contents of `resources/views/livewire/public/fuel/search.blade.php`:
|
||
|
||
```blade
|
||
<div class="space-y-3 px-5 pb-4 pt-5">
|
||
<form wire:submit="findStations">
|
||
|
||
<div
|
||
x-data="{
|
||
query: @js($search),
|
||
locatingUser: false,
|
||
_usedIpFallback: false,
|
||
async _postcodeFromLatLng(lat, lng) {
|
||
const res = await fetch(`https://api.postcodes.io/postcodes?lon=${lng}&lat=${lat}&limit=1&radius=1000`);
|
||
const data = await res.json();
|
||
return data?.result?.[0]?.postcode ?? null;
|
||
},
|
||
async locateUser() {
|
||
this.locatingUser = true;
|
||
this._usedIpFallback = false;
|
||
try {
|
||
let lat, lng;
|
||
try {
|
||
const pos = await new Promise((resolve, reject) =>
|
||
navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 })
|
||
);
|
||
lat = pos.coords.latitude;
|
||
lng = pos.coords.longitude;
|
||
} catch (e) {
|
||
const d = await fetch('https://ipapi.co/json/').then(r => r.json());
|
||
lat = d.latitude;
|
||
lng = d.longitude;
|
||
this._usedIpFallback = true;
|
||
}
|
||
const postcode = await this._postcodeFromLatLng(lat, lng);
|
||
if (postcode) {
|
||
this.query = postcode;
|
||
this.$wire.set('search', postcode);
|
||
this.$wire.findStations();
|
||
}
|
||
} catch (e) {
|
||
// silent
|
||
} finally {
|
||
this.locatingUser = false;
|
||
}
|
||
}
|
||
}"
|
||
class="relative mb-3"
|
||
>
|
||
<iconify-icon
|
||
icon="lucide:map-pin"
|
||
class="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-xl text-text-muted"
|
||
></iconify-icon>
|
||
<input
|
||
wire:model="search"
|
||
x-model="query"
|
||
x-ref="searchInput"
|
||
type="text"
|
||
name="search"
|
||
@focus="_usedIpFallback = false"
|
||
placeholder="Postcode, town or city"
|
||
class="h-14 w-full rounded-xl border border-border bg-surface pl-12 pr-36 text-base font-semibold text-text-base focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary"
|
||
/>
|
||
<div class="absolute inset-y-0 right-0 flex items-center gap-2 pr-3">
|
||
<button
|
||
x-show="query.length > 0"
|
||
x-cloak
|
||
type="button"
|
||
@click="query = ''; $wire.set('search', ''); _usedIpFallback = false"
|
||
class="text-text-muted hover:text-text-base"
|
||
>
|
||
<iconify-icon icon="lucide:x" class="text-base"></iconify-icon>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
@click="locateUser()"
|
||
:disabled="locatingUser"
|
||
class="flex items-center gap-1.5 rounded-full bg-surface-subtle px-3 py-1.5 text-sm font-semibold text-text-base disabled:opacity-40"
|
||
>
|
||
<iconify-icon x-show="!locatingUser" icon="lucide:locate-fixed" class="text-sm"></iconify-icon>
|
||
<iconify-icon x-show="locatingUser" icon="lucide:loader-circle" class="animate-spin text-sm"></iconify-icon>
|
||
<span x-text="locatingUser ? 'Finding...' : 'Near me'">Near me</span>
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
wire:loading.attr="disabled"
|
||
class="text-text-muted disabled:opacity-60"
|
||
>
|
||
<iconify-icon wire:loading.remove wire:target="findStations" icon="lucide:search" class="text-xl"></iconify-icon>
|
||
<iconify-icon wire:loading wire:target="findStations" icon="lucide:loader-circle" class="animate-spin text-xl"></iconify-icon>
|
||
</button>
|
||
</div>
|
||
|
||
{{-- IP fallback nudge --}}
|
||
<div
|
||
x-show="_usedIpFallback"
|
||
x-cloak
|
||
x-transition:enter="transition ease-out duration-300 delay-500"
|
||
x-transition:enter-start="opacity-0 -translate-y-2"
|
||
x-transition:enter-end="opacity-100 translate-y-0"
|
||
x-transition:leave="transition ease-in duration-200"
|
||
x-transition:leave-end="opacity-0"
|
||
class="absolute left-0 right-0 top-full z-40 mt-2"
|
||
>
|
||
<div
|
||
@click="$refs.searchInput.focus(); _usedIpFallback = false"
|
||
class="cursor-pointer rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 transition-colors hover:bg-amber-100"
|
||
>
|
||
<p class="text-center text-xs text-amber-800">
|
||
<span class="font-medium">Showing approximate location.</span>
|
||
<span class="underline">Enter your postcode above</span> for exact results.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
@error('search')
|
||
<p class="mb-2 text-sm text-red-600">{{ $message }}</p>
|
||
@enderror
|
||
|
||
<div class="flex gap-2 overflow-x-auto pb-1" style="-ms-overflow-style:none;scrollbar-width:none;">
|
||
<div class="shrink-0"><x-fuel.type-select wire:model.live="fuelType" /></div>
|
||
<div class="shrink-0"><x-fuel.sort-select wire:model.live="sort" /></div>
|
||
<div class="shrink-0"><x-fuel.radius-select wire:model.live="radius" /></div>
|
||
</div>
|
||
|
||
</form>
|
||
|
||
@if ($apiError)
|
||
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||
{{ $apiError }}
|
||
</div>
|
||
@endif
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 6: Run tests to confirm they pass**
|
||
|
||
```bash
|
||
php artisan test --compact --filter="SearchTest" --timeout=10
|
||
```
|
||
|
||
Expected: all 13 tests pass.
|
||
|
||
- [ ] **Step 7: Run Pint**
|
||
|
||
```bash
|
||
vendor/bin/pint app/Livewire/Public/Fuel/Search.php --format agent
|
||
```
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add app/Livewire/Public/Fuel/Search.php resources/views/livewire/public/fuel/search.blade.php tests/Feature/Livewire/Fuel/SearchTest.php
|
||
git commit -m "feat: extract fuel.search Livewire component with stations-found dispatch"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: fuel.station-list component
|
||
|
||
**Files:**
|
||
- Create: `app/Livewire/Public/Fuel/StationList.php`
|
||
- Create: `resources/views/livewire/public/fuel/station-list.blade.php`
|
||
- Create: `tests/Feature/Livewire/Fuel/StationListTest.php`
|
||
|
||
- [ ] **Step 1: Scaffold**
|
||
|
||
```bash
|
||
php artisan make:livewire Public/Fuel/StationList --no-interaction
|
||
php artisan make:test Feature/Livewire/Fuel/StationListTest --pest --no-interaction
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing tests**
|
||
|
||
Replace contents of `tests/Feature/Livewire/Fuel/StationListTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
use App\Livewire\Public\Fuel\StationList;
|
||
use Livewire\Livewire;
|
||
|
||
it('renders empty state before any search', function () {
|
||
Livewire::test(StationList::class)
|
||
->assertStatus(200)
|
||
->assertSet('hasSearched', false)
|
||
->assertDontSee('Stations Nearby');
|
||
});
|
||
|
||
it('shows station cards after stations-found event', function () {
|
||
$station = [
|
||
'station_id' => 'abc123',
|
||
'name' => 'BP Garage',
|
||
'brand' => 'BP',
|
||
'is_supermarket' => false,
|
||
'address' => '1 High Street',
|
||
'postcode' => 'SW1A 1AA',
|
||
'lat' => 51.5074,
|
||
'lng' => -0.1278,
|
||
'distance_km' => 1.5,
|
||
'fuel_type' => 'e10',
|
||
'price_pence' => 14390,
|
||
'price' => 143.9,
|
||
'price_updated_at' => '2026-04-05T08:00:00.000Z',
|
||
'price_classification' => 'current',
|
||
'price_classification_label' => 'Current',
|
||
];
|
||
$meta = ['count' => 1, 'lowest_pence' => 14390, 'avg_pence' => 14390.0];
|
||
|
||
Livewire::test(StationList::class)
|
||
->dispatch('stations-found', results: [$station], meta: $meta, prediction: null, radius: 5)
|
||
->assertSet('hasSearched', true)
|
||
->assertSee('Stations Nearby')
|
||
->assertSee('BP Garage')
|
||
->assertSee('1 Result');
|
||
});
|
||
|
||
it('shows empty state message when stations-found has no results', function () {
|
||
Livewire::test(StationList::class)
|
||
->set('search', 'ZZ99 9ZZ')
|
||
->dispatch('stations-found', results: [], meta: ['count' => 0], prediction: null, radius: 5)
|
||
->assertSet('hasSearched', true)
|
||
->assertSee('No stations found');
|
||
});
|
||
|
||
it('updates results when stations-found fires again', function () {
|
||
$station = [
|
||
'station_id' => 'abc123',
|
||
'name' => 'BP Garage',
|
||
'brand' => 'BP',
|
||
'is_supermarket' => false,
|
||
'address' => '1 High Street',
|
||
'postcode' => 'SW1A 1AA',
|
||
'lat' => 51.5074,
|
||
'lng' => -0.1278,
|
||
'distance_km' => 1.5,
|
||
'fuel_type' => 'e10',
|
||
'price_pence' => 14390,
|
||
'price' => 143.9,
|
||
'price_updated_at' => '2026-04-05T08:00:00.000Z',
|
||
'price_classification' => 'current',
|
||
'price_classification_label' => 'Current',
|
||
];
|
||
|
||
Livewire::test(StationList::class)
|
||
->dispatch('stations-found', results: [$station], meta: ['count' => 1], prediction: null, radius: 5)
|
||
->assertSee('BP Garage')
|
||
->dispatch('stations-found', results: [], meta: ['count' => 0], prediction: null, radius: 5)
|
||
->assertDontSee('BP Garage');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run tests to confirm they fail**
|
||
|
||
```bash
|
||
php artisan test --compact --filter="StationListTest" --timeout=10
|
||
```
|
||
|
||
Expected: all fail — class not found or component has no `handle` method yet.
|
||
|
||
- [ ] **Step 4: Implement StationList.php**
|
||
|
||
Replace contents of `app/Livewire/Public/Fuel/StationList.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Livewire\Public\Fuel;
|
||
|
||
use Illuminate\View\View;
|
||
use Livewire\Attributes\On;
|
||
use Livewire\Component;
|
||
|
||
final class StationList extends Component
|
||
{
|
||
public array $results = [];
|
||
|
||
public array $meta = [];
|
||
|
||
public bool $hasSearched = false;
|
||
|
||
public string $search = '';
|
||
|
||
public int $radius = 5;
|
||
|
||
#[On('stations-found')]
|
||
public function handle(array $results, array $meta, int $radius = 5, ?array $prediction = null): void
|
||
{
|
||
$this->results = $results;
|
||
$this->meta = $meta;
|
||
$this->radius = $radius;
|
||
$this->hasSearched = true;
|
||
}
|
||
|
||
public function render(): View
|
||
{
|
||
return view('livewire.public.fuel.station-list');
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Implement station-list.blade.php**
|
||
|
||
Replace contents of `resources/views/livewire/public/fuel/station-list.blade.php`:
|
||
|
||
```blade
|
||
<div>
|
||
@if ($hasSearched)
|
||
<div class="px-5 pb-5">
|
||
@if (! empty($meta))
|
||
<div class="mb-3 flex items-center justify-between">
|
||
<h3 class="text-base font-bold text-text-base">Stations Nearby</h3>
|
||
<span class="text-[10px] font-bold uppercase tracking-widest text-text-muted">
|
||
{{ $meta['count'] ?? 0 }} {{ str('Result')->plural($meta['count'] ?? 0) }}
|
||
</span>
|
||
</div>
|
||
@endif
|
||
|
||
@forelse ($results as $station)
|
||
<div class="mb-2">
|
||
<x-fuel.station-card :station="$station" />
|
||
</div>
|
||
@empty
|
||
<p class="text-sm text-text-muted">
|
||
No stations found within {{ $radius }} {{ str('mile')->plural($radius) }} of "{{ $search }}".
|
||
</p>
|
||
@endforelse
|
||
</div>
|
||
@endif
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 6: Run tests to confirm they pass**
|
||
|
||
```bash
|
||
php artisan test --compact --filter="StationListTest" --timeout=10
|
||
```
|
||
|
||
Expected: all 4 tests pass.
|
||
|
||
- [ ] **Step 7: Run Pint**
|
||
|
||
```bash
|
||
vendor/bin/pint app/Livewire/Public/Fuel/StationList.php --format agent
|
||
```
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add app/Livewire/Public/Fuel/StationList.php resources/views/livewire/public/fuel/station-list.blade.php tests/Feature/Livewire/Fuel/StationListTest.php
|
||
git commit -m "feat: extract fuel.station-list Livewire component"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: fuel.recommendation component
|
||
|
||
**Files:**
|
||
- Create: `app/Livewire/Public/Fuel/Recommendation.php`
|
||
- Create: `resources/views/livewire/public/fuel/recommendation.blade.php`
|
||
- Create: `tests/Feature/Livewire/Fuel/RecommendationTest.php`
|
||
|
||
- [ ] **Step 1: Scaffold**
|
||
|
||
```bash
|
||
php artisan make:livewire Public/Fuel/Recommendation --no-interaction
|
||
php artisan make:test Feature/Livewire/Fuel/RecommendationTest --pest --no-interaction
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing tests**
|
||
|
||
Replace contents of `tests/Feature/Livewire/Fuel/RecommendationTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
use App\Livewire\Public\Fuel\Recommendation;
|
||
use Livewire\Livewire;
|
||
|
||
it('renders nothing before stations-found fires', function () {
|
||
Livewire::test(Recommendation::class)
|
||
->assertStatus(200)
|
||
->assertSet('prediction', null)
|
||
->assertDontSee('Recommendation');
|
||
});
|
||
|
||
it('shows recommendation card when stations-found includes a prediction', function () {
|
||
$prediction = [
|
||
'action' => 'fill_now',
|
||
'confidence_score' => 80.0,
|
||
'confidence_label' => 'high',
|
||
'reasoning' => 'Prices are rising sharply.',
|
||
'predicted_direction' => 'up',
|
||
'predicted_change_pence' => 3.5,
|
||
];
|
||
|
||
Livewire::test(Recommendation::class)
|
||
->dispatch('stations-found', results: [], meta: [], prediction: $prediction, radius: 5)
|
||
->assertSet('prediction', $prediction)
|
||
->assertSee('Recommendation')
|
||
->assertSee('Fill up now');
|
||
});
|
||
|
||
it('shows nothing when stations-found has null prediction', function () {
|
||
Livewire::test(Recommendation::class)
|
||
->dispatch('stations-found', results: [], meta: [], prediction: null, radius: 5)
|
||
->assertSet('prediction', null)
|
||
->assertDontSee('Recommendation');
|
||
});
|
||
|
||
it('clears previous prediction when new stations-found fires with null prediction', function () {
|
||
$prediction = [
|
||
'action' => 'fill_now',
|
||
'confidence_score' => 80.0,
|
||
'confidence_label' => 'high',
|
||
'reasoning' => 'Prices rising.',
|
||
'predicted_direction' => 'up',
|
||
'predicted_change_pence' => 3.5,
|
||
];
|
||
|
||
Livewire::test(Recommendation::class)
|
||
->dispatch('stations-found', results: [], meta: [], prediction: $prediction, radius: 5)
|
||
->assertSee('Recommendation')
|
||
->dispatch('stations-found', results: [], meta: [], prediction: null, radius: 5)
|
||
->assertDontSee('Recommendation');
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run tests to confirm they fail**
|
||
|
||
```bash
|
||
php artisan test --compact --filter="RecommendationTest" --timeout=10
|
||
```
|
||
|
||
Expected: all fail — class not found or no `handle` method.
|
||
|
||
- [ ] **Step 4: Implement Recommendation.php**
|
||
|
||
Replace contents of `app/Livewire/Public/Fuel/Recommendation.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Livewire\Public\Fuel;
|
||
|
||
use Illuminate\View\View;
|
||
use Livewire\Attributes\On;
|
||
use Livewire\Component;
|
||
|
||
final class Recommendation extends Component
|
||
{
|
||
public ?array $prediction = null;
|
||
|
||
#[On('stations-found')]
|
||
public function handle(array $results, array $meta, int $radius = 5, ?array $prediction = null): void
|
||
{
|
||
$this->prediction = $prediction;
|
||
}
|
||
|
||
public function render(): View
|
||
{
|
||
return view('livewire.public.fuel.recommendation');
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Implement recommendation.blade.php**
|
||
|
||
Replace contents of `resources/views/livewire/public/fuel/recommendation.blade.php`:
|
||
|
||
```blade
|
||
<div>
|
||
@if ($prediction)
|
||
<div class="px-5 pb-5">
|
||
<x-fuel.recommendation :prediction="$prediction" />
|
||
</div>
|
||
@endif
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 6: Run tests to confirm they pass**
|
||
|
||
```bash
|
||
php artisan test --compact --filter="RecommendationTest" --timeout=10
|
||
```
|
||
|
||
Expected: all 4 tests pass.
|
||
|
||
- [ ] **Step 7: Run Pint**
|
||
|
||
```bash
|
||
vendor/bin/pint app/Livewire/Public/Fuel/Recommendation.php --format agent
|
||
```
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add app/Livewire/Public/Fuel/Recommendation.php resources/views/livewire/public/fuel/recommendation.blade.php tests/Feature/Livewire/Fuel/RecommendationTest.php
|
||
git commit -m "feat: extract fuel.recommendation Livewire component"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: fuel.map component + JS update
|
||
|
||
**Files:**
|
||
- Create: `app/Livewire/Public/Fuel/Map.php`
|
||
- Create: `resources/views/livewire/public/fuel/map.blade.php`
|
||
- Create: `tests/Feature/Livewire/Fuel/MapTest.php`
|
||
- Modify: `resources/js/maps/station-map.js`
|
||
- Modify: `resources/views/components/fuel/station-map.blade.php`
|
||
|
||
- [ ] **Step 1: Scaffold**
|
||
|
||
```bash
|
||
php artisan make:livewire Public/Fuel/Map --no-interaction
|
||
php artisan make:test Feature/Livewire/Fuel/MapTest --pest --no-interaction
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing tests**
|
||
|
||
Replace contents of `tests/Feature/Livewire/Fuel/MapTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
use App\Livewire\Public\Fuel\Map;
|
||
use Livewire\Livewire;
|
||
|
||
it('renders the map component', function () {
|
||
Livewire::test(Map::class)
|
||
->assertStatus(200);
|
||
});
|
||
|
||
it('dispatches map-update browser event when stations-found is received', function () {
|
||
Livewire::test(Map::class)
|
||
->dispatch('stations-found',
|
||
results: [['name' => 'BP Garage']],
|
||
meta: ['lat' => 51.5, 'lng' => -0.1, 'count' => 1],
|
||
radius: 5,
|
||
prediction: null
|
||
)
|
||
->assertDispatched('map-update');
|
||
});
|
||
|
||
it('passes radius in map-update payload', function () {
|
||
Livewire::test(Map::class)
|
||
->dispatch('stations-found',
|
||
results: [],
|
||
meta: ['lat' => 51.5, 'lng' => -0.1, 'count' => 0],
|
||
radius: 10,
|
||
prediction: null
|
||
)
|
||
->assertDispatched('map-update', fn ($event, $params) =>
|
||
$params['radius'] === 10
|
||
);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run tests to confirm they fail**
|
||
|
||
```bash
|
||
php artisan test --compact --filter="MapTest" --timeout=10
|
||
```
|
||
|
||
Expected: all fail — class not found.
|
||
|
||
- [ ] **Step 4: Implement Map.php**
|
||
|
||
Replace contents of `app/Livewire/Public/Fuel/Map.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Livewire\Public\Fuel;
|
||
|
||
use Illuminate\View\View;
|
||
use Livewire\Attributes\On;
|
||
use Livewire\Component;
|
||
|
||
final class Map extends Component
|
||
{
|
||
#[On('stations-found')]
|
||
public function handle(array $results, array $meta, int $radius = 5, ?array $prediction = null): void
|
||
{
|
||
$this->dispatch('map-update', results: $results, meta: $meta, radius: $radius);
|
||
}
|
||
|
||
public function render(): View
|
||
{
|
||
return view('livewire.public.fuel.map');
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Implement map.blade.php**
|
||
|
||
Replace contents of `resources/views/livewire/public/fuel/map.blade.php`:
|
||
|
||
```blade
|
||
<div class="mb-4" wire:ignore>
|
||
<x-fuel.station-map />
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 6: Run PHP tests to confirm they pass**
|
||
|
||
```bash
|
||
php artisan test --compact --filter="MapTest" --timeout=10
|
||
```
|
||
|
||
Expected: all 3 tests pass.
|
||
|
||
- [ ] **Step 7: Update station-map.js**
|
||
|
||
Replace the `init()` method in `resources/js/maps/station-map.js` (lines 59–74). The old `init()`:
|
||
|
||
```js
|
||
init() {
|
||
injectUserMarkerStyles();
|
||
this._map = L.map(this.$el, { zoomControl: true }).setView(UK_CENTRE, UK_ZOOM);
|
||
|
||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||
maxZoom: 19,
|
||
}).addTo(this._map);
|
||
|
||
if (this.results && this.results.length > 0) {
|
||
this._plotMarkers();
|
||
}
|
||
|
||
this.$watch('results', () => this._plotMarkers());
|
||
this.locateUser();
|
||
},
|
||
```
|
||
|
||
New `init()`:
|
||
|
||
```js
|
||
init() {
|
||
injectUserMarkerStyles();
|
||
this._map = L.map(this.$el, { zoomControl: true }).setView(UK_CENTRE, UK_ZOOM);
|
||
|
||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||
maxZoom: 19,
|
||
}).addTo(this._map);
|
||
|
||
window.addEventListener('map-update', (e) => {
|
||
this.results = e.detail.results;
|
||
this.meta = e.detail.meta;
|
||
this.radius = e.detail.radius;
|
||
this._plotMarkers();
|
||
});
|
||
|
||
this.locateUser();
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 8: Update station-map.blade.php**
|
||
|
||
Replace the full contents of `resources/views/components/fuel/station-map.blade.php`:
|
||
|
||
```blade
|
||
<div
|
||
x-data="stationMap([], {}, 5)"
|
||
class="h-56 w-full overflow-hidden border-y border-border md:h-96"
|
||
></div>
|
||
```
|
||
|
||
- [ ] **Step 9: Run Pint**
|
||
|
||
```bash
|
||
vendor/bin/pint app/Livewire/Public/Fuel/Map.php --format agent
|
||
```
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add app/Livewire/Public/Fuel/Map.php resources/views/livewire/public/fuel/map.blade.php tests/Feature/Livewire/Fuel/MapTest.php resources/js/maps/station-map.js resources/views/components/fuel/station-map.blade.php
|
||
git commit -m "feat: extract fuel.map component and wire Leaflet to map-update browser event"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Strip FuelFinder to shell
|
||
|
||
**Files:**
|
||
- Modify: `app/Livewire/Public/FuelFinder.php`
|
||
- Modify: `resources/views/livewire/public/fuel-finder.blade.php`
|
||
- Modify: `tests/Feature/Livewire/FuelFinderTest.php`
|
||
|
||
- [ ] **Step 1: Update FuelFinder.php**
|
||
|
||
Replace the full contents of `app/Livewire/Public/FuelFinder.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
namespace App\Livewire\Public;
|
||
|
||
use Illuminate\View\View;
|
||
use Livewire\Attributes\Layout;
|
||
use Livewire\Component;
|
||
|
||
#[Layout('layouts.shell')]
|
||
final class FuelFinder extends Component
|
||
{
|
||
public function render(): View
|
||
{
|
||
return view('livewire.public.fuel-finder');
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Update fuel-finder.blade.php**
|
||
|
||
Replace the full contents of `resources/views/livewire/public/fuel-finder.blade.php`:
|
||
|
||
```blade
|
||
<div class="flex h-dvh flex-col bg-surface-page">
|
||
|
||
{{-- HEADER --}}
|
||
<header class="shrink-0 z-50 bg-surface border-b border-border shadow-sm
|
||
flex items-center justify-between px-5 pb-4 md:px-8"
|
||
style="padding-top: max(1rem, env(safe-area-inset-top))">
|
||
|
||
<a href="{{ route('home') }}" class="flex items-center gap-2.5">
|
||
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary shadow-md md:h-10 md:w-10">
|
||
<iconify-icon icon="lucide:fuel" class="text-lg text-white md:text-xl"></iconify-icon>
|
||
</div>
|
||
<span class="text-xl font-black tracking-tighter text-primary md:text-2xl">FuelAlert</span>
|
||
</a>
|
||
|
||
<nav class="hidden items-center gap-8 md:flex">
|
||
<a href="#" class="text-sm font-semibold text-text-muted transition-colors hover:text-primary">Prices</a>
|
||
<a href="#" class="text-sm font-semibold text-text-muted transition-colors hover:text-primary">Alerts</a>
|
||
<a href="#" class="text-sm font-semibold text-text-muted transition-colors hover:text-primary">Trends</a>
|
||
</nav>
|
||
|
||
@auth
|
||
<a href="{{ route('dashboard') }}"
|
||
class="flex h-9 w-9 items-center justify-center rounded-full border border-border bg-surface-subtle">
|
||
<iconify-icon icon="lucide:user" class="text-base text-text-muted"></iconify-icon>
|
||
</a>
|
||
@else
|
||
<a href="{{ route('login') }}"
|
||
class="flex h-9 w-9 items-center justify-center rounded-full border border-border bg-surface-subtle">
|
||
<iconify-icon icon="lucide:user" class="text-base text-text-muted"></iconify-icon>
|
||
</a>
|
||
@endauth
|
||
</header>
|
||
|
||
{{-- MAIN --}}
|
||
<main class="flex-1 overflow-y-auto" style="-ms-overflow-style:none;scrollbar-width:none;">
|
||
<div class="md:mx-auto md:max-w-3xl">
|
||
|
||
<livewire:fuel.search />
|
||
<livewire:fuel.recommendation />
|
||
<livewire:fuel.map />
|
||
<livewire:fuel.station-list />
|
||
|
||
<section class="px-5 pb-8">
|
||
<x-fuel.forecast />
|
||
</section>
|
||
|
||
</div>
|
||
</main>
|
||
|
||
{{-- BOTTOM TAB BAR --}}
|
||
@php
|
||
$tabs = [
|
||
['label' => 'Prices', 'icon' => 'lucide:fuel', 'route' => 'home'],
|
||
['label' => 'Alerts', 'icon' => 'lucide:bell', 'route' => null],
|
||
['label' => 'Forecourts', 'icon' => 'lucide:map-pin', 'route' => null],
|
||
['label' => 'Trends', 'icon' => 'lucide:trending-up', 'route' => null],
|
||
];
|
||
@endphp
|
||
<nav class="shrink-0 border-t border-border bg-surface md:hidden"
|
||
style="padding-bottom: max(0.5rem, env(safe-area-inset-bottom))">
|
||
<div class="flex pt-3">
|
||
@foreach ($tabs as $tab)
|
||
@php $active = $tab['route'] && request()->routeIs($tab['route']); @endphp
|
||
<div class="flex flex-1 flex-col items-center gap-1">
|
||
<iconify-icon
|
||
icon="{{ $tab['icon'] }}"
|
||
class="text-xl {{ $active ? 'text-primary' : 'text-text-muted' }}"
|
||
></iconify-icon>
|
||
<span class="text-[10px] font-bold uppercase tracking-wide {{ $active ? 'text-primary' : 'text-text-muted' }}">
|
||
{{ $tab['label'] }}
|
||
</span>
|
||
</div>
|
||
@endforeach
|
||
</div>
|
||
</nav>
|
||
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 3: Update FuelFinderTest.php**
|
||
|
||
Replace the full contents of `tests/Feature/Livewire/FuelFinderTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
use App\Livewire\Public\FuelFinder;
|
||
use Livewire\Livewire;
|
||
|
||
it('renders the fuel finder shell', function () {
|
||
Livewire::test(FuelFinder::class)
|
||
->assertStatus(200);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run the full test suite**
|
||
|
||
```bash
|
||
php artisan test --compact --timeout=10
|
||
```
|
||
|
||
Expected: 1 failure in `StatsOverviewWidgetTest` (pre-existing, unrelated to this work). All other tests pass, including the new `SearchTest`, `StationListTest`, `RecommendationTest`, and `MapTest`.
|
||
|
||
- [ ] **Step 5: Run Pint**
|
||
|
||
```bash
|
||
vendor/bin/pint app/Livewire/Public/FuelFinder.php --format agent
|
||
```
|
||
|
||
- [ ] **Step 6: 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: strip FuelFinder to layout shell, wire sub-components"
|
||
```
|