kithkin/app/Http/Controllers/AccountController.php

318 lines
10 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Http\Requests\AccountUpdateRequest;
use App\Models\Location;
use App\Models\UserAddress;
use App\Services\Location\Geocoder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class AccountController extends Controller
{
/**
* landing page
*/
public function index(): RedirectResponse
{
return Redirect::route('account.info');
}
/**
* info pane (name, email, timezone, etc.)
*/
public function infoForm(Request $request)
{
$user = $request->user();
return $this->frame('account.settings.info', [
'title' => __('Account info'),
'sub' => __('Update your personal information and sign-in email.'),
'user' => $user,
]);
}
/**
* save info pane
*/
public function infoStore(AccountUpdateRequest $request): RedirectResponse
{
$user = $request->user();
$user->fill($request->validated());
if ($user->isDirty('email')) {
$user->email_verified_at = null;
}
$user->save();
return Redirect::route('account.info')
->with('toast', __('Account updated!'));
}
/**
* addresses pane (home + work)
*/
public function addressesForm(Request $request)
{
$user = $request->user();
// try primary of each kind; fall back to the first of that kind
$home = $user->addresses()->where('kind', 'home')->where('is_primary', 1)->first()
?? $user->addresses()->where('kind', 'home')->first();
$work = $user->addresses()->where('kind', 'work')->where('is_primary', 1)->first()
?? $user->addresses()->where('kind', 'work')->first();
// if neither is explicitly billing, fall back to "work if present, else home"
$billing =
($home && $home->is_billing) ? 'home' :
(($work && $work->is_billing) ? 'work' :
($work ? 'work' : 'home'));
return $this->frame('account.settings.addresses', [
'title' => __('account.settings.addresses.title'),
'sub' => __('account.settings.addresses.subtitle'),
'user' => $user,
'home' => $home,
'work' => $work,
'billing' => $billing,
]);
}
/**
* save addresses pane
*/
public function addressesStore(Request $request, Geocoder $geocoder): RedirectResponse
{
$user = $request->user();
$data = $request->validate([
'billing' => ['required', 'in:home,work'],
'home' => ['sometimes', 'array'],
'home.label' => ['nullable', 'string', 'max:100'],
'home.line1' => ['nullable', 'string', 'max:255'],
'home.line2' => ['nullable', 'string', 'max:255'],
'home.city' => ['nullable', 'string', 'max:120'],
'home.state' => ['nullable', 'string', 'max:64'],
'home.postal' => ['nullable', 'string', 'max:32'],
'home.country' => ['nullable', 'string', 'max:64'],
'work' => ['sometimes', 'array'],
'work.label' => ['nullable', 'string', 'max:100'],
'work.line1' => ['nullable', 'string', 'max:255'],
'work.line2' => ['nullable', 'string', 'max:255'],
'work.city' => ['nullable', 'string', 'max:120'],
'work.state' => ['nullable', 'string', 'max:64'],
'work.postal' => ['nullable', 'string', 'max:32'],
'work.country' => ['nullable', 'string', 'max:64'],
]);
DB::transaction(function () use ($user, $data, $geocoder) {
$saved = []; // kind => UserAddress
$save = function (string $kind, array $payload) use ($user, $geocoder, &$saved) {
// short-circuit if nothing filled
$filled = collect($payload)
->only(['line1', 'line2', 'city', 'state', 'postal', 'country'])
->filter(fn ($v) => filled($v));
if ($filled->isEmpty()) {
return;
}
// upsert a single primary address of this kind
$addr = UserAddress::updateOrCreate(
['user_id' => $user->id, 'kind' => $kind, 'is_primary' => 1],
[
'label' => $payload['label'] ?? ucfirst($kind),
'line1' => $payload['line1'] ?? null,
'line2' => $payload['line2'] ?? null,
'city' => $payload['city'] ?? null,
'state' => $payload['state'] ?? null,
'postal' => $payload['postal'] ?? null,
'country' => $payload['country'] ?? null,
]
);
$saved[$kind] = $addr;
// geocode
$singleLine = collect([
$addr->line1,
$addr->line2,
$addr->city,
$addr->state,
$addr->postal,
$addr->country,
])->filter()->implode(', ');
if ($singleLine === '') {
return;
}
$norm = $geocoder->forward($singleLine);
if (!$norm) {
return;
}
$loc = Location::findOrCreateNormalized($norm, $singleLine);
$addr->location_id = $loc->id;
$addr->save();
};
if (isset($data['home'])) {
$save('home', $data['home']);
}
if (isset($data['work'])) {
$save('work', $data['work']);
}
// billing flag: clear all billing flags for this user
UserAddress::where('user_id', $user->id)->update(['is_billing' => 0]);
// set billing on the chosen kind if we have that address
$kind = $data['billing'];
// if the chosen kind wasn't saved in this submission (e.g., user didn't fill it),
// try to find an existing primary address of that kind.
$billingAddr = $saved[$kind] ?? UserAddress::where('user_id', $user->id)
->where('kind', $kind)
->where('is_primary', 1)
->first();
if ($billingAddr) {
$billingAddr->is_billing = 1;
$billingAddr->save();
}
});
return Redirect::route('account.addresses')
->with('toast', __('Addresses updated!'));
}
/**
* password pane
*/
public function passwordForm(Request $request)
{
return $this->frame('account.settings.password', [
'title' => __('Password'),
'sub' => __('Change your password.'),
'user' => $request->user(),
]);
}
/**
* save password pane
*/
public function passwordStore(Request $request): RedirectResponse
{
$data = $request->validate([
'current_password' => ['required', 'current_password'],
'password' => ['required', 'string', 'min:12', 'confirmed'],
]);
$user = $request->user();
$user->forceFill([
'password' => Hash::make($data['password']),
])->save();
return Redirect::route('account.password')
->with('toast', __('Password updated!'));
}
/**
* delete pane
*/
public function deleteForm(Request $request)
{
return $this->frame('account.settings.delete', [
'title' => __('account.settings.delete.title'),
'sub' => __('account.settings.delete.subtitle'),
'user' => $request->user(),
]);
}
/**
* delete confirmation modal/page
*/
public function deleteConfirm(Request $request)
{
if ($request->header('HX-Request')) {
return view('account.partials.delete-modal');
}
// no-js full page
return $this->frame('account.settings.delete-confirm', [
'title' => __('account.settings.delete-confirm.title'),
'sub' => __('account.settings.delete-confirm.subtitle'),
'user' => $request->user(),
]);
}
/**
* delete the user's account
*/
public function destroy(Request $request)
{
// validate: on failure
// - full page: redirects to confirm page, flashes errors + input + toast
// - htmx: returns 422 modal html + HX-Trigger toast
$this->validateHtmx($request, [
'password' => ['required', 'current_password'],
], [
'bag' => 'userDeletion',
'redirect' => 'account.delete.confirm', // no-js failure lands back on confirm
'htmx_view' => 'account.modals.delete', // modal content to re-render on 422
'toast_type' => 'error', // optional, default is already error
// optional: override toast message (default is first validation message)
// 'toast_message' => __('Please correct the password and try again.'),
// optional: disable toast on validation failures:
// 'toast' => false,
]);
// passed validation, delete account
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
// success: for no-js, redirect home with toast
// for htmx, send HX-Redirect + HX-Trigger (handled inside helper)
return $this->redirectWithToast(
$request,
'dashboard', // or whatever route you want, could be a url('/') helper variant too
__('Your account has been deleted.'),
'success'
);
}
/**
* content frame handler
*/
private function frame(?string $view = null, array $data = [])
{
return view('account.index', [
'view' => $view,
'data' => $data,
]);
}
}