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 supportresources/js/router/index.js— Vue Router with Home, Dashboard, and dashboard sub-routesresources/js/composables/useAuth.js— Auth state: current user, tier, isAuthenticatedresources/js/composables/useStations.js— Calls/api/stations, holds results + loading stateresources/js/composables/usePrediction.js— Calls/api/prediction, holds result + loading stateresources/js/composables/useSavedStations.js— Calls/api/user/saved-stations, holds list + CRUD actionsresources/js/views/Home.vue— Homepage: search → map + list + predictionresources/js/views/dashboard/DashboardLayout.vue— Authenticated shell with sidebar navresources/js/views/dashboard/Overview.vue— Dashboard home: shortcuts + summaryresources/js/views/dashboard/SavedStations.vue— Saved stations list with remove actionresources/js/views/dashboard/Preferences.vue— Fuel type + postcode preferences formresources/js/components/SearchBar.vue— Postcode input with debounceresources/js/components/StationCard.vue— Single station row (name, price, distance, brand)resources/js/components/StationList.vue— Renders list of StationCards with sort tabsresources/js/components/LeafletMap.vue— Foldable Leaflet map with station markersresources/js/components/PredictionCard.vue— Fill up / wait card, gated for paid tiersresources/views/app.blade.php— Single SPA shell blade viewapp/Http/Controllers/Api/UserController.php— Authenticated user API: preferences + saved stationsdatabase/migrations/XXXX_add_preferred_fuel_type_to_users_table.php— Add preferred_fuel_type columndatabase/migrations/XXXX_create_saved_stations_table.php— Saved stations pivot
Modified files:
vite.config.js— Add@vitejs/plugin-vuepluginroutes/web.php— Remove old homepage/fuel-finder routes, add SPA catch-allbootstrap/app.php— Enable Sanctum stateful API middlewareapp/Http/Middleware/VerifyApiKey.php— Also allow Sanctum-authenticated sessions.env— AddSANCTUM_STATEFUL_DOMAINSandSESSION_DOMAIN
Deleted files (cleanup):
app/Livewire/Public/FuelFinder.phpapp/Livewire/Public/Fuel/Map.phpapp/Livewire/Public/Fuel/Recommendation.phpapp/Livewire/Public/Fuel/Search.phpapp/Livewire/Public/Fuel/StationList.phpresources/views/homepage.blade.phpresources/views/livewire/public/(all files)resources/js/maps/station-map.js
Deleted files:
resources/views/homepage.blade.php— Replaced by Vue Home.vueresources/views/dashboard.blade.php— Replaced by Vue DashboardLayout.vueresources/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
.vuefiles - 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