Compare commits

...

20 Commits

Author SHA1 Message Date
Ovidiu U
c6e65330b2 fix: make SPA catch-all param optional, add named dashboard route
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
2026-04-10 18:17:49 +01:00
Ovidiu U
6224dedd45 fix: restore route('home'), remove dashboard Blade route, load iconify in SPA
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 18:14:58 +01:00
Ovidiu U
1bfcb84402 feat: add dashboard Overview, SavedStations, and Preferences views 2026-04-10 18:09:31 +01:00
Ovidiu U
4c3ef5af99 feat: add DashboardLayout with sidebar navigation 2026-04-10 18:08:59 +01:00
Ovidiu U
fe01d2d6d0 feat: add useSavedStations composable 2026-04-10 18:08:38 +01:00
Ovidiu U
580f9c6929 feat: add user preferences and saved stations API endpoints
Adds authenticated endpoints for reading/updating fuel type preferences and managing saved stations, backed by new migrations and a SavedStation model.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 18:06:31 +01:00
Ovidiu U
0bae0945c0 feat: build full Home.vue with search, station list, map, and prediction 2026-04-10 18:04:10 +01:00
Ovidiu U
d7054402dd feat: add PredictionCard component with tier gating 2026-04-10 18:02:23 +01:00
Ovidiu U
f9befb463f feat: add usePrediction composable 2026-04-10 18:02:02 +01:00
Ovidiu U
6785bf952f feat: add LeafletMap component (foldable), remove legacy station-map.js 2026-04-10 18:01:55 +01:00
Ovidiu U
393c9cc147 feat: add StationList component with sort tabs 2026-04-10 18:01:38 +01:00
Ovidiu U
d25e4e3747 feat: add StationCard component 2026-04-10 18:01:29 +01:00
Ovidiu U
bbbef2d60c feat: add useStations composable 2026-04-10 18:01:18 +01:00
Ovidiu U
acade5a735 feat: add SearchBar component with debounce 2026-04-10 18:01:10 +01:00
Ovidiu U
52bbfa5592 feat: add useAuth composable with user tier detection 2026-04-10 18:00:59 +01:00
Ovidiu U
87e7a9aa84 feat: bootstrap Vue 3 app with Vue Router and Axios
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:59:06 +01:00
Ovidiu U
05b5d1f3b3 feat: add SPA Blade shell and catch-all route
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:57:54 +01:00
Ovidiu U
acaa791eda feat: allow Sanctum-authenticated sessions through VerifyApiKey middleware
Enables stateful API via Sanctum so the Vue SPA can call /api/* routes
using cookie auth, without requiring an X-Api-Key header.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:56:14 +01:00
Ovidiu U
8cf5e210de feat: add Vue 3 and Axios, configure Vite plugin
Install vue@3.5, @vitejs/plugin-vue@6.0 (Vite 8 compatible), and
axios@1.9. Add vue() plugin to vite.config.js alongside existing
laravel and tailwindcss plugins.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 17:54:32 +01:00
Ovidiu U
69e52afa7c chore: remove Livewire public components and homepage, prepare for Vue 2026-04-10 17:53:03 +01:00
45 changed files with 3811 additions and 837 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
.claude/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,62 @@
<?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()->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();
}
}

View File

@@ -4,9 +4,10 @@ namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class VerifyApiKey
final class VerifyApiKey
{
/**
* Handle an incoming request.
@@ -15,6 +16,10 @@ class VerifyApiKey
*/
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);
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class SavedStation extends Model
{
protected $fillable = ['user_id', 'station_id'];
}

View File

@@ -9,13 +9,14 @@ use Filament\Panel;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;
#[Fillable(['name', 'email', 'password', 'is_admin', 'postcode'])]
#[Fillable(['name', 'email', 'password', 'is_admin', 'postcode', 'preferred_fuel_type'])]
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
class User extends Authenticatable implements FilamentUser
{
@@ -52,4 +53,9 @@ class User extends Authenticatable implements FilamentUser
->map(fn ($word) => Str::substr($word, 0, 1))
->implode('');
}
public function savedStations(): HasMany
{
return $this->hasMany(SavedStation::class);
}
}

View File

@@ -13,7 +13,7 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
$middleware->statefulApi();
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(fn (Request $request) => $request->is('api/*'));

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
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');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropColumn('preferred_fuel_type');
});
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
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']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('saved_stations');
}
};

File diff suppressed because it is too large Load Diff

522
package-lock.json generated
View File

@@ -6,13 +6,17 @@
"": {
"dependencies": {
"@tailwindcss/vite": "^4.1.11",
"@vitejs/plugin-vue": "^6.0.5",
"autoprefixer": "^10.4.20",
"axios": "^1.15.0",
"concurrently": "^9.0.1",
"iconify-icon": "^3.0.2",
"laravel-vite-plugin": "^3.0.0",
"leaflet": "^1.9.4",
"tailwindcss": "^4.0.7",
"vite": "^8.0.0"
"vite": "^8.0.0",
"vue": "^3.5.32",
"vue-router": "^4.6.4"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.9.5",
@@ -20,6 +24,52 @@
"lightningcss-linux-x64-gnu": "^1.29.1"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
@@ -690,6 +740,134 @@
"tslib": "^2.4.0"
}
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz",
"integrity": "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==",
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "1.0.0-rc.2"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"vue": "^3.2.25"
}
},
"node_modules/@vitejs/plugin-vue/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.2",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
"integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==",
"license": "MIT"
},
"node_modules/@vue/compiler-core": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz",
"integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.2",
"@vue/shared": "3.5.32",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz",
"integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.32",
"@vue/shared": "3.5.32"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz",
"integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.2",
"@vue/compiler-core": "3.5.32",
"@vue/compiler-dom": "3.5.32",
"@vue/compiler-ssr": "3.5.32",
"@vue/shared": "3.5.32",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.8",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz",
"integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.32",
"@vue/shared": "3.5.32"
}
},
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/reactivity": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz",
"integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.32"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz",
"integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.32",
"@vue/shared": "3.5.32"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz",
"integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.32",
"@vue/runtime-core": "3.5.32",
"@vue/shared": "3.5.32",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz",
"integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.32",
"@vue/shared": "3.5.32"
},
"peerDependencies": {
"vue": "3.5.32"
}
},
"node_modules/@vue/shared": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz",
"integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==",
"license": "MIT"
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -714,6 +892,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/autoprefixer": {
"version": "10.4.27",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
@@ -750,6 +934,17 @@
"postcss": "^8.1.0"
}
},
"node_modules/axios": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.17",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz",
@@ -795,6 +990,19 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001787",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz",
@@ -875,6 +1083,18 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
@@ -899,6 +1119,21 @@
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -908,6 +1143,20 @@
"node": ">=8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.334",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz",
@@ -933,6 +1182,63 @@
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -942,6 +1248,12 @@
"node": ">=6"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -959,6 +1271,42 @@
}
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
@@ -986,6 +1334,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@@ -995,6 +1352,55 @@
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -1010,6 +1416,45 @@
"node": ">=8"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/iconify-icon": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/iconify-icon/-/iconify-icon-3.0.2.tgz",
@@ -1336,6 +1781,36 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -1412,6 +1887,15 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -1704,6 +2188,42 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vue": {
"version": "3.5.32",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz",
"integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.32",
"@vue/compiler-sfc": "3.5.32",
"@vue/runtime-dom": "3.5.32",
"@vue/server-renderer": "3.5.32",
"@vue/shared": "3.5.32"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

View File

@@ -8,13 +8,17 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.1.11",
"@vitejs/plugin-vue": "^6.0.5",
"autoprefixer": "^10.4.20",
"axios": "^1.15.0",
"concurrently": "^9.0.1",
"iconify-icon": "^3.0.2",
"laravel-vite-plugin": "^3.0.0",
"leaflet": "^1.9.4",
"tailwindcss": "^4.0.7",
"vite": "^8.0.0"
"vite": "^8.0.0",
"vue": "^3.5.32",
"vue-router": "^4.6.4"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.9.5",

15
resources/js/App.vue Normal file
View File

@@ -0,0 +1,15 @@
<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>

View File

@@ -1,6 +1,6 @@
import 'iconify-icon';
import { stationMap } from './maps/station-map.js';
import 'iconify-icon'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router/index.js'
document.addEventListener('alpine:init', () => {
Alpine.data('stationMap', stationMap);
});
createApp(App).use(router).mount('#app')

13
resources/js/axios.js Normal file
View File

@@ -0,0 +1,13 @@
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

View File

@@ -0,0 +1,97 @@
<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, 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>

View File

@@ -0,0 +1,87 @@
<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>

View File

@@ -0,0 +1,40 @@
<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>

View File

@@ -0,0 +1,45 @@
<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>

View File

@@ -0,0 +1,58 @@
<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>

View File

@@ -0,0 +1,44 @@
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 }
}

View File

@@ -0,0 +1,31 @@
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 }
}

View File

@@ -0,0 +1,33 @@
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 }
}

View File

@@ -0,0 +1,38 @@
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 }
}

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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: '<div class="fuelalert-user-marker"><div class="fuelalert-user-ring"></div><div class="fuelalert-user-dot"></div></div>',
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
? '<span style="display:inline-block;background:#84cc16;color:#fff;font-size:10px;padding:1px 5px;border-radius:3px;margin-left:4px;">Supermarket</span>'
: '';
const popup = `
<div style="min-width:160px">
<strong style="font-size:13px">${escHtml(station.name)}</strong>${supermarketTag}<br>
<span style="font-size:20px;font-weight:700;color:${colour}">${Number(station.price).toFixed(1)}p</span><br>
<span style="font-size:12px;color:#6b7280">${escHtml(miles)} miles away</span><br>
<span style="font-size:11px;color:#9ca3af">${escHtml(station.address)}, ${escHtml(station.postcode)}</span>
</div>
`;
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);
},
};
}

View File

@@ -0,0 +1,24 @@
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,
})

149
resources/js/views/Home.vue Normal file
View File

@@ -0,0 +1,149 @@
<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>

View File

@@ -0,0 +1,60 @@
<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>

View File

@@ -0,0 +1,42 @@
<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>

View File

@@ -0,0 +1,71 @@
<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>

View File

@@ -0,0 +1,43 @@
<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>

View File

@@ -0,0 +1,13 @@
<!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>

View File

@@ -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',
};

View File

@@ -1,369 +0,0 @@
<x-layouts::public title="FuelAlert — Fill up now or wait?">
<div class="min-h-screen bg-zinc-100">
{{-- Navigation --}}
<nav class="fixed top-0 w-full z-50 bg-zinc-50 border-b border-zinc-300 px-6 py-4 md:px-12">
<div class="max-w-7xl mx-auto flex items-center justify-between">
<a href="{{ route('home') }}" class="flex items-center gap-3">
<div class="w-10 h-10 md:w-12 md:h-12 rounded-lg bg-primary flex items-center justify-center shadow-md">
<iconify-icon icon="lucide:fuel" class="text-white text-xl md:text-2xl"></iconify-icon>
</div>
<span class="font-display text-2xl md:text-3xl font-black tracking-tighter text-primary">FuelAlert</span>
</a>
<div class="hidden md:flex items-center gap-10">
<a href="#how-it-works" class="text-sm font-semibold text-zinc-500 hover:text-primary transition-colors">How it Works</a>
<a href="#features" class="text-sm font-semibold text-zinc-500 hover:text-primary transition-colors">Features</a>
<a href="#pricing" class="text-sm font-semibold text-zinc-500 hover:text-primary transition-colors">Pricing</a>
</div>
<div class="flex items-center gap-4">
@auth
<a href="{{ route('dashboard') }}" class="text-sm font-bold text-zinc-500 hover:text-zinc-800 transition-colors">Dashboard</a>
@else
<a href="{{ route('login') }}" class="text-sm font-bold text-zinc-500 hover:text-zinc-800 transition-colors">Login</a>
<a href="{{ route('register') }}" class="bg-primary text-white px-6 py-2.5 rounded-full text-sm font-bold shadow-lg hover:bg-primary-dark transition-all hover:scale-105 active:scale-95">Get Started</a>
@endauth
</div>
</div>
</nav>
{{-- Hero Section --}}
<section class="relative pt-40 pb-24 px-6 hero-gradient overflow-hidden">
<div class="max-w-7xl mx-auto grid lg:grid-cols-2 gap-16 items-center">
<div class="space-y-8">
<div class="inline-flex items-center gap-2 px-3 py-1 bg-primary/10 text-primary rounded-full text-[10px] md:text-xs font-bold uppercase tracking-wider">
<iconify-icon icon="lucide:sparkles"></iconify-icon>
Save up to £250/year on fuel
</div>
<h1 class="font-display text-3xl md:text-5xl lg:text-7xl font-black text-zinc-800 leading-[1.1] tracking-tighter">
Stop Overpaying <br> <span class="text-primary">for Fuel.</span>
</h1>
<p class="text-base md:text-xl text-zinc-500 max-w-lg leading-relaxed">
Join 50,000+ UK drivers using real-time insights to find the cheapest petrol and time their fill-ups perfectly.
</p>
{{-- Search Section --}}
<div class="flex flex-col sm:flex-row gap-3 max-w-md">
<div class="relative flex-1">
<iconify-icon icon="lucide:map-pin" class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 text-xl"></iconify-icon>
<input type="text" placeholder="Enter Postcode"
class="w-full h-14 pl-12 pr-4 bg-white border border-zinc-300 rounded-xl text-lg shadow-inner focus:outline-none focus:ring-2 focus:ring-primary">
</div>
<button class="h-14 px-8 bg-primary text-white rounded-xl font-bold text-lg shadow-xl hover:bg-primary-dark transition-all">
Find Prices
</button>
</div>
{{-- Search Section --}}
<div class="flex items-center gap-4 pt-4">
<div class="flex -space-x-2">
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=1" alt="" class="w-8 h-8 rounded-full border-2 border-white">
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=2" alt="" class="w-8 h-8 rounded-full border-2 border-white">
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=3" alt="" class="w-8 h-8 rounded-full border-2 border-white">
</div>
<span class="text-sm text-zinc-500 font-medium italic">"Saved me £12 on my first tank!"</span>
</div>
</div>
{{-- Hero card mockup --}}
<div class="relative hidden lg:block">
<div class="absolute -inset-4 bg-primary/5 rounded-[2.5rem] blur-2xl"></div>
<div class="relative glass-card p-6 rounded-4xl shadow-2xl space-y-4 max-w-md mx-auto rotate-2">
<div class="flex justify-between items-center">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded bg-primary flex items-center justify-center">
<iconify-icon icon="lucide:fuel" class="text-white"></iconify-icon>
</div>
<span class="font-display font-black text-primary">FuelAlert</span>
</div>
<span class="text-xs font-bold text-zinc-500">SW1A 1AA</span>
</div>
<div class="bg-zinc-50 p-4 rounded-xl border border-zinc-300 shadow-sm">
<p class="text-[10px] font-bold uppercase tracking-widest text-zinc-500 mb-1">Recommendation</p>
<h3 class="font-display text-2xl font-black text-mauve">Fill up now</h3>
<div class="mt-2 h-1.5 w-full bg-zinc-200 rounded-full overflow-hidden">
<div class="h-full bg-mauve w-4/5"></div>
</div>
</div>
<div class="space-y-2">
<div class="flex justify-between items-center p-3 bg-white rounded-lg border border-zinc-200">
<span class="font-bold text-sm">Tesco Superstore</span>
<span class="font-black text-status-good">142.9p</span>
</div>
<div class="flex justify-between items-center p-3 bg-white rounded-lg border border-zinc-200">
<span class="font-bold text-sm">Shell V-Power</span>
<span class="font-black text-zinc-500">148.9p</span>
</div>
</div>
</div>
</div>
</div>
</section>
{{-- How It Works --}}
<section id="how-it-works" class="py-24 px-6 bg-zinc-50">
<div class="max-w-7xl mx-auto">
<div class="text-center mb-16 space-y-4">
<h2 class="font-display text-4xl md:text-5xl font-black text-zinc-800">Smart Savings in 3 Steps</h2>
<p class="text-zinc-500 text-lg max-w-2xl mx-auto">Stop guessing when to fill up. Our engine analyses thousands of data points daily to save you money.</p>
</div>
<div class="grid md:grid-cols-3 gap-12">
<div class="text-center space-y-4">
<div class="w-16 h-16 bg-primary/10 text-primary rounded-2xl flex items-center justify-center mx-auto text-3xl">
<iconify-icon icon="lucide:search"></iconify-icon>
</div>
<h3 class="font-display text-2xl font-bold">1. Search</h3>
<p class="text-zinc-500">Enter your postcode or location to find every forecourt within a 520 mile radius instantly.</p>
</div>
<div class="text-center space-y-4">
<div class="w-16 h-16 bg-primary/10 text-primary rounded-2xl flex items-center justify-center mx-auto text-3xl">
<iconify-icon icon="lucide:trending-up"></iconify-icon>
</div>
<h3 class="font-display text-2xl font-bold">2. Get Advice</h3>
<p class="text-zinc-500">Our AI compares local prices against national wholesale trends to give you a Fill Up/Wait recommendation.</p>
</div>
<div class="text-center space-y-4">
<div class="w-16 h-16 bg-primary/10 text-primary rounded-2xl flex items-center justify-center mx-auto text-3xl">
<iconify-icon icon="lucide:wallet"></iconify-icon>
</div>
<h3 class="font-display text-2xl font-bold">3. Fill Up Smart</h3>
<p class="text-zinc-500">Navigate to the cheapest station and fill up with confidence knowing you've secured the best price.</p>
</div>
</div>
</div>
</section>
{{-- Features --}}
<section id="features" class="py-24 px-6">
<div class="max-w-7xl mx-auto">
<div class="grid lg:grid-cols-2 gap-20 items-center">
<div class="order-2 lg:order-1 grid grid-cols-2 gap-6">
<div class="p-6 bg-zinc-50 border border-zinc-300 rounded-2xl space-y-3">
<iconify-icon icon="lucide:zap" class="text-3xl text-primary"></iconify-icon>
<h4 class="font-bold text-lg">Real-Time Prices</h4>
<p class="text-sm text-zinc-500">Verified daily prices from thousands of UK forecourts.</p>
</div>
<div class="p-6 bg-zinc-50 border border-zinc-300 rounded-2xl space-y-3">
<iconify-icon icon="lucide:calendar" class="text-3xl text-primary"></iconify-icon>
<h4 class="font-bold text-lg">Timing Predictions</h4>
<p class="text-sm text-zinc-500">Proprietary 14-day forecasts for petrol and diesel trends.</p>
</div>
<div class="p-6 bg-zinc-50 border border-zinc-300 rounded-2xl space-y-3">
<iconify-icon icon="lucide:shopping-bag" class="text-3xl text-primary"></iconify-icon>
<h4 class="font-bold text-lg">Supermarket Anchors</h4>
<p class="text-sm text-zinc-500">Track local supermarkets to find the absolute lowest base price.</p>
</div>
<div class="p-6 bg-zinc-50 border border-zinc-300 rounded-2xl space-y-3">
<iconify-icon icon="lucide:bell-ring" class="text-3xl text-primary"></iconify-icon>
<h4 class="font-bold text-lg">Smart Price Alerts</h4>
<p class="text-sm text-zinc-500">Get notified when local prices drop below your set target.</p>
</div>
</div>
<div class="order-1 lg:order-2 space-y-8">
<h2 class="font-display text-4xl md:text-5xl font-black text-zinc-800">The ultimate fuel companion.</h2>
<p class="text-lg text-zinc-500">Whether you're a daily commuter, a delivery professional, or just planning a weekend road trip, FuelAlert gives you the edge at the pump.</p>
<ul class="space-y-4">
<li class="flex items-center gap-3 font-bold">
<iconify-icon icon="lucide:check-circle-2" class="text-primary"></iconify-icon>
Coverage for 98% of UK Forecourts
</li>
<li class="flex items-center gap-3 font-bold">
<iconify-icon icon="lucide:check-circle-2" class="text-primary"></iconify-icon>
Hyper-local Map Visualisation
</li>
<li class="flex items-center gap-3 font-bold">
<iconify-icon icon="lucide:check-circle-2" class="text-primary"></iconify-icon>
Historic Price Benchmarking
</li>
</ul>
<a href="{{ route('register') }}" class="inline-flex items-center gap-2 text-primary font-black text-lg group">
Explore all features
<iconify-icon icon="lucide:arrow-right" class="transition-transform group-hover:translate-x-1"></iconify-icon>
</a>
</div>
</div>
</div>
</section>
{{-- Pricing --}}
<section id="pricing" class="py-24 px-6 bg-zinc-50">
<div class="max-w-7xl mx-auto">
<div class="text-center mb-16">
<h2 class="font-display text-4xl md:text-5xl font-black text-zinc-800 mb-4">Pricing for every driver</h2>
<p class="text-zinc-500 text-lg">Save hundreds for less than the cost of a coffee.</p>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
<x-pricing-card
name="Free"
price="£0"
button-text="Get Started"
:perks="[
['text' => 'Basic Search', 'included' => true],
['text' => 'Daily Updates', 'included' => true],
['text' => 'No Alerts', 'included' => false],
]"
/>
<x-pricing-card
name="Basic"
price="£0.99"
button-text="Select Basic"
:perks="[
['text' => 'Ad-free Experience', 'included' => true],
['text' => '14-day Trend Data', 'included' => true],
['text' => '3 Daily Price Alerts','included' => true],
]"
/>
<x-pricing-card
name="Plus"
price="£2.49"
button-text="Join Plus"
:featured="true"
:perks="[
['text' => 'Supermarket Anchor', 'included' => true],
['text' => 'Priority Price Alerts', 'included' => true],
['text' => 'Multi-location Tracking','included' => true],
]"
/>
<x-pricing-card
name="Pro"
price="£3.99"
button-text="Go Pro"
:dark="true"
:perks="[
['text' => 'AI Price Predictions', 'included' => true],
['text' => 'Multi-Vehicle Fleet', 'included' => true],
['text' => 'Exportable Price History','included' => true],
]"
/>
</div>
</div>
</section>
{{-- Social Proof --}}
<section class="py-24 px-6">
<div class="max-w-7xl mx-auto">
<div class="flex flex-col md:flex-row gap-12 items-center">
<div class="md:w-1/3">
<h2 class="font-display text-4xl font-black text-zinc-800 mb-4">Loved by commuters.</h2>
<div class="flex items-center gap-1 text-status-warn mb-4 text-xl">
<iconify-icon icon="lucide:star"></iconify-icon>
<iconify-icon icon="lucide:star"></iconify-icon>
<iconify-icon icon="lucide:star"></iconify-icon>
<iconify-icon icon="lucide:star"></iconify-icon>
<iconify-icon icon="lucide:star"></iconify-icon>
</div>
<p class="text-zinc-500">Join thousands of UK drivers saving every single month.</p>
</div>
<div class="md:w-2/3 grid sm:grid-cols-2 gap-6">
<div class="p-6 bg-zinc-50 border border-zinc-300 rounded-2xl shadow-sm italic text-zinc-800">
"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."
<div class="mt-4 flex items-center gap-3 not-italic">
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=John" alt="James R." class="w-10 h-10 rounded-full">
<div>
<p class="font-bold text-sm">James R.</p>
<p class="text-[10px] text-zinc-500 uppercase font-bold tracking-widest">Daily Commuter</p>
</div>
</div>
</div>
<div class="p-6 bg-zinc-50 border border-zinc-300 rounded-2xl shadow-sm italic text-zinc-800">
"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."
<div class="mt-4 flex items-center gap-3 not-italic">
<img src="https://api.dicebear.com/7.x/avataaars/svg?seed=Sarah" alt="Sarah M." class="w-10 h-10 rounded-full">
<div>
<p class="font-bold text-sm">Sarah M.</p>
<p class="text-[10px] text-zinc-500 uppercase font-bold tracking-widest">Delivery Driver</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{{-- CTA Banner --}}
<section class="py-24 px-6 bg-primary text-white text-center">
<div class="max-w-3xl mx-auto space-y-8">
<h2 class="font-display text-4xl md:text-5xl font-black leading-tight">Ready to outsmart the pumps?</h2>
<p class="text-xl text-white/80">Sign up for free today and never pay over the odds for fuel again.</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="{{ route('register') }}" class="bg-white text-primary px-10 py-4 rounded-xl text-lg font-black shadow-2xl hover:bg-zinc-100 transition-all">Create Free Account</a>
<a href="#" class="border-2 border-white/30 text-white px-10 py-4 rounded-xl text-lg font-bold hover:bg-white/10 transition-all">Watch Demo Video</a>
</div>
</div>
</section>
{{-- Footer --}}
<footer class="bg-zinc-50 border-t border-zinc-300 pt-16 pb-8 px-6">
<div class="max-w-7xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-12 mb-12">
<div class="col-span-2 md:col-span-1 space-y-4">
<a href="{{ route('home') }}" class="flex items-center gap-2">
<div class="w-8 h-8 rounded bg-primary flex items-center justify-center">
<iconify-icon icon="lucide:fuel" class="text-white"></iconify-icon>
</div>
<span class="font-display text-xl font-black tracking-tighter text-primary">FuelAlert</span>
</a>
<p class="text-sm text-zinc-500 leading-relaxed">Helping UK drivers save money at the pump since 2021. Real-time data, smarter choices.</p>
<div class="flex gap-4">
<iconify-icon icon="mdi:twitter" class="text-2xl text-zinc-500 hover:text-primary cursor-pointer transition-colors"></iconify-icon>
<iconify-icon icon="mdi:facebook" class="text-2xl text-zinc-500 hover:text-primary cursor-pointer transition-colors"></iconify-icon>
<iconify-icon icon="mdi:instagram" class="text-2xl text-zinc-500 hover:text-primary cursor-pointer transition-colors"></iconify-icon>
</div>
</div>
<div class="space-y-4">
<h5 class="font-black uppercase text-xs text-zinc-800 tracking-widest">Product</h5>
<ul class="space-y-2 text-sm text-zinc-500">
<li><a href="#pricing" class="hover:text-primary transition-colors">Pricing</a></li>
<li><a href="#features" class="hover:text-primary transition-colors">Features</a></li>
<li><a href="#" class="hover:text-primary transition-colors">FuelAlert Pro</a></li>
<li><a href="#" class="hover:text-primary transition-colors">Enterprise API</a></li>
</ul>
</div>
<div class="space-y-4">
<h5 class="font-black uppercase text-xs text-zinc-800 tracking-widest">Resources</h5>
<ul class="space-y-2 text-sm text-zinc-500">
<li><a href="#" class="hover:text-primary transition-colors">Market Insights</a></li>
<li><a href="#" class="hover:text-primary transition-colors">How We Track</a></li>
<li><a href="#" class="hover:text-primary transition-colors">Help Centre</a></li>
<li><a href="#" class="hover:text-primary transition-colors">Driver Safety</a></li>
</ul>
</div>
<div class="space-y-4">
<h5 class="font-black uppercase text-xs text-zinc-800 tracking-widest">Legal</h5>
<ul class="space-y-2 text-sm text-zinc-500">
<li><a href="#" class="hover:text-primary transition-colors">Privacy Policy</a></li>
<li><a href="#" class="hover:text-primary transition-colors">Terms of Service</a></li>
<li><a href="#" class="hover:text-primary transition-colors">Cookie Settings</a></li>
</ul>
</div>
</div>
<div class="max-w-7xl mx-auto pt-8 border-t border-zinc-300 flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] font-bold uppercase tracking-widest text-zinc-500">
<p>© {{ date('Y') }} FuelAlert UK Limited. All Rights Reserved.</p>
<p>Data provided by official UK retail price transparency schemes.</p>
</div>
</footer>
</div>
</x-layouts::public>

View File

@@ -1,77 +0,0 @@
<div class="flex h-dvh flex-col bg-surface-page">
{{-- HEADER --}}
<header class="shrink-0 z-50 bg-surface border-b border-border shadow-sm
flex items-center justify-between px-5 pb-4 md:px-8"
style="padding-top: max(1rem, env(safe-area-inset-top))">
<a href="{{ route('home') }}" class="flex items-center gap-2.5">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary shadow-md md:h-10 md:w-10">
<iconify-icon icon="lucide:fuel" class="text-lg text-white md:text-xl"></iconify-icon>
</div>
<span class="text-xl font-black tracking-tighter text-primary md:text-2xl">FuelAlert</span>
</a>
<nav class="hidden items-center gap-8 md:flex">
<a href="#" class="text-sm font-semibold text-text-muted transition-colors hover:text-primary">Prices</a>
<a href="#" class="text-sm font-semibold text-text-muted transition-colors hover:text-primary">Alerts</a>
<a href="#" class="text-sm font-semibold text-text-muted transition-colors hover:text-primary">Trends</a>
</nav>
@auth
<a href="{{ route('dashboard') }}"
class="flex h-9 w-9 items-center justify-center rounded-full border border-border bg-surface-subtle">
<iconify-icon icon="lucide:user" class="text-base text-text-muted"></iconify-icon>
</a>
@else
<a href="{{ route('login') }}"
class="flex h-9 w-9 items-center justify-center rounded-full border border-border bg-surface-subtle">
<iconify-icon icon="lucide:user" class="text-base text-text-muted"></iconify-icon>
</a>
@endauth
</header>
{{-- MAIN --}}
<main class="flex-1 overflow-y-auto" style="-ms-overflow-style:none;scrollbar-width:none;">
<div class="md:mx-auto md:max-w-3xl">
<livewire:public.fuel.search />
<livewire:public.fuel.recommendation />
<livewire:public.fuel.map />
<livewire:public.fuel.station-list />
<section class="px-5 pb-8">
<x-fuel.forecast />
</section>
</div>
</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
<nav class="shrink-0 border-t border-border bg-surface md:hidden"
style="padding-bottom: max(0.5rem, env(safe-area-inset-bottom))">
<div class="flex pt-3">
@foreach ($tabs as $tab)
@php $active = $tab['route'] && request()->routeIs($tab['route']); @endphp
<div class="flex flex-1 flex-col items-center gap-1">
<iconify-icon
icon="{{ $tab['icon'] }}"
class="text-xl {{ $active ? 'text-primary' : 'text-text-muted' }}"
></iconify-icon>
<span class="text-[10px] font-bold uppercase tracking-wide {{ $active ? 'text-primary' : 'text-text-muted' }}">
{{ $tab['label'] }}
</span>
</div>
@endforeach
</div>
</nav>
</div>

View File

@@ -1,3 +0,0 @@
<div class="mb-4" wire:ignore>
<x-fuel.station-map />
</div>

View File

@@ -1,7 +0,0 @@
<div>
@if ($prediction)
<div class="px-5 pb-5">
<x-fuel.recommendation :prediction="$prediction" />
</div>
@endif
</div>

View File

@@ -1,130 +0,0 @@
<div class="space-y-3 px-5 pb-4 pt-5">
<form wire:submit="findStations">
<div
x-data="{
query: @js($search),
locatingUser: false,
_usedIpFallback: false,
async _postcodeFromLatLng(lat, lng) {
const res = await fetch(`https://api.postcodes.io/postcodes?lon=${lng}&lat=${lat}&limit=1&radius=1000`);
const data = await res.json();
return data?.result?.[0]?.postcode ?? null;
},
async locateUser() {
this.locatingUser = true;
this._usedIpFallback = false;
try {
let lat, lng;
try {
const pos = await new Promise((resolve, reject) =>
navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 })
);
lat = pos.coords.latitude;
lng = pos.coords.longitude;
} catch (e) {
const d = await fetch('https://ipapi.co/json/').then(r => r.json());
lat = d.latitude;
lng = d.longitude;
this._usedIpFallback = true;
}
const postcode = await this._postcodeFromLatLng(lat, lng);
if (postcode) {
this.query = postcode;
this.$wire.set('search', postcode);
this.$wire.findStations();
}
} catch (e) {
// silent
} finally {
this.locatingUser = false;
}
}
}"
class="relative mb-3"
>
<iconify-icon
icon="lucide:map-pin"
class="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-xl text-text-muted"
></iconify-icon>
<input
wire:model="search"
x-model="query"
x-ref="searchInput"
type="text"
name="search"
@focus="_usedIpFallback = false"
placeholder="Postcode, town or city"
class="h-14 w-full rounded-xl border border-border bg-surface pl-12 pr-36 text-base font-semibold text-text-base focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary"
/>
<div class="absolute inset-y-0 right-0 flex items-center gap-2 pr-3">
<button
x-show="query.length > 0"
x-cloak
type="button"
@click="query = ''; $wire.set('search', ''); _usedIpFallback = false"
class="text-text-muted hover:text-text-base"
>
<iconify-icon icon="lucide:x" class="text-base"></iconify-icon>
</button>
<button
type="button"
@click="locateUser()"
:disabled="locatingUser"
class="flex items-center gap-1.5 rounded-full bg-surface-subtle px-3 py-1.5 text-sm font-semibold text-text-base disabled:opacity-40"
>
<iconify-icon x-show="!locatingUser" icon="lucide:locate-fixed" class="text-sm"></iconify-icon>
<iconify-icon x-show="locatingUser" icon="lucide:loader-circle" class="animate-spin text-sm"></iconify-icon>
<span x-text="locatingUser ? 'Finding...' : 'Near me'">Near me</span>
</button>
<button
type="submit"
wire:loading.attr="disabled"
class="text-text-muted disabled:opacity-60"
>
<iconify-icon wire:loading.remove wire:target="findStations" icon="lucide:search" class="text-xl"></iconify-icon>
<iconify-icon wire:loading wire:target="findStations" icon="lucide:loader-circle" class="animate-spin text-xl"></iconify-icon>
</button>
</div>
{{-- IP fallback nudge --}}
<div
x-show="_usedIpFallback"
x-cloak
x-transition:enter="transition ease-out duration-300 delay-500"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-end="opacity-0"
class="absolute left-0 right-0 top-full z-40 mt-2"
>
<div
@click="$refs.searchInput.focus(); _usedIpFallback = false"
class="cursor-pointer rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 transition-colors hover:bg-amber-100"
>
<p class="text-center text-xs text-amber-800">
<span class="font-medium">Showing approximate location.</span>
<span class="underline">Enter your postcode above</span> for exact results.
</p>
</div>
</div>
</div>
@error('search')
<p class="mb-2 text-sm text-red-600">{{ $message }}</p>
@enderror
<div class="flex gap-2 overflow-x-auto pb-1" style="-ms-overflow-style:none;scrollbar-width:none;">
<div class="shrink-0"><x-fuel.type-select wire:model.live="fuelType" /></div>
<div class="shrink-0"><x-fuel.sort-select wire:model.live="sort" /></div>
<div class="shrink-0"><x-fuel.radius-select wire:model.live="radius" /></div>
</div>
</form>
@if ($apiError)
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ $apiError }}
</div>
@endif
</div>

View File

@@ -1,24 +0,0 @@
<div>
@if ($hasSearched)
<div class="px-5 pb-5">
@if (! empty($meta))
<div class="mb-3 flex items-center justify-between">
<h3 class="text-base font-bold text-text-base">Stations Nearby</h3>
<span class="text-[10px] font-bold uppercase tracking-widest text-text-muted">
{{ $meta['count'] ?? 0 }} {{ str('Result')->plural($meta['count'] ?? 0) }}
</span>
</div>
@endif
@forelse ($results as $station)
<div class="mb-2">
<x-fuel.station-card :station="$station" />
</div>
@empty
<p class="text-sm text-text-muted">
No stations found within {{ $radius }} {{ str('mile')->plural($radius) }}.
</p>
@endforelse
</div>
@endif
</div>

View File

@@ -4,6 +4,7 @@ use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\PredictionController;
use App\Http\Controllers\Api\StationController;
use App\Http\Controllers\Api\StatsController;
use App\Http\Controllers\Api\UserController;
use App\Http\Middleware\VerifyApiKey;
use Illuminate\Support\Facades\Route;
@@ -22,4 +23,11 @@ Route::middleware(['throttle:60,1', VerifyApiKey::class])->group(function (): vo
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']);
});

View File

@@ -1,15 +1,11 @@
<?php
//use App\Livewire\Public\FuelFinder;
use Illuminate\Support\Facades\Route;
require __DIR__.'/settings.php';
//Route::get('/fuel-finder', FuelFinder::class)->name('fuel-finder');
// Named dashboard route so route('dashboard') resolves; Vue Router handles rendering
Route::get('/dashboard', fn () => view('app'))->middleware(['auth', 'verified'])->name('dashboard');
Route::view('/', 'homepage')->name('home');
Route::middleware(['auth', 'verified'])->group(function () {
Route::view('dashboard', 'dashboard')->name('dashboard');
});
require __DIR__.'/settings.php';
// SPA catch-all — must be last
Route::get('/{any?}', fn () => view('app'))->where('any', '.*')->name('home');

View File

@@ -0,0 +1,67 @@
<?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();
});

View File

@@ -0,0 +1,23 @@
<?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' => '']);
});

View File

@@ -0,0 +1,29 @@
<?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
expect($response->status())->not->toBe(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');
expect($response->status())->not->toBe(403);
});

View File

@@ -1,8 +1,7 @@
import {
defineConfig
} from 'vite';
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import tailwindcss from "@tailwindcss/vite";
import tailwindcss from '@tailwindcss/vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [
@@ -11,6 +10,13 @@ export default defineConfig({
refresh: true,
}),
tailwindcss(),
vue({
template: {
compilerOptions: {
isCustomElement: tag => tag === 'iconify-icon',
},
},
}),
],
server: {
cors: true,