livewire kit
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

This commit is contained in:
Ovidiu U
2026-04-09 15:10:53 +01:00
parent 19d5c6eb0b
commit 1074681dd9
46 changed files with 1073 additions and 1731 deletions

1
.gitignore vendored
View File

@@ -21,3 +21,4 @@ yarn-error.log
/.nova
/.vscode
/.zed
/.tmp/

View File

@@ -1,21 +0,0 @@
<?php
namespace App\Livewire\Public\Fuel;
use Illuminate\View\View;
use Livewire\Attributes\On;
use Livewire\Component;
final class Map extends Component
{
#[On('stations-found')]
public function handle(array $results, array $meta, int $radius = 5, ?array $prediction = null): void
{
$this->dispatch('map-update', results: $results, meta: $meta, radius: $radius);
}
public function render(): View
{
return view('livewire.public.fuel.map');
}
}

View File

@@ -1,23 +0,0 @@
<?php
namespace App\Livewire\Public\Fuel;
use Illuminate\View\View;
use Livewire\Attributes\On;
use Livewire\Component;
final class Recommendation extends Component
{
public ?array $prediction = null;
#[On('stations-found')]
public function handle(array $results, array $meta, int $radius = 5, ?array $prediction = null): void
{
$this->prediction = $prediction;
}
public function render(): View
{
return view('livewire.public.fuel.recommendation');
}
}

View File

@@ -1,118 +0,0 @@
<?php
namespace App\Livewire\Public\Fuel;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\View\View;
use Livewire\Attributes\Validate;
use Livewire\Component;
final class Search extends Component
{
#[Validate('required|string', message: 'Please enter a postcode, town or city.')]
public string $search = '';
#[Validate('required|string', message: 'Please select a fuel type.')]
public string $fuelType = 'petrol';
#[Validate('required|integer|min:1|max:20')]
public int $radius = 5;
#[Validate('required|string|in:price,distance,updated,brand,reliable')]
public string $sort = 'reliable';
public ?string $apiError = null;
public bool $hasSearched = false;
public function updatedFuelType(): void
{
if ($this->hasSearched) {
$this->findStations();
}
}
public function updatedRadius(): void
{
if ($this->hasSearched) {
$this->findStations();
}
}
public function updatedSort(): void
{
if ($this->hasSearched) {
$this->findStations();
}
}
public function findStations(): void
{
$this->validate();
$this->apiError = null;
$this->hasSearched = false;
$radiusKm = round($this->radius * 1.60934, 2);
try {
$response = Http::timeout(10)
->withHeaders(['X-Api-Key' => config('app.api_secret_key')])
->get(url('/api/stations'), [
'postcode' => $this->search,
'fuel_type' => $this->fuelType,
'radius' => $radiusKm,
'sort' => $this->sort,
]);
} catch (ConnectionException) {
$this->apiError = 'Unable to fetch stations. Please try again.';
return;
}
if ($response->status() === 422) {
$errors = $response->json('errors', []);
$this->apiError = collect($errors)->flatten()->first()
?? $response->json('message', 'Validation error.');
return;
}
if (! $response->successful()) {
$this->apiError = 'Unable to fetch stations. Please try again.';
return;
}
$results = $response->json('data', []);
$meta = $response->json('meta', []);
$this->hasSearched = true;
$prediction = null;
try {
$predictionResponse = Http::timeout(10)
->withHeaders(['X-Api-Key' => config('app.api_secret_key')])
->get(url('/api/prediction'));
if ($predictionResponse->successful()) {
$prediction = $predictionResponse->json();
}
} catch (ConnectionException) {
// Prediction failure is silent — stations are more important
}
$this->dispatch('stations-found',
results: $results,
meta: $meta,
prediction: $prediction,
radius: $this->radius,
);
}
public function render(): View
{
return view('livewire.public.fuel.search');
}
}

View File

@@ -1,33 +0,0 @@
<?php
namespace App\Livewire\Public\Fuel;
use Illuminate\View\View;
use Livewire\Attributes\On;
use Livewire\Component;
final class StationList extends Component
{
public array $results = [];
public array $meta = [];
public bool $hasSearched = false;
public int $radius = 5;
#[On('stations-found')]
public function handle(array $results, array $meta, int $radius = 5, ?array $prediction = null): void
{
// $prediction is handled by fuel.recommendation component
$this->results = $results;
$this->meta = $meta;
$this->radius = $radius;
$this->hasSearched = true;
}
public function render(): View
{
return view('livewire.public.fuel.station-list');
}
}

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Livewire\Public;
use Illuminate\View\View;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('layouts.shell')]
final class FuelFinder extends Component
{
public function render(): View
{
return view('livewire.public.fuel-finder');
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Livewire\Settings;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Appearance settings')]
class Appearance extends Component
{
//
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Livewire\Settings;
use App\Concerns\PasswordValidationRules;
use App\Livewire\Actions\Logout;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class DeleteUserForm extends Component
{
use PasswordValidationRules;
public string $password = '';
/**
* Delete the currently authenticated user.
*/
public function deleteUser(Logout $logout): void
{
$this->validate([
'password' => $this->currentPasswordRules(),
]);
tap(Auth::user(), $logout(...))->delete();
$this->redirect('/', navigate: true);
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Livewire\Settings;
use App\Concerns\ProfileValidationRules;
use Flux\Flux;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Profile settings')]
class Profile extends Component
{
use ProfileValidationRules;
public string $name = '';
public string $email = '';
/**
* Mount the component.
*/
public function mount(): void
{
$this->name = Auth::user()->name;
$this->email = Auth::user()->email;
}
/**
* Update the profile information for the currently authenticated user.
*/
public function updateProfileInformation(): void
{
$user = Auth::user();
$validated = $this->validate($this->profileRules($user->id));
$user->fill($validated);
if ($user->isDirty('email')) {
$user->email_verified_at = null;
}
$user->save();
Flux::toast(variant: 'success', text: __('Profile updated.'));
}
/**
* Send an email verification notification to the current user.
*/
public function resendVerificationNotification(): void
{
$user = Auth::user();
if ($user->hasVerifiedEmail()) {
$this->redirectIntended(default: route('dashboard', absolute: false));
return;
}
$user->sendEmailVerificationNotification();
Flux::toast(text: __('A new verification link has been sent to your email address.'));
}
#[Computed]
public function hasUnverifiedEmail(): bool
{
return Auth::user() instanceof MustVerifyEmail && ! Auth::user()->hasVerifiedEmail();
}
#[Computed]
public function showDeleteUser(): bool
{
return ! Auth::user() instanceof MustVerifyEmail
|| (Auth::user() instanceof MustVerifyEmail && Auth::user()->hasVerifiedEmail());
}
}

View File

@@ -0,0 +1,225 @@
<?php
namespace App\Livewire\Settings;
use App\Concerns\PasswordValidationRules;
use Exception;
use Flux\Flux;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Laravel\Fortify\Features;
use Laravel\Fortify\Fortify;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Title('Security settings')]
class Security extends Component
{
use PasswordValidationRules;
public string $current_password = '';
public string $password = '';
public string $password_confirmation = '';
#[Locked]
public bool $canManageTwoFactor;
#[Locked]
public bool $twoFactorEnabled;
#[Locked]
public bool $requiresConfirmation;
#[Locked]
public string $qrCodeSvg = '';
#[Locked]
public string $manualSetupKey = '';
public bool $showModal = false;
public bool $showVerificationStep = false;
#[Validate('required|string|size:6', onUpdate: false)]
public string $code = '';
/**
* Mount the component.
*/
public function mount(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void
{
$this->canManageTwoFactor = Features::canManageTwoFactorAuthentication();
if ($this->canManageTwoFactor) {
if (Fortify::confirmsTwoFactorAuthentication() && is_null(auth()->user()->two_factor_confirmed_at)) {
$disableTwoFactorAuthentication(auth()->user());
}
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
$this->requiresConfirmation = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm');
}
}
/**
* Update the password for the currently authenticated user.
*/
public function updatePassword(): void
{
try {
$validated = $this->validate([
'current_password' => $this->currentPasswordRules(),
'password' => $this->passwordRules(),
]);
} catch (ValidationException $e) {
$this->reset('current_password', 'password', 'password_confirmation');
throw $e;
}
Auth::user()->update([
'password' => $validated['password'],
]);
$this->reset('current_password', 'password', 'password_confirmation');
Flux::toast(variant: 'success', text: __('Password updated.'));
}
/**
* Enable two-factor authentication for the user.
*/
public function enable(EnableTwoFactorAuthentication $enableTwoFactorAuthentication): void
{
$enableTwoFactorAuthentication(auth()->user());
if (! $this->requiresConfirmation) {
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
}
$this->loadSetupData();
$this->showModal = true;
}
/**
* Load the two-factor authentication setup data for the user.
*/
private function loadSetupData(): void
{
$user = auth()->user();
try {
$this->qrCodeSvg = $user?->twoFactorQrCodeSvg();
$this->manualSetupKey = decrypt($user->two_factor_secret);
} catch (Exception) {
$this->addError('setupData', 'Failed to fetch setup data.');
$this->reset('qrCodeSvg', 'manualSetupKey');
}
}
/**
* Show the two-factor verification step if necessary.
*/
public function showVerificationIfNecessary(): void
{
if ($this->requiresConfirmation) {
$this->showVerificationStep = true;
$this->resetErrorBag();
return;
}
$this->closeModal();
}
/**
* Close the two-factor authentication modal.
*/
public function closeModal(): void
{
$this->reset(
'code',
'manualSetupKey',
'qrCodeSvg',
'showModal',
'showVerificationStep',
);
$this->resetErrorBag();
if (! $this->requiresConfirmation) {
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
}
}
/**
* Confirm two-factor authentication for the user.
*/
public function confirmTwoFactor(ConfirmTwoFactorAuthentication $confirmTwoFactorAuthentication): void
{
$this->validate();
$confirmTwoFactorAuthentication(auth()->user(), $this->code);
$this->closeModal();
$this->twoFactorEnabled = true;
}
/**
* Reset two-factor verification state.
*/
public function resetVerification(): void
{
$this->reset('code', 'showVerificationStep');
$this->resetErrorBag();
}
/**
* Disable two-factor authentication for the user.
*/
public function disable(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void
{
$disableTwoFactorAuthentication(auth()->user());
$this->twoFactorEnabled = false;
}
/**
* Get the current modal configuration state.
*/
public function getModalConfigProperty(): array
{
if ($this->twoFactorEnabled) {
return [
'title' => __('Two-factor authentication enabled'),
'description' => __('Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.'),
'buttonText' => __('Close'),
];
}
if ($this->showVerificationStep) {
return [
'title' => __('Verify authentication code'),
'description' => __('Enter the 6-digit code from your authenticator app.'),
'buttonText' => __('Continue'),
];
}
return [
'title' => __('Enable two-factor authentication'),
'description' => __('To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app.'),
'buttonText' => __('Continue'),
];
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Livewire\Settings\TwoFactor;
use Exception;
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
use Livewire\Attributes\Locked;
use Livewire\Component;
class RecoveryCodes extends Component
{
#[Locked]
public array $recoveryCodes = [];
/**
* Mount the component.
*/
public function mount(): void
{
$this->loadRecoveryCodes();
}
/**
* Load the recovery codes for the user.
*/
private function loadRecoveryCodes(): void
{
$user = auth()->user();
if ($user->hasEnabledTwoFactorAuthentication() && $user->two_factor_recovery_codes) {
try {
$this->recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true);
} catch (Exception) {
$this->addError('recoveryCodes', 'Failed to load recovery codes');
$this->recoveryCodes = [];
}
}
}
/**
* Generate new recovery codes for the user.
*/
public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generateNewRecoveryCodes): void
{
$generateNewRecoveryCodes(auth()->user());
$this->loadRecoveryCodes();
}
}

View File

@@ -45,13 +45,13 @@ class FortifyServiceProvider extends ServiceProvider
*/
private function configureViews(): void
{
Fortify::loginView(fn () => view('pages::auth.login'));
Fortify::verifyEmailView(fn () => view('pages::auth.verify-email'));
Fortify::twoFactorChallengeView(fn () => view('pages::auth.two-factor-challenge'));
Fortify::confirmPasswordView(fn () => view('pages::auth.confirm-password'));
Fortify::registerView(fn () => view('pages::auth.register'));
Fortify::resetPasswordView(fn () => view('pages::auth.reset-password'));
Fortify::requestPasswordResetLinkView(fn () => view('pages::auth.forgot-password'));
Fortify::loginView(fn () => view('livewire.auth.login'));
Fortify::verifyEmailView(fn () => view('livewire.auth.verify-email'));
Fortify::twoFactorChallengeView(fn () => view('livewire.auth.two-factor-challenge'));
Fortify::confirmPasswordView(fn () => view('livewire.auth.confirm-password'));
Fortify::registerView(fn () => view('livewire.auth.register'));
Fortify::resetPasswordView(fn () => view('livewire.auth.reset-password'));
Fortify::requestPasswordResetLinkView(fn () => view('livewire.auth.forgot-password'));
}
/**

194
package-lock.json generated
View File

@@ -103,9 +103,9 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz",
"integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz",
"integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -121,18 +121,18 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.122.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
"integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
"version": "0.124.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz",
"integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
"integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==",
"cpu": [
"arm64"
],
@@ -146,9 +146,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz",
"integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==",
"cpu": [
"arm64"
],
@@ -162,9 +162,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz",
"integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz",
"integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==",
"cpu": [
"x64"
],
@@ -178,9 +178,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz",
"integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz",
"integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==",
"cpu": [
"x64"
],
@@ -194,9 +194,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz",
"integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz",
"integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==",
"cpu": [
"arm"
],
@@ -210,9 +210,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==",
"cpu": [
"arm64"
],
@@ -229,9 +229,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz",
"integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz",
"integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==",
"cpu": [
"arm64"
],
@@ -248,9 +248,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==",
"cpu": [
"ppc64"
],
@@ -267,9 +267,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==",
"cpu": [
"s390x"
],
@@ -286,9 +286,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz",
"integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==",
"cpu": [
"x64"
],
@@ -305,9 +305,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz",
"integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz",
"integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==",
"cpu": [
"x64"
],
@@ -324,9 +324,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz",
"integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz",
"integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==",
"cpu": [
"arm64"
],
@@ -340,25 +340,27 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz",
"integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz",
"integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==",
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^1.1.1"
"@emnapi/core": "1.9.2",
"@emnapi/runtime": "1.9.2",
"@napi-rs/wasm-runtime": "^1.1.3"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz",
"integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz",
"integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==",
"cpu": [
"arm64"
],
@@ -372,9 +374,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz",
"integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz",
"integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==",
"cpu": [
"x64"
],
@@ -388,9 +390,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz",
"integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz",
"integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==",
"license": "MIT"
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
@@ -749,9 +751,9 @@
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.14",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz",
"integrity": "sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==",
"version": "2.10.16",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz",
"integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
@@ -794,9 +796,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001784",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz",
"integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==",
"version": "1.0.30001787",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz",
"integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==",
"funding": [
{
"type": "opencollective",
@@ -907,9 +909,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.331",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz",
"integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==",
"version": "1.5.334",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz",
"integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==",
"license": "ISC"
},
"node_modules/emoji-regex": {
@@ -1377,9 +1379,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"version": "8.5.9",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
"integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==",
"funding": [
{
"type": "opencollective",
@@ -1420,13 +1422,13 @@
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz",
"integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==",
"version": "1.0.0-rc.15",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz",
"integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==",
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.122.0",
"@rolldown/pluginutils": "1.0.0-rc.12"
"@oxc-project/types": "=0.124.0",
"@rolldown/pluginutils": "1.0.0-rc.15"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -1435,21 +1437,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.12",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.12",
"@rolldown/binding-darwin-x64": "1.0.0-rc.12",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.12",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.12",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.12",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.12",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
"@rolldown/binding-android-arm64": "1.0.0-rc.15",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.15",
"@rolldown/binding-darwin-x64": "1.0.0-rc.15",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.15",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.15",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.15",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.15",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15"
}
},
"node_modules/rxjs": {
@@ -1543,13 +1545,13 @@
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
@@ -1604,15 +1606,15 @@
}
},
"node_modules/vite": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",
"integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==",
"version": "8.0.8",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz",
"integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==",
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.8",
"rolldown": "1.0.0-rc.12",
"rolldown": "1.0.0-rc.15",
"tinyglobby": "^0.2.15"
},
"bin": {
@@ -1630,7 +1632,7 @@
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0",
"esbuild": "^0.27.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",

View File

@@ -1,4 +1,3 @@
@import 'leaflet/dist/leaflet.css';
@import 'tailwindcss';
@import '../../vendor/livewire/flux/dist/flux.css';
@@ -9,7 +8,6 @@
@custom-variant dark (&:where(.dark, .dark *));
/* Remap Flux's zinc scale to FuelAlert's warm brown neutrals */
@theme {
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
@@ -55,10 +53,11 @@
--font-display: 'Manrope', ui-sans-serif, system-ui, sans-serif;
}
@layer base {
h1, h2, h3, h4 {
font-family: var(--font-display);
letter-spacing: -0.02em;
@layer theme {
.dark {
--color-accent: var(--color-white);
--color-accent-content: var(--color-white);
--color-accent-foreground: var(--color-neutral-800);
}
}

View File

@@ -1,14 +0,0 @@
@props([
'on',
])
<div
x-data="{ shown: false, timeout: null }"
x-init="@this.on('{{ $on }}', () => { clearTimeout(timeout); shown = true; timeout = setTimeout(() => { shown = false }, 2000); })"
x-show.transition.out.opacity.duration.1500ms="shown"
x-transition:leave.opacity.duration.1500ms
style="display: none"
{{ $attributes->merge(['class' => 'text-sm']) }}
>
{{ $slot->isEmpty() ? __('Saved.') : $slot }}
</div>

View File

@@ -1,620 +0,0 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FuelAlert | Stop Overpaying for Fuel</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Manrope:wght@600;700;800;900&display=swap" rel="stylesheet">
@vite(['resources/css/app.css', 'resources/js/app.js'])
<style>
body {
font-family: 'Inter', sans-serif;
color: #4a3f3b;
}
h1, h2, h3, h4 {
font-family: 'Manrope', sans-serif;
letter-spacing: -0.02em;
}
.hero-gradient {
background: radial-gradient(circle at top right, #bb5b3e15, transparent 50%),
radial-gradient(circle at bottom left, #bb5b3e10, transparent 40%);
}
.glass-card {
background: rgba(250, 246, 243, 0.9);
backdrop-filter: blur(12px);
border: 1px solid rgba(229, 222, 215, 0.6);
}
</style>
</head>
<body class="bg-[#f5ede5]">
{{-- Mobile App Layout (hidden on desktop) --}}
<div class="flex flex-col h-dvh md:hidden bg-[#f5ede5]">
{{-- Mobile Header --}}
<header class="shrink-0 pt-14 px-5 pb-4 bg-[#faf6f3] border-b border-[#e5ded7] flex items-center justify-between shadow-sm z-50">
<div class="flex items-center gap-2.5">
<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>
</div>
<button class="w-10 h-10 rounded-full bg-[#f5ede5] border border-[#e5ded7] flex items-center justify-center">
<iconify-icon icon="lucide:user" class="text-[#89726c] text-lg"></iconify-icon>
</button>
</header>
{{-- Mobile Scrollable Main --}}
<main class="flex-1 overflow-y-auto" style="-ms-overflow-style:none;scrollbar-width:none;">
{{-- Search & Filters --}}
<section class="px-5 pt-5 pb-4 space-y-3">
<div class="relative">
<iconify-icon icon="lucide:map-pin" class="absolute left-4 top-1/2 -translate-y-1/2 text-[#89726c] text-xl pointer-events-none"></iconify-icon>
<input
type="text"
placeholder="Enter postcode (e.g. SW1A)"
value="SW1A 1AA"
class="w-full h-14 pl-12 pr-4 bg-[#faf6f3] border border-[#e5ded7] rounded-xl text-base font-semibold text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e] focus:border-transparent"
>
</div>
<div class="flex gap-2 overflow-x-auto pb-1" style="-ms-overflow-style:none;scrollbar-width:none;">
<button class="flex items-center gap-1.5 px-4 py-2 bg-[#faf6f3] border border-[#e5ded7] rounded-lg shrink-0 text-sm font-semibold text-[#4a3f3b]">
<iconify-icon icon="lucide:droplets" class="text-[#bb5b3e]"></iconify-icon>
Petrol
<iconify-icon icon="lucide:chevron-down" class="text-[#89726c] text-xs"></iconify-icon>
</button>
<button class="flex items-center gap-1.5 px-4 py-2 bg-[#faf6f3] border border-[#e5ded7] rounded-lg shrink-0 text-sm font-semibold text-[#4a3f3b]">
<iconify-icon icon="lucide:maximize" class="text-[#bb5b3e]"></iconify-icon>
5 miles
<iconify-icon icon="lucide:chevron-down" class="text-[#89726c] text-xs"></iconify-icon>
</button>
<button class="flex items-center gap-1.5 px-4 py-2 bg-[#faf6f3] border border-[#e5ded7] rounded-lg shrink-0 text-sm font-semibold text-[#4a3f3b]">
<iconify-icon icon="lucide:arrow-up-down" class="text-[#bb5b3e]"></iconify-icon>
Price
<iconify-icon icon="lucide:chevron-down" class="text-[#89726c] text-xs"></iconify-icon>
</button>
</div>
</section>
{{-- Recommendation Card --}}
<section class="px-5 pb-5">
<div class="bg-[#faf6f3] p-5 rounded-2xl border border-[#e5ded7] shadow-sm">
<div class="flex items-start justify-between mb-3">
<div>
<p class="text-[10px] font-bold uppercase tracking-widest text-[#89726c] mb-1">Recommendation</p>
<h2 class="text-3xl font-black text-[#8B4860] leading-tight">Fill up now</h2>
</div>
<div class="flex flex-col items-center gap-1">
<div class="relative w-12 h-12">
<svg class="w-full h-full -rotate-90" viewBox="0 0 48 48">
<circle cx="24" cy="24" r="20" stroke="#eeeae5" stroke-width="4" fill="transparent"/>
<circle cx="24" cy="24" r="20" stroke="#8B4860" stroke-width="4" fill="transparent"
stroke-dasharray="125.6" stroke-dashoffset="25.1" stroke-linecap="round"/>
</svg>
<span class="absolute inset-0 flex items-center justify-center text-[11px] font-black text-[#4a3f3b]">80%</span>
</div>
<span class="text-[9px] font-bold uppercase tracking-wider text-[#89726c]">Confidence</span>
</div>
</div>
<p class="text-[#6b5a55] text-sm leading-relaxed font-medium">
Local prices are at a 30-day low. Regional wholesale trends indicate a 3p/litre increase starting Monday. Securing fuel today is highly advised.
</p>
</div>
</section>
{{-- Map Section --}}
<section class="relative h-56 w-full bg-[#eeeae5] border-y border-[#e5ded7]">
{{-- Simulated map grid --}}
<div class="absolute inset-0 opacity-10" style="background-image:radial-gradient(#89726c 1px,transparent 1px);background-size:30px 30px;"></div>
<div class="absolute inset-0 opacity-20" style="background-image:linear-gradient(0deg,transparent 24%,rgba(137,114,108,.2) 25%,rgba(137,114,108,.2) 26%,transparent 27%,transparent 74%,rgba(137,114,108,.2) 75%,rgba(137,114,108,.2) 76%,transparent 77%,transparent),linear-gradient(90deg,transparent 24%,rgba(137,114,108,.2) 25%,rgba(137,114,108,.2) 26%,transparent 27%,transparent 74%,rgba(137,114,108,.2) 75%,rgba(137,114,108,.2) 76%,transparent 77%,transparent);background-size:100px 100px;"></div>
{{-- Map Markers --}}
<div class="absolute top-1/4 left-1/4 flex flex-col items-center">
<div class="px-1.5 py-0.5 bg-white border-2 border-[#22c55e] rounded text-[10px] font-bold text-[#4a3f3b] mb-0.5 shadow-sm">142.9p</div>
<iconify-icon icon="mdi:map-marker" class="text-[#22c55e] text-3xl"></iconify-icon>
</div>
<div class="absolute top-1/2 left-3/4 flex flex-col items-center">
<div class="px-1.5 py-0.5 bg-white border-2 border-[#89726c] rounded text-[10px] font-bold text-[#4a3f3b] mb-0.5 shadow-sm">145.7p</div>
<iconify-icon icon="mdi:map-marker" class="text-[#89726c] text-3xl"></iconify-icon>
</div>
<div class="absolute bottom-1/4 left-1/2 flex flex-col items-center">
<div class="px-1.5 py-0.5 bg-white border-2 border-[#f59e0b] rounded text-[10px] font-bold text-[#4a3f3b] mb-0.5 shadow-sm">148.9p</div>
<iconify-icon icon="mdi:map-marker" class="text-[#f59e0b] text-3xl"></iconify-icon>
</div>
{{-- Legend --}}
<div class="absolute bottom-3 left-3 bg-[#faf6f3]/90 backdrop-blur-sm border border-[#e5ded7] rounded-lg px-3 py-2 flex gap-3">
<div class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-[#22c55e] shrink-0"></span>
<span class="text-[9px] font-bold uppercase text-[#4a3f3b]">Current</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-[#89726c] shrink-0"></span>
<span class="text-[9px] font-bold uppercase text-[#4a3f3b]">Recent</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full bg-[#f59e0b] shrink-0"></span>
<span class="text-[9px] font-bold uppercase text-[#4a3f3b]">Stale</span>
</div>
</div>
</section>
{{-- Nearby Stations --}}
<section class="px-5 py-5">
<div class="flex items-center justify-between mb-3">
<h3 class="text-base font-bold text-[#4a3f3b]">Stations Nearby</h3>
<span class="text-[10px] font-bold uppercase tracking-widest text-[#89726c]">26 Results</span>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between bg-[#faf6f3] px-4 py-3.5 rounded-xl border border-[#e5ded7]">
<h4 class="font-bold text-sm text-[#4a3f3b]">Tesco Superstore</h4>
<div class="text-xl font-black text-[#22c55e]">142.9<span class="text-xs font-bold ml-0.5">p</span></div>
</div>
<div class="flex items-center justify-between bg-[#faf6f3] px-4 py-3.5 rounded-xl border border-[#e5ded7]">
<h4 class="font-bold text-sm text-[#4a3f3b]">Sainsbury's Fuel</h4>
<div class="text-xl font-black text-[#22c55e]">143.1<span class="text-xs font-bold ml-0.5">p</span></div>
</div>
<div class="flex items-center justify-between bg-[#faf6f3] px-4 py-3.5 rounded-xl border border-[#e5ded7]">
<h4 class="font-bold text-sm text-[#4a3f3b]">BP Connect</h4>
<div class="text-xl font-black text-[#89726c]">145.7<span class="text-xs font-bold ml-0.5">p</span></div>
</div>
<div class="flex items-center justify-between bg-[#faf6f3] px-4 py-3.5 rounded-xl border border-[#e5ded7]">
<h4 class="font-bold text-sm text-[#4a3f3b]">Shell V-Power</h4>
<div class="text-xl font-black text-[#f59e0b]">148.9<span class="text-xs font-bold ml-0.5">p</span></div>
</div>
<div class="flex items-center justify-between bg-[#faf6f3] px-4 py-3.5 rounded-xl border border-[#e5ded7]">
<h4 class="font-bold text-sm text-[#4a3f3b]">Esso Express</h4>
<div class="text-xl font-black text-[#ef4444]">151.2<span class="text-xs font-bold ml-0.5">p</span></div>
</div>
</div>
</section>
{{-- 14-Day Forecast (Pro) --}}
<section class="px-5 pb-8">
<div class="relative bg-[#faf6f3] border border-[#e5ded7] rounded-2xl overflow-hidden">
<div class="p-5">
<div class="flex items-center justify-between mb-5">
<h3 class="text-base font-bold text-[#4a3f3b]">14-Day Forecast</h3>
<div class="flex items-center gap-1 px-2 py-0.5 bg-[#bb5b3e]/10 text-[#bb5b3e] rounded text-[10px] font-bold uppercase tracking-wide">
<iconify-icon icon="lucide:sparkles"></iconify-icon>
Pro
</div>
</div>
<div class="relative h-20 w-full">
<svg class="w-full h-full" viewBox="0 0 100 40" preserveAspectRatio="none">
<path d="M0 35 Q 10 32, 20 36 T 40 30 T 60 38 T 80 25 T 100 32"
fill="none" stroke="#bb5b3e" stroke-width="2" stroke-linecap="round"/>
</svg>
<div class="absolute inset-y-0 right-0 w-3/5 flex items-center justify-center"
style="background:linear-gradient(to right,transparent,rgba(250,246,243,0.85) 30%,#faf6f3);backdrop-filter:blur(3px);">
<button class="flex items-center gap-1.5 px-4 py-2 bg-[#bb5b3e] text-white rounded-full text-xs font-bold shadow-lg">
<iconify-icon icon="lucide:lock-keyhole"></iconify-icon>
Unlock Forecast
</button>
</div>
</div>
</div>
</div>
</section>
</main>
{{-- Mobile Tab Bar --}}
<footer class="shrink-0 bg-[#faf6f3] border-t border-[#e5ded7] pb-8">
<nav class="flex justify-around items-center pt-3">
<a href="#" class="flex flex-col items-center gap-1 text-[#bb5b3e]">
<iconify-icon icon="lucide:search" class="text-2xl"></iconify-icon>
<span class="text-[9px] font-bold uppercase tracking-wide">Prices</span>
</a>
<a href="#" class="flex flex-col items-center gap-1 text-[#89726c]">
<iconify-icon icon="lucide:bell" class="text-2xl"></iconify-icon>
<span class="text-[9px] font-bold uppercase tracking-wide">Alerts</span>
</a>
<a href="#" class="flex flex-col items-center gap-1 text-[#89726c]">
<iconify-icon icon="lucide:map" class="text-2xl"></iconify-icon>
<span class="text-[9px] font-bold uppercase tracking-wide">Forecourts</span>
</a>
<a href="#" class="flex flex-col items-center gap-1 text-[#89726c]">
<iconify-icon icon="lucide:bar-chart-3" class="text-2xl"></iconify-icon>
<span class="text-[9px] font-bold uppercase tracking-wide">Trends</span>
</a>
</nav>
</footer>
</div>{{-- end mobile layout --}}
{{-- Desktop Layout (hidden on mobile) --}}
<div class="hidden md:block">
{{-- 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">
<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-[#bb5b3e] 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="text-2xl md:text-3xl font-black tracking-tighter text-[#bb5b3e]">FuelAlert</span>
</a>
<div class="hidden md:flex items-center gap-10">
<a href="#how-it-works" class="text-sm font-semibold text-[#89726c] hover:text-[#bb5b3e] transition-colors">How it Works</a>
<a href="#features" class="text-sm font-semibold text-[#89726c] hover:text-[#bb5b3e] transition-colors">Features</a>
<a href="#pricing" class="text-sm font-semibold text-[#89726c] hover:text-[#bb5b3e] transition-colors">Pricing</a>
</div>
<div class="flex items-center gap-4">
@auth
<a href="{{ route('dashboard') }}" class="text-sm font-bold text-[#89726c] hover:text-[#4a3f3b] transition-colors">Dashboard</a>
@else
<a href="{{ route('login') }}" class="text-sm font-bold text-[#89726c] hover:text-[#4a3f3b] transition-colors">Login</a>
<a href="{{ route('register') }}" class="bg-[#bb5b3e] text-white px-6 py-2.5 rounded-full text-sm font-bold shadow-lg hover:bg-[#a34a31] transition-all transform 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-[#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-7xl font-black text-[#4a3f3b] leading-[1.1] tracking-tighter">
Stop Overpaying <br><span class="text-[#bb5b3e]">for Fuel.</span>
</h1>
<p class="text-xl text-[#89726c] 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>
<form action="{{ route('fuel-finder') }}" method="GET" 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-[#89726c] text-xl pointer-events-none"></iconify-icon>
<input type="text" name="postcode" placeholder="Enter Postcode" 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-lg text-[#4a3f3b]">
</div>
<button type="submit" class="h-14 px-8 bg-[#bb5b3e] text-white rounded-xl font-bold text-lg shadow-xl hover:bg-[#a34a31] transition-all">Find Prices</button>
</form>
<div class="flex items-center gap-4 pt-4">
<div class="flex -space-x-2">
<div class="w-8 h-8 rounded-full border-2 border-white bg-[#bb5b3e]/20 flex items-center justify-center text-xs font-bold text-[#bb5b3e]">JR</div>
<div class="w-8 h-8 rounded-full border-2 border-white bg-[#4A7C7E]/20 flex items-center justify-center text-xs font-bold text-[#4A7C7E]">SM</div>
<div class="w-8 h-8 rounded-full border-2 border-white bg-[#9B8B6B]/20 flex items-center justify-center text-xs font-bold text-[#9B8B6B]">DK</div>
</div>
<span class="text-sm text-[#89726c] font-medium italic">"Saved me £12 on my first tank!"</span>
</div>
</div>
{{-- Visual Mockup --}}
<div class="relative hidden lg:block">
<div class="absolute -inset-4 bg-[#bb5b3e]/5 rounded-[2.5rem] blur-2xl"></div>
<div class="relative glass-card p-6 rounded-[2rem] shadow-2xl space-y-4 max-w-md mx-auto transform rotate-2">
<div class="flex justify-between items-center mb-4">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded bg-[#bb5b3e] flex items-center justify-center">
<iconify-icon icon="lucide:fuel" class="text-white"></iconify-icon>
</div>
<span class="font-black text-[#bb5b3e]">FuelAlert</span>
</div>
<div class="text-xs font-bold text-[#89726c]">SW1A 1AA</div>
</div>
<div class="bg-[#faf6f3] p-4 rounded-xl border border-[#e5ded7] shadow-sm">
<p class="text-[10px] font-bold uppercase tracking-widest text-[#89726c] mb-1">Recommendation</p>
<h3 class="text-2xl font-black text-[#8B4860]">Fill up now</h3>
<div class="mt-2 h-1.5 w-full bg-[#eeeae5] rounded-full overflow-hidden">
<div class="h-full bg-[#8B4860] 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-[#eeeae5]">
<span class="font-bold text-sm text-[#4a3f3b]">Tesco Superstore</span>
<span class="font-black text-[#22c55e]">142.9p</span>
</div>
<div class="flex justify-between items-center p-3 bg-white rounded-lg border border-[#eeeae5]">
<span class="font-bold text-sm text-[#4a3f3b]">Sainsbury's Fuel</span>
<span class="font-black text-[#22c55e]">143.1p</span>
</div>
<div class="flex justify-between items-center p-3 bg-white rounded-lg border border-[#eeeae5]">
<span class="font-bold text-sm text-[#4a3f3b]">Shell V-Power</span>
<span class="font-black text-[#89726c]">148.9p</span>
</div>
</div>
</div>
</div>
</div>
</section>
{{-- How It Works --}}
<section id="how-it-works" class="py-24 px-6 bg-[#faf6f3]">
<div class="max-w-7xl mx-auto">
<div class="text-center mb-16 space-y-4">
<h2 class="text-4xl md:text-5xl font-black text-[#4a3f3b]">Smart Savings in 3 Steps</h2>
<p class="text-[#89726c] 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-[#bb5b3e]/10 text-[#bb5b3e] rounded-2xl flex items-center justify-center mx-auto text-3xl">
<iconify-icon icon="lucide:search"></iconify-icon>
</div>
<h3 class="text-2xl font-bold text-[#4a3f3b]">1. Search</h3>
<p class="text-[#89726c]">Enter your postcode to find every forecourt within a 520 mile radius instantly.</p>
</div>
<div class="text-center space-y-4">
<div class="w-16 h-16 bg-[#bb5b3e]/10 text-[#bb5b3e] rounded-2xl flex items-center justify-center mx-auto text-3xl">
<iconify-icon icon="lucide:trending-up"></iconify-icon>
</div>
<h3 class="text-2xl font-bold text-[#4a3f3b]">2. Get Advice</h3>
<p class="text-[#89726c]">Our engine 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-[#bb5b3e]/10 text-[#bb5b3e] rounded-2xl flex items-center justify-center mx-auto text-3xl">
<iconify-icon icon="lucide:wallet"></iconify-icon>
</div>
<h3 class="text-2xl font-bold text-[#4a3f3b]">3. Fill Up Smart</h3>
<p class="text-[#89726c]">Navigate to the cheapest station and fill up with confidence knowing you've secured the best price.</p>
</div>
</div>
</div>
</section>
{{-- Features Section --}}
<section id="features" class="py-24 px-6 bg-[#f5ede5]">
<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">
<div class="grid grid-cols-2 gap-6">
<div class="p-6 bg-[#faf6f3] border border-[#e5ded7] rounded-2xl space-y-3">
<iconify-icon icon="lucide:zap" class="text-3xl text-[#bb5b3e]"></iconify-icon>
<h4 class="font-bold text-lg text-[#4a3f3b]">Real-Time Prices</h4>
<p class="text-sm text-[#89726c]">Verified daily prices from thousands of UK forecourts updated every 15 minutes.</p>
</div>
<div class="p-6 bg-[#faf6f3] border border-[#e5ded7] rounded-2xl space-y-3">
<iconify-icon icon="lucide:calendar" class="text-3xl text-[#bb5b3e]"></iconify-icon>
<h4 class="font-bold text-lg text-[#4a3f3b]">Timing Predictions</h4>
<p class="text-sm text-[#89726c]">Proprietary 14-day forecasts for petrol and diesel trends near you.</p>
</div>
<div class="p-6 bg-[#faf6f3] border border-[#e5ded7] rounded-2xl space-y-3">
<iconify-icon icon="lucide:shopping-bag" class="text-3xl text-[#bb5b3e]"></iconify-icon>
<h4 class="font-bold text-lg text-[#4a3f3b]">Supermarket Anchors</h4>
<p class="text-sm text-[#89726c]">Track local supermarkets they set the floor price that independents must follow.</p>
</div>
<div class="p-6 bg-[#faf6f3] border border-[#e5ded7] rounded-2xl space-y-3">
<iconify-icon icon="lucide:bell-ring" class="text-3xl text-[#bb5b3e]"></iconify-icon>
<h4 class="font-bold text-lg text-[#4a3f3b]">Smart Price Alerts</h4>
<p class="text-sm text-[#89726c]">Get notified by email, WhatsApp, or SMS when local prices hit your target.</p>
</div>
</div>
</div>
<div class="order-1 lg:order-2 space-y-8">
<h2 class="text-4xl md:text-5xl font-black text-[#4a3f3b]">The ultimate fuel companion.</h2>
<p class="text-lg text-[#89726c]">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 text-[#4a3f3b]">
<iconify-icon icon="lucide:check-circle-2" class="text-[#bb5b3e] text-xl shrink-0"></iconify-icon>
Coverage for 98% of UK Forecourts
</li>
<li class="flex items-center gap-3 font-bold text-[#4a3f3b]">
<iconify-icon icon="lucide:check-circle-2" class="text-[#bb5b3e] text-xl shrink-0"></iconify-icon>
Hyper-local Map Visualisation
</li>
<li class="flex items-center gap-3 font-bold text-[#4a3f3b]">
<iconify-icon icon="lucide:check-circle-2" class="text-[#bb5b3e] text-xl shrink-0"></iconify-icon>
Historic Price Benchmarking
</li>
</ul>
<a href="{{ route('fuel-finder') }}" class="inline-flex items-center gap-2 text-[#bb5b3e] font-black text-lg group">
Find prices near you
<iconify-icon icon="lucide:arrow-right" class="group-hover:translate-x-1 transition-transform"></iconify-icon>
</a>
</div>
</div>
</div>
</section>
{{-- Pricing Section --}}
<section id="pricing" class="py-24 px-6 bg-[#faf6f3]">
<div class="max-w-7xl mx-auto">
<div class="text-center mb-16">
<h2 class="text-4xl md:text-5xl font-black text-[#4a3f3b] mb-4">Pricing for every driver</h2>
<p class="text-[#89726c] 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">
{{-- Free --}}
<div class="bg-white border border-[#e5ded7] p-8 rounded-3xl flex flex-col h-full">
<div class="mb-8">
<h4 class="text-xl font-bold mb-2 text-[#4a3f3b]">Free</h4>
<div class="flex items-baseline gap-1">
<span class="text-4xl font-black text-[#4a3f3b]">£0</span>
<span class="text-[#89726c] text-sm">/mo</span>
</div>
</div>
<ul class="space-y-4 mb-8 flex-1 text-[#4a3f3b]">
<li class="text-sm flex gap-2 items-start"><iconify-icon icon="lucide:check" class="text-[#bb5b3e] shrink-0 mt-0.5"></iconify-icon> Nationwide station search</li>
<li class="text-sm flex gap-2 items-start"><iconify-icon icon="lucide:check" class="text-[#bb5b3e] shrink-0 mt-0.5"></iconify-icon> Weekly email digest</li>
<li class="text-sm flex gap-2 items-start text-[#89726c]"><iconify-icon icon="lucide:x" class="text-[#e5ded7] shrink-0 mt-0.5"></iconify-icon> No push / SMS alerts</li>
<li class="text-sm flex gap-2 items-start text-[#89726c]"><iconify-icon icon="lucide:x" class="text-[#e5ded7] shrink-0 mt-0.5"></iconify-icon> No trend forecasts</li>
</ul>
<a href="{{ route('register') }}" class="w-full py-3 px-4 border border-[#e5ded7] rounded-xl text-center font-bold hover:bg-[#faf6f3] transition-colors text-[#4a3f3b]">Get Started</a>
</div>
{{-- Basic --}}
<div class="bg-white border border-[#e5ded7] p-8 rounded-3xl flex flex-col h-full">
<div class="mb-8">
<h4 class="text-xl font-bold mb-2 text-[#4a3f3b]">Basic</h4>
<div class="flex items-baseline gap-1">
<span class="text-4xl font-black text-[#4a3f3b]">£0.99</span>
<span class="text-[#89726c] text-sm">/mo</span>
</div>
</div>
<ul class="space-y-4 mb-8 flex-1 text-[#4a3f3b]">
<li class="text-sm flex gap-2 items-start"><iconify-icon icon="lucide:check" class="text-[#bb5b3e] shrink-0 mt-0.5"></iconify-icon> Daily email + push alerts</li>
<li class="text-sm flex gap-2 items-start"><iconify-icon icon="lucide:check" class="text-[#bb5b3e] shrink-0 mt-0.5"></iconify-icon> WhatsApp price alerts</li>
<li class="text-sm flex gap-2 items-start"><iconify-icon icon="lucide:check" class="text-[#bb5b3e] shrink-0 mt-0.5"></iconify-icon> 14-day trend data</li>
<li class="text-sm flex gap-2 items-start text-[#89726c]"><iconify-icon icon="lucide:x" class="text-[#e5ded7] shrink-0 mt-0.5"></iconify-icon> No SMS alerts</li>
</ul>
<a href="{{ route('register') }}" class="w-full py-3 px-4 border border-[#e5ded7] rounded-xl text-center font-bold hover:bg-[#faf6f3] transition-colors text-[#4a3f3b]">Select Basic</a>
</div>
{{-- Plus (highlighted) --}}
<div class="bg-white border-2 border-[#bb5b3e] p-8 rounded-3xl flex flex-col h-full relative">
<div class="absolute -top-4 left-1/2 -translate-x-1/2 bg-[#bb5b3e] text-white px-4 py-1 rounded-full text-[10px] font-black uppercase tracking-widest whitespace-nowrap">Most Popular</div>
<div class="mb-8">
<h4 class="text-xl font-bold mb-2 text-[#4a3f3b]">Plus</h4>
<div class="flex items-baseline gap-1">
<span class="text-4xl font-black text-[#bb5b3e]">£2.49</span>
<span class="text-[#89726c] text-sm">/mo</span>
</div>
</div>
<ul class="space-y-4 mb-8 flex-1 text-[#4a3f3b]">
<li class="text-sm flex gap-2 items-start font-bold"><iconify-icon icon="lucide:check" class="text-[#bb5b3e] shrink-0 mt-0.5"></iconify-icon> Everything in Basic</li>
<li class="text-sm flex gap-2 items-start"><iconify-icon icon="lucide:check" class="text-[#bb5b3e] shrink-0 mt-0.5"></iconify-icon> SMS alerts (up to 1/day)</li>
<li class="text-sm flex gap-2 items-start"><iconify-icon icon="lucide:check" class="text-[#bb5b3e] shrink-0 mt-0.5"></iconify-icon> Supermarket anchor tracking</li>
<li class="text-sm flex gap-2 items-start"><iconify-icon icon="lucide:check" class="text-[#bb5b3e] shrink-0 mt-0.5"></iconify-icon> Multi-location tracking</li>
</ul>
<a href="{{ route('register') }}" class="w-full py-3 px-4 bg-[#bb5b3e] text-white rounded-xl text-center font-bold shadow-lg hover:bg-[#a34a31] transition-all">Join Plus</a>
</div>
{{-- Pro --}}
<div class="bg-[#4a3f3b] border border-[#4a3f3b] p-8 rounded-3xl flex flex-col h-full text-white">
<div class="mb-8">
<h4 class="text-xl font-bold mb-2">Pro</h4>
<div class="flex items-baseline gap-1">
<span class="text-4xl font-black">£3.99</span>
<span class="text-white/60 text-sm">/mo</span>
</div>
</div>
<ul class="space-y-4 mb-8 flex-1">
<li class="text-sm flex gap-2 items-start font-bold"><iconify-icon icon="lucide:check" class="text-[#bb5b3e] shrink-0 mt-0.5"></iconify-icon> Everything in Plus</li>
<li class="text-sm flex gap-2 items-start"><iconify-icon icon="lucide:sparkles" class="text-[#bb5b3e] shrink-0 mt-0.5"></iconify-icon> AI price predictions</li>
<li class="text-sm flex gap-2 items-start"><iconify-icon icon="lucide:check" class="text-[#bb5b3e] shrink-0 mt-0.5"></iconify-icon> SMS alerts (up to 3/day)</li>
<li class="text-sm flex gap-2 items-start"><iconify-icon icon="lucide:check" class="text-[#bb5b3e] shrink-0 mt-0.5"></iconify-icon> Exportable price history</li>
</ul>
<a href="{{ route('register') }}" class="w-full py-3 px-4 bg-white text-[#4a3f3b] rounded-xl text-center font-bold hover:bg-gray-100 transition-colors">Go Pro</a>
</div>
</div>
</div>
</section>
{{-- Social Proof --}}
<section class="py-24 px-6 bg-[#f5ede5]">
<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="text-4xl font-black text-[#4a3f3b] mb-4">Loved by commuters.</h2>
<div class="flex items-center gap-1 text-[#f59e0b] 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-[#89726c]">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-[#faf6f3] border border-[#e5ded7] rounded-2xl shadow-sm">
<p class="italic text-[#4a3f3b]">"I used to just go to the nearest station. Now I check FuelAlert and there's always a station 2 miles away that's 5p cheaper. Over a month it adds up to a free tank per year."</p>
<div class="mt-4 flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-[#bb5b3e]/20 flex items-center justify-center text-sm font-bold text-[#bb5b3e]">JR</div>
<div>
<p class="font-bold text-sm text-[#4a3f3b]">James R.</p>
<p class="text-[10px] text-[#89726c] uppercase font-bold tracking-widest">Daily Commuter</p>
</div>
</div>
</div>
<div class="p-6 bg-[#faf6f3] border border-[#e5ded7] rounded-2xl shadow-sm">
<p class="italic text-[#4a3f3b]">"The predictions are eerily accurate. I was going to fill up Friday, FuelAlert said wait until Monday. Sure enough, my local Tesco dropped 3p. Brilliant."</p>
<div class="mt-4 flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-[#4A7C7E]/20 flex items-center justify-center text-sm font-bold text-[#4A7C7E]">SM</div>
<div>
<p class="font-bold text-sm text-[#4a3f3b]">Sarah M.</p>
<p class="text-[10px] text-[#89726c] uppercase font-bold tracking-widest">Delivery Driver</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{{-- CTA Banner --}}
<section class="py-24 px-6 bg-[#bb5b3e] text-white text-center">
<div class="max-w-3xl mx-auto space-y-8">
<h2 class="text-4xl md:text-5xl font-black leading-tight">Ready to outsmart the pumps?</h2>
<p class="text-xl text-white/80">Sign up 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-[#bb5b3e] px-10 py-4 rounded-xl text-lg font-black shadow-2xl hover:bg-gray-100 transition-all">Create Free Account</a>
<a href="{{ route('fuel-finder') }}" class="bg-transparent border-2 border-white/30 text-white px-10 py-4 rounded-xl text-lg font-bold hover:bg-white/10 transition-all">Search Prices Now</a>
</div>
</div>
</section>
{{-- Footer --}}
<footer class="bg-[#faf6f3] border-t border-[#e5ded7] 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-[#bb5b3e] flex items-center justify-center">
<iconify-icon icon="lucide:fuel" class="text-white"></iconify-icon>
</div>
<span class="text-xl font-black tracking-tighter text-[#bb5b3e]">FuelAlert</span>
</a>
<p class="text-sm text-[#89726c] leading-relaxed">
Helping UK drivers save money at the pump. Real-time data, smarter choices.
</p>
</div>
<div class="space-y-4">
<h5 class="font-black uppercase text-xs text-[#4a3f3b] tracking-widest">Product</h5>
<ul class="space-y-2 text-sm text-[#89726c]">
<li><a href="#pricing" class="hover:text-[#bb5b3e] transition-colors">Pricing</a></li>
<li><a href="#features" class="hover:text-[#bb5b3e] transition-colors">Features</a></li>
<li><a href="{{ route('fuel-finder') }}" class="hover:text-[#bb5b3e] transition-colors">Find Prices</a></li>
</ul>
</div>
<div class="space-y-4">
<h5 class="font-black uppercase text-xs text-[#4a3f3b] tracking-widest">Account</h5>
<ul class="space-y-2 text-sm text-[#89726c]">
<li><a href="{{ route('register') }}" class="hover:text-[#bb5b3e] transition-colors">Sign Up Free</a></li>
<li><a href="{{ route('login') }}" class="hover:text-[#bb5b3e] transition-colors">Login</a></li>
@auth
<li><a href="{{ route('dashboard') }}" class="hover:text-[#bb5b3e] transition-colors">Dashboard</a></li>
@endauth
</ul>
</div>
<div class="space-y-4">
<h5 class="font-black uppercase text-xs text-[#4a3f3b] tracking-widest">Legal</h5>
<ul class="space-y-2 text-sm text-[#89726c]">
<li><span class="cursor-default">Privacy Policy</span></li>
<li><span class="cursor-default">Terms of Service</span></li>
<li><span class="cursor-default">Cookie Settings</span></li>
</ul>
</div>
</div>
<div class="max-w-7xl mx-auto pt-8 border-t border-[#e5ded7] flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] font-bold uppercase tracking-widest text-[#89726c]">
<p>© {{ date('Y') }} FuelAlert. All Rights Reserved.</p>
<p>Data sourced from the UK Fuel Finder transparency scheme.</p>
</div>
</footer>
</div>{{-- end desktop layout --}}
</body>
</html>

View File

@@ -1,5 +1,5 @@
<x-layouts::app.header :title="$title ?? null">
<flux:main container>
<flux:main>
{{ $slot }}
</flux:main>
</x-layouts::app.header>

View File

@@ -73,6 +73,12 @@
{{ $slot }}
@persist('toast')
<flux:toast.group>
<flux:toast />
</flux:toast.group>
@endpersist
@fluxScripts
</body>
</html>

View File

@@ -90,6 +90,12 @@
{{ $slot }}
@persist('toast')
<flux:toast.group>
<flux:toast />
</flux:toast.group>
@endpersist
@fluxScripts
</body>
</html>

View File

@@ -21,6 +21,12 @@
</div>
</div>
</div>
@persist('toast')
<flux:toast.group>
<flux:toast />
</flux:toast.group>
@endpersist
@fluxScripts
</body>
</html>

View File

@@ -17,6 +17,12 @@
</div>
</div>
</div>
@persist('toast')
<flux:toast.group>
<flux:toast />
</flux:toast.group>
@endpersist
@fluxScripts
</body>
</html>

View File

@@ -38,6 +38,12 @@
</div>
</div>
</div>
@persist('toast')
<flux:toast.group>
<flux:toast />
</flux:toast.group>
@endpersist
@fluxScripts
</body>
</html>

View File

@@ -1,9 +0,0 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('partials.head')
</head>
<body class="font-sans antialiased">
{{ $slot }}
</body>
</html>

View File

@@ -1,10 +0,0 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('partials.head', ['title' => $title ?? null])
</head>
<body class="bg-surface-page text-text-base font-sans antialiased">
{{ $slot }}
@fluxScripts
</body>
</html>

View File

@@ -1,22 +1,13 @@
<?php
use Livewire\Component;
use Livewire\Attributes\Title;
new #[Title('Appearance settings')] class extends Component {
//
}; ?>
<section class="w-full">
@include('partials.settings-heading')
<flux:heading class="sr-only">{{ __('Appearance settings') }}</flux:heading>
<x-pages::settings.layout :heading="__('Appearance')" :subheading="__('Update the appearance settings for your account')">
<x-settings.layout :heading="__('Appearance')" :subheading=" __('Update the appearance settings for your account')">
<flux:radio.group x-data variant="segmented" x-model="$flux.appearance">
<flux:radio value="light" icon="sun">{{ __('Light') }}</flux:radio>
<flux:radio value="dark" icon="moon">{{ __('Dark') }}</flux:radio>
<flux:radio value="system" icon="computer-desktop">{{ __('System') }}</flux:radio>
</flux:radio.group>
</x-pages::settings.layout>
</x-settings.layout>
</section>

View File

@@ -0,0 +1,34 @@
<section class="mt-10 space-y-6">
<div class="relative mb-5">
<flux:heading>{{ __('Delete account') }}</flux:heading>
<flux:subheading>{{ __('Delete your account and all of its resources') }}</flux:subheading>
</div>
<flux:modal.trigger name="confirm-user-deletion">
<flux:button variant="danger" x-data="" x-on:click.prevent="$dispatch('open-modal', 'confirm-user-deletion')">
{{ __('Delete account') }}
</flux:button>
</flux:modal.trigger>
<flux:modal name="confirm-user-deletion" :show="$errors->isNotEmpty()" focusable class="max-w-lg">
<form method="POST" wire:submit="deleteUser" class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Are you sure you want to delete your account?') }}</flux:heading>
<flux:subheading>
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }}
</flux:subheading>
</div>
<flux:input wire:model="password" :label="__('Password')" type="password" viewable />
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close>
<flux:button variant="filled">{{ __('Cancel') }}</flux:button>
</flux:modal.close>
<flux:button variant="danger" type="submit">{{ __('Delete account') }}</flux:button>
</div>
</form>
</flux:modal>
</section>

View File

@@ -0,0 +1,36 @@
<section class="w-full">
@include('partials.settings-heading')
<flux:heading class="sr-only">{{ __('Profile settings') }}</flux:heading>
<x-settings.layout :heading="__('Profile')" :subheading="__('Update your name and email address')">
<form wire:submit="updateProfileInformation" class="my-6 w-full space-y-6">
<flux:input wire:model="name" :label="__('Name')" type="text" required autofocus autocomplete="name" />
<div>
<flux:input wire:model="email" :label="__('Email')" type="email" required autocomplete="email" />
@if ($this->hasUnverifiedEmail)
<div>
<flux:text class="mt-4">
{{ __('Your email address is unverified.') }}
<flux:link class="text-sm cursor-pointer" wire:click.prevent="resendVerificationNotification">
{{ __('Click here to re-send the verification email.') }}
</flux:link>
</flux:text>
</div>
@endif
</div>
<div class="flex items-center gap-4">
<flux:button variant="primary" type="submit">{{ __('Save') }}</flux:button>
</div>
</form>
@if ($this->showDeleteUser)
<livewire:settings.delete-user-form />
@endif
</x-settings.layout>
</section>

View File

@@ -0,0 +1,233 @@
<section class="w-full">
@include('partials.settings-heading')
<flux:heading class="sr-only">{{ __('Security settings') }}</flux:heading>
<x-settings.layout :heading="__('Update password')" :subheading="__('Ensure your account is using a long, random password to stay secure')">
<form method="POST" wire:submit="updatePassword" class="mt-6 space-y-6">
<flux:input
wire:model="current_password"
:label="__('Current password')"
type="password"
required
autocomplete="current-password"
viewable
/>
<flux:input
wire:model="password"
:label="__('New password')"
type="password"
required
autocomplete="new-password"
viewable
/>
<flux:input
wire:model="password_confirmation"
:label="__('Confirm password')"
type="password"
required
autocomplete="new-password"
viewable
/>
<div class="flex items-center gap-4">
<flux:button variant="primary" type="submit" data-test="update-password-button">{{ __('Save') }}</flux:button>
</div>
</form>
@if ($canManageTwoFactor)
<section class="mt-12">
<flux:heading>{{ __('Two-factor authentication') }}</flux:heading>
<flux:subheading>{{ __('Manage your two-factor authentication settings') }}</flux:subheading>
<div class="flex flex-col w-full mx-auto space-y-6 text-sm" wire:cloak>
@if ($twoFactorEnabled)
<div class="space-y-4">
<flux:text>
{{ __('You will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.') }}
</flux:text>
<div class="flex justify-start">
<flux:button
variant="danger"
wire:click="disable"
>
{{ __('Disable 2FA') }}
</flux:button>
</div>
<livewire:settings.two-factor.recovery-codes :$requiresConfirmation/>
</div>
@else
<div class="space-y-4">
<flux:text variant="subtle">
{{ __('When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.') }}
</flux:text>
<flux:button
variant="primary"
wire:click="enable"
>
{{ __('Enable 2FA') }}
</flux:button>
</div>
@endif
</div>
</section>
<flux:modal
name="two-factor-setup-modal"
class="max-w-md md:min-w-md"
@close="closeModal"
wire:model="showModal"
>
<div class="space-y-6">
<div class="flex flex-col items-center space-y-4">
<div class="p-0.5 w-auto rounded-full border border-stone-100 dark:border-stone-600 bg-white dark:bg-stone-800 shadow-sm">
<div class="p-2.5 rounded-full border border-stone-200 dark:border-stone-600 overflow-hidden bg-stone-100 dark:bg-stone-200 relative">
<div class="flex items-stretch absolute inset-0 w-full h-full divide-x [&>div]:flex-1 divide-stone-200 dark:divide-stone-300 justify-around opacity-50">
@for ($i = 1; $i <= 5; $i++)
<div></div>
@endfor
</div>
<div class="flex flex-col items-stretch absolute w-full h-full divide-y [&>div]:flex-1 inset-0 divide-stone-200 dark:divide-stone-300 justify-around opacity-50">
@for ($i = 1; $i <= 5; $i++)
<div></div>
@endfor
</div>
<flux:icon.qr-code class="relative z-20 dark:text-accent-foreground"/>
</div>
</div>
<div class="space-y-2 text-center">
<flux:heading size="lg">{{ $this->modalConfig['title'] }}</flux:heading>
<flux:text>{{ $this->modalConfig['description'] }}</flux:text>
</div>
</div>
@if ($showVerificationStep)
<div class="space-y-6">
<div class="flex flex-col items-center space-y-3 justify-center">
<flux:otp
name="code"
wire:model="code"
length="6"
label="OTP Code"
label:sr-only
class="mx-auto"
/>
</div>
<div class="flex items-center space-x-3">
<flux:button
variant="outline"
class="flex-1"
wire:click="resetVerification"
>
{{ __('Back') }}
</flux:button>
<flux:button
variant="primary"
class="flex-1"
wire:click="confirmTwoFactor"
x-bind:disabled="$wire.code.length < 6"
>
{{ __('Confirm') }}
</flux:button>
</div>
</div>
@else
@error('setupData')
<flux:callout variant="danger" icon="x-circle" heading="{{ $message }}"/>
@enderror
<div class="flex justify-center">
<div class="relative w-64 overflow-hidden border rounded-lg border-stone-200 dark:border-stone-700 aspect-square">
@empty($qrCodeSvg)
<div class="absolute inset-0 flex items-center justify-center bg-white dark:bg-stone-700 animate-pulse">
<flux:icon.loading/>
</div>
@else
<div x-data class="flex items-center justify-center h-full p-4">
<div
class="bg-white p-3 rounded"
:style="($flux.appearance === 'dark' || ($flux.appearance === 'system' && $flux.dark)) ? 'filter: invert(1) brightness(1.5)' : ''"
>
{!! $qrCodeSvg !!}
</div>
</div>
@endempty
</div>
</div>
<div>
<flux:button
:disabled="$errors->has('setupData')"
variant="primary"
class="w-full"
wire:click="showVerificationIfNecessary"
>
{{ $this->modalConfig['buttonText'] }}
</flux:button>
</div>
<div class="space-y-4">
<div class="relative flex items-center justify-center w-full">
<div class="absolute inset-0 w-full h-px top-1/2 bg-stone-200 dark:bg-stone-600"></div>
<span class="relative px-2 text-sm bg-white dark:bg-stone-800 text-stone-600 dark:text-stone-400">
{{ __('or, enter the code manually') }}
</span>
</div>
<div
class="flex items-center space-x-2"
x-data="{
copied: false,
async copy() {
try {
await navigator.clipboard.writeText('{{ $manualSetupKey }}');
this.copied = true;
setTimeout(() => this.copied = false, 1500);
} catch (e) {
console.warn('Could not copy to clipboard');
}
}
}"
>
<div class="flex items-stretch w-full border rounded-xl dark:border-stone-700">
@empty($manualSetupKey)
<div class="flex items-center justify-center w-full p-3 bg-stone-100 dark:bg-stone-700">
<flux:icon.loading variant="mini"/>
</div>
@else
<input
type="text"
readonly
value="{{ $manualSetupKey }}"
class="w-full p-3 bg-transparent outline-none text-stone-900 dark:text-stone-100"
/>
<button
@click="copy()"
class="px-3 transition-colors border-l cursor-pointer border-stone-200 dark:border-stone-600"
>
<flux:icon.document-duplicate x-show="!copied" variant="outline"></flux:icon>
<flux:icon.check
x-show="copied"
variant="solid"
class="text-green-500"
></flux:icon>
</button>
@endempty
</div>
</div>
</div>
@endif
</div>
</flux:modal>
@endif
</x-settings.layout>
</section>

View File

@@ -1,50 +1,3 @@
<?php
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
use Livewire\Attributes\Locked;
use Livewire\Component;
new class extends Component {
#[Locked]
public array $recoveryCodes = [];
/**
* Mount the component.
*/
public function mount(): void
{
$this->loadRecoveryCodes();
}
/**
* Generate new recovery codes for the user.
*/
public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generateNewRecoveryCodes): void
{
$generateNewRecoveryCodes(auth()->user());
$this->loadRecoveryCodes();
}
/**
* Load the recovery codes for the user.
*/
private function loadRecoveryCodes(): void
{
$user = auth()->user();
if ($user->hasEnabledTwoFactorAuthentication() && $user->two_factor_recovery_codes) {
try {
$this->recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true);
} catch (Exception) {
$this->addError('recoveryCodes', 'Failed to load recovery codes');
$this->recoveryCodes = [];
}
}
}
}; ?>
<div
class="py-6 space-y-6 border shadow-sm rounded-xl border-zinc-200 dark:border-white/10"
wire:cloak

View File

@@ -1,20 +0,0 @@
<?php
use Livewire\Component;
new class extends Component {}; ?>
<section class="mt-10 space-y-6">
<div class="relative mb-5">
<flux:heading>{{ __('Delete account') }}</flux:heading>
<flux:subheading>{{ __('Delete your account and all of its resources') }}</flux:subheading>
</div>
<flux:modal.trigger name="confirm-user-deletion">
<flux:button variant="danger" data-test="delete-user-button">
{{ __('Delete account') }}
</flux:button>
</flux:modal.trigger>
<livewire:pages::settings.delete-user-modal />
</section>

View File

@@ -1,50 +0,0 @@
<?php
use App\Concerns\PasswordValidationRules;
use App\Livewire\Actions\Logout;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
new class extends Component {
use PasswordValidationRules;
public string $password = '';
/**
* Delete the currently authenticated user.
*/
public function deleteUser(Logout $logout): void
{
$this->validate([
'password' => $this->currentPasswordRules(),
]);
tap(Auth::user(), $logout(...))->delete();
$this->redirect('/', navigate: true);
}
}; ?>
<flux:modal name="confirm-user-deletion" :show="$errors->isNotEmpty()" focusable class="max-w-lg">
<form method="POST" wire:submit="deleteUser" class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Are you sure you want to delete your account?') }}</flux:heading>
<flux:subheading>
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }}
</flux:subheading>
</div>
<flux:input wire:model="password" :label="__('Password')" type="password" viewable />
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close>
<flux:button variant="filled">{{ __('Cancel') }}</flux:button>
</flux:modal.close>
<flux:button variant="danger" type="submit" data-test="confirm-delete-user-button">
{{ __('Delete account') }}
</flux:button>
</div>
</form>
</flux:modal>

View File

@@ -1,126 +0,0 @@
<?php
use App\Concerns\ProfileValidationRules;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Title;
use Livewire\Component;
new #[Title('Profile settings')] class extends Component {
use ProfileValidationRules;
public string $name = '';
public string $email = '';
/**
* Mount the component.
*/
public function mount(): void
{
$this->name = Auth::user()->name;
$this->email = Auth::user()->email;
}
/**
* Update the profile information for the currently authenticated user.
*/
public function updateProfileInformation(): void
{
$user = Auth::user();
$validated = $this->validate($this->profileRules($user->id));
$user->fill($validated);
if ($user->isDirty('email')) {
$user->email_verified_at = null;
}
$user->save();
$this->dispatch('profile-updated', name: $user->name);
}
/**
* Send an email verification notification to the current user.
*/
public function resendVerificationNotification(): void
{
$user = Auth::user();
if ($user->hasVerifiedEmail()) {
$this->redirectIntended(default: route('dashboard', absolute: false));
return;
}
$user->sendEmailVerificationNotification();
Session::flash('status', 'verification-link-sent');
}
#[Computed]
public function hasUnverifiedEmail(): bool
{
return Auth::user() instanceof MustVerifyEmail && ! Auth::user()->hasVerifiedEmail();
}
#[Computed]
public function showDeleteUser(): bool
{
return ! Auth::user() instanceof MustVerifyEmail
|| (Auth::user() instanceof MustVerifyEmail && Auth::user()->hasVerifiedEmail());
}
}; ?>
<section class="w-full">
@include('partials.settings-heading')
<flux:heading class="sr-only">{{ __('Profile settings') }}</flux:heading>
<x-pages::settings.layout :heading="__('Profile')" :subheading="__('Update your name and email address')">
<form wire:submit="updateProfileInformation" class="my-6 w-full space-y-6">
<flux:input wire:model="name" :label="__('Name')" type="text" required autofocus autocomplete="name" />
<div>
<flux:input wire:model="email" :label="__('Email')" type="email" required autocomplete="email" />
@if ($this->hasUnverifiedEmail)
<div>
<flux:text class="mt-4">
{{ __('Your email address is unverified.') }}
<flux:link class="text-sm cursor-pointer" wire:click.prevent="resendVerificationNotification">
{{ __('Click here to re-send the verification email.') }}
</flux:link>
</flux:text>
@if (session('status') === 'verification-link-sent')
<flux:text class="mt-2 font-medium !dark:text-green-400 !text-green-600">
{{ __('A new verification link has been sent to your email address.') }}
</flux:text>
@endif
</div>
@endif
</div>
<div class="flex items-center gap-4">
<div class="flex items-center justify-end">
<flux:button variant="primary" type="submit" class="w-full" data-test="update-profile-button">
{{ __('Save') }}
</flux:button>
</div>
<x-action-message class="me-3" on="profile-updated">
{{ __('Saved.') }}
</x-action-message>
</div>
</form>
@if ($this->showDeleteUser)
<livewire:pages::settings.delete-user-form />
@endif
</x-pages::settings.layout>
</section>

View File

@@ -1,178 +0,0 @@
<?php
use App\Concerns\PasswordValidationRules;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Features;
use Laravel\Fortify\Fortify;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
new #[Title('Security settings')] class extends Component {
use PasswordValidationRules;
public string $current_password = '';
public string $password = '';
public string $password_confirmation = '';
public bool $canManageTwoFactor;
public bool $twoFactorEnabled;
public bool $requiresConfirmation;
/**
* Mount the component.
*/
public function mount(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void
{
$this->canManageTwoFactor = Features::canManageTwoFactorAuthentication();
if ($this->canManageTwoFactor) {
if (Fortify::confirmsTwoFactorAuthentication() && is_null(auth()->user()->two_factor_confirmed_at)) {
$disableTwoFactorAuthentication(auth()->user());
}
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
$this->requiresConfirmation = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm');
}
}
/**
* Update the password for the currently authenticated user.
*/
public function updatePassword(): void
{
try {
$validated = $this->validate([
'current_password' => $this->currentPasswordRules(),
'password' => $this->passwordRules(),
]);
} catch (ValidationException $e) {
$this->reset('current_password', 'password', 'password_confirmation');
throw $e;
}
Auth::user()->update([
'password' => $validated['password'],
]);
$this->reset('current_password', 'password', 'password_confirmation');
$this->dispatch('password-updated');
}
/**
* Handle the two-factor authentication enabled event.
*/
#[On('two-factor-enabled')]
public function onTwoFactorEnabled(): void
{
$this->twoFactorEnabled = true;
}
/**
* Disable two-factor authentication for the user.
*/
public function disable(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void
{
$disableTwoFactorAuthentication(auth()->user());
$this->twoFactorEnabled = false;
}
}; ?>
<section class="w-full">
@include('partials.settings-heading')
<flux:heading class="sr-only">{{ __('Security settings') }}</flux:heading>
<x-pages::settings.layout :heading="__('Update password')" :subheading="__('Ensure your account is using a long, random password to stay secure')">
<form method="POST" wire:submit="updatePassword" class="mt-6 space-y-6">
<flux:input
wire:model="current_password"
:label="__('Current password')"
type="password"
required
autocomplete="current-password"
viewable
/>
<flux:input
wire:model="password"
:label="__('New password')"
type="password"
required
autocomplete="new-password"
viewable
/>
<flux:input
wire:model="password_confirmation"
:label="__('Confirm password')"
type="password"
required
autocomplete="new-password"
viewable
/>
<div class="flex items-center gap-4">
<div class="flex items-center justify-end">
<flux:button variant="primary" type="submit" class="w-full" data-test="update-password-button">
{{ __('Save') }}
</flux:button>
</div>
<x-action-message class="me-3" on="password-updated">
{{ __('Saved.') }}
</x-action-message>
</div>
</form>
@if ($canManageTwoFactor)
<section class="mt-12">
<flux:heading>{{ __('Two-factor authentication') }}</flux:heading>
<flux:subheading>{{ __('Manage your two-factor authentication settings') }}</flux:subheading>
<div class="flex flex-col w-full mx-auto space-y-6 text-sm" wire:cloak>
@if ($twoFactorEnabled)
<div class="space-y-4">
<flux:text>
{{ __('You will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.') }}
</flux:text>
<div class="flex justify-start">
<flux:button
variant="danger"
wire:click="disable"
>
{{ __('Disable 2FA') }}
</flux:button>
</div>
<livewire:pages::settings.two-factor.recovery-codes :$requiresConfirmation />
</div>
@else
<div class="space-y-4">
<flux:text variant="subtle">
{{ __('When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.') }}
</flux:text>
<flux:modal.trigger name="two-factor-setup-modal">
<flux:button
variant="primary"
wire:click="$dispatch('start-two-factor-setup')"
>
{{ __('Enable 2FA') }}
</flux:button>
</flux:modal.trigger>
<livewire:pages::settings.two-factor-setup-modal :requires-confirmation="$requiresConfirmation" />
</div>
@endif
</div>
</section>
@endif
</x-pages::settings.layout>
</section>

View File

@@ -1,304 +0,0 @@
<?php
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use Livewire\Attributes\Validate;
use Livewire\Component;
new class extends Component {
#[Locked]
public bool $requiresConfirmation;
#[Locked]
public string $qrCodeSvg = '';
#[Locked]
public string $manualSetupKey = '';
public bool $showVerificationStep = false;
public bool $setupComplete = false;
#[Validate('required|string|size:6', onUpdate: false)]
public string $code = '';
/**
* Mount the component.
*/
public function mount(bool $requiresConfirmation): void
{
$this->requiresConfirmation = $requiresConfirmation;
}
#[On('start-two-factor-setup')]
public function startTwoFactorSetup(): void
{
$enableTwoFactorAuthentication = app(EnableTwoFactorAuthentication::class);
$enableTwoFactorAuthentication(auth()->user());
$this->loadSetupData();
}
/**
* Load the two-factor authentication setup data for the user.
*/
private function loadSetupData(): void
{
$user = auth()->user()?->fresh();
try {
if (! $user || ! $user->two_factor_secret) {
throw new Exception('Two-factor setup secret is not available.');
}
$this->qrCodeSvg = $user->twoFactorQrCodeSvg();
$this->manualSetupKey = decrypt($user->two_factor_secret);
} catch (Exception) {
$this->addError('setupData', 'Failed to fetch setup data.');
$this->reset('qrCodeSvg', 'manualSetupKey');
}
}
/**
* Show the two-factor verification step if necessary.
*/
public function showVerificationIfNecessary(): void
{
if ($this->requiresConfirmation) {
$this->showVerificationStep = true;
$this->resetErrorBag();
return;
}
$this->closeModal();
$this->dispatch('two-factor-enabled');
}
/**
* Confirm two-factor authentication for the user.
*/
public function confirmTwoFactor(ConfirmTwoFactorAuthentication $confirmTwoFactorAuthentication): void
{
$this->validate();
$confirmTwoFactorAuthentication(auth()->user(), $this->code);
$this->setupComplete = true;
$this->closeModal();
$this->dispatch('two-factor-enabled');
}
/**
* Reset two-factor verification state.
*/
public function resetVerification(): void
{
$this->reset('code', 'showVerificationStep');
$this->resetErrorBag();
}
/**
* Close the two-factor authentication modal.
*/
public function closeModal(): void
{
$this->reset(
'code',
'manualSetupKey',
'qrCodeSvg',
'showVerificationStep',
'setupComplete',
);
$this->resetErrorBag();
}
/**
* Get the current modal configuration state.
*/
public function getModalConfigProperty(): array
{
if ($this->setupComplete) {
return [
'title' => __('Two-factor authentication enabled'),
'description' => __('Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.'),
'buttonText' => __('Close'),
];
}
if ($this->showVerificationStep) {
return [
'title' => __('Verify authentication code'),
'description' => __('Enter the 6-digit code from your authenticator app.'),
'buttonText' => __('Continue'),
];
}
return [
'title' => __('Enable two-factor authentication'),
'description' => __('To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app.'),
'buttonText' => __('Continue'),
];
}
}; ?>
<flux:modal
name="two-factor-setup-modal"
class="max-w-md md:min-w-md"
@close="closeModal"
>
<div class="space-y-6">
<div class="flex flex-col items-center space-y-4">
<div class="p-0.5 w-auto rounded-full border border-stone-100 dark:border-stone-600 bg-white dark:bg-stone-800 shadow-sm">
<div class="p-2.5 rounded-full border border-stone-200 dark:border-stone-600 overflow-hidden bg-stone-100 dark:bg-stone-200 relative">
<div class="flex items-stretch absolute inset-0 w-full h-full divide-x [&>div]:flex-1 divide-stone-200 dark:divide-stone-300 justify-around opacity-50">
@for ($i = 1; $i <= 5; $i++)
<div></div>
@endfor
</div>
<div class="flex flex-col items-stretch absolute w-full h-full divide-y [&>div]:flex-1 inset-0 divide-stone-200 dark:divide-stone-300 justify-around opacity-50">
@for ($i = 1; $i <= 5; $i++)
<div></div>
@endfor
</div>
<flux:icon.qr-code class="relative z-20 dark:text-accent-foreground"/>
</div>
</div>
<div class="space-y-2 text-center">
<flux:heading size="lg">{{ $this->modalConfig['title'] }}</flux:heading>
<flux:text>{{ $this->modalConfig['description'] }}</flux:text>
</div>
</div>
@if ($showVerificationStep)
<div class="space-y-6">
<div class="flex flex-col items-center space-y-3 justify-center">
<flux:otp
name="code"
wire:model="code"
length="6"
label="OTP Code"
label:sr-only
class="mx-auto"
/>
</div>
<div class="flex items-center space-x-3">
<flux:button
variant="outline"
class="flex-1"
wire:click="resetVerification"
>
{{ __('Back') }}
</flux:button>
<flux:button
variant="primary"
class="flex-1"
wire:click="confirmTwoFactor"
x-bind:disabled="$wire.code.length < 6"
>
{{ __('Confirm') }}
</flux:button>
</div>
</div>
@else
@error('setupData')
<flux:callout variant="danger" icon="x-circle" heading="{{ $message }}"/>
@enderror
<div class="flex justify-center">
<div class="relative w-64 overflow-hidden border rounded-lg border-stone-200 dark:border-stone-700 aspect-square">
@empty($qrCodeSvg)
<div class="absolute inset-0 flex items-center justify-center bg-white dark:bg-stone-700 animate-pulse">
<flux:icon.loading/>
</div>
@else
<div x-data class="flex items-center justify-center h-full p-4">
<div
class="bg-white p-3 rounded"
:style="($flux.appearance === 'dark' || ($flux.appearance === 'system' && $flux.dark)) ? 'filter: invert(1) brightness(1.5)' : ''"
>
{!! $qrCodeSvg !!}
</div>
</div>
@endempty
</div>
</div>
<div>
<flux:button
:disabled="$errors->has('setupData')"
variant="primary"
class="w-full"
wire:click="showVerificationIfNecessary"
>
{{ $this->modalConfig['buttonText'] }}
</flux:button>
</div>
<div class="space-y-4">
<div class="relative flex items-center justify-center w-full">
<div class="absolute inset-0 w-full h-px top-1/2 bg-stone-200 dark:bg-stone-600"></div>
<span class="relative px-2 text-sm bg-white dark:bg-stone-800 text-stone-600 dark:text-stone-400">
{{ __('or, enter the code manually') }}
</span>
</div>
<div
class="flex items-center space-x-2"
x-data="{
copied: false,
async copy() {
try {
await navigator.clipboard.writeText('{{ $manualSetupKey }}');
this.copied = true;
setTimeout(() => this.copied = false, 1500);
} catch (e) {
console.warn('Could not copy to clipboard');
}
}
}"
>
<div class="flex items-stretch w-full border rounded-xl dark:border-stone-700">
@empty($manualSetupKey)
<div class="flex items-center justify-center w-full p-3 bg-stone-100 dark:bg-stone-700">
<flux:icon.loading variant="mini"/>
</div>
@else
<input
type="text"
readonly
value="{{ $manualSetupKey }}"
class="w-full p-3 bg-transparent outline-none text-stone-900 dark:text-stone-100"
/>
<button
@click="copy()"
class="px-3 transition-colors border-l cursor-pointer border-stone-200 dark:border-stone-600"
>
<flux:icon.document-duplicate x-show="!copied" variant="outline"></flux:icon>
<flux:icon.check
x-show="copied"
variant="solid"
class="text-green-500"
></flux:icon>
</button>
@endempty
</div>
</div>
</div>
@endif
</div>
</flux:modal>

View File

@@ -10,7 +10,7 @@
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600|inter:400,500,600|manrope:600,700,800,900" rel="stylesheet" />
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
@vite(['resources/css/app.css', 'resources/js/app.js'])
@fluxAppearance

File diff suppressed because one or more lines are too long

View File

@@ -1,18 +1,21 @@
<?php
use App\Livewire\Settings\Appearance;
use App\Livewire\Settings\Profile;
use App\Livewire\Settings\Security;
use Illuminate\Support\Facades\Route;
use Laravel\Fortify\Features;
Route::middleware(['auth'])->group(function () {
Route::redirect('settings', 'settings/profile');
Route::livewire('settings/profile', 'pages::settings.profile')->name('profile.edit');
Route::livewire('settings/profile', Profile::class)->name('profile.edit');
});
Route::middleware(['auth', 'verified'])->group(function () {
Route::livewire('settings/appearance', 'pages::settings.appearance')->name('appearance.edit');
Route::livewire('settings/appearance', Appearance::class)->name('appearance.edit');
Route::livewire('settings/security', 'pages::settings.security')
Route::livewire('settings/security', Security::class)
->middleware(
when(
Features::canManageTwoFactorAuthentication()

View File

@@ -3,9 +3,10 @@
use App\Livewire\Public\FuelFinder;
use Illuminate\Support\Facades\Route;
Route::view('/', 'homepage')->name('home');
Route::get('/fuel-finder', FuelFinder::class)->name('fuel-finder');
//Route::get('/fuel-finder', FuelFinder::class)->name('fuel-finder');
Route::view('/', 'welcome')->name('home');
Route::middleware(['auth', 'verified'])->group(function () {
Route::view('dashboard', 'dashboard')->name('dashboard');