Files
fuel-price/docs/superpowers/plans/2026-04-10-vue-frontend-setup.md

63 KiB

Vue 3 Frontend Setup 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 Livewire-driven homepage and dashboard with a Vue 3 SPA that calls the existing /api/* endpoints, while keeping Filament, auth pages (login/register/password reset), and the Livewire package untouched.

Architecture: Laravel serves a single Blade shell (app.blade.php) for all frontend routes. Vue 3 mounts into it and handles routing, state, and rendering. Sanctum cookie-based auth is used for same-domain requests — the Vue app authenticates the user and the VerifyApiKey middleware is modified to also allow through Sanctum-authenticated sessions. External API consumers continue to use X-Api-Key. The authenticated dashboard is part of the same Vue SPA — no Livewire dashboard views.

Tech Stack: Vue 3, @vitejs/plugin-vue, Axios, Vue Router 4, Leaflet (already installed), Laravel Sanctum (already installed), Tailwind CSS v4 (already configured)


File Map

New files:

  • resources/js/app.js — Vue entry point (replaces current Alpine/iconify setup)
  • resources/js/App.vue — Root component with <RouterView>
  • resources/js/axios.js — Configured Axios instance with Sanctum cookie support
  • resources/js/router/index.js — Vue Router with Home, Dashboard, and dashboard sub-routes
  • resources/js/composables/useAuth.js — Auth state: current user, tier, isAuthenticated
  • resources/js/composables/useStations.js — Calls /api/stations, holds results + loading state
  • resources/js/composables/usePrediction.js — Calls /api/prediction, holds result + loading state
  • resources/js/composables/useSavedStations.js — Calls /api/user/saved-stations, holds list + CRUD actions
  • resources/js/views/Home.vue — Homepage: search → map + list + prediction
  • resources/js/views/dashboard/DashboardLayout.vue — Authenticated shell with sidebar nav
  • resources/js/views/dashboard/Overview.vue — Dashboard home: shortcuts + summary
  • resources/js/views/dashboard/SavedStations.vue — Saved stations list with remove action
  • resources/js/views/dashboard/Preferences.vue — Fuel type + postcode preferences form
  • resources/js/components/SearchBar.vue — Postcode input with debounce
  • resources/js/components/StationCard.vue — Single station row (name, price, distance, brand)
  • resources/js/components/StationList.vue — Renders list of StationCards with sort tabs
  • resources/js/components/LeafletMap.vue — Foldable Leaflet map with station markers
  • resources/js/components/PredictionCard.vue — Fill up / wait card, gated for paid tiers
  • resources/views/app.blade.php — Single SPA shell blade view
  • app/Http/Controllers/Api/UserController.php — Authenticated user API: preferences + saved stations
  • database/migrations/XXXX_add_preferred_fuel_type_to_users_table.php — Add preferred_fuel_type column
  • database/migrations/XXXX_create_saved_stations_table.php — Saved stations pivot

Modified files:

  • vite.config.js — Add @vitejs/plugin-vue plugin
  • routes/web.php — Remove old homepage/fuel-finder routes, add SPA catch-all
  • bootstrap/app.php — Enable Sanctum stateful API middleware
  • app/Http/Middleware/VerifyApiKey.php — Also allow Sanctum-authenticated sessions
  • .env — Add SANCTUM_STATEFUL_DOMAINS and SESSION_DOMAIN

Deleted files (cleanup):

  • app/Livewire/Public/FuelFinder.php
  • app/Livewire/Public/Fuel/Map.php
  • app/Livewire/Public/Fuel/Recommendation.php
  • app/Livewire/Public/Fuel/Search.php
  • app/Livewire/Public/Fuel/StationList.php
  • resources/views/homepage.blade.php
  • resources/views/livewire/public/ (all files)
  • resources/js/maps/station-map.js

Deleted files:

  • resources/views/homepage.blade.php — Replaced by Vue Home.vue
  • resources/views/dashboard.blade.php — Replaced by Vue DashboardLayout.vue
  • resources/js/maps/station-map.js — Replaced by LeafletMap.vue

Task 0: Cleanup — remove old Livewire public components and homepage

Remove all Livewire components and views that are being replaced by Vue. The Livewire dashboard, auth pages, and settings are kept untouched.

Files:

  • Delete: app/Livewire/Public/FuelFinder.php

  • Delete: app/Livewire/Public/Fuel/Map.php

  • Delete: app/Livewire/Public/Fuel/Recommendation.php

  • Delete: app/Livewire/Public/Fuel/Search.php

  • Delete: app/Livewire/Public/Fuel/StationList.php

  • Delete: resources/views/homepage.blade.php

  • Delete: resources/views/livewire/public/ (entire directory)

  • Delete: resources/js/maps/station-map.js

  • Modify: routes/web.php

  • Step 1: Delete Livewire Public components

rm app/Livewire/Public/FuelFinder.php
rm app/Livewire/Public/Fuel/Map.php
rm app/Livewire/Public/Fuel/Recommendation.php
rm app/Livewire/Public/Fuel/Search.php
rm app/Livewire/Public/Fuel/StationList.php
rmdir app/Livewire/Public/Fuel
rmdir app/Livewire/Public
  • Step 2: Delete old Blade views
rm resources/views/homepage.blade.php
rm -rf resources/views/livewire/public
rm resources/js/maps/station-map.js
  • Step 3: Clean up routes/web.php

Replace the full file:

<?php

use Illuminate\Support\Facades\Route;

Route::middleware(['auth', 'verified'])->group(function (): void {
    Route::view('dashboard', 'dashboard')->name('dashboard');
});

require __DIR__.'/settings.php';

Note: the SPA catch-all is added in Task 3 once the Blade shell exists. The Livewire dashboard route stays until the Vue dashboard is ready (Task 16).

  • Step 4: Verify the app still boots
php artisan route:list --except-vendor --compact

Expected: no errors, no routes referencing deleted classes.

  • Step 5: Commit
git add -A
git commit -m "chore: remove Livewire public components and homepage, prepare for Vue"

Task 1: Install Vue 3 dependencies and configure Vite

Files:

  • Modify: package.json (via npm install)

  • Modify: vite.config.js

  • Step 1: Install npm packages

npm install vue@^3.5 @vitejs/plugin-vue@^5.2 axios@^1.9

Expected: packages added to node_modules, package.json updated.

  • Step 2: Add Vue plugin to vite.config.js

Replace the entire file:

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import tailwindcss from '@tailwindcss/vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.js'],
            refresh: true,
        }),
        tailwindcss(),
        vue(),
    ],
    server: {
        cors: true,
        watch: {
            ignored: ['**/storage/framework/views/**'],
        },
    },
});
  • Step 3: Verify Vite starts without errors
npm run dev

Expected: Vite starts, no errors. Ctrl+C to stop.

  • Step 4: Commit
git add vite.config.js package.json package-lock.json
git commit -m "feat: add Vue 3 and Axios, configure Vite plugin"

Task 2: Enable Sanctum stateful API and update VerifyApiKey middleware

This allows the Vue SPA to call /api/* routes using the existing Laravel session (cookie auth) instead of an API key. External consumers continue to use X-Api-Key.

Files:

  • Modify: bootstrap/app.php

  • Modify: app/Http/Middleware/VerifyApiKey.php

  • Modify: .env

  • Test: tests/Feature/VerifyApiKeyMiddlewareTest.php

  • Step 1: Write the failing test

php artisan make:test --pest VerifyApiKeyMiddlewareTest

Open tests/Feature/VerifyApiKeyMiddlewareTest.php and replace its contents:

<?php

use App\Models\User;
use Laravel\Sanctum\Sanctum;

it('rejects requests without api key or sanctum session', function (): void {
    $response = $this->getJson('/api/stations?postcode=SW1A1AA&fuel_type=petrol');

    $response->assertStatus(403);
});

it('accepts requests with valid api key', function (): void {
    config(['app.api_secret_key' => 'test-secret']);

    $response = $this->withHeader('X-Api-Key', 'test-secret')
        ->getJson('/api/stations?postcode=SW1A1AA&fuel_type=petrol');

    // 403 would mean middleware rejected — any other status means it passed through
    $response->assertStatus(fn ($status) => $status !== 403);
});

it('accepts requests from sanctum authenticated users', function (): void {
    $user = User::factory()->create();
    Sanctum::actingAs($user);

    $response = $this->getJson('/api/stations?postcode=SW1A1AA&fuel_type=petrol');

    $response->assertStatus(fn ($status) => $status !== 403);
});
  • Step 2: Run the test to see it fail
php artisan test --compact --filter=VerifyApiKeyMiddlewareTest --timeout=10

Expected: third test fails — Sanctum auth is not yet recognised by the middleware.

  • Step 3: Enable Sanctum stateful API in bootstrap/app.php

Replace the withMiddleware closure:

->withMiddleware(function (Middleware $middleware): void {
    $middleware->statefulApi();
})
  • Step 4: Update VerifyApiKey middleware to allow Sanctum sessions

Replace the full file:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;

final class VerifyApiKey
{
    /**
     * Handle an incoming request.
     *
     * @param  Closure(Request): (Response)  $next
     */
    public function handle(Request $request, Closure $next): Response
    {
        if (Auth::guard('sanctum')->check()) {
            return $next($request);
        }

        if ($request->header('X-Api-Key') !== config('app.api_secret_key')) {
            abort(403);
        }

        return $next($request);
    }
}
  • Step 5: Add Sanctum stateful domain to .env

Add these two lines to .env (replace fuel-price.test with your actual Herd domain if different):

SANCTUM_STATEFUL_DOMAINS=fuel-price.test
SESSION_DOMAIN=.fuel-price.test
  • Step 6: Run tests to verify all three pass
php artisan test --compact --filter=VerifyApiKeyMiddlewareTest --timeout=10

Expected: all 3 tests pass.

  • Step 7: Run Pint
vendor/bin/pint --dirty --format agent
  • Step 8: Commit
git add bootstrap/app.php app/Http/Middleware/VerifyApiKey.php tests/Feature/VerifyApiKeyMiddlewareTest.php .env
git commit -m "feat: allow Sanctum-authenticated sessions through VerifyApiKey middleware"

Task 3: Create the Blade SPA shell and catch-all route

Files:

  • Create: resources/views/app.blade.php

  • Modify: routes/web.php

  • Test: tests/Feature/SpaRouteTest.php

  • Step 1: Write the failing test

php artisan make:test --pest SpaRouteTest

Replace contents of tests/Feature/SpaRouteTest.php:

<?php

it('serves the spa shell for the root path', function (): void {
    $response = $this->get('/');

    $response->assertStatus(200);
    $response->assertSee('<div id="app">', false);
});

it('serves the spa shell for unknown frontend paths', function (): void {
    $response = $this->get('/some/frontend/route');

    $response->assertStatus(200);
    $response->assertSee('<div id="app">', false);
});

it('does not intercept api routes', function (): void {
    $response = $this->get('/api/stations');

    // API route handles it (403 from missing key, not SPA HTML)
    $response->assertStatus(403);
    $response->assertJson(['message' => 'Forbidden.']);
});
  • Step 2: Run tests to verify they fail
php artisan test --compact --filter=SpaRouteTest --timeout=10

Expected: first two tests fail (no SPA shell yet).

  • Step 3: Create the SPA Blade shell

Create resources/views/app.blade.php:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <title>FuelAlert</title>
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="bg-[#f5ede5]">
    <div id="app"></div>
</body>
</html>
  • Step 4: Add the catch-all route to web.php

Add this as the last route in routes/web.php (after all existing routes and requires):

// SPA catch-all — must be last
Route::get('/{any}', fn () => view('app'))->where('any', '.*')->name('spa');

Also remove the old homepage route since Vue will handle it:

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

// Remove this line:
Route::get('/fuel-finder', FuelFinder::class)->name('fuel-finder');

And remove the use App\Livewire\Public\FuelFinder; import at the top.

The final routes/web.php should look like:

<?php

use Illuminate\Support\Facades\Route;

require __DIR__.'/settings.php';

// SPA catch-all — must be last
Route::get('/{any}', fn () => view('app'))->where('any', '.*')->name('spa');

Note: the Livewire Route::view('dashboard', 'dashboard') route is removed — /dashboard is now handled by the Vue SPA catch-all. The dashboard.blade.php view is no longer needed and can be deleted.

  • Step 5: Run tests
php artisan test --compact --filter=SpaRouteTest --timeout=10

Expected: all 3 pass.

  • Step 6: Run Pint
vendor/bin/pint --dirty --format agent
  • Step 7: Commit
git add resources/views/app.blade.php routes/web.php tests/Feature/SpaRouteTest.php
git commit -m "feat: add SPA Blade shell and catch-all route"

Task 4: Bootstrap Vue app with Router and Axios

Files:

  • Modify: resources/js/app.js

  • Create: resources/js/App.vue

  • Create: resources/js/router/index.js

  • Create: resources/js/axios.js

  • Step 1: Create the Axios instance

Create resources/js/axios.js:

import axios from 'axios'

const api = axios.create({
    baseURL: '/api',
    withCredentials: true,
    withXSRFToken: true,
    headers: {
        'Accept': 'application/json',
        'X-Requested-With': 'XMLHttpRequest',
    },
})

export default api
  • Step 2: Create Vue Router

Create resources/js/router/index.js:

import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import DashboardLayout from '../views/dashboard/DashboardLayout.vue'
import Overview from '../views/dashboard/Overview.vue'
import SavedStations from '../views/dashboard/SavedStations.vue'
import Preferences from '../views/dashboard/Preferences.vue'

const routes = [
    { path: '/', component: Home, name: 'home' },
    {
        path: '/dashboard',
        component: DashboardLayout,
        children: [
            { path: '', component: Overview, name: 'dashboard' },
            { path: 'saved-stations', component: SavedStations, name: 'dashboard.saved-stations' },
            { path: 'preferences', component: Preferences, name: 'dashboard.preferences' },
        ],
    },
]

export default createRouter({
    history: createWebHistory(),
    routes,
})
  • Step 3: Create the root App component

Create resources/js/App.vue:

<template>
    <RouterView />
</template>

<script setup>
import { RouterView } from 'vue-router'
</script>
  • Step 4: Replace app.js with Vue bootstrap

Replace resources/js/app.js:

import { createApp } from 'vue'
import App from './App.vue'
import router from './router/index.js'

createApp(App).use(router).mount('#app')
  • Step 5: Create stub view files so the router resolves

Create resources/js/views/Home.vue:

<template>
    <div class="min-h-screen flex items-center justify-center">
        <p class="text-xl font-bold text-[#bb5b3e]">FuelAlert  Home (coming soon)</p>
    </div>
</template>

Create resources/js/views/dashboard/DashboardLayout.vue:

<template>
    <div class="min-h-screen flex items-center justify-center">
        <RouterView />
    </div>
</template>
<script setup>
import { RouterView } from 'vue-router'
</script>

Create resources/js/views/dashboard/Overview.vue:

<template><div class="p-8 font-bold text-[#bb5b3e]">Dashboard Overview (coming soon)</div></template>

Create resources/js/views/dashboard/SavedStations.vue:

<template><div class="p-8 font-bold text-[#bb5b3e]">Saved Stations (coming soon)</div></template>

Create resources/js/views/dashboard/Preferences.vue:

<template><div class="p-8 font-bold text-[#bb5b3e]">Preferences (coming soon)</div></template>
  • Step 6: Build assets and verify in browser
npm run build

Open https://fuel-price.test in the browser. Expected: page loads, shows "FuelAlert — Home (coming soon)". No console errors.

  • Step 7: Commit
git add resources/js/app.js resources/js/App.vue resources/js/axios.js resources/js/router/index.js resources/js/views/Home.vue resources/js/views/Account.vue
git commit -m "feat: bootstrap Vue 3 app with Vue Router and Axios"

Task 5: Auth composable (useAuth)

Fetches the current user from /api/auth/me. Provides user, isAuthenticated, and userTier to any component that needs them.

Files:

  • Create: resources/js/composables/useAuth.js

  • Step 1: Create the composable

Create resources/js/composables/useAuth.js:

import { ref, computed } from 'vue'
import api from '../axios.js'

const user = ref(null)
const loading = ref(false)
const fetched = ref(false)

export function useAuth() {
    const isAuthenticated = computed(() => user.value !== null)

    const userTier = computed(() => {
        if (!user.value) {
            return 'guest'
        }
        return user.value.tier ?? 'free'
    })

    const isPaidTier = computed(() => {
        return ['basic', 'plus', 'pro'].includes(userTier.value)
    })

    async function fetchUser() {
        if (fetched.value) {
            return
        }
        loading.value = true
        try {
            const response = await api.get('/auth/me')
            user.value = response.data
        } catch {
            user.value = null
        } finally {
            loading.value = false
            fetched.value = true
        }
    }

    function clearUser() {
        user.value = null
        fetched.value = false
    }

    return { user, loading, isAuthenticated, userTier, isPaidTier, fetchUser, clearUser }
}
  • Step 2: Call fetchUser in App.vue on mount

Replace resources/js/App.vue:

<template>
    <RouterView />
</template>

<script setup>
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
import { useAuth } from './composables/useAuth.js'

const { fetchUser } = useAuth()

onMounted(async () => {
    await fetchUser()
})
</script>
  • Step 3: Verify in browser (npm run dev)
npm run dev

Open https://fuel-price.test. In browser DevTools Network tab, confirm a request to /api/auth/me fires on page load. If not logged in, expect a 401 — that is correct (user is null, guest state).

  • Step 4: Commit
git add resources/js/composables/useAuth.js resources/js/App.vue
git commit -m "feat: add useAuth composable with user tier detection"

Task 6: SearchBar component

Postcode input with 400ms debounce. Emits a search event with the postcode string when the user stops typing.

Files:

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

  • Step 1: Create the component

Create resources/js/components/SearchBar.vue:

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

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

const emit = defineEmits(['search'])
const postcode = ref('')
let debounceTimer = null

function onInput() {
    clearTimeout(debounceTimer)
    debounceTimer = setTimeout(() => {
        if (postcode.value.trim().length >= 2) {
            emit('search', postcode.value.trim())
        }
    }, 400)
}
</script>
  • Step 2: Commit
git add resources/js/components/SearchBar.vue
git commit -m "feat: add SearchBar component with debounce"

Task 7: useStations composable

Calls GET /api/stations and holds the result, loading state, and any error.

Files:

  • Create: resources/js/composables/useStations.js

  • Step 1: Create the composable

Create resources/js/composables/useStations.js:

import { ref } from 'vue'
import api from '../axios.js'

export function useStations() {
    const stations = ref([])
    const meta = ref(null)
    const loading = ref(false)
    const error = ref(null)

    async function search({ postcode, lat, lng, fuelType = 'petrol', radius = 10, sort = 'price' }) {
        loading.value = true
        error.value = null
        stations.value = []
        meta.value = null

        const params = { fuel_type: fuelType, radius, sort }

        if (postcode) {
            params.postcode = postcode
        } else if (lat && lng) {
            params.lat = lat
            params.lng = lng
        }

        try {
            const response = await api.get('/stations', { params })
            stations.value = response.data.data
            meta.value = response.data.meta
        } catch (err) {
            error.value = err.response?.data?.errors
                ?? { general: ['Unable to load stations. Please try again.'] }
        } finally {
            loading.value = false
        }
    }

    return { stations, meta, loading, error, search }
}
  • Step 2: Commit
git add resources/js/composables/useStations.js
git commit -m "feat: add useStations composable"

Task 8: StationCard component

Renders a single station row: brand logo placeholder, name, price, distance, last updated.

Files:

  • Create: resources/js/components/StationCard.vue

  • Step 1: Create the component

Create resources/js/components/StationCard.vue:

<template>
    <div class="flex items-center justify-between p-4 bg-white rounded-xl border border-[#e5ded7] hover:border-[#bb5b3e] transition-colors">
        <div class="flex items-center gap-3 min-w-0">
            <div class="w-10 h-10 rounded-lg bg-[#bb5b3e]/10 flex items-center justify-center flex-shrink-0">
                <iconify-icon
                    :icon="station.is_supermarket ? 'lucide:shopping-cart' : 'lucide:fuel'"
                    style="font-size:1.25rem"
                    class="text-[#bb5b3e]"
                ></iconify-icon>
            </div>
            <div class="min-w-0">
                <p class="font-bold text-[#4a3f3b] truncate">{{ station.name }}</p>
                <p class="text-xs text-[#89726c]">{{ station.distance_km.toFixed(1) }} km away · Updated {{ updatedAgo }}</p>
            </div>
        </div>
        <div class="text-right flex-shrink-0 ml-4">
            <p class="text-xl font-black" :class="priceColor">{{ station.price }}p</p>
        </div>
    </div>
</template>

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

const props = defineProps({
    station: { type: Object, required: true },
    lowestPrice: { type: Number, default: null },
})

const priceColor = computed(() => {
    if (!props.lowestPrice) return 'text-[#4a3f3b]'
    if (props.station.price_pence === props.lowestPrice) return 'text-[#22c55e]'
    if (props.station.price_pence > props.lowestPrice + 500) return 'text-[#ef4444]'
    return 'text-[#4a3f3b]'
})

const updatedAgo = computed(() => {
    const updated = new Date(props.station.price_updated_at)
    const diff = Math.floor((Date.now() - updated) / 60000)
    if (diff < 60) return `${diff}m ago`
    const hours = Math.floor(diff / 60)
    if (hours < 24) return `${hours}h ago`
    return `${Math.floor(hours / 24)}d ago`
})
</script>
  • Step 2: Commit
git add resources/js/components/StationCard.vue
git commit -m "feat: add StationCard component"

Task 9: StationList component

Renders a list of StationCards with sort tabs (Price, Distance, Updated).

Files:

  • Create: resources/js/components/StationList.vue

  • Step 1: Create the component

Create resources/js/components/StationList.vue:

<template>
    <div class="space-y-3">
        <!-- Sort tabs -->
        <div class="flex gap-2">
            <button
                v-for="option in sortOptions"
                :key="option.value"
                @click="emit('sort', option.value)"
                :class="[
                    'px-4 py-1.5 rounded-full text-sm font-bold transition-colors',
                    currentSort === option.value
                        ? 'bg-[#bb5b3e] text-white'
                        : 'bg-white border border-[#e5ded7] text-[#89726c] hover:border-[#bb5b3e]'
                ]"
            >
                {{ option.label }}
            </button>
        </div>

        <!-- Count -->
        <p class="text-sm text-[#89726c] font-medium">
            {{ stations.length }} station{{ stations.length !== 1 ? 's' : '' }} found
        </p>

        <!-- Results -->
        <div class="space-y-2">
            <StationCard
                v-for="station in stations"
                :key="station.station_id"
                :station="station"
                :lowest-price="lowestPrice"
            />
        </div>
    </div>
</template>

<script setup>
import { computed } from 'vue'
import StationCard from './StationCard.vue'

const props = defineProps({
    stations: { type: Array, required: true },
    currentSort: { type: String, default: 'price' },
})

const emit = defineEmits(['sort'])

const sortOptions = [
    { label: 'Price', value: 'price' },
    { label: 'Distance', value: 'distance' },
    { label: 'Updated', value: 'updated' },
]

const lowestPrice = computed(() => {
    if (!props.stations.length) return null
    return Math.min(...props.stations.map(s => s.price_pence))
})
</script>
  • Step 2: Commit
git add resources/js/components/StationList.vue
git commit -m "feat: add StationList component with sort tabs"

Task 10: LeafletMap component (foldable)

Renders a Leaflet map with a marker per station. Foldable via a toggle button. Map re-invalidates its size when shown to avoid rendering issues.

Files:

  • Delete: resources/js/maps/station-map.js (replaced by this component)

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

  • Step 1: Delete the old map file

rm resources/js/maps/station-map.js
  • Step 2: Create the component

Create resources/js/components/LeafletMap.vue:

<template>
    <div class="space-y-2">
        <button
            @click="toggleMap"
            class="flex items-center gap-2 text-sm font-bold text-[#bb5b3e] hover:text-[#a34a31] transition-colors"
        >
            <iconify-icon :icon="isOpen ? 'lucide:chevron-up' : 'lucide:chevron-down'"></iconify-icon>
            {{ isOpen ? 'Hide map' : 'Show map' }}
        </button>

        <div
            v-show="isOpen"
            ref="mapContainer"
            class="w-full h-72 rounded-2xl overflow-hidden border border-[#e5ded7] shadow-sm"
        ></div>
    </div>
</template>

<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 },
})

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()
    }
}

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

onUnmounted(() => {
    if (mapInstance) {
        mapInstance.remove()
        mapInstance = null
    }
})
</script>
  • Step 3: Commit
git add resources/js/components/LeafletMap.vue
git rm resources/js/maps/station-map.js
git commit -m "feat: add LeafletMap component (foldable), remove legacy station-map.js"

Task 11: usePrediction composable

Calls GET /api/prediction and holds the result.

Files:

  • Create: resources/js/composables/usePrediction.js

  • Step 1: Create the composable

Create resources/js/composables/usePrediction.js:

import { ref } from 'vue'
import api from '../axios.js'

export function usePrediction() {
    const prediction = ref(null)
    const loading = ref(false)
    const error = ref(null)

    async function fetch({ lat, lng } = {}) {
        loading.value = true
        error.value = null
        prediction.value = null

        const params = {}
        if (lat && lng) {
            params.lat = lat
            params.lng = lng
        }

        try {
            const response = await api.get('/prediction', { params })
            prediction.value = response.data
        } catch (err) {
            error.value = 'Unable to load prediction.'
        } finally {
            loading.value = false
        }
    }

    return { prediction, loading, error, fetch }
}
  • Step 2: Commit
git add resources/js/composables/usePrediction.js
git commit -m "feat: add usePrediction composable"

Task 12: PredictionCard component (tier-gated)

Shows the fill-up/wait recommendation. Free users see a blur + upgrade prompt instead of the full card.

Files:

  • Create: resources/js/components/PredictionCard.vue

  • Step 1: Create the component

Create resources/js/components/PredictionCard.vue:

<template>
    <div class="relative">
        <!-- Gated overlay for free/guest users -->
        <div
            v-if="!isPaidTier"
            class="absolute inset-0 z-10 rounded-2xl backdrop-blur-sm bg-white/60 flex flex-col items-center justify-center gap-3 text-center px-6"
        >
            <iconify-icon icon="lucide:lock" class="text-[#bb5b3e] text-3xl"></iconify-icon>
            <p class="font-bold text-[#4a3f3b]">Price predictions are available on paid plans</p>
            <a
                href="/pricing"
                class="px-6 py-2 bg-[#bb5b3e] text-white rounded-full text-sm font-bold hover:bg-[#a34a31] transition-colors"
            >
                Upgrade from £0.99/mo
            </a>
        </div>

        <!-- Card content (blurred for free users, fully visible for paid) -->
        <div
            :class="['p-6 bg-white rounded-2xl border border-[#e5ded7] space-y-4', !isPaidTier && 'select-none pointer-events-none']"
        >
            <p class="text-xs font-bold uppercase tracking-widest text-[#89726c]">Price Prediction</p>

            <!-- Loading state -->
            <template v-if="loading">
                <div class="animate-pulse space-y-2">
                    <div class="h-8 bg-[#e5ded7] rounded w-1/2"></div>
                    <div class="h-4 bg-[#e5ded7] rounded w-3/4"></div>
                </div>
            </template>

            <!-- Loaded state -->
            <template v-else-if="prediction">
                <h3
                    class="text-2xl font-black"
                    :class="prediction.action === 'fill_now' ? 'text-[#8B4860]' : prediction.action === 'wait' ? 'text-[#4A7C7E]' : 'text-[#9B8B6B]'"
                >
                    {{ actionLabel }}
                </h3>

                <div class="w-full h-2 bg-[#eeeae5] rounded-full overflow-hidden">
                    <div
                        class="h-full rounded-full transition-all"
                        :class="prediction.action === 'fill_now' ? 'bg-[#8B4860]' : 'bg-[#4A7C7E]'"
                        :style="{ width: prediction.confidence_score + '%' }"
                    ></div>
                </div>

                <p class="text-sm text-[#89726c] leading-relaxed">{{ prediction.reasoning }}</p>

                <div class="flex items-center gap-4 text-xs text-[#89726c] font-medium">
                    <span>Avg: {{ prediction.current_avg }}p</span>
                    <span>Confidence: {{ prediction.confidence_label }}</span>
                    <span v-if="prediction.predicted_change_pence">
                        {{ prediction.predicted_change_pence > 0 ? '+' : '' }}{{ prediction.predicted_change_pence.toFixed(1) }}p expected
                    </span>
                </div>
            </template>

            <!-- Empty state (placeholder for gated view) -->
            <template v-else>
                <h3 class="text-2xl font-black text-[#8B4860]">Fill up now</h3>
                <div class="h-2 bg-[#eeeae5] rounded-full"><div class="h-full bg-[#8B4860] w-4/5 rounded-full"></div></div>
                <p class="text-sm text-[#89726c]">Prices in your area are rising  best to fill up today.</p>
            </template>
        </div>
    </div>
</template>

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

const props = defineProps({
    prediction: { type: Object, default: null },
    loading: { type: Boolean, default: false },
    isPaidTier: { type: Boolean, default: false },
})

const actionLabel = computed(() => {
    if (!props.prediction) return ''
    return {
        fill_now: 'Fill up now',
        wait: 'Wait — prices falling',
        no_signal: 'No clear signal',
    }[props.prediction.action] ?? 'Check local prices'
})
</script>
  • Step 2: Commit
git add resources/js/components/PredictionCard.vue
git commit -m "feat: add PredictionCard component with tier gating"

Task 13: Home.vue — wire everything together

Replaces the stub with the full homepage: nav, hero search, results (map + list + prediction).

Files:

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

  • Step 1: Replace Home.vue with the full implementation

Replace resources/js/views/Home.vue:

<template>
    <div class="min-h-screen bg-[#f5ede5]">
        <!-- Navigation -->
        <nav class="fixed top-0 w-full z-50 bg-[#faf6f3] border-b border-[#e5ded7] px-6 py-4 md:px-12">
            <div class="max-w-7xl mx-auto flex items-center justify-between">
                <RouterLink to="/" class="flex items-center gap-3">
                    <div class="w-10 h-10 rounded-lg bg-[#bb5b3e] flex items-center justify-center shadow-md">
                        <iconify-icon icon="lucide:fuel" class="text-white text-xl"></iconify-icon>
                    </div>
                    <span class="text-2xl font-black tracking-tighter text-[#bb5b3e]">FuelAlert</span>
                </RouterLink>

                <div class="flex items-center gap-4">
                    <template v-if="isAuthenticated">
                        <RouterLink to="/account" class="text-sm font-bold text-[#89726c] hover:text-[#4a3f3b]">Account</RouterLink>
                    </template>
                    <template v-else>
                        <a href="/login" class="text-sm font-bold text-[#89726c] hover:text-[#4a3f3b]">Login</a>
                        <a href="/register" class="bg-[#bb5b3e] text-white px-6 py-2.5 rounded-full text-sm font-bold shadow-lg hover:bg-[#a34a31] transition-all">Get Started</a>
                    </template>
                </div>
            </div>
        </nav>

        <!-- Hero -->
        <section class="relative pt-36 pb-16 px-6">
            <div class="max-w-2xl mx-auto text-center space-y-6">
                <div class="inline-flex items-center gap-2 px-3 py-1 bg-[#bb5b3e]/10 text-[#bb5b3e] rounded-full text-xs font-bold uppercase tracking-wider">
                    <iconify-icon icon="lucide:sparkles"></iconify-icon>
                    Save up to £250/year on fuel
                </div>
                <h1 class="text-5xl md:text-6xl font-black text-[#4a3f3b] leading-tight tracking-tighter">
                    Stop Overpaying <span class="text-[#bb5b3e]">for Fuel.</span>
                </h1>
                <p class="text-lg text-[#89726c] max-w-lg mx-auto">Find the cheapest petrol near you and know the best time to fill up.</p>

                <div class="flex justify-center">
                    <SearchBar @search="onSearch" />
                </div>

                <p v-if="stationError" class="text-sm text-red-500 font-medium">
                    {{ Object.values(stationError).flat().join(' ') }}
                </p>
            </div>
        </section>

        <!-- Results -->
        <section v-if="hasSearched" class="px-6 pb-24">
            <div class="max-w-4xl mx-auto space-y-6">
                <!-- Fuel type selector -->
                <div class="flex gap-2 flex-wrap">
                    <button
                        v-for="fuel in fuelOptions"
                        :key="fuel.value"
                        @click="changeFuelType(fuel.value)"
                        :class="[
                            'px-4 py-1.5 rounded-full text-sm font-bold transition-colors',
                            currentFuelType === fuel.value
                                ? 'bg-[#4a3f3b] text-white'
                                : 'bg-white border border-[#e5ded7] text-[#89726c] hover:border-[#4a3f3b]'
                        ]"
                    >
                        {{ fuel.label }}
                    </button>
                </div>

                <div class="grid lg:grid-cols-3 gap-6">
                    <!-- Map + List (2/3 width) -->
                    <div class="lg:col-span-2 space-y-4">
                        <LeafletMap :stations="stations" />

                        <template v-if="stationsLoading">
                            <div class="space-y-2">
                                <div v-for="i in 5" :key="i" class="h-16 bg-white rounded-xl animate-pulse border border-[#e5ded7]"></div>
                            </div>
                        </template>
                        <template v-else>
                            <StationList
                                :stations="stations"
                                :current-sort="currentSort"
                                @sort="changeSort"
                            />
                        </template>
                    </div>

                    <!-- Prediction (1/3 width) -->
                    <div>
                        <PredictionCard
                            :prediction="prediction"
                            :loading="predictionLoading"
                            :is-paid-tier="isPaidTier"
                        />
                    </div>
                </div>
            </div>
        </section>
    </div>
</template>

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

const { isAuthenticated, isPaidTier } = useAuth()
const { stations, loading: stationsLoading, error: stationError, search } = useStations()
const { prediction, loading: predictionLoading, fetch: fetchPrediction } = usePrediction()

const hasSearched = ref(false)
const currentSort = ref('price')
const currentFuelType = ref('petrol')
const lastPostcode = ref('')

const fuelOptions = [
    { label: 'Petrol (E10)', value: 'petrol' },
    { label: 'Diesel', value: 'diesel' },
    { label: 'Premium Unleaded', value: 'e5' },
    { label: 'Premium Diesel', value: 'b7_premium' },
]

async function onSearch(postcode) {
    lastPostcode.value = postcode
    hasSearched.value = true
    await Promise.all([
        search({ postcode, fuelType: currentFuelType.value, sort: currentSort.value }),
        fetchPrediction(),
    ])
}

async function changeSort(sort) {
    currentSort.value = sort
    if (lastPostcode.value) {
        await search({ postcode: lastPostcode.value, fuelType: currentFuelType.value, sort })
    }
}

async function changeFuelType(fuelType) {
    currentFuelType.value = fuelType
    if (lastPostcode.value) {
        await search({ postcode: lastPostcode.value, fuelType, sort: currentSort.value })
    }
}
</script>
  • Step 2: Build and test in browser
npm run build

Open https://fuel-price.test. Enter a postcode (e.g. SW1A 1AA), click Find Prices. Expected: station list loads, map toggle works, prediction card shows (gated if not logged in to a paid tier).

  • Step 3: Commit
git add resources/js/views/Home.vue
git commit -m "feat: build full Home.vue with search, station list, map, and prediction"

Task 14: Dashboard API endpoints (preferences + saved stations)

Adds authenticated endpoints the Vue dashboard will consume. All routes use auth:sanctum middleware.

Files:

  • Create: app/Http/Controllers/Api/UserController.php

  • Create: database/migrations/XXXX_add_preferred_fuel_type_to_users_table.php

  • Create: database/migrations/XXXX_create_saved_stations_table.php

  • Modify: routes/api.php

  • Modify: app/Models/User.php

  • Test: tests/Feature/Api/UserControllerTest.php

  • Step 1: Create the migrations

php artisan make:migration add_preferred_fuel_type_to_users_table --no-interaction
php artisan make:migration create_saved_stations_table --no-interaction

In the add_preferred_fuel_type migration's up():

Schema::table('users', function (Blueprint $table): void {
    $table->string('preferred_fuel_type', 20)->default('petrol')->after('postcode')
        ->comment('User\'s default fuel type for homepage search');
});

In the create_saved_stations migration's up():

Schema::create('saved_stations', function (Blueprint $table): void {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('station_id', 64);
    $table->timestamps();

    $table->unique(['user_id', 'station_id']);
    $table->index(['user_id']);
});
  • Step 2: Run migrations
php artisan migrate --no-interaction
  • Step 3: Write the failing tests
php artisan make:test --pest Api/UserControllerTest --no-interaction

Replace tests/Feature/Api/UserControllerTest.php:

<?php

use App\Models\User;
use Laravel\Sanctum\Sanctum;

it('returns user preferences for authenticated user', function (): void {
    $user = User::factory()->create(['preferred_fuel_type' => 'diesel']);
    Sanctum::actingAs($user);

    $this->getJson('/api/user/preferences')
        ->assertOk()
        ->assertJsonFragment(['preferred_fuel_type' => 'diesel']);
});

it('updates user preferences', function (): void {
    $user = User::factory()->create(['preferred_fuel_type' => 'petrol']);
    Sanctum::actingAs($user);

    $this->putJson('/api/user/preferences', ['preferred_fuel_type' => 'diesel'])
        ->assertOk()
        ->assertJsonFragment(['preferred_fuel_type' => 'diesel']);

    expect($user->fresh()->preferred_fuel_type)->toBe('diesel');
});

it('rejects invalid fuel type in preferences update', function (): void {
    $user = User::factory()->create();
    Sanctum::actingAs($user);

    $this->putJson('/api/user/preferences', ['preferred_fuel_type' => 'aviation_fuel'])
        ->assertUnprocessable();
});

it('returns saved stations for authenticated user', function (): void {
    $user = User::factory()->create();
    Sanctum::actingAs($user);

    $this->getJson('/api/user/saved-stations')
        ->assertOk()
        ->assertJsonStructure(['data']);
});

it('saves a station', function (): void {
    $user = User::factory()->create();
    Sanctum::actingAs($user);

    $this->postJson('/api/user/saved-stations', ['station_id' => 'abc123'])
        ->assertCreated();

    expect($user->savedStations()->where('station_id', 'abc123')->exists())->toBeTrue();
});

it('removes a saved station', function (): void {
    $user = User::factory()->create();
    $user->savedStations()->create(['station_id' => 'abc123']);
    Sanctum::actingAs($user);

    $this->deleteJson('/api/user/saved-stations/abc123')
        ->assertNoContent();

    expect($user->savedStations()->where('station_id', 'abc123')->exists())->toBeFalse();
});

it('rejects unauthenticated requests to user endpoints', function (): void {
    $this->getJson('/api/user/preferences')->assertUnauthorized();
    $this->getJson('/api/user/saved-stations')->assertUnauthorized();
});
  • Step 4: Run tests to confirm they fail
php artisan test --compact --filter=UserControllerTest --timeout=10

Expected: all fail (routes don't exist yet).

  • Step 5: Create the UserController
php artisan make:controller Api/UserController --no-interaction

Replace app/Http/Controllers/Api/UserController.php:

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Validation\Rule;

final class UserController extends Controller
{
    public function preferences(Request $request): JsonResponse
    {
        return response()->json([
            'preferred_fuel_type' => $request->user()->preferred_fuel_type,
            'postcode' => $request->user()->postcode,
        ]);
    }

    public function updatePreferences(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'preferred_fuel_type' => ['sometimes', Rule::in(['petrol', 'diesel', 'e5', 'b7_premium', 'b10', 'hvo'])],
            'postcode' => ['sometimes', 'string', 'max:8'],
        ]);

        $request->user()->update($validated);

        return response()->json([
            'preferred_fuel_type' => $request->user()->fresh()->preferred_fuel_type,
            'postcode' => $request->user()->fresh()->postcode,
        ]);
    }

    public function savedStations(Request $request): JsonResponse
    {
        $stations = $request->user()
            ->savedStations()
            ->join('stations', 'saved_stations.station_id', '=', 'stations.station_id')
            ->select('stations.*', 'saved_stations.created_at as saved_at')
            ->get();

        return response()->json(['data' => $stations]);
    }

    public function saveStation(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'station_id' => ['required', 'string', 'max:64'],
        ]);

        $request->user()->savedStations()->firstOrCreate([
            'station_id' => $validated['station_id'],
        ]);

        return response()->json(null, 201);
    }

    public function removeStation(Request $request, string $stationId): Response
    {
        $request->user()->savedStations()->where('station_id', $stationId)->delete();

        return response()->noContent();
    }
}
  • Step 6: Add the relationship to User model

Open app/Models/User.php and add:

public function savedStations(): \Illuminate\Database\Eloquent\Relations\HasMany
{
    return $this->hasMany(\App\Models\SavedStation::class);
}

Also add preferred_fuel_type to $fillable.

  • Step 7: Create the SavedStation model
php artisan make:model SavedStation --no-interaction

Replace app/Models/SavedStation.php:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

final class SavedStation extends Model
{
    protected $fillable = ['user_id', 'station_id'];
}
  • Step 8: Register the API routes

Add to routes/api.php inside a new Sanctum group:

Route::middleware('auth:sanctum')->group(function (): void {
    Route::get('/auth/me', [AuthController::class, 'me']);
    Route::post('/auth/logout', [AuthController::class, 'logout']);

    // User dashboard endpoints
    Route::get('/user/preferences', [UserController::class, 'preferences']);
    Route::put('/user/preferences', [UserController::class, 'updatePreferences']);
    Route::get('/user/saved-stations', [UserController::class, 'savedStations']);
    Route::post('/user/saved-stations', [UserController::class, 'saveStation']);
    Route::delete('/user/saved-stations/{stationId}', [UserController::class, 'removeStation']);
});

Add use App\Http\Controllers\Api\UserController; to the imports at the top.

  • Step 9: Run tests to confirm they pass
php artisan test --compact --filter=UserControllerTest --timeout=10

Expected: all 7 pass.

  • Step 10: Run Pint
vendor/bin/pint --dirty --format agent
  • Step 11: Commit
git add app/Http/Controllers/Api/UserController.php app/Models/SavedStation.php app/Models/User.php routes/api.php database/migrations/ tests/Feature/Api/UserControllerTest.php
git commit -m "feat: add user preferences and saved stations API endpoints"

Task 15: useSavedStations composable

Files:

  • Create: resources/js/composables/useSavedStations.js

  • Step 1: Create the composable

Create resources/js/composables/useSavedStations.js:

import { ref } from 'vue'
import api from '../axios.js'

export function useSavedStations() {
    const savedStations = ref([])
    const loading = ref(false)

    async function fetch() {
        loading.value = true
        try {
            const response = await api.get('/user/saved-stations')
            savedStations.value = response.data.data
        } finally {
            loading.value = false
        }
    }

    async function save(stationId) {
        await api.post('/user/saved-stations', { station_id: stationId })
        await fetch()
    }

    async function remove(stationId) {
        await api.delete(`/user/saved-stations/${stationId}`)
        savedStations.value = savedStations.value.filter(s => s.station_id !== stationId)
    }

    function isSaved(stationId) {
        return savedStations.value.some(s => s.station_id === stationId)
    }

    return { savedStations, loading, fetch, save, remove, isSaved }
}
  • Step 2: Commit
git add resources/js/composables/useSavedStations.js
git commit -m "feat: add useSavedStations composable"

Task 16: DashboardLayout.vue — authenticated shell with sidebar

Files:

  • Modify: resources/js/views/dashboard/DashboardLayout.vue

  • Step 1: Replace the stub with the full layout

Replace resources/js/views/dashboard/DashboardLayout.vue:

<template>
    <div class="min-h-screen bg-[#f5ede5] flex flex-col">
        <!-- Top nav -->
        <nav class="fixed top-0 w-full z-50 bg-[#faf6f3] border-b border-[#e5ded7] px-6 py-4">
            <div class="max-w-7xl mx-auto flex items-center justify-between">
                <RouterLink to="/" class="flex items-center gap-3">
                    <div class="w-10 h-10 rounded-lg bg-[#bb5b3e] flex items-center justify-center shadow-md">
                        <iconify-icon icon="lucide:fuel" class="text-white text-xl"></iconify-icon>
                    </div>
                    <span class="text-2xl font-black tracking-tighter text-[#bb5b3e]">FuelAlert</span>
                </RouterLink>
                <div class="flex items-center gap-4">
                    <RouterLink to="/" class="text-sm font-bold text-[#89726c] hover:text-[#4a3f3b]">
                         Find fuel
                    </RouterLink>
                    <span class="text-sm text-[#89726c]">{{ user?.email }}</span>
                </div>
            </div>
        </nav>

        <div class="flex pt-20 max-w-7xl mx-auto w-full px-6 py-8 gap-8">
            <!-- Sidebar -->
            <aside class="w-56 flex-shrink-0 hidden md:block">
                <nav class="space-y-1">
                    <RouterLink
                        v-for="item in navItems"
                        :key="item.to"
                        :to="item.to"
                        class="flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-bold transition-colors"
                        :class="$route.path === item.to
                            ? 'bg-[#bb5b3e] text-white'
                            : 'text-[#89726c] hover:bg-white hover:text-[#4a3f3b]'"
                    >
                        <iconify-icon :icon="item.icon"></iconify-icon>
                        {{ item.label }}
                    </RouterLink>
                </nav>
            </aside>

            <!-- Content -->
            <main class="flex-1 min-w-0">
                <RouterView />
            </main>
        </div>
    </div>
</template>

<script setup>
import { RouterLink, RouterView, useRoute } from 'vue-router'
import { useAuth } from '../../composables/useAuth.js'

const { user } = useAuth()
const $route = useRoute()

const navItems = [
    { to: '/dashboard', label: 'Overview', icon: 'lucide:layout-dashboard' },
    { to: '/dashboard/saved-stations', label: 'Saved Stations', icon: 'lucide:bookmark' },
    { to: '/dashboard/preferences', label: 'Preferences', icon: 'lucide:settings' },
]
</script>
  • Step 2: Build and verify
npm run build

Navigate to https://fuel-price.test/dashboard. Expected: sidebar renders, nav links highlight active route.

  • Step 3: Commit
git add resources/js/views/dashboard/DashboardLayout.vue
git commit -m "feat: add DashboardLayout with sidebar navigation"

Task 17: Overview.vue, SavedStations.vue, Preferences.vue

Files:

  • Modify: resources/js/views/dashboard/Overview.vue

  • Modify: resources/js/views/dashboard/SavedStations.vue

  • Modify: resources/js/views/dashboard/Preferences.vue

  • Step 1: Replace Overview.vue

Replace resources/js/views/dashboard/Overview.vue:

<template>
    <div class="space-y-6">
        <div>
            <h1 class="text-2xl font-black text-[#4a3f3b]">Welcome back{{ user ? ', ' + user.name : '' }}</h1>
            <p class="text-[#89726c] mt-1">Your FuelAlert dashboard.</p>
        </div>

        <div class="grid sm:grid-cols-3 gap-4">
            <RouterLink
                v-for="item in quickLinks"
                :key="item.to"
                :to="item.to"
                class="p-6 bg-white rounded-2xl border border-[#e5ded7] hover:border-[#bb5b3e] transition-colors space-y-3"
            >
                <iconify-icon :icon="item.icon" class="text-[#bb5b3e] text-2xl"></iconify-icon>
                <p class="font-bold text-[#4a3f3b]">{{ item.label }}</p>
                <p class="text-sm text-[#89726c]">{{ item.description }}</p>
            </RouterLink>
        </div>

        <div class="p-6 bg-white rounded-2xl border border-[#e5ded7] space-y-2">
            <p class="text-sm font-bold uppercase tracking-widest text-[#89726c]">Your plan</p>
            <p class="text-xl font-black text-[#4a3f3b] capitalize">{{ userTier }}</p>
            <a v-if="userTier === 'free'" href="/pricing" class="inline-block text-sm font-bold text-[#bb5b3e] hover:underline">
                Upgrade for alerts + predictions 
            </a>
        </div>
    </div>
</template>

<script setup>
import { RouterLink } from 'vue-router'
import { useAuth } from '../../composables/useAuth.js'

const { user, userTier } = useAuth()

const quickLinks = [
    { to: '/dashboard/saved-stations', label: 'Saved Stations', icon: 'lucide:bookmark', description: 'Stations you\'ve bookmarked for quick access.' },
    { to: '/dashboard/preferences', label: 'Preferences', icon: 'lucide:settings', description: 'Set your default fuel type and postcode.' },
    { to: '/', label: 'Find Fuel', icon: 'lucide:search', description: 'Search live prices near you.' },
]
</script>
  • Step 2: Replace SavedStations.vue

Replace resources/js/views/dashboard/SavedStations.vue:

<template>
    <div class="space-y-6">
        <h1 class="text-2xl font-black text-[#4a3f3b]">Saved Stations</h1>

        <div v-if="loading" class="space-y-2">
            <div v-for="i in 3" :key="i" class="h-16 bg-white rounded-xl animate-pulse border border-[#e5ded7]"></div>
        </div>

        <div v-else-if="savedStations.length === 0" class="p-8 bg-white rounded-2xl border border-[#e5ded7] text-center text-[#89726c]">
            <iconify-icon icon="lucide:bookmark" class="text-3xl mb-2"></iconify-icon>
            <p class="font-medium">No saved stations yet.</p>
            <p class="text-sm mt-1">Search for fuel and bookmark stations to see them here.</p>
        </div>

        <div v-else class="space-y-2">
            <div
                v-for="station in savedStations"
                :key="station.station_id"
                class="flex items-center justify-between p-4 bg-white rounded-xl border border-[#e5ded7]"
            >
                <div>
                    <p class="font-bold text-[#4a3f3b]">{{ station.name }}</p>
                    <p class="text-sm text-[#89726c]">{{ station.postcode }}</p>
                </div>
                <button
                    @click="remove(station.station_id)"
                    class="text-sm font-bold text-red-400 hover:text-red-600 transition-colors"
                >
                    Remove
                </button>
            </div>
        </div>
    </div>
</template>

<script setup>
import { onMounted } from 'vue'
import { useSavedStations } from '../../composables/useSavedStations.js'

const { savedStations, loading, fetch, remove } = useSavedStations()

onMounted(fetch)
</script>
  • Step 3: Replace Preferences.vue

Replace resources/js/views/dashboard/Preferences.vue:

<template>
    <div class="space-y-6 max-w-lg">
        <h1 class="text-2xl font-black text-[#4a3f3b]">Preferences</h1>

        <form @submit.prevent="save" class="space-y-5 p-6 bg-white rounded-2xl border border-[#e5ded7]">
            <div class="space-y-2">
                <label class="text-sm font-bold text-[#4a3f3b]">Default fuel type</label>
                <select
                    v-model="form.preferred_fuel_type"
                    class="w-full h-12 px-4 bg-[#faf6f3] border border-[#e5ded7] rounded-xl font-medium text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e]"
                >
                    <option value="petrol">Petrol (E10)</option>
                    <option value="diesel">Diesel (B7)</option>
                    <option value="e5">Premium Unleaded (E5)</option>
                    <option value="b7_premium">Premium Diesel</option>
                    <option value="b10">B10 Biodiesel</option>
                    <option value="hvo">HVO</option>
                </select>
            </div>

            <div class="space-y-2">
                <label class="text-sm font-bold text-[#4a3f3b]">Home postcode</label>
                <input
                    v-model="form.postcode"
                    type="text"
                    placeholder="e.g. SW1A 1AA"
                    maxlength="8"
                    class="w-full h-12 px-4 bg-[#faf6f3] border border-[#e5ded7] rounded-xl font-medium text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e]"
                />
            </div>

            <div class="flex items-center gap-4">
                <button
                    type="submit"
                    :disabled="saving"
                    class="px-8 py-3 bg-[#bb5b3e] text-white rounded-xl font-bold hover:bg-[#a34a31] transition-all disabled:opacity-50"
                >
                    {{ saving ? 'Saving…' : 'Save preferences' }}
                </button>
                <p v-if="saved" class="text-sm font-bold text-green-600">Saved!</p>
            </div>
        </form>
    </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import api from '../../axios.js'

const form = ref({ preferred_fuel_type: 'petrol', postcode: '' })
const saving = ref(false)
const saved = ref(false)

onMounted(async () => {
    const response = await api.get('/user/preferences')
    form.value.preferred_fuel_type = response.data.preferred_fuel_type ?? 'petrol'
    form.value.postcode = response.data.postcode ?? ''
})

async function save() {
    saving.value = true
    saved.value = false
    try {
        await api.put('/user/preferences', form.value)
        saved.value = true
        setTimeout(() => { saved.value = false }, 3000)
    } finally {
        saving.value = false
    }
}
</script>
  • Step 4: Build and verify all three dashboard views
npm run build
  • Navigate to https://fuel-price.test/dashboard — overview with quick links

  • Navigate to https://fuel-price.test/dashboard/saved-stations — empty state or saved list

  • Navigate to https://fuel-price.test/dashboard/preferences — form with fuel type + postcode

  • Step 5: Commit

git add resources/js/views/dashboard/
git commit -m "feat: add dashboard Overview, SavedStations, and Preferences views"

Done

The Vue 3 frontend is fully wired:

  • Vite builds .vue files
  • Sanctum SPA auth — session cookie used for all API calls
  • Homepage: postcode search → station list + foldable map + prediction card (tier-gated)
  • Dashboard: sidebar layout with Overview, Saved Stations, Preferences
  • Auth pages (login/register/password reset): Livewire starter kit — untouched
  • Filament /admin: untouched
  • External API consumers: continue using X-Api-Key