Compare commits
15 Commits
c6e65330b2
...
069a85cf11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
069a85cf11 | ||
|
|
02b004f381 | ||
|
|
977ae8a5a1 | ||
|
|
25770445bc | ||
|
|
3895356b0d | ||
|
|
ea7a5b4f10 | ||
|
|
83809cd4f3 | ||
|
|
f714169183 | ||
|
|
00e99044f6 | ||
|
|
5bf8868124 | ||
|
|
bd68a179d8 | ||
|
|
7976b9facc | ||
|
|
e90078d39e | ||
|
|
94d695d637 | ||
|
|
1d39c69fe4 |
@@ -1,156 +0,0 @@
|
|||||||
---
|
|
||||||
name: livewire-development
|
|
||||||
description: "Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, wire:sort, or islands, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, drag-and-drop, loading states, migrating from Livewire 3 to 4, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire."
|
|
||||||
license: MIT
|
|
||||||
metadata:
|
|
||||||
author: laravel
|
|
||||||
---
|
|
||||||
|
|
||||||
# Livewire Development
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
Use `search-docs` for detailed Livewire 4 patterns and documentation.
|
|
||||||
|
|
||||||
## Basic Usage
|
|
||||||
|
|
||||||
### Creating Components
|
|
||||||
|
|
||||||
```bash
|
|
||||||
|
|
||||||
# Single-file component (default in v4)
|
|
||||||
|
|
||||||
php artisan make:livewire create-post
|
|
||||||
|
|
||||||
# Multi-file component
|
|
||||||
|
|
||||||
php artisan make:livewire create-post --mfc
|
|
||||||
|
|
||||||
# Class-based component (v3 style)
|
|
||||||
|
|
||||||
php artisan make:livewire create-post --class
|
|
||||||
|
|
||||||
# With namespace
|
|
||||||
|
|
||||||
php artisan make:livewire Posts/CreatePost
|
|
||||||
```
|
|
||||||
|
|
||||||
### Converting Between Formats
|
|
||||||
|
|
||||||
Use `php artisan livewire:convert create-post` to convert between single-file, multi-file, and class-based formats.
|
|
||||||
|
|
||||||
### Choosing a Component Format
|
|
||||||
|
|
||||||
Before creating a component, check `config/livewire.php` for directory overrides, which change where files are stored. Then, look at existing files in those directories (defaulting to `app/Livewire/` and `resources/views/livewire/`) to match the established convention.
|
|
||||||
|
|
||||||
### Component Format Reference
|
|
||||||
|
|
||||||
| Format | Flag | Class Path | View Path |
|
|
||||||
|--------|------|------------|-----------|
|
|
||||||
| Single-file (SFC) | default | — | `resources/views/livewire/create-post.blade.php` (PHP + Blade in one file) |
|
|
||||||
| Multi-file (MFC) | `--mfc` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` |
|
|
||||||
| Class-based | `--class` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` |
|
|
||||||
| View-based | ⚡ prefix | — | `resources/views/livewire/create-post.blade.php` (Blade-only with functional state) |
|
|
||||||
|
|
||||||
Namespaced components map to subdirectories: `make:livewire Posts/CreatePost` creates files at `app/Livewire/Posts/CreatePost.php` and `resources/views/livewire/posts/create-post.blade.php`.
|
|
||||||
|
|
||||||
### Single-File Component Example
|
|
||||||
|
|
||||||
<!-- Single-File Component Example -->
|
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
new class extends Component {
|
|
||||||
public int $count = 0;
|
|
||||||
|
|
||||||
public function increment(): void
|
|
||||||
{
|
|
||||||
$this->count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<button wire:click="increment">Count: @{{ $count }}</button>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Livewire 4 Specifics
|
|
||||||
|
|
||||||
### Key Changes From Livewire 3
|
|
||||||
|
|
||||||
These things changed in Livewire 4, but may not have been updated in this application. Verify this application's setup to ensure you follow existing conventions.
|
|
||||||
|
|
||||||
- Use `Route::livewire()` for full-page components (e.g., `Route::livewire('/posts/create', CreatePost::class)`); config keys renamed: `layout` → `component_layout`, `lazy_placeholder` → `component_placeholder`.
|
|
||||||
- `wire:model` now ignores child events by default (use `wire:model.deep` for old behavior); `wire:scroll` renamed to `wire:navigate:scroll`.
|
|
||||||
- Component tags must be properly closed; `wire:transition` now uses View Transitions API (modifiers removed).
|
|
||||||
- JavaScript: `$wire.$js('name', fn)` → `$wire.$js.name = fn`; `commit`/`request` hooks → `interceptMessage()`/`interceptRequest()`.
|
|
||||||
|
|
||||||
### New Features
|
|
||||||
|
|
||||||
- Component formats: single-file (SFC), multi-file (MFC), view-based components.
|
|
||||||
- Islands (`@island`) for isolated updates; async actions (`wire:click.async`, `#[Async]`) for parallel execution.
|
|
||||||
- Deferred/bundled loading: `defer`, `lazy.bundle` for optimized component loading.
|
|
||||||
|
|
||||||
| Feature | Usage | Purpose |
|
|
||||||
|---------|-------|---------|
|
|
||||||
| Islands | `@island(name: 'stats')` | Isolated update regions |
|
|
||||||
| Async | `wire:click.async` or `#[Async]` | Non-blocking actions |
|
|
||||||
| Deferred | `defer` attribute | Load after page render |
|
|
||||||
| Bundled | `lazy.bundle` | Load multiple together |
|
|
||||||
|
|
||||||
### New Directives
|
|
||||||
|
|
||||||
- `wire:sort`, `wire:intersect`, `wire:ref`, `.renderless`, `.preserve-scroll` are available for use.
|
|
||||||
- `data-loading` attribute automatically added to elements triggering network requests.
|
|
||||||
|
|
||||||
| Directive | Purpose |
|
|
||||||
|-----------|---------|
|
|
||||||
| `wire:sort` | Drag-and-drop sorting |
|
|
||||||
| `wire:intersect` | Viewport intersection detection |
|
|
||||||
| `wire:ref` | Element references for JS |
|
|
||||||
| `.renderless` | Component without rendering |
|
|
||||||
| `.preserve-scroll` | Preserve scroll position |
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
- Always use `wire:key` in loops
|
|
||||||
- Use `wire:loading` for loading states
|
|
||||||
- Use `wire:model.live` for instant updates (default is debounced)
|
|
||||||
- Validate and authorize in actions (treat like HTTP requests)
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
- `smart_wire_keys` defaults to `true`; new configs: `component_locations`, `component_namespaces`, `make_command`, `csp_safe`.
|
|
||||||
|
|
||||||
## Alpine & JavaScript
|
|
||||||
|
|
||||||
- `wire:transition` uses browser View Transitions API; `$errors` and `$intercept` magic properties available.
|
|
||||||
- Non-blocking `wire:poll` and parallel `wire:model.live` updates improve performance.
|
|
||||||
|
|
||||||
For interceptors and hooks, see [reference/javascript-hooks.md](reference/javascript-hooks.md).
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
<!-- Testing Example -->
|
|
||||||
```php
|
|
||||||
Livewire::test(Counter::class)
|
|
||||||
->assertSet('count', 0)
|
|
||||||
->call('increment')
|
|
||||||
->assertSet('count', 1);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
1. Browser console: Check for JS errors
|
|
||||||
2. Network tab: Verify Livewire requests return 200
|
|
||||||
3. Ensure `wire:key` on all `@foreach` loops
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
- Missing `wire:key` in loops → unexpected re-rendering
|
|
||||||
- Expecting `wire:model` real-time → use `wire:model.live`
|
|
||||||
- Unclosed component tags → syntax errors in v4
|
|
||||||
- Using deprecated config keys or JS hooks
|
|
||||||
- Including Alpine.js separately (already bundled in Livewire 4)
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Livewire 4 JavaScript Integration
|
|
||||||
|
|
||||||
## Interceptor System (v4)
|
|
||||||
|
|
||||||
### Intercept Messages
|
|
||||||
|
|
||||||
```js
|
|
||||||
Livewire.interceptMessage(({ component, message, onFinish, onSuccess, onError }) => {
|
|
||||||
onFinish(() => { /* After response, before processing */ });
|
|
||||||
onSuccess(({ payload }) => { /* payload.snapshot, payload.effects */ });
|
|
||||||
onError(() => { /* Server errors */ });
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Intercept Requests
|
|
||||||
|
|
||||||
```js
|
|
||||||
Livewire.interceptRequest(({ request, onResponse, onSuccess, onError, onFailure }) => {
|
|
||||||
onResponse(({ response }) => { /* When received */ });
|
|
||||||
onSuccess(({ response, responseJson }) => { /* Success */ });
|
|
||||||
onError(({ response, responseBody, preventDefault }) => { /* 4xx/5xx */ });
|
|
||||||
onFailure(({ error }) => { /* Network failures */ });
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Component-Scoped Interceptors
|
|
||||||
|
|
||||||
```blade
|
|
||||||
<script>
|
|
||||||
this.$intercept('save', ({ component, onSuccess }) => {
|
|
||||||
onSuccess(() => console.log('Saved!'));
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Magic Properties
|
|
||||||
|
|
||||||
- `$errors` - Access validation errors from JavaScript
|
|
||||||
- `$intercept` - Component-scoped interceptors
|
|
||||||
@@ -8,7 +8,7 @@ use Illuminate\Http\JsonResponse;
|
|||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
use Laravel\Sanctum\PersonalAccessToken;
|
use Laravel\Sanctum\TransientToken;
|
||||||
|
|
||||||
class AuthController extends Controller
|
class AuthController extends Controller
|
||||||
{
|
{
|
||||||
@@ -46,9 +46,15 @@ class AuthController extends Controller
|
|||||||
|
|
||||||
public function logout(Request $request): JsonResponse
|
public function logout(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
/** @var PersonalAccessToken $token */
|
|
||||||
$token = $request->user()->currentAccessToken();
|
$token = $request->user()->currentAccessToken();
|
||||||
|
|
||||||
|
// TransientToken means session-based auth (no Bearer token) — invalidate session instead
|
||||||
|
if ($token instanceof TransientToken) {
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
} else {
|
||||||
$token->delete();
|
$token->delete();
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json(['message' => 'Logged out.']);
|
return response()->json(['message' => 'Logged out.']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,14 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
final class UserController extends Controller
|
final class UserController extends Controller
|
||||||
{
|
{
|
||||||
@@ -59,4 +63,58 @@ final class UserController extends Controller
|
|||||||
|
|
||||||
return response()->noContent();
|
return response()->noContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updateProfile(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => ['required', 'string', 'email', 'max:255', Rule::unique(User::class)->ignore($request->user()->id)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
$user->fill($validated);
|
||||||
|
|
||||||
|
if ($user->isDirty('email')) {
|
||||||
|
$user->email_verified_at = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
return response()->json($user->fresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatePassword(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'current_password' => ['required', 'string'],
|
||||||
|
'password' => ['required', 'string', Password::defaults(), 'confirmed'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! Hash::check($request->string('current_password'), $request->user()->password)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'current_password' => [__('The provided password does not match your current password.')],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->user()->update(['password' => $request->string('password')]);
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Password updated.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteAccount(Request $request): Response
|
||||||
|
{
|
||||||
|
$request->validate(['password' => ['required', 'string']]);
|
||||||
|
|
||||||
|
if (! Hash::check($request->string('password'), $request->user()->password)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'password' => [__('The provided password does not match your current password.')],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
$user->tokens()->delete();
|
||||||
|
$user->delete();
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Livewire\Settings;
|
|
||||||
|
|
||||||
use Livewire\Attributes\Title;
|
|
||||||
use Livewire\Component;
|
|
||||||
|
|
||||||
#[Title('Appearance settings')]
|
|
||||||
class Appearance extends Component
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
<?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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
<?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'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<?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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1366
docs/superpowers/plans/2026-04-11-settings-vue-migration.md
Normal file
1366
docs/superpowers/plans/2026-04-11-settings-vue-migration.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
|||||||
|
# Settings Vue Migration — Design Spec
|
||||||
|
|
||||||
|
**Date:** 2026-04-11
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Migrate the Livewire settings pages (Profile, Security, Appearance) into the Vue SPA so the app is fully unified under one frontend. Auth pages (`/login`, `/register`, Fortify) remain Livewire and are untouched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Routing
|
||||||
|
|
||||||
|
New Vue Router routes nested under `/dashboard/settings`:
|
||||||
|
|
||||||
|
```
|
||||||
|
/dashboard/settings → redirect to /dashboard/settings/profile
|
||||||
|
/dashboard/settings/profile
|
||||||
|
/dashboard/settings/security
|
||||||
|
/dashboard/settings/appearance
|
||||||
|
```
|
||||||
|
|
||||||
|
A `SettingsLayout.vue` wraps all three views, providing a sub-nav sidebar (Profile / Security / Appearance) that mirrors the existing Livewire settings layout structure and uses the same warm colour palette (`#faf6f3`, `#bb5b3e`, `#89726c`, `#4a3f3b`, `#e5ded7`).
|
||||||
|
|
||||||
|
### Top Nav — User Dropdown
|
||||||
|
|
||||||
|
`DashboardLayout.vue` top nav: replace the plain `{{ user?.email }}` text with a user avatar button showing the user's initials (e.g. "OU"). Clicking opens a dropdown showing:
|
||||||
|
|
||||||
|
- Avatar + full name + email
|
||||||
|
- Settings link → `/dashboard/settings`
|
||||||
|
- Log out (calls `POST /api/auth/logout`, clears user, redirects to `/`)
|
||||||
|
|
||||||
|
The dropdown is implemented with Alpine.js `x-data` / `x-show` inline, consistent with the project's existing Alpine usage pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### New files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `resources/js/views/dashboard/settings/SettingsLayout.vue` | Sub-nav wrapper (Profile / Security / Appearance) |
|
||||||
|
| `resources/js/views/dashboard/settings/Profile.vue` | Name/email form + delete account |
|
||||||
|
| `resources/js/views/dashboard/settings/Security.vue` | Password change + 2FA management |
|
||||||
|
| `resources/js/views/dashboard/settings/Appearance.vue` | Light/dark/system theme toggle |
|
||||||
|
|
||||||
|
### Modified files
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `resources/js/router/index.js` | Add `/dashboard/settings` routes |
|
||||||
|
| `resources/js/views/dashboard/DashboardLayout.vue` | Replace email text with user dropdown |
|
||||||
|
| `resources/js/composables/useAuth.js` | Expose `updateProfile`, `updatePassword`, `deleteAccount` methods |
|
||||||
|
| `app/Http/Controllers/Api/UserController.php` | Add `updateProfile`, `updatePassword`, `deleteAccount` actions |
|
||||||
|
| `routes/api.php` | Register 3 new endpoints |
|
||||||
|
| `routes/settings.php` | Remove Livewire settings routes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### Profile update
|
||||||
|
|
||||||
|
1. Vue calls `PUT /api/user/profile` with `{ name, email }`
|
||||||
|
2. `UserController@updateProfile` validates, updates `users` table, nulls `email_verified_at` if email changed
|
||||||
|
3. Returns updated user JSON; `useAuth` updates the `user` ref
|
||||||
|
|
||||||
|
### Password update
|
||||||
|
|
||||||
|
1. Vue calls `PUT /api/user/password` with `{ current_password, password, password_confirmation }`
|
||||||
|
2. `UserController@updatePassword` verifies current password via `Hash::check`, updates hash
|
||||||
|
3. Returns `200` on success; Vue shows inline success message
|
||||||
|
|
||||||
|
### Delete account
|
||||||
|
|
||||||
|
1. User clicks "Delete account", enters password in a confirmation modal
|
||||||
|
2. Vue calls `DELETE /api/user` with `{ password }`
|
||||||
|
3. `UserController@deleteAccount` verifies password, deletes user, revokes Sanctum tokens
|
||||||
|
4. Vue calls logout, redirects to `/`
|
||||||
|
|
||||||
|
### 2FA (uses Fortify web routes directly)
|
||||||
|
|
||||||
|
Fortify's web routes accept `application/json` and work with session cookies (axios already sends these via `withCredentials: true`):
|
||||||
|
|
||||||
|
| Action | Endpoint |
|
||||||
|
|--------|----------|
|
||||||
|
| Enable | `POST /user/two-factor-authentication` |
|
||||||
|
| Disable | `DELETE /user/two-factor-authentication` |
|
||||||
|
| Confirm code | `POST /user/confirmed-two-factor-authentication` |
|
||||||
|
| Get QR code SVG | `GET /user/two-factor-qr-code` |
|
||||||
|
| Get secret key | `GET /user/two-factor-secret-key` |
|
||||||
|
| Get recovery codes | `GET /user/two-factor-recovery-codes` |
|
||||||
|
| Regenerate recovery codes | `POST /user/two-factor-recovery-codes` |
|
||||||
|
|
||||||
|
A separate axios instance (base URL `/`, same credentials) is used for these Fortify calls to avoid the `/api` prefix.
|
||||||
|
|
||||||
|
### Appearance
|
||||||
|
|
||||||
|
Theme preference stored in `localStorage` under key `appearance` with values `light` / `dark` / `system`. Applied via a class on `<html>`. No backend call needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Validation errors from API returned as `422` with `errors` object; displayed inline under each field
|
||||||
|
- Network errors shown as a generic top-level message within the form card
|
||||||
|
- 2FA setup errors (QR fetch failure, bad confirmation code) shown inline with retry option
|
||||||
|
- Delete account modal clears and closes on any error; error shown inside the modal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cleanup (after Vue implementation verified)
|
||||||
|
|
||||||
|
- Remove `routes/settings.php` (all three `Route::livewire` entries)
|
||||||
|
- Remove `app/Livewire/Settings/` directory (Profile, Security, Appearance, DeleteUserForm, TwoFactor/RecoveryCodes)
|
||||||
|
- Remove `resources/views/livewire/settings/` directory
|
||||||
|
- Remove `resources/views/components/settings/layout.blade.php`
|
||||||
|
- Remove `resources/views/partials/settings-heading.blade.php` if it exists
|
||||||
|
- Remove `app/Concerns/ProfileValidationRules.php` and `PasswordValidationRules.php` if unused elsewhere
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- `/login`, `/register`, Fortify password reset flows — remain Livewire, untouched
|
||||||
|
- Subscription management (separate feature)
|
||||||
|
- WhatsApp/SMS notification preferences (separate feature)
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<button
|
<button
|
||||||
@click="toggleMap"
|
@click="toggleMap"
|
||||||
class="flex items-center gap-2 text-sm font-bold text-[#bb5b3e] hover:text-[#a34a31] transition-colors"
|
class="flex items-center gap-2 text-sm font-bold text-accent hover:text-accent-content transition-colors"
|
||||||
>
|
>
|
||||||
<iconify-icon :icon="isOpen ? 'lucide:chevron-up' : 'lucide:chevron-down'"></iconify-icon>
|
<iconify-icon :icon="isOpen ? 'lucide:chevron-up' : 'lucide:chevron-down'"></iconify-icon>
|
||||||
{{ isOpen ? 'Hide map' : 'Show map' }}
|
{{ isOpen ? 'Hide map' : 'Show map' }}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<div
|
<div
|
||||||
v-show="isOpen"
|
v-show="isOpen"
|
||||||
ref="mapContainer"
|
ref="mapContainer"
|
||||||
class="w-full h-72 rounded-2xl overflow-hidden border border-[#e5ded7] shadow-sm"
|
class="w-full h-72 rounded-2xl overflow-hidden border border-zinc-300 shadow-sm"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
v-if="!isPaidTier"
|
v-if="!isPaidTier"
|
||||||
class="absolute inset-0 z-10 rounded-2xl backdrop-blur-sm bg-white/60 flex flex-col items-center justify-center gap-3 text-center px-6"
|
class="absolute inset-0 z-10 rounded-2xl backdrop-blur-sm bg-white/60 flex flex-col items-center justify-center gap-3 text-center px-6"
|
||||||
>
|
>
|
||||||
<iconify-icon icon="lucide:lock" class="text-[#bb5b3e] text-3xl"></iconify-icon>
|
<iconify-icon class="text-accent text-3xl" icon="lucide:lock"></iconify-icon>
|
||||||
<p class="font-bold text-[#4a3f3b]">Price predictions are available on paid plans</p>
|
<p class="font-bold text-zinc-800">Price predictions are available on paid plans</p>
|
||||||
<a
|
<a
|
||||||
href="/pricing"
|
href="/pricing"
|
||||||
class="px-6 py-2 bg-[#bb5b3e] text-white rounded-full text-sm font-bold hover:bg-[#a34a31] transition-colors"
|
class="px-6 py-2 bg-accent text-white rounded-full text-sm font-bold hover:bg-accent-content transition-colors"
|
||||||
>
|
>
|
||||||
Upgrade from £0.99/mo
|
Upgrade from £0.99/mo
|
||||||
</a>
|
</a>
|
||||||
@@ -17,15 +17,15 @@
|
|||||||
|
|
||||||
<!-- Card content (blurred for free users, fully visible for paid) -->
|
<!-- Card content (blurred for free users, fully visible for paid) -->
|
||||||
<div
|
<div
|
||||||
:class="['p-6 bg-white rounded-2xl border border-[#e5ded7] space-y-4', !isPaidTier && 'select-none pointer-events-none']"
|
:class="['p-6 bg-white rounded-2xl border border-zinc-300 space-y-4', !isPaidTier && 'select-none pointer-events-none']"
|
||||||
>
|
>
|
||||||
<p class="text-xs font-bold uppercase tracking-widest text-[#89726c]">Price Prediction</p>
|
<p class="text-xs font-bold uppercase tracking-widest text-zinc-500">Price Prediction</p>
|
||||||
|
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
<template v-if="loading">
|
<template v-if="loading">
|
||||||
<div class="animate-pulse space-y-2">
|
<div class="animate-pulse space-y-2">
|
||||||
<div class="h-8 bg-[#e5ded7] rounded w-1/2"></div>
|
<div class="h-8 bg-zinc-300 rounded w-1/2"></div>
|
||||||
<div class="h-4 bg-[#e5ded7] rounded w-3/4"></div>
|
<div class="h-4 bg-zinc-300 rounded w-3/4"></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -33,22 +33,22 @@
|
|||||||
<template v-else-if="prediction">
|
<template v-else-if="prediction">
|
||||||
<h3
|
<h3
|
||||||
class="text-2xl font-black"
|
class="text-2xl font-black"
|
||||||
:class="prediction.action === 'fill_now' ? 'text-[#8B4860]' : prediction.action === 'wait' ? 'text-[#4A7C7E]' : 'text-[#9B8B6B]'"
|
:class="prediction.action === 'fill_now' ? 'text-mauve' : prediction.action === 'wait' ? 'text-teal' : 'text-tan'"
|
||||||
>
|
>
|
||||||
{{ actionLabel }}
|
{{ actionLabel }}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="w-full h-2 bg-[#eeeae5] rounded-full overflow-hidden">
|
<div class="w-full h-2 bg-zinc-200 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
class="h-full rounded-full transition-all"
|
class="h-full rounded-full transition-all"
|
||||||
:class="prediction.action === 'fill_now' ? 'bg-[#8B4860]' : 'bg-[#4A7C7E]'"
|
:class="prediction.action === 'fill_now' ? 'bg-mauve' : 'bg-teal'"
|
||||||
:style="{ width: prediction.confidence_score + '%' }"
|
:style="{ width: prediction.confidence_score + '%' }"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-sm text-[#89726c] leading-relaxed">{{ prediction.reasoning }}</p>
|
<p class="text-sm text-zinc-500 leading-relaxed">{{ prediction.reasoning }}</p>
|
||||||
|
|
||||||
<div class="flex items-center gap-4 text-xs text-[#89726c] font-medium">
|
<div class="flex items-center gap-4 text-xs text-zinc-500 font-medium">
|
||||||
<span>Avg: {{ prediction.current_avg }}p</span>
|
<span>Avg: {{ prediction.current_avg }}p</span>
|
||||||
<span>Confidence: {{ prediction.confidence_label }}</span>
|
<span>Confidence: {{ prediction.confidence_label }}</span>
|
||||||
<span v-if="prediction.predicted_change_pence">
|
<span v-if="prediction.predicted_change_pence">
|
||||||
@@ -59,9 +59,9 @@
|
|||||||
|
|
||||||
<!-- Empty state (placeholder for gated view) -->
|
<!-- Empty state (placeholder for gated view) -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<h3 class="text-2xl font-black text-[#8B4860]">Fill up now</h3>
|
<h3 class="text-2xl font-black text-mauve">Fill up now</h3>
|
||||||
<div class="h-2 bg-[#eeeae5] rounded-full"><div class="h-full bg-[#8B4860] w-4/5 rounded-full"></div></div>
|
<div class="h-2 bg-zinc-200 rounded-full"><div class="h-full bg-mauve w-4/5 rounded-full"></div></div>
|
||||||
<p class="text-sm text-[#89726c]">Prices in your area are rising — best to fill up today.</p>
|
<p class="text-sm text-zinc-500">Prices in your area are rising — best to fill up today.</p>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative flex flex-col sm:flex-row gap-3 max-w-md w-full">
|
<div class="relative flex flex-col sm:flex-row gap-3 max-w-md w-full">
|
||||||
<div class="relative flex-1">
|
<div class="relative flex-1">
|
||||||
<span class="absolute left-4 top-1/2 -translate-y-1/2 text-[#89726c]">
|
<span class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500">
|
||||||
<iconify-icon icon="lucide:map-pin" style="font-size:1.25rem"></iconify-icon>
|
<iconify-icon icon="lucide:map-pin" style="font-size:1.25rem"></iconify-icon>
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
@@ -9,13 +9,13 @@
|
|||||||
@input="onInput"
|
@input="onInput"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter postcode, e.g. SW1A 1AA"
|
placeholder="Enter postcode, e.g. SW1A 1AA"
|
||||||
class="w-full h-14 pl-12 pr-4 bg-white border border-[#e5ded7] rounded-xl focus:outline-none focus:ring-2 focus:ring-[#bb5b3e] shadow-inner text-base"
|
class="w-full h-14 pl-12 pr-4 bg-white border border-zinc-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-accent shadow-inner text-base"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="emit('search', postcode)"
|
@click="emit('search', postcode)"
|
||||||
:disabled="!postcode.trim()"
|
:disabled="!postcode.trim()"
|
||||||
class="h-14 px-8 bg-[#bb5b3e] text-white rounded-xl font-bold text-base shadow-xl hover:bg-[#a34a31] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
class="h-14 px-8 bg-accent text-white rounded-xl font-bold text-base shadow-xl hover:bg-accent-content transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Find Prices
|
Find Prices
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-between p-4 bg-white rounded-xl border border-[#e5ded7] hover:border-[#bb5b3e] transition-colors">
|
<div class="flex items-center justify-between p-4 bg-white rounded-xl border border-zinc-300 hover:border-accent transition-colors">
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<div class="w-10 h-10 rounded-lg bg-[#bb5b3e]/10 flex items-center justify-center flex-shrink-0">
|
<div class="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center flex-shrink-0">
|
||||||
<iconify-icon
|
<iconify-icon
|
||||||
:icon="station.is_supermarket ? 'lucide:shopping-cart' : 'lucide:fuel'"
|
:icon="station.is_supermarket ? 'lucide:shopping-cart' : 'lucide:fuel'"
|
||||||
style="font-size:1.25rem"
|
style="font-size:1.25rem"
|
||||||
class="text-[#bb5b3e]"
|
class="text-accent"
|
||||||
></iconify-icon>
|
></iconify-icon>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="font-bold text-[#4a3f3b] truncate">{{ station.name }}</p>
|
<p class="font-bold text-zinc-800 truncate">{{ station.name }}</p>
|
||||||
<p class="text-xs text-[#89726c]">{{ station.distance_km.toFixed(1) }} km away · Updated {{ updatedAgo }}</p>
|
<p class="text-xs text-zinc-500">{{ station.distance_km.toFixed(1) }} km away · Updated {{ updatedAgo }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right flex-shrink-0 ml-4">
|
<div class="text-right flex-shrink-0 ml-4">
|
||||||
@@ -28,10 +28,10 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const priceColor = computed(() => {
|
const priceColor = computed(() => {
|
||||||
if (!props.lowestPrice) return 'text-[#4a3f3b]'
|
if (!props.lowestPrice) return 'text-zinc-800'
|
||||||
if (props.station.price_pence === props.lowestPrice) return 'text-[#22c55e]'
|
if (props.station.price_pence === props.lowestPrice) return 'text-status-good'
|
||||||
if (props.station.price_pence > props.lowestPrice + 500) return 'text-[#ef4444]'
|
if (props.station.price_pence > props.lowestPrice + 500) return 'text-status-bad'
|
||||||
return 'text-[#4a3f3b]'
|
return 'text-zinc-800'
|
||||||
})
|
})
|
||||||
|
|
||||||
const updatedAgo = computed(() => {
|
const updatedAgo = computed(() => {
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
:class="[
|
:class="[
|
||||||
'px-4 py-1.5 rounded-full text-sm font-bold transition-colors',
|
'px-4 py-1.5 rounded-full text-sm font-bold transition-colors',
|
||||||
currentSort === option.value
|
currentSort === option.value
|
||||||
? 'bg-[#bb5b3e] text-white'
|
? 'bg-accent text-white'
|
||||||
: 'bg-white border border-[#e5ded7] text-[#89726c] hover:border-[#bb5b3e]'
|
: 'bg-white border border-zinc-300 text-zinc-500 hover:border-accent'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Count -->
|
<!-- Count -->
|
||||||
<p class="text-sm text-[#89726c] font-medium">
|
<p class="text-sm text-zinc-500 font-medium">
|
||||||
{{ stations.length }} station{{ stations.length !== 1 ? 's' : '' }} found
|
{{ stations.length }} station{{ stations.length !== 1 ? 's' : '' }} found
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -40,5 +40,39 @@ export function useAuth() {
|
|||||||
fetched.value = false
|
fetched.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
return { user, loading, isAuthenticated, userTier, isPaidTier, fetchUser, clearUser }
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await api.post('/auth/logout')
|
||||||
|
} finally {
|
||||||
|
clearUser()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateProfile(data) {
|
||||||
|
const response = await api.put('/user/profile', data)
|
||||||
|
user.value = response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePassword(data) {
|
||||||
|
await api.put('/user/password', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAccount(password) {
|
||||||
|
await api.delete('/user', { data: { password } })
|
||||||
|
clearUser()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
isAuthenticated,
|
||||||
|
userTier,
|
||||||
|
isPaidTier,
|
||||||
|
fetchUser,
|
||||||
|
clearUser,
|
||||||
|
logout,
|
||||||
|
updateProfile,
|
||||||
|
updatePassword,
|
||||||
|
deleteAccount,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import DashboardLayout from '../views/dashboard/DashboardLayout.vue'
|
|||||||
import Overview from '../views/dashboard/Overview.vue'
|
import Overview from '../views/dashboard/Overview.vue'
|
||||||
import SavedStations from '../views/dashboard/SavedStations.vue'
|
import SavedStations from '../views/dashboard/SavedStations.vue'
|
||||||
import Preferences from '../views/dashboard/Preferences.vue'
|
import Preferences from '../views/dashboard/Preferences.vue'
|
||||||
|
import SettingsLayout from '../views/dashboard/settings/SettingsLayout.vue'
|
||||||
|
import Profile from '../views/dashboard/settings/Profile.vue'
|
||||||
|
import Security from '../views/dashboard/settings/Security.vue'
|
||||||
|
import Appearance from '../views/dashboard/settings/Appearance.vue'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: '/', component: Home, name: 'home' },
|
{ path: '/', component: Home, name: 'home' },
|
||||||
@@ -14,6 +18,16 @@ const routes = [
|
|||||||
{ path: '', component: Overview, name: 'dashboard' },
|
{ path: '', component: Overview, name: 'dashboard' },
|
||||||
{ path: 'saved-stations', component: SavedStations, name: 'dashboard.saved-stations' },
|
{ path: 'saved-stations', component: SavedStations, name: 'dashboard.saved-stations' },
|
||||||
{ path: 'preferences', component: Preferences, name: 'dashboard.preferences' },
|
{ path: 'preferences', component: Preferences, name: 'dashboard.preferences' },
|
||||||
|
{
|
||||||
|
path: 'settings',
|
||||||
|
component: SettingsLayout,
|
||||||
|
redirect: '/dashboard/settings/profile',
|
||||||
|
children: [
|
||||||
|
{ path: 'profile', component: Profile, name: 'dashboard.settings.profile' },
|
||||||
|
{ path: 'security', component: Security, name: 'dashboard.settings.security' },
|
||||||
|
{ path: 'appearance', component: Appearance, name: 'dashboard.settings.appearance' },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,149 +1,395 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-[#f5ede5]">
|
<div class="min-h-screen bg-zinc-100">
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<nav class="fixed top-0 w-full z-50 bg-[#faf6f3] border-b border-[#e5ded7] px-6 py-4 md:px-12">
|
<nav class="fixed top-0 w-full z-50 bg-zinc-50 border-b border-zinc-300 px-6 py-4 md:px-12">
|
||||||
<div class="max-w-7xl mx-auto flex items-center justify-between">
|
<div class="max-w-7xl mx-auto flex items-center justify-between">
|
||||||
<RouterLink to="/" class="flex items-center gap-3">
|
<RouterLink class="flex items-center gap-3" to="/">
|
||||||
<div class="w-10 h-10 rounded-lg bg-[#bb5b3e] flex items-center justify-center shadow-md">
|
<div class="w-10 h-10 md:w-12 md:h-12 rounded-lg bg-accent flex items-center justify-center shadow-md">
|
||||||
<iconify-icon icon="lucide:fuel" class="text-white text-xl"></iconify-icon>
|
<iconify-icon class="text-white text-xl md:text-2xl" icon="lucide:fuel"></iconify-icon>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-2xl font-black tracking-tighter text-[#bb5b3e]">FuelAlert</span>
|
<span class="text-2xl md:text-3xl font-black font-display tracking-tighter text-accent">FuelAlert</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
|
<div class="hidden md:flex items-center gap-10">
|
||||||
|
<a class="text-sm font-semibold text-zinc-500 hover:text-accent transition-colors" href="#how-it-works">How it Works</a>
|
||||||
|
<a class="text-sm font-semibold text-zinc-500 hover:text-accent transition-colors" href="#features">Features</a>
|
||||||
|
<a class="text-sm font-semibold text-zinc-500 hover:text-accent transition-colors" href="#pricing">Pricing</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<template v-if="isAuthenticated">
|
<template v-if="isAuthenticated">
|
||||||
<RouterLink to="/account" class="text-sm font-bold text-[#89726c] hover:text-[#4a3f3b]">Account</RouterLink>
|
<RouterLink class="text-sm font-bold text-zinc-500 hover:text-zinc-800" to="/dashboard">Dashboard</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<a href="/login" class="text-sm font-bold text-[#89726c] hover:text-[#4a3f3b]">Login</a>
|
<a class="text-sm font-bold text-zinc-500 hover:text-zinc-800" href="/login">Login</a>
|
||||||
<a href="/register" class="bg-[#bb5b3e] text-white px-6 py-2.5 rounded-full text-sm font-bold shadow-lg hover:bg-[#a34a31] transition-all">Get Started</a>
|
<a class="bg-accent text-white px-6 py-2.5 rounded-full text-sm font-bold shadow-lg hover:bg-primary-dark transition-all transform hover:scale-105 active:scale-95" href="/register">Get Started</a>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Hero -->
|
<!-- Hero -->
|
||||||
<section class="relative pt-36 pb-16 px-6">
|
<section class="relative pt-40 pb-24 px-6 hero-gradient overflow-hidden">
|
||||||
<div class="max-w-2xl mx-auto text-center space-y-6">
|
<div class="max-w-7xl mx-auto grid lg:grid-cols-2 gap-16 items-center">
|
||||||
<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">
|
<div class="space-y-8">
|
||||||
|
<div class="inline-flex items-center gap-2 px-3 py-1 bg-accent/10 text-accent rounded-full text-xs font-bold uppercase tracking-wider">
|
||||||
<iconify-icon icon="lucide:sparkles"></iconify-icon>
|
<iconify-icon icon="lucide:sparkles"></iconify-icon>
|
||||||
Save up to £250/year on fuel
|
Save up to £250/year on fuel
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-5xl md:text-6xl font-black text-[#4a3f3b] leading-tight tracking-tighter">
|
<h1 class="text-5xl md:text-7xl font-black font-display text-zinc-800 leading-[1.1] tracking-tighter">
|
||||||
Stop Overpaying <span class="text-[#bb5b3e]">for Fuel.</span>
|
Stop Overpaying <br><span class="text-accent">for Fuel.</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-lg text-[#89726c] max-w-lg mx-auto">Find the cheapest petrol near you and know the best time to fill up.</p>
|
<p class="text-xl text-zinc-500 max-w-lg leading-relaxed">
|
||||||
|
Join 50,000+ UK drivers using real-time insights to find the cheapest petrol and time their fill-ups perfectly.
|
||||||
<div class="flex justify-center">
|
|
||||||
<SearchBar @search="onSearch" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="stationError" class="text-sm text-red-500 font-medium">
|
|
||||||
{{ Object.values(stationError).flat().join(' ') }}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Results -->
|
<div class="flex flex-col sm:flex-row gap-3 max-w-md">
|
||||||
<section v-if="hasSearched" class="px-6 pb-24">
|
<div class="relative flex-1">
|
||||||
<div class="max-w-4xl mx-auto space-y-6">
|
<iconify-icon class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 text-xl" icon="lucide:map-pin"></iconify-icon>
|
||||||
<!-- Fuel type selector -->
|
<input
|
||||||
<div class="flex gap-2 flex-wrap">
|
class="w-full h-14 pl-12 pr-4 bg-white border border-zinc-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-accent shadow-inner text-lg"
|
||||||
<button
|
placeholder="Enter Postcode"
|
||||||
v-for="fuel in fuelOptions"
|
type="text"
|
||||||
:key="fuel.value"
|
|
||||||
@click="changeFuelType(fuel.value)"
|
|
||||||
:class="[
|
|
||||||
'px-4 py-1.5 rounded-full text-sm font-bold transition-colors',
|
|
||||||
currentFuelType === fuel.value
|
|
||||||
? 'bg-[#4a3f3b] text-white'
|
|
||||||
: 'bg-white border border-[#e5ded7] text-[#89726c] hover:border-[#4a3f3b]'
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
{{ fuel.label }}
|
</div>
|
||||||
|
<button class="h-14 px-8 bg-accent text-white rounded-xl font-bold text-lg shadow-xl hover:bg-primary-dark transition-all">
|
||||||
|
Find Prices
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid lg:grid-cols-3 gap-6">
|
<div class="flex items-center gap-4 pt-4">
|
||||||
<!-- Map + List (2/3 width) -->
|
<div class="flex -space-x-2">
|
||||||
<div class="lg:col-span-2 space-y-4">
|
<img alt="User" class="w-8 h-8 rounded-full border-2 border-white" src="https://api.dicebear.com/7.x/avataaars/svg?seed=1">
|
||||||
<LeafletMap :stations="stations" />
|
<img alt="User" class="w-8 h-8 rounded-full border-2 border-white" src="https://api.dicebear.com/7.x/avataaars/svg?seed=2">
|
||||||
|
<img alt="User" class="w-8 h-8 rounded-full border-2 border-white" src="https://api.dicebear.com/7.x/avataaars/svg?seed=3">
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-zinc-500 font-medium italic">"Saved me £12 on my first tank!"</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Visual mockup card -->
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute -inset-4 bg-accent/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-accent flex items-center justify-center">
|
||||||
|
<iconify-icon class="text-white" icon="lucide:fuel"></iconify-icon>
|
||||||
|
</div>
|
||||||
|
<span class="font-black text-accent">FuelAlert</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs font-bold text-zinc-500">SW1A 1AA</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-zinc-50 p-4 rounded-xl border border-zinc-300 shadow-sm">
|
||||||
|
<p class="text-[10px] font-bold uppercase tracking-widest text-zinc-500 mb-1">Recommendation</p>
|
||||||
|
<h3 class="text-2xl font-black font-display text-mauve">Fill up now</h3>
|
||||||
|
<div class="mt-2 h-1.5 w-full bg-zinc-200 rounded-full overflow-hidden">
|
||||||
|
<div class="h-full bg-mauve w-[80%]"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template v-if="stationsLoading">
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div v-for="i in 5" :key="i" class="h-16 bg-white rounded-xl animate-pulse border border-[#e5ded7]"></div>
|
<div class="flex justify-between items-center p-3 bg-white rounded-lg border border-zinc-200">
|
||||||
|
<span class="font-bold text-sm">Tesco Superstore</span>
|
||||||
|
<span class="font-black text-status-good">142.9p</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center p-3 bg-white rounded-lg border border-zinc-200">
|
||||||
|
<span class="font-bold text-sm">Shell V-Power</span>
|
||||||
|
<span class="font-black text-zinc-500">148.9p</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<StationList
|
|
||||||
:stations="stations"
|
|
||||||
:current-sort="currentSort"
|
|
||||||
@sort="changeSort"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Prediction (1/3 width) -->
|
|
||||||
<div>
|
|
||||||
<PredictionCard
|
|
||||||
:prediction="prediction"
|
|
||||||
:loading="predictionLoading"
|
|
||||||
:is-paid-tier="isPaidTier"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- How It Works -->
|
||||||
|
<section id="how-it-works" class="py-24 px-6 bg-zinc-50">
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<div class="text-center mb-16 space-y-4">
|
||||||
|
<h2 class="text-4xl md:text-5xl font-black font-display text-zinc-800">Smart Savings in 3 Steps</h2>
|
||||||
|
<p class="text-zinc-500 text-lg max-w-2xl mx-auto">Stop guessing when to fill up. Our engine analyzes 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-accent/10 text-accent 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 font-display">1. Search</h3>
|
||||||
|
<p class="text-zinc-500">Enter your postcode or location to find every forecourt within a 5–20 mile radius instantly.</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center space-y-4">
|
||||||
|
<div class="w-16 h-16 bg-accent/10 text-accent 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 font-display">2. Get Advice</h3>
|
||||||
|
<p class="text-zinc-500">Our AI compares local prices against national wholesale trends to give you a Fill Up / Wait recommendation.</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center space-y-4">
|
||||||
|
<div class="w-16 h-16 bg-accent/10 text-accent 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 font-display">3. Fill Up Smart</h3>
|
||||||
|
<p class="text-zinc-500">Navigate to the cheapest station and fill up with confidence knowing you've secured the best price.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Features -->
|
||||||
|
<section id="features" class="py-24 px-6">
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<div class="grid lg:grid-cols-2 gap-20 items-center">
|
||||||
|
<div class="order-2 lg:order-1">
|
||||||
|
<div class="grid grid-cols-2 gap-6">
|
||||||
|
<div class="p-6 bg-zinc-50 border border-zinc-300 rounded-2xl space-y-3">
|
||||||
|
<iconify-icon class="text-3xl text-accent" icon="lucide:zap"></iconify-icon>
|
||||||
|
<h4 class="font-bold text-lg font-display">Real-Time Prices</h4>
|
||||||
|
<p class="text-sm text-zinc-500">Verified daily prices from thousands of UK forecourts.</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 bg-zinc-50 border border-zinc-300 rounded-2xl space-y-3">
|
||||||
|
<iconify-icon class="text-3xl text-accent" icon="lucide:calendar"></iconify-icon>
|
||||||
|
<h4 class="font-bold text-lg font-display">Timing Predictions</h4>
|
||||||
|
<p class="text-sm text-zinc-500">Proprietary 14-day forecasts for petrol and diesel trends.</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 bg-zinc-50 border border-zinc-300 rounded-2xl space-y-3">
|
||||||
|
<iconify-icon class="text-3xl text-accent" icon="lucide:shopping-bag"></iconify-icon>
|
||||||
|
<h4 class="font-bold text-lg font-display">Supermarket Anchors</h4>
|
||||||
|
<p class="text-sm text-zinc-500">Track local supermarkets to find the absolute lowest base price.</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 bg-zinc-50 border border-zinc-300 rounded-2xl space-y-3">
|
||||||
|
<iconify-icon class="text-3xl text-accent" icon="lucide:bell-ring"></iconify-icon>
|
||||||
|
<h4 class="font-bold text-lg font-display">Smart Price Alerts</h4>
|
||||||
|
<p class="text-sm text-zinc-500">Get notified when local prices drop below your set target.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="order-1 lg:order-2 space-y-8">
|
||||||
|
<h2 class="text-4xl md:text-5xl font-black font-display text-zinc-800">The ultimate fuel companion.</h2>
|
||||||
|
<p class="text-lg text-zinc-500">Whether you're a daily commuter, a delivery professional, or just planning a weekend road trip, FuelAlert gives you the edge at the pump.</p>
|
||||||
|
<ul class="space-y-4">
|
||||||
|
<li class="flex items-center gap-3 font-bold">
|
||||||
|
<iconify-icon class="text-accent" icon="lucide:check-circle-2"></iconify-icon>
|
||||||
|
Coverage for 98% of UK Forecourts
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-3 font-bold">
|
||||||
|
<iconify-icon class="text-accent" icon="lucide:check-circle-2"></iconify-icon>
|
||||||
|
Hyper-local Map Visualization
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-3 font-bold">
|
||||||
|
<iconify-icon class="text-accent" icon="lucide:check-circle-2"></iconify-icon>
|
||||||
|
Historic Price Benchmarking
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<button class="inline-flex items-center gap-2 text-accent font-black text-lg group">
|
||||||
|
Explore all features
|
||||||
|
<iconify-icon class="group-hover:translate-x-1 transition-transform" icon="lucide:arrow-right"></iconify-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Pricing -->
|
||||||
|
<section id="pricing" class="py-24 px-6 bg-zinc-50">
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<div class="text-center mb-16">
|
||||||
|
<h2 class="text-4xl md:text-5xl font-black font-display text-zinc-800 mb-4">Pricing for every driver</h2>
|
||||||
|
<p class="text-zinc-500 text-lg">Save hundreds for less than the cost of a coffee.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<!-- Free -->
|
||||||
|
<div class="bg-white border border-zinc-300 p-8 rounded-3xl flex flex-col h-full">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h4 class="text-xl font-bold font-display mb-2">Free</h4>
|
||||||
|
<div class="flex items-baseline gap-1">
|
||||||
|
<span class="text-4xl font-black">£0</span>
|
||||||
|
<span class="text-zinc-500 text-sm">/mo</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-4 mb-8 flex-1">
|
||||||
|
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Basic Search</li>
|
||||||
|
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Daily Updates</li>
|
||||||
|
<li class="text-sm flex gap-2 text-zinc-500"><iconify-icon class="text-zinc-300" icon="lucide:x"></iconify-icon> No Alerts</li>
|
||||||
|
</ul>
|
||||||
|
<a class="w-full py-3 px-4 border border-zinc-300 rounded-xl text-center font-bold hover:bg-zinc-100 transition-colors" href="/register">Get Started</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Basic -->
|
||||||
|
<div class="bg-white border border-zinc-300 p-8 rounded-3xl flex flex-col h-full">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h4 class="text-xl font-bold font-display mb-2">Basic</h4>
|
||||||
|
<div class="flex items-baseline gap-1">
|
||||||
|
<span class="text-4xl font-black">£0.99</span>
|
||||||
|
<span class="text-zinc-500 text-sm">/mo</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-4 mb-8 flex-1">
|
||||||
|
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Ad-free Experience</li>
|
||||||
|
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> 14-day Trend Data</li>
|
||||||
|
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> 3 Daily Price Alerts</li>
|
||||||
|
</ul>
|
||||||
|
<a class="w-full py-3 px-4 border border-zinc-300 rounded-xl text-center font-bold hover:bg-zinc-100 transition-colors" href="/register">Select Basic</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Plus -->
|
||||||
|
<div class="bg-white border-2 border-accent p-8 rounded-3xl flex flex-col h-full relative">
|
||||||
|
<div class="absolute -top-4 left-1/2 -translate-x-1/2 bg-accent 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 font-display mb-2">Plus</h4>
|
||||||
|
<div class="flex items-baseline gap-1">
|
||||||
|
<span class="text-4xl font-black text-accent">£2.49</span>
|
||||||
|
<span class="text-zinc-500 text-sm">/mo</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-4 mb-8 flex-1">
|
||||||
|
<li class="text-sm flex gap-2 font-bold"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Supermarket Anchor</li>
|
||||||
|
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Priority Price Alerts</li>
|
||||||
|
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Multi-location tracking</li>
|
||||||
|
</ul>
|
||||||
|
<a class="w-full py-3 px-4 bg-accent text-white rounded-xl text-center font-bold shadow-lg hover:bg-primary-dark transition-all" href="/register">Join Plus</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pro -->
|
||||||
|
<div class="bg-zinc-800 border border-zinc-800 p-8 rounded-3xl flex flex-col h-full text-white">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h4 class="text-xl font-bold font-display mb-2">Pro</h4>
|
||||||
|
<div class="flex items-baseline gap-1">
|
||||||
|
<span class="text-4xl font-black">£3.99</span>
|
||||||
|
<span class="text-zinc-400 text-sm">/mo</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul class="space-y-4 mb-8 flex-1">
|
||||||
|
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:sparkles"></iconify-icon> AI Price Predictions</li>
|
||||||
|
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Multi-Vehicle Fleet</li>
|
||||||
|
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Exportable Price History</li>
|
||||||
|
</ul>
|
||||||
|
<a class="w-full py-3 px-4 bg-white text-zinc-800 rounded-xl text-center font-bold hover:bg-zinc-100 transition-colors" href="/register">Go Pro</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Testimonials -->
|
||||||
|
<section class="py-24 px-6">
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<div class="flex flex-col md:flex-row gap-12 items-center">
|
||||||
|
<div class="md:w-1/3">
|
||||||
|
<h2 class="text-4xl font-black font-display text-zinc-800 mb-4">Loved by commuters.</h2>
|
||||||
|
<div class="flex items-center gap-1 text-status-warn mb-4 text-xl">
|
||||||
|
<iconify-icon icon="lucide:star"></iconify-icon>
|
||||||
|
<iconify-icon icon="lucide:star"></iconify-icon>
|
||||||
|
<iconify-icon icon="lucide:star"></iconify-icon>
|
||||||
|
<iconify-icon icon="lucide:star"></iconify-icon>
|
||||||
|
<iconify-icon icon="lucide:star"></iconify-icon>
|
||||||
|
</div>
|
||||||
|
<p class="text-zinc-500">Join thousands of UK drivers saving every single month.</p>
|
||||||
|
</div>
|
||||||
|
<div class="md:w-2/3 grid sm:grid-cols-2 gap-6">
|
||||||
|
<div class="p-6 bg-zinc-50 border border-zinc-300 rounded-2xl shadow-sm italic text-zinc-800">
|
||||||
|
"I used to just go to the station on my way home. Now I check FuelAlert and realise there's a station 2 miles away that's 5p cheaper! Over a month, it adds up to a free tank per year."
|
||||||
|
<div class="mt-4 flex items-center gap-3 not-italic">
|
||||||
|
<img alt="James R." class="w-10 h-10 rounded-full" src="https://api.dicebear.com/7.x/avataaars/svg?seed=John">
|
||||||
|
<div>
|
||||||
|
<p class="font-bold text-sm">James R.</p>
|
||||||
|
<p class="text-[10px] text-zinc-500 uppercase font-bold tracking-widest">Daily Commuter</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 bg-zinc-50 border border-zinc-300 rounded-2xl shadow-sm italic text-zinc-800">
|
||||||
|
"The predictions are eerily accurate. I was going to fill up Friday, but FuelAlert said 'Hold on' for Monday. Sure enough, prices dropped at my local Tesco by 3p. Brilliant."
|
||||||
|
<div class="mt-4 flex items-center gap-3 not-italic">
|
||||||
|
<img alt="Sarah M." class="w-10 h-10 rounded-full" src="https://api.dicebear.com/7.x/avataaars/svg?seed=Sarah">
|
||||||
|
<div>
|
||||||
|
<p class="font-bold text-sm">Sarah M.</p>
|
||||||
|
<p class="text-[10px] text-zinc-500 uppercase font-bold tracking-widest">Delivery Driver</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
<section class="py-24 px-6 bg-accent text-white text-center">
|
||||||
|
<div class="max-w-3xl mx-auto space-y-8">
|
||||||
|
<h2 class="text-4xl md:text-5xl font-black font-display leading-tight">Ready to outsmart the pumps?</h2>
|
||||||
|
<p class="text-xl text-white/80">Sign up for free today and never pay over the odds for fuel again.</p>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<a class="bg-white text-accent px-10 py-4 rounded-xl text-lg font-black shadow-2xl hover:bg-zinc-100 transition-all" href="/register">Create Free Account</a>
|
||||||
|
<a 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" href="#">Watch Demo Video</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="bg-zinc-50 border-t border-zinc-300 pt-16 pb-8 px-6">
|
||||||
|
<div class="max-w-7xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-12 mb-12">
|
||||||
|
<div class="col-span-2 md:col-span-1 space-y-4">
|
||||||
|
<RouterLink class="flex items-center gap-2" to="/">
|
||||||
|
<div class="w-8 h-8 rounded bg-accent flex items-center justify-center">
|
||||||
|
<iconify-icon class="text-white" icon="lucide:fuel"></iconify-icon>
|
||||||
|
</div>
|
||||||
|
<span class="text-xl font-black font-display tracking-tighter text-accent">FuelAlert</span>
|
||||||
|
</RouterLink>
|
||||||
|
<p class="text-sm text-zinc-500 leading-relaxed">
|
||||||
|
Helping UK drivers save money at the pump since 2021. Real-time data, smarter choices.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<iconify-icon class="text-2xl text-zinc-500 hover:text-accent cursor-pointer transition-colors" icon="mdi:twitter"></iconify-icon>
|
||||||
|
<iconify-icon class="text-2xl text-zinc-500 hover:text-accent cursor-pointer transition-colors" icon="mdi:facebook"></iconify-icon>
|
||||||
|
<iconify-icon class="text-2xl text-zinc-500 hover:text-accent cursor-pointer transition-colors" icon="mdi:instagram"></iconify-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h5 class="font-black uppercase text-xs text-zinc-800 tracking-widest">Product</h5>
|
||||||
|
<ul class="space-y-2 text-sm text-zinc-500">
|
||||||
|
<li><a class="hover:text-accent transition-colors" href="#pricing">Pricing</a></li>
|
||||||
|
<li><a class="hover:text-accent transition-colors" href="#features">Features</a></li>
|
||||||
|
<li><a class="hover:text-accent transition-colors" href="#">FuelAlert Pro</a></li>
|
||||||
|
<li><a class="hover:text-accent transition-colors" href="#">Enterprise API</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h5 class="font-black uppercase text-xs text-zinc-800 tracking-widest">Resources</h5>
|
||||||
|
<ul class="space-y-2 text-sm text-zinc-500">
|
||||||
|
<li><a class="hover:text-accent transition-colors" href="#">Market Insights</a></li>
|
||||||
|
<li><a class="hover:text-accent transition-colors" href="#">How We Track</a></li>
|
||||||
|
<li><a class="hover:text-accent transition-colors" href="#">Help Center</a></li>
|
||||||
|
<li><a class="hover:text-accent transition-colors" href="#">Driver Safety</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h5 class="font-black uppercase text-xs text-zinc-800 tracking-widest">Legal</h5>
|
||||||
|
<ul class="space-y-2 text-sm text-zinc-500">
|
||||||
|
<li><a class="hover:text-accent transition-colors" href="#">Privacy Policy</a></li>
|
||||||
|
<li><a class="hover:text-accent transition-colors" href="#">Terms of Service</a></li>
|
||||||
|
<li><a class="hover:text-accent transition-colors" href="#">Cookie Settings</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-7xl mx-auto pt-8 border-t border-zinc-300 flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] font-bold uppercase tracking-widest text-zinc-500">
|
||||||
|
<p>© 2024 FuelAlert UK Limited. All Rights Reserved.</p>
|
||||||
|
<p>Data provided by official UK retail price transparency schemes.</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import { useAuth } from '../composables/useAuth.js'
|
import { useAuth } from '../composables/useAuth.js'
|
||||||
import { useStations } from '../composables/useStations.js'
|
|
||||||
import { usePrediction } from '../composables/usePrediction.js'
|
|
||||||
import SearchBar from '../components/SearchBar.vue'
|
|
||||||
import LeafletMap from '../components/LeafletMap.vue'
|
|
||||||
import StationList from '../components/StationList.vue'
|
|
||||||
import PredictionCard from '../components/PredictionCard.vue'
|
|
||||||
|
|
||||||
const { isAuthenticated, isPaidTier } = useAuth()
|
const { isAuthenticated } = useAuth()
|
||||||
const { stations, loading: stationsLoading, error: stationError, search } = useStations()
|
|
||||||
const { prediction, loading: predictionLoading, fetch: fetchPrediction } = usePrediction()
|
|
||||||
|
|
||||||
const hasSearched = ref(false)
|
|
||||||
const currentSort = ref('price')
|
|
||||||
const currentFuelType = ref('petrol')
|
|
||||||
const lastPostcode = ref('')
|
|
||||||
|
|
||||||
const fuelOptions = [
|
|
||||||
{ label: 'Petrol (E10)', value: 'petrol' },
|
|
||||||
{ label: 'Diesel', value: 'diesel' },
|
|
||||||
{ label: 'Premium Unleaded', value: 'e5' },
|
|
||||||
{ label: 'Premium Diesel', value: 'b7_premium' },
|
|
||||||
]
|
|
||||||
|
|
||||||
async function onSearch(postcode) {
|
|
||||||
lastPostcode.value = postcode
|
|
||||||
hasSearched.value = true
|
|
||||||
await Promise.all([
|
|
||||||
search({ postcode, fuelType: currentFuelType.value, sort: currentSort.value }),
|
|
||||||
fetchPrediction(),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
async function changeSort(sort) {
|
|
||||||
currentSort.value = sort
|
|
||||||
if (lastPostcode.value) {
|
|
||||||
await search({ postcode: lastPostcode.value, fuelType: currentFuelType.value, sort })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function changeFuelType(fuelType) {
|
|
||||||
currentFuelType.value = fuelType
|
|
||||||
if (lastPostcode.value) {
|
|
||||||
await search({ postcode: lastPostcode.value, fuelType, sort: currentSort.value })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,19 +1,65 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-[#f5ede5] flex flex-col">
|
<div class="min-h-screen bg-zinc-100 flex flex-col">
|
||||||
<!-- Top nav -->
|
<!-- Top nav -->
|
||||||
<nav class="fixed top-0 w-full z-50 bg-[#faf6f3] border-b border-[#e5ded7] px-6 py-4">
|
<nav class="fixed top-0 w-full z-50 bg-zinc-50 border-b border-zinc-300 px-6 py-4">
|
||||||
<div class="max-w-7xl mx-auto flex items-center justify-between">
|
<div class="max-w-7xl mx-auto flex items-center justify-between">
|
||||||
<RouterLink to="/" class="flex items-center gap-3">
|
<RouterLink to="/" class="flex items-center gap-3">
|
||||||
<div class="w-10 h-10 rounded-lg bg-[#bb5b3e] flex items-center justify-center shadow-md">
|
<div class="w-10 h-10 rounded-lg bg-accent flex items-center justify-center shadow-md">
|
||||||
<iconify-icon icon="lucide:fuel" class="text-white text-xl"></iconify-icon>
|
<iconify-icon icon="lucide:fuel" class="text-white text-xl"></iconify-icon>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-2xl font-black tracking-tighter text-[#bb5b3e]">FuelAlert</span>
|
<span class="text-2xl font-black tracking-tighter text-accent">FuelAlert</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<RouterLink to="/" class="text-sm font-bold text-[#89726c] hover:text-[#4a3f3b]">
|
<RouterLink class="text-sm font-bold text-zinc-500 hover:text-zinc-800" to="/">
|
||||||
← Find fuel
|
← Find fuel
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<span class="text-sm text-[#89726c]">{{ user?.email }}</span>
|
|
||||||
|
<!-- User dropdown -->
|
||||||
|
<div ref="dropdownRef" class="relative">
|
||||||
|
<button
|
||||||
|
@click="dropdownOpen = !dropdownOpen"
|
||||||
|
class="w-9 h-9 rounded-full bg-accent flex items-center justify-center text-white text-sm font-black hover:bg-accent-content transition-colors"
|
||||||
|
:aria-expanded="dropdownOpen ? 'true' : 'false'"
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
{{ userInitials }}
|
||||||
|
</button>
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition ease-out duration-100"
|
||||||
|
enter-from-class="transform opacity-0 scale-95"
|
||||||
|
enter-to-class="transform opacity-100 scale-100"
|
||||||
|
leave-active-class="transition ease-in duration-75"
|
||||||
|
leave-from-class="transform opacity-100 scale-100"
|
||||||
|
leave-to-class="transform opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-show="dropdownOpen"
|
||||||
|
class="absolute right-0 top-full mt-2 w-64 bg-white border border-zinc-300 rounded-2xl shadow-lg overflow-hidden z-50"
|
||||||
|
>
|
||||||
|
<div class="px-4 py-3 border-b border-zinc-300">
|
||||||
|
<p class="text-sm font-black text-zinc-800">{{ user?.name }}</p>
|
||||||
|
<p class="text-xs text-zinc-500 truncate">{{ user?.email }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="py-1">
|
||||||
|
<RouterLink
|
||||||
|
to="/dashboard/settings"
|
||||||
|
@click="dropdownOpen = false"
|
||||||
|
class="flex items-center gap-3 px-4 py-2.5 text-sm font-bold text-zinc-500 hover:bg-zinc-50 hover:text-zinc-800 transition-colors"
|
||||||
|
>
|
||||||
|
<iconify-icon icon="lucide:settings"></iconify-icon>
|
||||||
|
Settings
|
||||||
|
</RouterLink>
|
||||||
|
<button
|
||||||
|
@click="handleLogout"
|
||||||
|
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm font-bold text-zinc-500 hover:bg-zinc-50 hover:text-zinc-800 transition-colors"
|
||||||
|
>
|
||||||
|
<iconify-icon icon="lucide:log-out"></iconify-icon>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -27,9 +73,9 @@
|
|||||||
:key="item.to"
|
:key="item.to"
|
||||||
:to="item.to"
|
:to="item.to"
|
||||||
class="flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-bold transition-colors"
|
class="flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-bold transition-colors"
|
||||||
:class="$route.path === item.to
|
:class="isActive(item.to)
|
||||||
? 'bg-[#bb5b3e] text-white'
|
? 'bg-accent text-white'
|
||||||
: 'text-[#89726c] hover:bg-white hover:text-[#4a3f3b]'"
|
: 'text-zinc-500 hover:bg-white hover:text-zinc-800'"
|
||||||
>
|
>
|
||||||
<iconify-icon :icon="item.icon"></iconify-icon>
|
<iconify-icon :icon="item.icon"></iconify-icon>
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
@@ -46,15 +92,60 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { RouterLink, RouterView, useRoute } from 'vue-router'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuth } from '../../composables/useAuth.js'
|
import { useAuth } from '../../composables/useAuth.js'
|
||||||
|
|
||||||
const { user } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
const $route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const dropdownOpen = ref(false)
|
||||||
|
const dropdownRef = ref(null)
|
||||||
|
|
||||||
|
function handleClickOutside(event) {
|
||||||
|
if (dropdownRef.value && !dropdownRef.value.contains(event.target)) {
|
||||||
|
dropdownOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', handleClickOutside)
|
||||||
|
})
|
||||||
|
|
||||||
|
const userInitials = computed(() => {
|
||||||
|
if (!user.value?.name) {
|
||||||
|
return '?'
|
||||||
|
}
|
||||||
|
return user.value.name
|
||||||
|
.split(' ')
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((w) => w[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
dropdownOpen.value = false
|
||||||
|
await logout()
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive(to) {
|
||||||
|
if (to === '/dashboard') {
|
||||||
|
return route.path === '/dashboard'
|
||||||
|
}
|
||||||
|
return route.path.startsWith(to)
|
||||||
|
}
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/dashboard', label: 'Overview', icon: 'lucide:layout-dashboard' },
|
{ to: '/dashboard', label: 'Overview', icon: 'lucide:layout-dashboard' },
|
||||||
{ to: '/dashboard/saved-stations', label: 'Saved Stations', icon: 'lucide:bookmark' },
|
{ to: '/dashboard/saved-stations', label: 'Saved Stations', icon: 'lucide:bookmark' },
|
||||||
{ to: '/dashboard/preferences', label: 'Preferences', icon: 'lucide:settings' },
|
{ to: '/dashboard/preferences', label: 'Preferences', icon: 'lucide:settings' },
|
||||||
|
{ to: '/dashboard/settings', label: 'Account', icon: 'lucide:user' },
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-black text-[#4a3f3b]">Welcome back{{ user ? ', ' + user.name : '' }}</h1>
|
<h1 class="text-2xl font-black text-zinc-800">Welcome back{{ user ? ', ' + user.name : '' }}</h1>
|
||||||
<p class="text-[#89726c] mt-1">Your FuelAlert dashboard.</p>
|
<p class="text-zinc-500 mt-1">Your FuelAlert dashboard.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid sm:grid-cols-3 gap-4">
|
<div class="grid sm:grid-cols-3 gap-4">
|
||||||
@@ -10,18 +10,18 @@
|
|||||||
v-for="item in quickLinks"
|
v-for="item in quickLinks"
|
||||||
:key="item.to"
|
:key="item.to"
|
||||||
:to="item.to"
|
:to="item.to"
|
||||||
class="p-6 bg-white rounded-2xl border border-[#e5ded7] hover:border-[#bb5b3e] transition-colors space-y-3"
|
class="p-6 bg-white rounded-2xl border border-zinc-300 hover:border-accent transition-colors space-y-3"
|
||||||
>
|
>
|
||||||
<iconify-icon :icon="item.icon" class="text-[#bb5b3e] text-2xl"></iconify-icon>
|
<iconify-icon :icon="item.icon" class="text-accent text-2xl"></iconify-icon>
|
||||||
<p class="font-bold text-[#4a3f3b]">{{ item.label }}</p>
|
<p class="font-bold text-zinc-800">{{ item.label }}</p>
|
||||||
<p class="text-sm text-[#89726c]">{{ item.description }}</p>
|
<p class="text-sm text-zinc-500">{{ item.description }}</p>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-6 bg-white rounded-2xl border border-[#e5ded7] space-y-2">
|
<div class="p-6 bg-white rounded-2xl border border-zinc-300 space-y-2">
|
||||||
<p class="text-sm font-bold uppercase tracking-widest text-[#89726c]">Your plan</p>
|
<p class="text-sm font-bold uppercase tracking-widest text-zinc-500">Your plan</p>
|
||||||
<p class="text-xl font-black text-[#4a3f3b] capitalize">{{ userTier }}</p>
|
<p class="text-xl font-black text-zinc-800 capitalize">{{ userTier }}</p>
|
||||||
<a v-if="userTier === 'free'" href="/pricing" class="inline-block text-sm font-bold text-[#bb5b3e] hover:underline">
|
<a v-if="userTier === 'free'" class="inline-block text-sm font-bold text-accent hover:underline" href="/pricing">
|
||||||
Upgrade for alerts + predictions →
|
Upgrade for alerts + predictions →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6 max-w-lg">
|
<div class="space-y-6 max-w-lg">
|
||||||
<h1 class="text-2xl font-black text-[#4a3f3b]">Preferences</h1>
|
<h1 class="text-2xl font-black text-zinc-800">Preferences</h1>
|
||||||
|
|
||||||
<form @submit.prevent="save" class="space-y-5 p-6 bg-white rounded-2xl border border-[#e5ded7]">
|
<form class="space-y-5 p-6 bg-white rounded-2xl border border-zinc-300" @submit.prevent="save">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="text-sm font-bold text-[#4a3f3b]">Default fuel type</label>
|
<label class="text-sm font-bold text-zinc-800">Default fuel type</label>
|
||||||
<select
|
<select
|
||||||
v-model="form.preferred_fuel_type"
|
v-model="form.preferred_fuel_type"
|
||||||
class="w-full h-12 px-4 bg-[#faf6f3] border border-[#e5ded7] rounded-xl font-medium text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e]"
|
class="w-full h-12 px-4 bg-zinc-50 border border-zinc-300 rounded-xl font-medium text-zinc-800 focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
>
|
>
|
||||||
<option value="petrol">Petrol (E10)</option>
|
<option value="petrol">Petrol (E10)</option>
|
||||||
<option value="diesel">Diesel (B7)</option>
|
<option value="diesel">Diesel (B7)</option>
|
||||||
@@ -19,13 +19,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="text-sm font-bold text-[#4a3f3b]">Home postcode</label>
|
<label class="text-sm font-bold text-zinc-800">Home postcode</label>
|
||||||
<input
|
<input
|
||||||
v-model="form.postcode"
|
v-model="form.postcode"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="e.g. SW1A 1AA"
|
placeholder="e.g. SW1A 1AA"
|
||||||
maxlength="8"
|
maxlength="8"
|
||||||
class="w-full h-12 px-4 bg-[#faf6f3] border border-[#e5ded7] rounded-xl font-medium text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e]"
|
class="w-full h-12 px-4 bg-zinc-50 border border-zinc-300 rounded-xl font-medium text-zinc-800 focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
class="px-8 py-3 bg-[#bb5b3e] text-white rounded-xl font-bold hover:bg-[#a34a31] transition-all disabled:opacity-50"
|
class="px-8 py-3 bg-accent text-white rounded-xl font-bold hover:bg-accent-content transition-all disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{{ saving ? 'Saving…' : 'Save preferences' }}
|
{{ saving ? 'Saving…' : 'Save preferences' }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<h1 class="text-2xl font-black text-[#4a3f3b]">Saved Stations</h1>
|
<h1 class="text-2xl font-black text-zinc-800">Saved Stations</h1>
|
||||||
|
|
||||||
<div v-if="loading" class="space-y-2">
|
<div v-if="loading" class="space-y-2">
|
||||||
<div v-for="i in 3" :key="i" class="h-16 bg-white rounded-xl animate-pulse border border-[#e5ded7]"></div>
|
<div v-for="i in 3" :key="i" class="h-16 bg-white rounded-xl animate-pulse border border-zinc-300"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="savedStations.length === 0" class="p-8 bg-white rounded-2xl border border-[#e5ded7] text-center text-[#89726c]">
|
<div v-else-if="savedStations.length === 0" class="p-8 bg-white rounded-2xl border border-zinc-300 text-center text-zinc-500">
|
||||||
<iconify-icon icon="lucide:bookmark" class="text-3xl mb-2"></iconify-icon>
|
<iconify-icon icon="lucide:bookmark" class="text-3xl mb-2"></iconify-icon>
|
||||||
<p class="font-medium">No saved stations yet.</p>
|
<p class="font-medium">No saved stations yet.</p>
|
||||||
<p class="text-sm mt-1">Search for fuel and bookmark stations to see them here.</p>
|
<p class="text-sm mt-1">Search for fuel and bookmark stations to see them here.</p>
|
||||||
@@ -16,11 +16,11 @@
|
|||||||
<div
|
<div
|
||||||
v-for="station in savedStations"
|
v-for="station in savedStations"
|
||||||
:key="station.station_id"
|
:key="station.station_id"
|
||||||
class="flex items-center justify-between p-4 bg-white rounded-xl border border-[#e5ded7]"
|
class="flex items-center justify-between p-4 bg-white rounded-xl border border-zinc-300"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-bold text-[#4a3f3b]">{{ station.name }}</p>
|
<p class="font-bold text-zinc-800">{{ station.name }}</p>
|
||||||
<p class="text-sm text-[#89726c]">{{ station.postcode }}</p>
|
<p class="text-sm text-zinc-500">{{ station.postcode }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="remove(station.station_id)"
|
@click="remove(station.station_id)"
|
||||||
|
|||||||
61
resources/js/views/dashboard/settings/Appearance.vue
Normal file
61
resources/js/views/dashboard/settings/Appearance.vue
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6 max-w-lg">
|
||||||
|
<div class="p-6 bg-white rounded-2xl border border-zinc-300 space-y-5">
|
||||||
|
<h2 class="text-lg font-black text-zinc-800">Appearance</h2>
|
||||||
|
<p class="text-sm text-zinc-500">Choose how FuelAlert looks to you.</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-3">
|
||||||
|
<button
|
||||||
|
v-for="option in themeOptions"
|
||||||
|
:key="option.value"
|
||||||
|
@click="setTheme(option.value)"
|
||||||
|
class="flex flex-col items-center gap-3 p-4 rounded-2xl border-2 transition-all"
|
||||||
|
:class="selectedTheme === option.value
|
||||||
|
? 'border-accent bg-zinc-50'
|
||||||
|
: 'border-zinc-300 hover:border-zinc-500'"
|
||||||
|
>
|
||||||
|
<iconify-icon :icon="option.icon" class="text-2xl text-accent"></iconify-icon>
|
||||||
|
<span class="text-sm font-bold text-zinc-800">{{ option.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-zinc-500">
|
||||||
|
Current: <span class="font-bold capitalize">{{ selectedTheme }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
|
const selectedTheme = ref('system')
|
||||||
|
|
||||||
|
const themeOptions = [
|
||||||
|
{ value: 'light', label: 'Light', icon: 'lucide:sun' },
|
||||||
|
{ value: 'dark', label: 'Dark', icon: 'lucide:moon' },
|
||||||
|
{ value: 'system', label: 'System', icon: 'lucide:monitor' },
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
selectedTheme.value = localStorage.getItem('appearance') ?? 'system'
|
||||||
|
})
|
||||||
|
|
||||||
|
function setTheme(value) {
|
||||||
|
selectedTheme.value = value
|
||||||
|
localStorage.setItem('appearance', value)
|
||||||
|
applyTheme(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(value) {
|
||||||
|
const html = document.documentElement
|
||||||
|
html.classList.remove('light', 'dark')
|
||||||
|
if (value === 'system') {
|
||||||
|
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||||
|
html.classList.add('dark')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html.classList.add(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
163
resources/js/views/dashboard/settings/Profile.vue
Normal file
163
resources/js/views/dashboard/settings/Profile.vue
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6 max-w-lg">
|
||||||
|
<!-- Profile form -->
|
||||||
|
<form class="p-6 bg-white rounded-2xl border border-zinc-300 space-y-5" @submit.prevent="saveProfile">
|
||||||
|
<h2 class="text-lg font-black text-zinc-800">Profile information</h2>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-bold text-zinc-800">Full name</label>
|
||||||
|
<input
|
||||||
|
v-model="profileForm.name"
|
||||||
|
type="text"
|
||||||
|
:class="profileErrors.name ? 'border-red-400' : 'border-zinc-300'"
|
||||||
|
class="w-full h-12 px-4 bg-zinc-50 border rounded-xl font-medium text-zinc-800 focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
|
/>
|
||||||
|
<p v-if="profileErrors.name" class="text-xs text-red-600">{{ profileErrors.name[0] }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-bold text-zinc-800">Email address</label>
|
||||||
|
<input
|
||||||
|
v-model="profileForm.email"
|
||||||
|
type="email"
|
||||||
|
:class="profileErrors.email ? 'border-red-400' : 'border-zinc-300'"
|
||||||
|
class="w-full h-12 px-4 bg-zinc-50 border rounded-xl font-medium text-zinc-800 focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
|
/>
|
||||||
|
<p v-if="profileErrors.email" class="text-xs text-red-600">{{ profileErrors.email[0] }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="profileNetworkError" class="text-sm text-red-600">{{ profileNetworkError }}</p>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="profileSaving"
|
||||||
|
class="px-8 py-3 bg-accent text-white rounded-xl font-bold hover:bg-accent-content transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ profileSaving ? 'Saving…' : 'Save changes' }}
|
||||||
|
</button>
|
||||||
|
<p v-if="profileSaved" class="text-sm font-bold text-green-600">Saved!</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Delete account -->
|
||||||
|
<div class="p-6 bg-white rounded-2xl border border-zinc-300 space-y-4">
|
||||||
|
<h2 class="text-lg font-black text-zinc-800">Delete account</h2>
|
||||||
|
<p class="text-sm text-zinc-500">Permanently delete your account and all associated data. This cannot be undone.</p>
|
||||||
|
<button
|
||||||
|
@click="deleteModalOpen = true"
|
||||||
|
class="px-6 py-2.5 bg-red-600 text-white rounded-xl text-sm font-bold hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Delete my account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete account modal -->
|
||||||
|
<div
|
||||||
|
v-if="deleteModalOpen"
|
||||||
|
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
@click.self="closeDeleteModal"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-2xl p-6 w-full max-w-md mx-4 space-y-5">
|
||||||
|
<h3 class="text-lg font-black text-zinc-800">Are you sure?</h3>
|
||||||
|
<p class="text-sm text-zinc-500">Enter your password to confirm account deletion.</p>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-bold text-zinc-800">Password</label>
|
||||||
|
<input
|
||||||
|
v-model="deletePassword"
|
||||||
|
type="password"
|
||||||
|
:class="deleteError ? 'border-red-400' : 'border-zinc-300'"
|
||||||
|
class="w-full h-12 px-4 bg-zinc-50 border rounded-xl font-medium text-zinc-800 focus:outline-none focus:ring-2 focus:ring-red-400"
|
||||||
|
/>
|
||||||
|
<p v-if="deleteError" class="text-xs text-red-600">{{ deleteError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
@click="closeDeleteModal"
|
||||||
|
class="flex-1 py-3 border border-zinc-300 rounded-xl text-sm font-bold text-zinc-500 hover:bg-zinc-50 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="confirmDelete"
|
||||||
|
:disabled="deleting"
|
||||||
|
class="flex-1 py-3 bg-red-600 text-white rounded-xl text-sm font-bold hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ deleting ? 'Deleting…' : 'Delete account' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuth } from '../../../composables/useAuth.js'
|
||||||
|
|
||||||
|
const { user, updateProfile, deleteAccount } = useAuth()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const profileForm = ref({ name: '', email: '' })
|
||||||
|
const profileErrors = ref({})
|
||||||
|
const profileNetworkError = ref('')
|
||||||
|
const profileSaving = ref(false)
|
||||||
|
const profileSaved = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
profileForm.value.name = user.value?.name ?? ''
|
||||||
|
profileForm.value.email = user.value?.email ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
async function saveProfile() {
|
||||||
|
profileSaving.value = true
|
||||||
|
profileSaved.value = false
|
||||||
|
profileErrors.value = {}
|
||||||
|
profileNetworkError.value = ''
|
||||||
|
try {
|
||||||
|
await updateProfile(profileForm.value)
|
||||||
|
profileSaved.value = true
|
||||||
|
setTimeout(() => { profileSaved.value = false }, 3000)
|
||||||
|
} catch (err) {
|
||||||
|
if (err.response?.status === 422) {
|
||||||
|
profileErrors.value = err.response.data.errors ?? {}
|
||||||
|
} else {
|
||||||
|
profileNetworkError.value = 'Something went wrong. Please try again.'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
profileSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteModalOpen = ref(false)
|
||||||
|
const deletePassword = ref('')
|
||||||
|
const deleteError = ref('')
|
||||||
|
const deleting = ref(false)
|
||||||
|
|
||||||
|
function closeDeleteModal() {
|
||||||
|
deleteModalOpen.value = false
|
||||||
|
deletePassword.value = ''
|
||||||
|
deleteError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
deleting.value = true
|
||||||
|
deleteError.value = ''
|
||||||
|
try {
|
||||||
|
await deleteAccount(deletePassword.value)
|
||||||
|
router.push('/')
|
||||||
|
} catch (err) {
|
||||||
|
if (err.response?.status === 422) {
|
||||||
|
deleteError.value = err.response.data.errors?.password?.[0] ?? 'Incorrect password.'
|
||||||
|
} else {
|
||||||
|
deleteError.value = 'Something went wrong. Please try again.'
|
||||||
|
}
|
||||||
|
deletePassword.value = ''
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
322
resources/js/views/dashboard/settings/Security.vue
Normal file
322
resources/js/views/dashboard/settings/Security.vue
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6 max-w-lg">
|
||||||
|
<!-- Password change -->
|
||||||
|
<form class="p-6 bg-white rounded-2xl border border-zinc-300 space-y-5" @submit.prevent="savePassword">
|
||||||
|
<h2 class="text-lg font-black text-zinc-800">Change password</h2>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-bold text-zinc-800">Current password</label>
|
||||||
|
<input
|
||||||
|
v-model="passwordForm.current_password"
|
||||||
|
type="password"
|
||||||
|
:class="passwordErrors.current_password ? 'border-red-400' : 'border-zinc-300'"
|
||||||
|
class="w-full h-12 px-4 bg-zinc-50 border rounded-xl font-medium text-zinc-800 focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
|
/>
|
||||||
|
<p v-if="passwordErrors.current_password" class="text-xs text-red-600">{{ passwordErrors.current_password[0] }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-bold text-zinc-800">New password</label>
|
||||||
|
<input
|
||||||
|
v-model="passwordForm.password"
|
||||||
|
type="password"
|
||||||
|
:class="passwordErrors.password ? 'border-red-400' : 'border-zinc-300'"
|
||||||
|
class="w-full h-12 px-4 bg-zinc-50 border rounded-xl font-medium text-zinc-800 focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
|
/>
|
||||||
|
<p v-if="passwordErrors.password" class="text-xs text-red-600">{{ passwordErrors.password[0] }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="text-sm font-bold text-zinc-800">Confirm new password</label>
|
||||||
|
<input
|
||||||
|
v-model="passwordForm.password_confirmation"
|
||||||
|
type="password"
|
||||||
|
:class="passwordErrors.password_confirmation ? 'border-red-400' : 'border-zinc-300'"
|
||||||
|
class="w-full h-12 px-4 bg-zinc-50 border rounded-xl font-medium text-zinc-800 focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
|
/>
|
||||||
|
<p v-if="passwordErrors.password_confirmation" class="text-xs text-red-600">{{ passwordErrors.password_confirmation[0] }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="passwordNetworkError" class="text-sm text-red-600">{{ passwordNetworkError }}</p>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="passwordSaving"
|
||||||
|
class="px-8 py-3 bg-accent text-white rounded-xl font-bold hover:bg-accent-content transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ passwordSaving ? 'Updating…' : 'Update password' }}
|
||||||
|
</button>
|
||||||
|
<p v-if="passwordSaved" class="text-sm font-bold text-green-600">Password updated!</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Two-factor authentication -->
|
||||||
|
<div class="p-6 bg-white rounded-2xl border border-zinc-300 space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-black text-zinc-800">Two-factor authentication</h2>
|
||||||
|
<p class="text-sm text-zinc-500 mt-0.5">
|
||||||
|
{{ twoFactorEnabled ? 'Enabled — your account is extra secure.' : 'Add extra security to your account.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="px-3 py-1 rounded-full text-xs font-black"
|
||||||
|
:class="twoFactorEnabled ? 'bg-green-100 text-green-700' : 'bg-zinc-50 text-zinc-500'"
|
||||||
|
>
|
||||||
|
{{ twoFactorEnabled ? 'ON' : 'OFF' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2FA enabling flow -->
|
||||||
|
<div v-if="enablingTwoFactor" class="space-y-4 border-t border-zinc-300 pt-4">
|
||||||
|
<p class="text-sm font-bold text-zinc-800">Scan this QR code in your authenticator app:</p>
|
||||||
|
|
||||||
|
<div v-if="setupData" class="flex flex-col items-start gap-4">
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||||
|
<div v-html="setupData.svg" class="[&_svg]:w-40 [&_svg]:h-40"></div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-xs font-bold text-zinc-500 uppercase tracking-widest">Or enter setup key manually</p>
|
||||||
|
<code class="text-xs bg-zinc-50 px-3 py-2 rounded-lg font-mono text-zinc-800 break-all block">{{ setupData.secretKey }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!twoFactorEnabled" class="space-y-2">
|
||||||
|
<label class="text-sm font-bold text-zinc-800">Enter the 6-digit code to confirm</label>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<input
|
||||||
|
v-model="confirmCode"
|
||||||
|
type="text"
|
||||||
|
maxlength="6"
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="000000"
|
||||||
|
:class="confirmError ? 'border-red-400' : 'border-zinc-300'"
|
||||||
|
class="w-36 h-12 px-4 bg-zinc-50 border rounded-xl font-mono text-center text-zinc-800 focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="confirmTwoFactor"
|
||||||
|
:disabled="confirmCode.length !== 6 || confirming"
|
||||||
|
class="px-6 py-3 bg-accent text-white rounded-xl text-sm font-bold hover:bg-accent-content transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ confirming ? 'Verifying…' : 'Confirm' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="confirmError" class="text-xs text-red-600">{{ confirmError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="twoFactorEnabled" class="text-sm font-bold text-green-600">
|
||||||
|
Two-factor authentication is now active.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="!twoFactorEnabled"
|
||||||
|
@click="cancelEnable"
|
||||||
|
class="text-sm text-zinc-500 hover:text-zinc-800"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recovery codes (when 2FA enabled and not mid-setup) -->
|
||||||
|
<div v-if="twoFactorEnabled && !enablingTwoFactor" class="border-t border-zinc-300 pt-4 space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-sm font-bold text-zinc-800">Recovery codes</p>
|
||||||
|
<button
|
||||||
|
@click="toggleRecoveryCodes"
|
||||||
|
class="text-xs font-bold text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{{ showRecoveryCodes ? 'Hide' : 'Show' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="showRecoveryCodes" class="space-y-2">
|
||||||
|
<div class="grid grid-cols-2 gap-1 bg-zinc-50 rounded-xl p-4">
|
||||||
|
<code
|
||||||
|
v-for="code in recoveryCodes"
|
||||||
|
:key="code"
|
||||||
|
class="text-xs font-mono text-zinc-800"
|
||||||
|
>{{ code }}</code>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="regenRecoveryCodes"
|
||||||
|
:disabled="regenLoading"
|
||||||
|
class="text-xs font-bold text-zinc-500 hover:text-zinc-800"
|
||||||
|
>
|
||||||
|
{{ regenLoading ? 'Regenerating…' : 'Regenerate codes' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<div class="flex gap-3 border-t border-zinc-300 pt-4">
|
||||||
|
<button
|
||||||
|
v-if="!twoFactorEnabled && !enablingTwoFactor"
|
||||||
|
@click="enableTwoFactor"
|
||||||
|
:disabled="tfaActionLoading"
|
||||||
|
class="px-6 py-2.5 bg-accent text-white rounded-xl text-sm font-bold hover:bg-accent-content transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ tfaActionLoading ? 'Enabling…' : 'Enable 2FA' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="twoFactorEnabled && !enablingTwoFactor"
|
||||||
|
@click="disableTwoFactor"
|
||||||
|
:disabled="tfaActionLoading"
|
||||||
|
class="px-6 py-2.5 bg-white border border-zinc-300 rounded-xl text-sm font-bold text-zinc-500 hover:border-red-300 hover:text-red-600 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ tfaActionLoading ? 'Disabling…' : 'Disable 2FA' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="tfaError" class="text-sm text-red-600">{{ tfaError }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { useAuth } from '../../../composables/useAuth.js'
|
||||||
|
|
||||||
|
const { user, updatePassword } = useAuth()
|
||||||
|
|
||||||
|
// Separate axios instance for Fortify web routes (base URL is / not /api)
|
||||||
|
const web = axios.create({
|
||||||
|
baseURL: '/',
|
||||||
|
withCredentials: true,
|
||||||
|
withXSRFToken: true,
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Password form
|
||||||
|
const passwordForm = ref({ current_password: '', password: '', password_confirmation: '' })
|
||||||
|
const passwordErrors = ref({})
|
||||||
|
const passwordNetworkError = ref('')
|
||||||
|
const passwordSaving = ref(false)
|
||||||
|
const passwordSaved = ref(false)
|
||||||
|
|
||||||
|
async function savePassword() {
|
||||||
|
passwordSaving.value = true
|
||||||
|
passwordSaved.value = false
|
||||||
|
passwordErrors.value = {}
|
||||||
|
passwordNetworkError.value = ''
|
||||||
|
try {
|
||||||
|
await updatePassword(passwordForm.value)
|
||||||
|
passwordForm.value = { current_password: '', password: '', password_confirmation: '' }
|
||||||
|
passwordSaved.value = true
|
||||||
|
setTimeout(() => { passwordSaved.value = false }, 3000)
|
||||||
|
} catch (err) {
|
||||||
|
if (err.response?.status === 422) {
|
||||||
|
passwordErrors.value = err.response.data.errors ?? {}
|
||||||
|
} else {
|
||||||
|
passwordNetworkError.value = 'Something went wrong. Please try again.'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
passwordSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2FA
|
||||||
|
const twoFactorEnabled = ref(false)
|
||||||
|
const enablingTwoFactor = ref(false)
|
||||||
|
const setupData = ref(null)
|
||||||
|
const confirmCode = ref('')
|
||||||
|
const confirmError = ref('')
|
||||||
|
const confirming = ref(false)
|
||||||
|
const showRecoveryCodes = ref(false)
|
||||||
|
const recoveryCodes = ref([])
|
||||||
|
const regenLoading = ref(false)
|
||||||
|
const tfaActionLoading = ref(false)
|
||||||
|
const tfaError = ref('')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
twoFactorEnabled.value = !!user.value?.two_factor_confirmed_at
|
||||||
|
// Ensure CSRF cookie is fresh for Fortify web routes
|
||||||
|
await axios.get('/sanctum/csrf-cookie', { withCredentials: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
async function enableTwoFactor() {
|
||||||
|
tfaActionLoading.value = true
|
||||||
|
tfaError.value = ''
|
||||||
|
try {
|
||||||
|
await web.post('/user/two-factor-authentication')
|
||||||
|
const [qrRes, keyRes] = await Promise.all([
|
||||||
|
web.get('/user/two-factor-qr-code'),
|
||||||
|
web.get('/user/two-factor-secret-key'),
|
||||||
|
])
|
||||||
|
setupData.value = { svg: qrRes.data.svg, secretKey: keyRes.data.secretKey }
|
||||||
|
enablingTwoFactor.value = true
|
||||||
|
} catch {
|
||||||
|
tfaError.value = 'Failed to enable 2FA. Please try again.'
|
||||||
|
} finally {
|
||||||
|
tfaActionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmTwoFactor() {
|
||||||
|
confirming.value = true
|
||||||
|
confirmError.value = ''
|
||||||
|
try {
|
||||||
|
await web.post('/user/confirmed-two-factor-authentication', { code: confirmCode.value })
|
||||||
|
twoFactorEnabled.value = true
|
||||||
|
confirmCode.value = ''
|
||||||
|
await loadRecoveryCodes()
|
||||||
|
} catch (err) {
|
||||||
|
confirmError.value = err.response?.data?.errors?.code?.[0] ?? 'Invalid code. Please try again.'
|
||||||
|
confirmCode.value = ''
|
||||||
|
} finally {
|
||||||
|
confirming.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableTwoFactor() {
|
||||||
|
tfaActionLoading.value = true
|
||||||
|
tfaError.value = ''
|
||||||
|
try {
|
||||||
|
await web.delete('/user/two-factor-authentication')
|
||||||
|
twoFactorEnabled.value = false
|
||||||
|
showRecoveryCodes.value = false
|
||||||
|
recoveryCodes.value = []
|
||||||
|
} catch {
|
||||||
|
tfaError.value = 'Failed to disable 2FA. Please try again.'
|
||||||
|
} finally {
|
||||||
|
tfaActionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEnable() {
|
||||||
|
enablingTwoFactor.value = false
|
||||||
|
setupData.value = null
|
||||||
|
confirmCode.value = ''
|
||||||
|
confirmError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecoveryCodes() {
|
||||||
|
try {
|
||||||
|
const response = await web.get('/user/two-factor-recovery-codes')
|
||||||
|
recoveryCodes.value = response.data
|
||||||
|
} catch {
|
||||||
|
// Non-fatal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRecoveryCodes() {
|
||||||
|
if (!showRecoveryCodes.value && recoveryCodes.value.length === 0) {
|
||||||
|
await loadRecoveryCodes()
|
||||||
|
}
|
||||||
|
showRecoveryCodes.value = !showRecoveryCodes.value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regenRecoveryCodes() {
|
||||||
|
regenLoading.value = true
|
||||||
|
try {
|
||||||
|
await web.post('/user/two-factor-recovery-codes')
|
||||||
|
await loadRecoveryCodes()
|
||||||
|
} catch {
|
||||||
|
// Silently fail
|
||||||
|
} finally {
|
||||||
|
regenLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
45
resources/js/views/dashboard/settings/SettingsLayout.vue
Normal file
45
resources/js/views/dashboard/settings/SettingsLayout.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-black text-zinc-800">Settings</h1>
|
||||||
|
<p class="text-zinc-500 mt-1">Manage your account and preferences.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-6">
|
||||||
|
<!-- Settings sub-nav -->
|
||||||
|
<aside class="w-44 shrink-0">
|
||||||
|
<nav class="space-y-1">
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in settingsNav"
|
||||||
|
:key="item.to"
|
||||||
|
:to="item.to"
|
||||||
|
class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm font-bold transition-colors"
|
||||||
|
:class="$route.path === item.to
|
||||||
|
? 'bg-accent text-white'
|
||||||
|
: 'text-zinc-500 hover:bg-white hover:text-zinc-800'"
|
||||||
|
>
|
||||||
|
<iconify-icon :icon="item.icon"></iconify-icon>
|
||||||
|
{{ item.label }}
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Settings content -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<RouterView />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { RouterLink, RouterView, useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
const $route = useRoute()
|
||||||
|
|
||||||
|
const settingsNav = [
|
||||||
|
{ to: '/dashboard/settings/profile', label: 'Profile', icon: 'lucide:user' },
|
||||||
|
{ to: '/dashboard/settings/security', label: 'Security', icon: 'lucide:shield' },
|
||||||
|
{ to: '/dashboard/settings/appearance', label: 'Appearance', icon: 'lucide:sun-moon' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<div class="flex items-start max-md:flex-col">
|
|
||||||
<div class="me-10 w-full pb-4 md:w-[220px]">
|
|
||||||
<flux:navlist aria-label="{{ __('Settings') }}">
|
|
||||||
<flux:navlist.item :href="route('profile.edit')" wire:navigate>{{ __('Profile') }}</flux:navlist.item>
|
|
||||||
<flux:navlist.item :href="route('security.edit')" wire:navigate>{{ __('Security') }}</flux:navlist.item>
|
|
||||||
<flux:navlist.item :href="route('appearance.edit')" wire:navigate>{{ __('Appearance') }}</flux:navlist.item>
|
|
||||||
</flux:navlist>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<flux:separator class="md:hidden" />
|
|
||||||
|
|
||||||
<div class="flex-1 self-stretch max-md:pt-6">
|
|
||||||
<flux:heading>{{ $heading ?? '' }}</flux:heading>
|
|
||||||
<flux:subheading>{{ $subheading ?? '' }}</flux:subheading>
|
|
||||||
|
|
||||||
<div class="mt-5 w-full max-w-lg">
|
|
||||||
{{ $slot }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<section class="w-full">
|
|
||||||
@include('partials.settings-heading')
|
|
||||||
|
|
||||||
<flux:heading class="sr-only">{{ __('Appearance settings') }}</flux:heading>
|
|
||||||
|
|
||||||
<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-settings.layout>
|
|
||||||
</section>
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
<div
|
|
||||||
class="py-6 space-y-6 border shadow-sm rounded-xl border-zinc-200 dark:border-white/10"
|
|
||||||
wire:cloak
|
|
||||||
x-data="{ showRecoveryCodes: false }"
|
|
||||||
>
|
|
||||||
<div class="px-6 space-y-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<flux:icon.lock-closed variant="outline" class="size-4"/>
|
|
||||||
<flux:heading size="lg" level="3">{{ __('2FA recovery codes') }}</flux:heading>
|
|
||||||
</div>
|
|
||||||
<flux:text variant="subtle">
|
|
||||||
{{ __('Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.') }}
|
|
||||||
</flux:text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="px-6">
|
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<flux:button
|
|
||||||
x-show="!showRecoveryCodes"
|
|
||||||
icon="eye"
|
|
||||||
icon:variant="outline"
|
|
||||||
variant="primary"
|
|
||||||
@click="showRecoveryCodes = true;"
|
|
||||||
aria-expanded="false"
|
|
||||||
aria-controls="recovery-codes-section"
|
|
||||||
>
|
|
||||||
{{ __('View recovery codes') }}
|
|
||||||
</flux:button>
|
|
||||||
|
|
||||||
<flux:button
|
|
||||||
x-show="showRecoveryCodes"
|
|
||||||
icon="eye-slash"
|
|
||||||
icon:variant="outline"
|
|
||||||
variant="primary"
|
|
||||||
@click="showRecoveryCodes = false"
|
|
||||||
aria-expanded="true"
|
|
||||||
aria-controls="recovery-codes-section"
|
|
||||||
>
|
|
||||||
{{ __('Hide recovery codes') }}
|
|
||||||
</flux:button>
|
|
||||||
|
|
||||||
@if (filled($recoveryCodes))
|
|
||||||
<flux:button
|
|
||||||
x-show="showRecoveryCodes"
|
|
||||||
icon="arrow-path"
|
|
||||||
variant="filled"
|
|
||||||
wire:click="regenerateRecoveryCodes"
|
|
||||||
>
|
|
||||||
{{ __('Regenerate codes') }}
|
|
||||||
</flux:button>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
x-show="showRecoveryCodes"
|
|
||||||
x-transition
|
|
||||||
id="recovery-codes-section"
|
|
||||||
class="relative overflow-hidden"
|
|
||||||
x-bind:aria-hidden="!showRecoveryCodes"
|
|
||||||
>
|
|
||||||
<div class="mt-3 space-y-3">
|
|
||||||
@error('recoveryCodes')
|
|
||||||
<flux:callout variant="danger" icon="x-circle" heading="{{$message}}"/>
|
|
||||||
@enderror
|
|
||||||
|
|
||||||
@if (filled($recoveryCodes))
|
|
||||||
<div
|
|
||||||
class="grid gap-1 p-4 font-mono text-sm rounded-lg bg-zinc-100 dark:bg-white/5"
|
|
||||||
role="list"
|
|
||||||
aria-label="{{ __('Recovery codes') }}"
|
|
||||||
>
|
|
||||||
@foreach($recoveryCodes as $code)
|
|
||||||
<div
|
|
||||||
role="listitem"
|
|
||||||
class="select-text"
|
|
||||||
wire:loading.class="opacity-50 animate-pulse"
|
|
||||||
>
|
|
||||||
{{ $code }}
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
<flux:text variant="subtle" class="text-xs">
|
|
||||||
{{ __('Each recovery code can be used once to access your account and will be removed after use. If you need more, click Regenerate codes above.') }}
|
|
||||||
</flux:text>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<div class="relative mb-6 w-full">
|
|
||||||
<flux:heading size="xl" level="1">{{ __('Settings') }}</flux:heading>
|
|
||||||
<flux:subheading size="lg" class="mb-6">{{ __('Manage your profile and account settings') }}</flux:subheading>
|
|
||||||
<flux:separator variant="subtle" />
|
|
||||||
</div>
|
|
||||||
@@ -30,4 +30,8 @@ Route::middleware('auth:sanctum')->group(function (): void {
|
|||||||
Route::get('/user/saved-stations', [UserController::class, 'savedStations']);
|
Route::get('/user/saved-stations', [UserController::class, 'savedStations']);
|
||||||
Route::post('/user/saved-stations', [UserController::class, 'saveStation']);
|
Route::post('/user/saved-stations', [UserController::class, 'saveStation']);
|
||||||
Route::delete('/user/saved-stations/{stationId}', [UserController::class, 'removeStation']);
|
Route::delete('/user/saved-stations/{stationId}', [UserController::class, 'removeStation']);
|
||||||
|
|
||||||
|
Route::put('/user/profile', [UserController::class, 'updateProfile']);
|
||||||
|
Route::put('/user/password', [UserController::class, 'updatePassword']);
|
||||||
|
Route::delete('/user', [UserController::class, 'deleteAccount']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
<?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', Profile::class)->name('profile.edit');
|
|
||||||
});
|
|
||||||
|
|
||||||
Route::middleware(['auth', 'verified'])->group(function () {
|
|
||||||
Route::livewire('settings/appearance', Appearance::class)->name('appearance.edit');
|
|
||||||
|
|
||||||
Route::livewire('settings/security', Security::class)
|
|
||||||
->middleware(
|
|
||||||
when(
|
|
||||||
Features::canManageTwoFactorAuthentication()
|
|
||||||
&& Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword'),
|
|
||||||
['password.confirm'],
|
|
||||||
[],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
->name('security.edit');
|
|
||||||
});
|
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
require __DIR__.'/settings.php';
|
|
||||||
|
|
||||||
// Named dashboard route so route('dashboard') resolves; Vue Router handles rendering
|
// Named dashboard route so route('dashboard') resolves; Vue Router handles rendering
|
||||||
Route::get('/dashboard', fn () => view('app'))->middleware(['auth', 'verified'])->name('dashboard');
|
Route::get('/dashboard', fn () => view('app'))->middleware(['auth', 'verified'])->name('dashboard');
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,95 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"skills": {
|
"skills": {
|
||||||
|
"antfu": {
|
||||||
|
"source": "antfu/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "6935586ef3ab02e18747a4477d396e692bc96e8f28b0d95b374ffc37fce74cda"
|
||||||
|
},
|
||||||
|
"nuxt": {
|
||||||
|
"source": "antfu/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "9e8237ec92083db9a6f4c77d5e5bf4b1d9873d670671235bc75455ee1ca39c74"
|
||||||
|
},
|
||||||
|
"pinia": {
|
||||||
|
"source": "antfu/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "897b6b0982956f41f5cc85128ca638b625e1b3c8bc619e303cd1e7661c95b7de"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"source": "antfu/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "318f9fca2441a3e06fedc336e195dd18be5dbe901064e4fa71b76b40b096ab3a"
|
||||||
|
},
|
||||||
|
"slidev": {
|
||||||
|
"source": "antfu/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "4973bc1baf08fa1303f99ef3abce8ec3aedfb2637e4315de30a2421746b610e3"
|
||||||
|
},
|
||||||
"superdesign": {
|
"superdesign": {
|
||||||
"source": "superdesigndev/superdesign-skill",
|
"source": "superdesigndev/superdesign-skill",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
"computedHash": "7208b5e2e1145d76abcd698b2a9085952d986022ec2d5d8b9f972106b1b2447b"
|
"computedHash": "7208b5e2e1145d76abcd698b2a9085952d986022ec2d5d8b9f972106b1b2447b"
|
||||||
|
},
|
||||||
|
"tsdown": {
|
||||||
|
"source": "antfu/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "5d7a0732aa15849d7363d168c13eec9c4367c9e534397de3fc550d582ffea09b"
|
||||||
|
},
|
||||||
|
"turborepo": {
|
||||||
|
"source": "antfu/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "bb531fab09e44812e3dc41e77f08708fbb67984ed9421d3c6f54f741fe70c2a0"
|
||||||
|
},
|
||||||
|
"unocss": {
|
||||||
|
"source": "antfu/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "4b7a9f7b89994d5aef70b9d55cb7f65d6e80cea437d5786cd33bb5cf7231c7c4"
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"source": "antfu/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "867ff66238f3152e9c494339ad08dc432dd0df5bcf5cc7a00b61a72b580eb908"
|
||||||
|
},
|
||||||
|
"vitepress": {
|
||||||
|
"source": "antfu/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "629d8ec4bcbf7cac86c15c3ca5f173933f01729bcb6396c0d74733755a896a41"
|
||||||
|
},
|
||||||
|
"vitest": {
|
||||||
|
"source": "antfu/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "669c7275f7e1379b06b2bcfa593603aead4c983b951c7e8edbc09640aa38d0a1"
|
||||||
|
},
|
||||||
|
"vue": {
|
||||||
|
"source": "antfu/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "9c241141e07e836e4f0537c63e8f929fba3767c2997250be38e699f19b75e3f2"
|
||||||
|
},
|
||||||
|
"vue-best-practices": {
|
||||||
|
"source": "antfu/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "d7d22c8cb343583c3904692c4d1d7b50382945e433e4f6e053f4aabb9846cbc3"
|
||||||
|
},
|
||||||
|
"vue-router-best-practices": {
|
||||||
|
"source": "antfu/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "e27384f4e6c8c70a612e76b74e4387efb8e291a6a1e3aa14a69102a4ce4b4654"
|
||||||
|
},
|
||||||
|
"vue-testing-best-practices": {
|
||||||
|
"source": "antfu/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "18c7d8f42f350f927e37de055e34c97b8cfb9f79c12cf942f7f3d2a0821057b5"
|
||||||
|
},
|
||||||
|
"vueuse-functions": {
|
||||||
|
"source": "antfu/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "fedfc78f035a88fe3e9d823f44725fe8981486c55b966c17fbd0d7c28339e49a"
|
||||||
|
},
|
||||||
|
"web-design-guidelines": {
|
||||||
|
"source": "antfu/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"computedHash": "65a2e7d85753383ae0f88df15475d58b9e39723e9c4bb6891421d6144a85f79c"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Laravel\Sanctum\Sanctum;
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
|
||||||
it('returns user preferences for authenticated user', function (): void {
|
it('returns user preferences for authenticated user', function (): void {
|
||||||
@@ -65,3 +66,135 @@ it('rejects unauthenticated requests to user endpoints', function (): void {
|
|||||||
$this->getJson('/api/user/preferences')->assertUnauthorized();
|
$this->getJson('/api/user/preferences')->assertUnauthorized();
|
||||||
$this->getJson('/api/user/saved-stations')->assertUnauthorized();
|
$this->getJson('/api/user/saved-stations')->assertUnauthorized();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Profile update ---
|
||||||
|
|
||||||
|
it('updates user profile name and email', function (): void {
|
||||||
|
$user = User::factory()->create(['name' => 'Old Name', 'email' => 'old@example.com']);
|
||||||
|
Sanctum::actingAs($user);
|
||||||
|
|
||||||
|
$this->putJson('/api/user/profile', ['name' => 'New Name', 'email' => 'new@example.com'])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonFragment(['name' => 'New Name', 'email' => 'new@example.com']);
|
||||||
|
|
||||||
|
expect($user->fresh()->name)->toBe('New Name');
|
||||||
|
expect($user->fresh()->email)->toBe('new@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nulls email_verified_at when email changes', function (): void {
|
||||||
|
$user = User::factory()->create(['email_verified_at' => now()]);
|
||||||
|
Sanctum::actingAs($user);
|
||||||
|
|
||||||
|
$this->putJson('/api/user/profile', ['name' => $user->name, 'email' => 'changed@example.com'])
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
expect($user->fresh()->email_verified_at)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not null email_verified_at when email is unchanged', function (): void {
|
||||||
|
$user = User::factory()->create(['email_verified_at' => now()]);
|
||||||
|
Sanctum::actingAs($user);
|
||||||
|
|
||||||
|
$this->putJson('/api/user/profile', ['name' => 'New Name', 'email' => $user->email])
|
||||||
|
->assertOk();
|
||||||
|
|
||||||
|
expect($user->fresh()->email_verified_at)->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects profile update with duplicate email', function (): void {
|
||||||
|
User::factory()->create(['email' => 'taken@example.com']);
|
||||||
|
$user = User::factory()->create();
|
||||||
|
Sanctum::actingAs($user);
|
||||||
|
|
||||||
|
$this->putJson('/api/user/profile', ['name' => 'Name', 'email' => 'taken@example.com'])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['email']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects profile update with missing name', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
Sanctum::actingAs($user);
|
||||||
|
|
||||||
|
$this->putJson('/api/user/profile', ['email' => 'new@example.com'])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['name']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Password update ---
|
||||||
|
|
||||||
|
it('updates user password', function (): void {
|
||||||
|
$user = User::factory()->create(['password' => Hash::make('old-password')]);
|
||||||
|
Sanctum::actingAs($user);
|
||||||
|
|
||||||
|
$this->putJson('/api/user/password', [
|
||||||
|
'current_password' => 'old-password',
|
||||||
|
'password' => 'new-password',
|
||||||
|
'password_confirmation' => 'new-password',
|
||||||
|
])->assertOk();
|
||||||
|
|
||||||
|
expect(Hash::check('new-password', $user->fresh()->password))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects wrong current password', function (): void {
|
||||||
|
$user = User::factory()->create(['password' => Hash::make('correct-password')]);
|
||||||
|
Sanctum::actingAs($user);
|
||||||
|
|
||||||
|
$this->putJson('/api/user/password', [
|
||||||
|
'current_password' => 'wrong-password',
|
||||||
|
'password' => 'new-password',
|
||||||
|
'password_confirmation' => 'new-password',
|
||||||
|
])->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['current_password']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects mismatched password confirmation', function (): void {
|
||||||
|
$user = User::factory()->create(['password' => Hash::make('correct-password')]);
|
||||||
|
Sanctum::actingAs($user);
|
||||||
|
|
||||||
|
$this->putJson('/api/user/password', [
|
||||||
|
'current_password' => 'correct-password',
|
||||||
|
'password' => 'new-password',
|
||||||
|
'password_confirmation' => 'different',
|
||||||
|
])->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['password']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Delete account ---
|
||||||
|
|
||||||
|
it('deletes user account with correct password', function (): void {
|
||||||
|
$user = User::factory()->create(['password' => Hash::make('my-password')]);
|
||||||
|
Sanctum::actingAs($user);
|
||||||
|
|
||||||
|
$this->deleteJson('/api/user', ['password' => 'my-password'])
|
||||||
|
->assertNoContent();
|
||||||
|
|
||||||
|
expect(User::find($user->id))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revokes sanctum tokens on account deletion', function (): void {
|
||||||
|
$user = User::factory()->create(['password' => Hash::make('my-password')]);
|
||||||
|
$user->createToken('test');
|
||||||
|
Sanctum::actingAs($user);
|
||||||
|
|
||||||
|
$this->deleteJson('/api/user', ['password' => 'my-password'])
|
||||||
|
->assertNoContent();
|
||||||
|
|
||||||
|
expect($user->tokens()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects account deletion with wrong password', function (): void {
|
||||||
|
$user = User::factory()->create(['password' => Hash::make('correct-password')]);
|
||||||
|
Sanctum::actingAs($user);
|
||||||
|
|
||||||
|
$this->deleteJson('/api/user', ['password' => 'wrong-password'])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['password']);
|
||||||
|
|
||||||
|
expect(User::find($user->id))->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unauthenticated requests to profile, password, and delete endpoints', function (): void {
|
||||||
|
$this->putJson('/api/user/profile', [])->assertUnauthorized();
|
||||||
|
$this->putJson('/api/user/password', [])->assertUnauthorized();
|
||||||
|
$this->deleteJson('/api/user', [])->assertUnauthorized();
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
test('profile page is displayed', function () {
|
|
||||||
$this->actingAs($user = User::factory()->create());
|
|
||||||
|
|
||||||
$this->get(route('profile.edit'))->assertOk();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('profile information can be updated', function () {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
$response = Livewire::test('pages::settings.profile')
|
|
||||||
->set('name', 'Test User')
|
|
||||||
->set('email', 'test@example.com')
|
|
||||||
->call('updateProfileInformation');
|
|
||||||
|
|
||||||
$response->assertHasNoErrors();
|
|
||||||
|
|
||||||
$user->refresh();
|
|
||||||
|
|
||||||
expect($user->name)->toEqual('Test User');
|
|
||||||
expect($user->email)->toEqual('test@example.com');
|
|
||||||
expect($user->email_verified_at)->toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('email verification status is unchanged when email address is unchanged', function () {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
$response = Livewire::test('pages::settings.profile')
|
|
||||||
->set('name', 'Test User')
|
|
||||||
->set('email', $user->email)
|
|
||||||
->call('updateProfileInformation');
|
|
||||||
|
|
||||||
$response->assertHasNoErrors();
|
|
||||||
|
|
||||||
expect($user->refresh()->email_verified_at)->not->toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('user can delete their account', function () {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
$response = Livewire::test('pages::settings.delete-user-modal')
|
|
||||||
->set('password', 'password')
|
|
||||||
->call('deleteUser');
|
|
||||||
|
|
||||||
$response
|
|
||||||
->assertHasNoErrors()
|
|
||||||
->assertRedirect('/');
|
|
||||||
|
|
||||||
expect($user->fresh())->toBeNull();
|
|
||||||
expect(auth()->check())->toBeFalse();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('correct password must be provided to delete account', function () {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
$response = Livewire::test('pages::settings.delete-user-modal')
|
|
||||||
->set('password', 'wrong-password')
|
|
||||||
->call('deleteUser');
|
|
||||||
|
|
||||||
$response->assertHasErrors(['password']);
|
|
||||||
|
|
||||||
expect($user->fresh())->not->toBeNull();
|
|
||||||
});
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Laravel\Fortify\Features;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
beforeEach(function () {
|
|
||||||
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
|
|
||||||
|
|
||||||
Features::twoFactorAuthentication([
|
|
||||||
'confirm' => true,
|
|
||||||
'confirmPassword' => true,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('security settings page can be rendered', function () {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession(['auth.password_confirmed_at' => time()])
|
|
||||||
->get(route('security.edit'))
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('Two-factor authentication')
|
|
||||||
->assertSee('Enable 2FA');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('security settings page requires password confirmation when enabled', function () {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
|
||||||
->get(route('security.edit'));
|
|
||||||
|
|
||||||
$response->assertRedirect(route('password.confirm'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('security settings page renders without two factor when feature is disabled', function () {
|
|
||||||
config(['fortify.features' => []]);
|
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession(['auth.password_confirmed_at' => time()])
|
|
||||||
->get(route('security.edit'))
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('Update password')
|
|
||||||
->assertDontSee('Two-factor authentication');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('two factor authentication disabled when confirmation abandoned between requests', function () {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$user->forceFill([
|
|
||||||
'two_factor_secret' => encrypt('test-secret'),
|
|
||||||
'two_factor_recovery_codes' => encrypt(json_encode(['code1', 'code2'])),
|
|
||||||
'two_factor_confirmed_at' => null,
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
$component = Livewire::test('pages::settings.security');
|
|
||||||
|
|
||||||
$component->assertSet('twoFactorEnabled', false);
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('users', [
|
|
||||||
'id' => $user->id,
|
|
||||||
'two_factor_secret' => null,
|
|
||||||
'two_factor_recovery_codes' => null,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('password can be updated', function () {
|
|
||||||
$user = User::factory()->create([
|
|
||||||
'password' => Hash::make('password'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
$response = Livewire::test('pages::settings.security')
|
|
||||||
->set('current_password', 'password')
|
|
||||||
->set('password', 'new-password')
|
|
||||||
->set('password_confirmation', 'new-password')
|
|
||||||
->call('updatePassword');
|
|
||||||
|
|
||||||
$response->assertHasNoErrors();
|
|
||||||
|
|
||||||
expect(Hash::check('new-password', $user->refresh()->password))->toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('correct password must be provided to update password', function () {
|
|
||||||
$user = User::factory()->create([
|
|
||||||
'password' => Hash::make('password'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
$response = Livewire::test('pages::settings.security')
|
|
||||||
->set('current_password', 'wrong-password')
|
|
||||||
->set('password', 'new-password')
|
|
||||||
->set('password_confirmation', 'new-password')
|
|
||||||
->call('updatePassword');
|
|
||||||
|
|
||||||
$response->assertHasErrors(['current_password']);
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user