2088 lines
63 KiB
Markdown
2088 lines
63 KiB
Markdown
# 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**
|
|
|
|
```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
|
|
<?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**
|
|
|
|
```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
|
|
<?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**
|
|
|
|
```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
|
|
<?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**
|
|
|
|
```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
|
|
<?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**
|
|
|
|
```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
|
|
<!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):
|
|
|
|
```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
|
|
<?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**
|
|
|
|
```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
|
|
<template>
|
|
<RouterView />
|
|
</template>
|
|
|
|
<script setup>
|
|
import { RouterView } from 'vue-router'
|
|
</script>
|
|
```
|
|
|
|
- [ ] **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
|
|
<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`:
|
|
|
|
```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`:
|
|
|
|
```vue
|
|
<template><div class="p-8 font-bold text-[#bb5b3e]">Dashboard Overview (coming soon)</div></template>
|
|
```
|
|
|
|
Create `resources/js/views/dashboard/SavedStations.vue`:
|
|
|
|
```vue
|
|
<template><div class="p-8 font-bold text-[#bb5b3e]">Saved Stations (coming soon)</div></template>
|
|
```
|
|
|
|
Create `resources/js/views/dashboard/Preferences.vue`:
|
|
|
|
```vue
|
|
<template><div class="p-8 font-bold text-[#bb5b3e]">Preferences (coming soon)</div></template>
|
|
```
|
|
|
|
- [ ] **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
|
|
<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)**
|
|
|
|
```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
|
|
<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**
|
|
|
|
```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
|
|
<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**
|
|
|
|
```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
|
|
<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**
|
|
|
|
```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
|
|
<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**
|
|
|
|
```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
|
|
<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**
|
|
|
|
```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
|
|
<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**
|
|
|
|
```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
|
|
<?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**
|
|
|
|
```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
|
|
<?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:
|
|
|
|
```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
|
|
<?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:
|
|
|
|
```php
|
|
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**
|
|
|
|
```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
|
|
<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**
|
|
|
|
```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
|
|
<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`:
|
|
|
|
```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`:
|
|
|
|
```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**
|
|
|
|
```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`
|