Compare commits
20 Commits
771f499f36
...
c6e65330b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6e65330b2 | ||
|
|
6224dedd45 | ||
|
|
1bfcb84402 | ||
|
|
4c3ef5af99 | ||
|
|
fe01d2d6d0 | ||
|
|
580f9c6929 | ||
|
|
0bae0945c0 | ||
|
|
d7054402dd | ||
|
|
f9befb463f | ||
|
|
6785bf952f | ||
|
|
393c9cc147 | ||
|
|
d25e4e3747 | ||
|
|
bbbef2d60c | ||
|
|
acade5a735 | ||
|
|
52bbfa5592 | ||
|
|
87e7a9aa84 | ||
|
|
05b5d1f3b3 | ||
|
|
acaa791eda | ||
|
|
8cf5e210de | ||
|
|
69e52afa7c |
BIN
.claude/.DS_Store
vendored
Normal file
BIN
.claude/.DS_Store
vendored
Normal file
Binary file not shown.
62
app/Http/Controllers/Api/UserController.php
Normal file
62
app/Http/Controllers/Api/UserController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
10
app/Models/SavedStation.php
Normal file
10
app/Models/SavedStation.php
Normal 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'];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/*'));
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
2087
docs/superpowers/plans/2026-04-10-vue-frontend-setup.md
Normal file
2087
docs/superpowers/plans/2026-04-10-vue-frontend-setup.md
Normal file
File diff suppressed because it is too large
Load Diff
522
package-lock.json
generated
522
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
15
resources/js/App.vue
Normal 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>
|
||||
@@ -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
13
resources/js/axios.js
Normal 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
|
||||
97
resources/js/components/LeafletMap.vue
Normal file
97
resources/js/components/LeafletMap.vue
Normal 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>
|
||||
87
resources/js/components/PredictionCard.vue
Normal file
87
resources/js/components/PredictionCard.vue
Normal 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>
|
||||
40
resources/js/components/SearchBar.vue
Normal file
40
resources/js/components/SearchBar.vue
Normal 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>
|
||||
45
resources/js/components/StationCard.vue
Normal file
45
resources/js/components/StationCard.vue
Normal 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>
|
||||
58
resources/js/components/StationList.vue
Normal file
58
resources/js/components/StationList.vue
Normal 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>
|
||||
44
resources/js/composables/useAuth.js
Normal file
44
resources/js/composables/useAuth.js
Normal 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 }
|
||||
}
|
||||
31
resources/js/composables/usePrediction.js
Normal file
31
resources/js/composables/usePrediction.js
Normal 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 }
|
||||
}
|
||||
33
resources/js/composables/useSavedStations.js
Normal file
33
resources/js/composables/useSavedStations.js
Normal 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 }
|
||||
}
|
||||
38
resources/js/composables/useStations.js
Normal file
38
resources/js/composables/useStations.js
Normal 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 }
|
||||
}
|
||||
@@ -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, '>')
|
||||
.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: '© <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);
|
||||
},
|
||||
};
|
||||
}
|
||||
24
resources/js/router/index.js
Normal file
24
resources/js/router/index.js
Normal 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
149
resources/js/views/Home.vue
Normal 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>
|
||||
60
resources/js/views/dashboard/DashboardLayout.vue
Normal file
60
resources/js/views/dashboard/DashboardLayout.vue
Normal 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>
|
||||
42
resources/js/views/dashboard/Overview.vue
Normal file
42
resources/js/views/dashboard/Overview.vue
Normal 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>
|
||||
71
resources/js/views/dashboard/Preferences.vue
Normal file
71
resources/js/views/dashboard/Preferences.vue
Normal 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>
|
||||
43
resources/js/views/dashboard/SavedStations.vue
Normal file
43
resources/js/views/dashboard/SavedStations.vue
Normal 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>
|
||||
13
resources/views/app.blade.php
Normal file
13
resources/views/app.blade.php
Normal 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>
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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 5–20 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>
|
||||
@@ -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>
|
||||
@@ -1,3 +0,0 @@
|
||||
<div class="mb-4" wire:ignore>
|
||||
<x-fuel.station-map />
|
||||
</div>
|
||||
@@ -1,7 +0,0 @@
|
||||
<div>
|
||||
@if ($prediction)
|
||||
<div class="px-5 pb-5">
|
||||
<x-fuel.recommendation :prediction="$prediction" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
<?php
|
||||
|
||||
//use App\Livewire\Public\FuelFinder;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
|
||||
//Route::get('/fuel-finder', FuelFinder::class)->name('fuel-finder');
|
||||
|
||||
Route::view('/', 'homepage')->name('home');
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::view('dashboard', 'dashboard')->name('dashboard');
|
||||
});
|
||||
|
||||
require __DIR__.'/settings.php';
|
||||
|
||||
// Named dashboard route so route('dashboard') resolves; Vue Router handles rendering
|
||||
Route::get('/dashboard', fn () => view('app'))->middleware(['auth', 'verified'])->name('dashboard');
|
||||
|
||||
// SPA catch-all — must be last
|
||||
Route::get('/{any?}', fn () => view('app'))->where('any', '.*')->name('home');
|
||||
|
||||
67
tests/Feature/Api/UserControllerTest.php
Normal file
67
tests/Feature/Api/UserControllerTest.php
Normal 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();
|
||||
});
|
||||
23
tests/Feature/SpaRouteTest.php
Normal file
23
tests/Feature/SpaRouteTest.php
Normal 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' => '']);
|
||||
});
|
||||
29
tests/Feature/VerifyApiKeyMiddlewareTest.php
Normal file
29
tests/Feature/VerifyApiKeyMiddlewareTest.php
Normal 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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user