diff --git a/.DS_Store b/.DS_Store index 6036a84..d4bb096 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.claude/.DS_Store b/.claude/.DS_Store new file mode 100644 index 0000000..7696751 Binary files /dev/null and b/.claude/.DS_Store differ diff --git a/docs/superpowers/plans/2026-04-10-vue-frontend-setup.md b/docs/superpowers/plans/2026-04-10-vue-frontend-setup.md new file mode 100644 index 0000000..99270e6 --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-vue-frontend-setup.md @@ -0,0 +1,2087 @@ +# 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` diff --git a/resources/js/maps/station-map.js b/resources/js/maps/station-map.js deleted file mode 100644 index b87ac88..0000000 --- a/resources/js/maps/station-map.js +++ /dev/null @@ -1,203 +0,0 @@ -import L from 'leaflet'; -import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'; -import markerIcon from 'leaflet/dist/images/marker-icon.png'; -import markerShadow from 'leaflet/dist/images/marker-shadow.png'; - -delete L.Icon.Default.prototype._getIconUrl; -L.Icon.Default.mergeOptions({ - iconUrl: markerIcon, - iconRetinaUrl: markerIcon2x, - shadowUrl: markerShadow, -}); - -function escHtml(str) { - return String(str ?? '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -const CLASSIFICATION_COLOURS = { - current: '#22c55e', - recent: '#64748b', - stale: '#f59e0b', - outdated: '#ef4444', -}; - -const UK_CENTRE = [54.0, -2.0]; -const UK_ZOOM = 7; - -const USER_MARKER_CSS = ` -@keyframes fuelalert-pulse { - 0% { transform: scale(1); opacity: 0.6; } - 70% { transform: scale(2.8); opacity: 0; } - 100% { transform: scale(1); opacity: 0; } -} -.fuelalert-user-marker { position: relative; width: 16px; height: 16px; } -.fuelalert-user-dot { position: absolute; inset: 0; border-radius: 50%; background: #3b82f6; border: 2px solid #fff; box-shadow: 0 0 0 2px #3b82f6; } -.fuelalert-user-ring { position: absolute; inset: 0; border-radius: 50%; background: #3b82f6; animation: fuelalert-pulse 2s ease-out infinite; } -`; - -function injectUserMarkerStyles() { - if (document.getElementById('fuelalert-user-marker-styles')) return; - const style = document.createElement('style'); - style.id = 'fuelalert-user-marker-styles'; - style.textContent = USER_MARKER_CSS; - document.head.appendChild(style); -} - -export function stationMap(results, meta, radius) { - return { - results, - meta, - radius, - _map: null, - _markers: [], - _userMarker: null, - - init() { - injectUserMarkerStyles(); - this._map = L.map(this.$el, { zoomControl: true }).setView(UK_CENTRE, UK_ZOOM); - - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - attribution: '© OpenStreetMap contributors', - maxZoom: 19, - }).addTo(this._map); - - window.addEventListener('map-update', (e) => { - this.results = e.detail.results; - this.meta = e.detail.meta; - this.radius = e.detail.radius; - this._plotMarkers(); - }); - - this.locateUser(); - }, - - getZoomForRadius(radiusMiles) { - if (radiusMiles <= 1) return 15; - if (radiusMiles <= 2) return 14; - if (radiusMiles <= 5) return 12; - if (radiusMiles <= 10) return 11; - if (radiusMiles <= 15) return 10; - if (radiusMiles <= 25) return 9; - if (radiusMiles <= 50) return 8; - return 7; - }, - - _clearMarkers() { - this._markers.forEach((m) => m.remove()); - this._markers = []; - }, - - addUserMarker(lat, lng) { - if (this._userMarker) { - this._userMarker.remove(); - } - - const icon = L.divIcon({ - className: '', - html: '
', - iconSize: [16, 16], - iconAnchor: [8, 8], - }); - - this._userMarker = L.marker([lat, lng], { icon, zIndexOffset: 1000 }) - .bindPopup('Your location') - .addTo(this._map); - - console.log(`[stationMap] user marker lat=${lat} lng=${lng}`); - }, - - locateUser() { - if (!navigator.geolocation) { - console.warn('[stationMap] Geolocation not supported'); - return; - } - - const ipFallback = () => { - fetch('https://ipapi.co/json/') - .then((r) => r.json()) - .then((d) => d.latitude && d.longitude && this.addUserMarker(d.latitude, d.longitude)) - .catch(() => {}); - }; - - // Quick low-accuracy fix first — places the marker immediately. - navigator.geolocation.getCurrentPosition( - (pos) => { - this.addUserMarker(pos.coords.latitude, pos.coords.longitude); - - // Then refine with high accuracy if GPS is available. - navigator.geolocation.getCurrentPosition( - (precise) => this.addUserMarker(precise.coords.latitude, precise.coords.longitude), - () => {}, - { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }, - ); - }, - () => ipFallback(), - { enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 }, - ); - }, - - destroy() { - if (this._map) { - this._map.remove(); - this._map = null; - this._markers = []; - this._userMarker = null; - } - }, - - _plotMarkers() { - if (!this._map) { - return; - } - this._clearMarkers(); - - if (!this.results || this.results.length === 0) { - return; - } - - this.results.forEach((station) => { - const colour = escHtml(CLASSIFICATION_COLOURS[station.price_classification] ?? '#64748b'); - const miles = (station.distance_km * 0.621371).toFixed(1); - const supermarketTag = station.is_supermarket - ? 'Supermarket' - : ''; - - const popup = ` -
- ${escHtml(station.name)}${supermarketTag}
- ${Number(station.price).toFixed(1)}p
- ${escHtml(miles)} miles away
- ${escHtml(station.address)}, ${escHtml(station.postcode)} -
- `; - - const marker = L.circleMarker([station.lat, station.lng], { - radius: 9, - fillColor: colour, - color: '#ffffff', - weight: 2, - opacity: 1, - fillOpacity: 0.85, - }).bindPopup(popup); - - marker.addTo(this._map); - this._markers.push(marker); - }); - - const map = this._map; - const lat = this.meta?.lat; - const lng = this.meta?.lng; - const zoom = this.getZoomForRadius(this.radius); - - setTimeout(() => { - map.invalidateSize(); - map.setView([lat, lng], zoom, { animate: true, duration: 0.5 }); - console.log(`[stationMap] setView lat=${lat} lng=${lng} zoom=${zoom} (radius=${this.radius}mi)`); - }, 50); - }, - }; -} diff --git a/resources/views/components/pricing-card.blade.php b/resources/views/components/pricing-card.blade.php index 0d11f0b..8a05332 100644 --- a/resources/views/components/pricing-card.blade.php +++ b/resources/views/components/pricing-card.blade.php @@ -9,7 +9,7 @@ @php $cardClass = match(true) { - $dark => 'bg-zinc-800 border border-zinc-800 text-white', + $dark => 'bg-primary border border-primary text-white', $featured => 'bg-white border-2 border-primary', default => 'bg-white border border-zinc-300', }; diff --git a/resources/views/homepage.blade.php b/resources/views/homepage.blade.php deleted file mode 100644 index f29b1de..0000000 --- a/resources/views/homepage.blade.php +++ /dev/null @@ -1,369 +0,0 @@ - -
- - {{-- Navigation --}} - - - {{-- Hero Section --}} -
-
-
-
- - Save up to £250/year on fuel -
- -

- Stop Overpaying
for Fuel. -

- -

- Join 50,000+ UK drivers using real-time insights to find the cheapest petrol and time their fill-ups perfectly. -

- - {{-- Search Section --}} -
-
- - -
- -
- {{-- Search Section --}} - -
-
- - - -
- "Saved me £12 on my first tank!" -
-
- - {{-- Hero card mockup --}} - -
-
- - {{-- How It Works --}} -
-
-
-

Smart Savings in 3 Steps

-

Stop guessing when to fill up. Our engine analyses thousands of data points daily to save you money.

-
- -
-
-
- -
-

1. Search

-

Enter your postcode or location to find every forecourt within a 5–20 mile radius instantly.

-
-
-
- -
-

2. Get Advice

-

Our AI compares local prices against national wholesale trends to give you a Fill Up/Wait recommendation.

-
-
-
- -
-

3. Fill Up Smart

-

Navigate to the cheapest station and fill up with confidence knowing you've secured the best price.

-
-
-
-
- - {{-- Features --}} -
-
-
- -
-
- -

Real-Time Prices

-

Verified daily prices from thousands of UK forecourts.

-
-
- -

Timing Predictions

-

Proprietary 14-day forecasts for petrol and diesel trends.

-
-
- -

Supermarket Anchors

-

Track local supermarkets to find the absolute lowest base price.

-
-
- -

Smart Price Alerts

-

Get notified when local prices drop below your set target.

-
-
- -
-

The ultimate fuel companion.

-

Whether you're a daily commuter, a delivery professional, or just planning a weekend road trip, FuelAlert gives you the edge at the pump.

-
    -
  • - - Coverage for 98% of UK Forecourts -
  • -
  • - - Hyper-local Map Visualisation -
  • -
  • - - Historic Price Benchmarking -
  • -
- - Explore all features - - -
- -
-
-
- - {{-- Pricing --}} -
-
-
-

Pricing for every driver

-

Save hundreds for less than the cost of a coffee.

-
- -
- - - - - - - - - -
-
-
- - {{-- Social Proof --}} -
-
-
-
-

Loved by commuters.

-
- - - - - -
-

Join thousands of UK drivers saving every single month.

-
-
-
- "I used to just go to the station on my way home. Now I check FuelAlert and realise there's a station 2 miles away that's 5p cheaper! Over a month, it adds up to a free tank per year." -
- James R. -
-

James R.

-

Daily Commuter

-
-
-
-
- "The predictions are eerily accurate. I was going to fill up Friday, but FuelAlert said 'Hold on' for Monday. Sure enough, prices dropped at my local Tesco by 3p. Brilliant." -
- Sarah M. -
-

Sarah M.

-

Delivery Driver

-
-
-
-
-
-
-
- - {{-- CTA Banner --}} -
-
-

Ready to outsmart the pumps?

-

Sign up for free today and never pay over the odds for fuel again.

- -
-
- - {{-- Footer --}} - - -
-
diff --git a/resources/views/livewire/public/fuel-finder.blade.php b/resources/views/livewire/public/fuel-finder.blade.php deleted file mode 100644 index a088a49..0000000 --- a/resources/views/livewire/public/fuel-finder.blade.php +++ /dev/null @@ -1,77 +0,0 @@ -
- - {{-- HEADER --}} -
- - -
- -
- FuelAlert -
- - - - @auth - - - - @else - - - - @endauth -
- - {{-- MAIN --}} -
-
- - - - - - -
- -
- -
-
- - {{-- BOTTOM TAB BAR --}} - @php - $tabs = [ - ['label' => 'Prices', 'icon' => 'lucide:fuel', 'route' => 'home'], - ['label' => 'Alerts', 'icon' => 'lucide:bell', 'route' => null], - ['label' => 'Forecourts', 'icon' => 'lucide:map-pin', 'route' => null], - ['label' => 'Trends', 'icon' => 'lucide:trending-up', 'route' => null], - ]; - @endphp - - -
diff --git a/resources/views/livewire/public/fuel/map.blade.php b/resources/views/livewire/public/fuel/map.blade.php deleted file mode 100644 index 5423f63..0000000 --- a/resources/views/livewire/public/fuel/map.blade.php +++ /dev/null @@ -1,3 +0,0 @@ -
- -
diff --git a/resources/views/livewire/public/fuel/recommendation.blade.php b/resources/views/livewire/public/fuel/recommendation.blade.php deleted file mode 100644 index a153557..0000000 --- a/resources/views/livewire/public/fuel/recommendation.blade.php +++ /dev/null @@ -1,7 +0,0 @@ -
- @if ($prediction) -
- -
- @endif -
diff --git a/resources/views/livewire/public/fuel/search.blade.php b/resources/views/livewire/public/fuel/search.blade.php deleted file mode 100644 index 4c871db..0000000 --- a/resources/views/livewire/public/fuel/search.blade.php +++ /dev/null @@ -1,130 +0,0 @@ -
-
- -
- - -
- - - -
- - {{-- IP fallback nudge --}} -
-
-

- Showing approximate location. - Enter your postcode above for exact results. -

-
-
-
- - @error('search') -

{{ $message }}

- @enderror - -
-
-
-
-
- -
- - @if ($apiError) -
- {{ $apiError }} -
- @endif -
diff --git a/resources/views/livewire/public/fuel/station-list.blade.php b/resources/views/livewire/public/fuel/station-list.blade.php deleted file mode 100644 index a5ccc36..0000000 --- a/resources/views/livewire/public/fuel/station-list.blade.php +++ /dev/null @@ -1,24 +0,0 @@ -
- @if ($hasSearched) -
- @if (! empty($meta)) -
-

Stations Nearby

- - {{ $meta['count'] ?? 0 }} {{ str('Result')->plural($meta['count'] ?? 0) }} - -
- @endif - - @forelse ($results as $station) -
- -
- @empty -

- No stations found within {{ $radius }} {{ str('mile')->plural($radius) }}. -

- @endforelse -
- @endif -
diff --git a/routes/web.php b/routes/web.php index dc5ba17..6e3732e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,15 +1,9 @@ name('fuel-finder'); - -Route::view('/', 'homepage')->name('home'); - -Route::middleware(['auth', 'verified'])->group(function () { - Route::view('dashboard', 'dashboard')->name('dashboard'); +Route::middleware(['auth', 'verified'])->group(function (): void { + Route::view('dashboard', 'dashboard')->name('dashboard'); }); -require __DIR__.'/settings.php'; \ No newline at end of file +require __DIR__.'/settings.php';