# 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 `` - `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** ```bash 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** ```bash 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 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** ```bash php artisan route:list --except-vendor --compact ``` Expected: no errors, no routes referencing deleted classes. - [ ] **Step 5: Commit** ```bash 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** ```bash 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: ```js 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** ```bash npm run dev ``` Expected: Vite starts, no errors. Ctrl+C to stop. - [ ] **Step 4: Commit** ```bash 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** ```bash php artisan make:test --pest VerifyApiKeyMiddlewareTest ``` Open `tests/Feature/VerifyApiKeyMiddlewareTest.php` and replace its contents: ```php 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** ```bash 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: ```php ->withMiddleware(function (Middleware $middleware): void { $middleware->statefulApi(); }) ``` - [ ] **Step 4: Update VerifyApiKey middleware to allow Sanctum sessions** Replace the full file: ```php 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** ```bash php artisan test --compact --filter=VerifyApiKeyMiddlewareTest --timeout=10 ``` Expected: all 3 tests pass. - [ ] **Step 7: Run Pint** ```bash vendor/bin/pint --dirty --format agent ``` - [ ] **Step 8: Commit** ```bash 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** ```bash php artisan make:test --pest SpaRouteTest ``` Replace contents of `tests/Feature/SpaRouteTest.php`: ```php get('/'); $response->assertStatus(200); $response->assertSee('
', false); }); it('serves the spa shell for unknown frontend paths', function (): void { $response = $this->get('/some/frontend/route'); $response->assertStatus(200); $response->assertSee('
', 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** ```bash 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`: ```html FuelAlert @vite(['resources/css/app.css', 'resources/js/app.js'])
``` - [ ] **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): ```php // 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: ```php // 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 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** ```bash php artisan test --compact --filter=SpaRouteTest --timeout=10 ``` Expected: all 3 pass. - [ ] **Step 6: Run Pint** ```bash vendor/bin/pint --dirty --format agent ``` - [ ] **Step 7: Commit** ```bash 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`: ```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`: ```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`: ```vue ``` - [ ] **Step 4: Replace app.js with Vue bootstrap** Replace `resources/js/app.js`: ```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`: ```vue ``` Create `resources/js/views/dashboard/DashboardLayout.vue`: ```vue ``` Create `resources/js/views/dashboard/Overview.vue`: ```vue ``` Create `resources/js/views/dashboard/SavedStations.vue`: ```vue ``` Create `resources/js/views/dashboard/Preferences.vue`: ```vue ``` - [ ] **Step 6: Build assets and verify in browser** ```bash 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** ```bash 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`: ```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`: ```vue ``` - [ ] **Step 3: Verify in browser (npm run dev)** ```bash 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** ```bash 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`: ```vue ``` - [ ] **Step 2: Commit** ```bash 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`: ```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** ```bash 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`: ```vue ``` - [ ] **Step 2: Commit** ```bash 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`: ```vue ``` - [ ] **Step 2: Commit** ```bash 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** ```bash rm resources/js/maps/station-map.js ``` - [ ] **Step 2: Create the component** Create `resources/js/components/LeafletMap.vue`: ```vue ``` - [ ] **Step 3: Commit** ```bash 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`: ```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** ```bash 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`: ```vue ``` - [ ] **Step 2: Commit** ```bash 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`: ```vue ``` - [ ] **Step 2: Build and test in browser** ```bash 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** ```bash 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** ```bash 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()`: ```php 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()`: ```php 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** ```bash php artisan migrate --no-interaction ``` - [ ] **Step 3: Write the failing tests** ```bash php artisan make:test --pest Api/UserControllerTest --no-interaction ``` Replace `tests/Feature/Api/UserControllerTest.php`: ```php 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** ```bash php artisan test --compact --filter=UserControllerTest --timeout=10 ``` Expected: all fail (routes don't exist yet). - [ ] **Step 5: Create the UserController** ```bash php artisan make:controller Api/UserController --no-interaction ``` Replace `app/Http/Controllers/Api/UserController.php`: ```php 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: ```php 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** ```bash php artisan make:model SavedStation --no-interaction ``` Replace `app/Models/SavedStation.php`: ```php 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** ```bash php artisan test --compact --filter=UserControllerTest --timeout=10 ``` Expected: all 7 pass. - [ ] **Step 10: Run Pint** ```bash vendor/bin/pint --dirty --format agent ``` - [ ] **Step 11: Commit** ```bash 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`: ```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** ```bash 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`: ```vue ``` - [ ] **Step 2: Build and verify** ```bash npm run build ``` Navigate to `https://fuel-price.test/dashboard`. Expected: sidebar renders, nav links highlight active route. - [ ] **Step 3: Commit** ```bash 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`: ```vue ``` - [ ] **Step 2: Replace SavedStations.vue** Replace `resources/js/views/dashboard/SavedStations.vue`: ```vue ``` - [ ] **Step 3: Replace Preferences.vue** Replace `resources/js/views/dashboard/Preferences.vue`: ```vue ``` - [ ] **Step 4: Build and verify all three dashboard views** ```bash 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** ```bash 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`