Files
fuel-price/docs/superpowers/plans/2026-04-11-homepage-search.md
2026-04-11 17:23:03 +01:00

11 KiB

Homepage Search 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: Wire up the homepage SearchBar to perform a live station search and render a map + station list inline below the hero section.

Architecture: SearchBar emits { postcode, fuelType, radius } on button click. Home.vue calls useStations.search() with those params and renders LeafletMap (open by default) + StationList in a full-width section below the hero. LeafletMap gains a defaultOpen prop to auto-initialise without user toggle.

Tech Stack: Vue 3 Composition API (<script setup>), Leaflet.js, useStations composable, Tailwind CSS v4


File Map

File Change
resources/js/components/SearchBar.vue Add fuel type + radius selects; change emit to object; remove debounce
resources/js/components/LeafletMap.vue Add defaultOpen prop; init map on mount when true
resources/js/views/Home.vue Wire useStations; hold lastParams/sort; render results section

Task 1: Update SearchBar — params + layout

Files:

  • Modify: resources/js/components/SearchBar.vue

  • Step 1: Replace SearchBar.vue with the new implementation

<template>
    <div class="flex flex-col gap-3 max-w-md w-full">
        <!-- Row 1: postcode + button -->
        <div class="relative flex flex-col sm:flex-row gap-3">
            <div class="relative flex-1">
                <span class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500">
                    <iconify-icon icon="lucide:map-pin" style="font-size:1.25rem"></iconify-icon>
                </span>
                <input
                    v-model="postcode"
                    type="text"
                    placeholder="Enter postcode, e.g. SW1A 1AA"
                    class="w-full h-14 pl-12 pr-4 bg-white border border-zinc-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-accent shadow-inner text-base"
                />
            </div>
            <button
                @click="onSearch"
                :disabled="!postcode.trim()"
                class="h-14 px-8 bg-accent text-white rounded-xl font-bold text-base shadow-xl hover:bg-accent-content transition-all disabled:opacity-50 disabled:cursor-not-allowed"
            >
                Find Prices
            </button>
        </div>

        <!-- Row 2: fuel type + radius -->
        <div class="flex gap-3">
            <select
                v-model="fuelType"
                class="flex-1 h-10 px-3 bg-white border border-zinc-300 rounded-xl text-sm font-medium text-zinc-700 focus:outline-none focus:ring-2 focus:ring-accent"
            >
                <option value="e10">Petrol (E10)</option>
                <option value="e5">Premium Petrol (E5)</option>
                <option value="b7_standard">Diesel (B7)</option>
                <option value="b7_premium">Premium Diesel (B7)</option>
                <option value="b10">Diesel (B10)</option>
                <option value="hvo">HVO</option>
            </select>
            <select
                v-model="radius"
                class="w-32 h-10 px-3 bg-white border border-zinc-300 rounded-xl text-sm font-medium text-zinc-700 focus:outline-none focus:ring-2 focus:ring-accent"
            >
                <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>
        </div>
    </div>
</template>

<script setup>
import { ref } from 'vue'

const emit = defineEmits(['search'])

const postcode = ref('')
const fuelType = ref('e10')
const radius = ref(10)

function onSearch() {
    if (!postcode.value.trim()) return
    emit('search', {
        postcode: postcode.value.trim(),
        fuelType: fuelType.value,
        radius: radius.value,
    })
}
</script>
  • Step 2: Commit
git add resources/js/components/SearchBar.vue
git commit -m "feat: add fuel type and radius selects to SearchBar"

Task 2: Update LeafletMap — defaultOpen prop

Files:

  • Modify: resources/js/components/LeafletMap.vue

  • Step 1: Add defaultOpen prop and auto-init on mount

Replace the <script setup> block in resources/js/components/LeafletMap.vue:

<script setup>
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'

// Fix Leaflet default marker icon path broken by Vite
delete L.Icon.Default.prototype._getIconUrl
L.Icon.Default.mergeOptions({
    iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
    iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
    shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
})

const props = defineProps({
    stations: { type: Array, required: true },
    defaultOpen: { type: Boolean, default: false },
})

const mapContainer = ref(null)
const isOpen = ref(false)
let mapInstance = null
let markersLayer = null

function initMap() {
    if (mapInstance || !mapContainer.value) return

    mapInstance = L.map(mapContainer.value)

    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
        attribution: '© OpenStreetMap contributors',
    }).addTo(mapInstance)

    markersLayer = L.layerGroup().addTo(mapInstance)
}

function renderMarkers() {
    if (!mapInstance || !markersLayer) return

    markersLayer.clearLayers()

    if (!props.stations.length) return

    const bounds = []

    props.stations.forEach(station => {
        const marker = L.marker([station.lat, station.lng])
            .bindPopup(`<strong>${station.name}</strong><br>${station.price}p`)
        markersLayer.addLayer(marker)
        bounds.push([station.lat, station.lng])
    })

    if (bounds.length) {
        mapInstance.fitBounds(bounds, { padding: [30, 30] })
    }
}

async function toggleMap() {
    isOpen.value = !isOpen.value

    if (isOpen.value) {
        await nextTick()
        initMap()
        mapInstance.invalidateSize()
        renderMarkers()
    }
}

onMounted(async () => {
    if (props.defaultOpen) {
        isOpen.value = true
        await nextTick()
        initMap()
        mapInstance.invalidateSize()
        renderMarkers()
    }
})

watch(() => props.stations, () => {
    if (isOpen.value) {
        renderMarkers()
    }
})

onUnmounted(() => {
    if (mapInstance) {
        mapInstance.remove()
        mapInstance = null
    }
})
</script>
  • Step 2: Commit
git add resources/js/components/LeafletMap.vue
git commit -m "feat: add defaultOpen prop to LeafletMap"

Task 3: Wire up Home.vue — results section

Files:

  • Modify: resources/js/views/Home.vue

  • Step 1: Update the script block

Replace the <script setup> block at the bottom of resources/js/views/Home.vue:

<script setup>
import { ref } from 'vue'
import { RouterLink } from 'vue-router'
import { useAuth } from '../composables/useAuth.js'
import { useStations } from '../composables/useStations.js'
import SearchBar from '../components/SearchBar.vue'
import LeafletMap from '../components/LeafletMap.vue'
import StationList from '../components/StationList.vue'

const { isAuthenticated } = useAuth()
const { stations, loading, error, search } = useStations()

const sort = ref('price')
const lastParams = ref(null)
const searchAttempted = ref(false)

async function onSearch(params) {
    lastParams.value = params
    searchAttempted.value = true
    await search({ ...params, sort: sort.value })
}

async function onSort(newSort) {
    sort.value = newSort
    if (lastParams.value) {
        await search({ ...lastParams.value, sort: newSort })
    }
}
</script>
  • Step 2: Add the results section to the template

Add this new <section> block directly after the closing </section> tag of the hero section (after line </section> that closes id="hero"), before the <!-- How It Works --> section:

<!-- Search Results -->
<section v-if="searchAttempted" class="px-6 py-10 bg-zinc-100">
    <div class="max-w-7xl mx-auto space-y-6">

        <!-- Loading -->
        <div v-if="loading" class="flex items-center justify-center py-16">
            <div class="flex items-center gap-3 text-zinc-500">
                <iconify-icon icon="lucide:loader-circle" class="animate-spin text-2xl text-accent"></iconify-icon>
                <span class="font-medium">Finding stations near you…</span>
            </div>
        </div>

        <!-- Error -->
        <div v-else-if="error" class="flex items-center gap-3 p-4 bg-white border border-zinc-300 rounded-xl text-status-bad">
            <iconify-icon icon="lucide:circle-alert" style="font-size:1.25rem"></iconify-icon>
            <span class="font-medium">{{ error.general?.[0] ?? 'Unable to load stations. Please try again.' }}</span>
        </div>

        <!-- Results -->
        <template v-else>
            <LeafletMap :stations="stations" :default-open="true" />
            <StationList :stations="stations" :current-sort="sort" @sort="onSort" />
        </template>

    </div>
</section>
  • Step 3: Verify the <SearchBar> tag in the hero still has @search="onSearch"

In the hero section, the tag should read:

<SearchBar @search="onSearch" />
  • Step 4: Commit
git add resources/js/views/Home.vue
git commit -m "feat: wire up homepage search with map and station list"

Task 4: Manual browser verification

  • Step 1: Start the dev server (if not already running)
npm run dev
  • Step 2: Open the homepage at the URL from herd (e.g. https://fuel-price.test)

  • Step 3: Verify the search bar has three controls — postcode input, fuel type select, radius select

  • Step 4: Enter a valid UK postcode (e.g. SW1A 1AA) and click "Find Prices"

  • Step 5: Verify the results section appears below the hero with:

    • Loading spinner shown briefly
    • Map opens automatically with markers at each station
    • Station list renders below the map with sort tabs (Price / Distance / Updated)
    • Cheapest station price is highlighted in green
  • Step 6: Click a sort tab (e.g. Distance) and verify the list re-fetches and reorders

  • Step 7: Click "Hide map" toggle and verify the map collapses; click "Show map" and verify it reopens

  • Step 8: Enter an invalid postcode and verify an error message appears instead of the results