Refactors account and calendar settings pages; installs language localization files; new traits for HTMX and toast handling; solid icon variants added; modals improved
317
app/Http/Controllers/AccountController.php
Normal file
@ -0,0 +1,317 @@
|
||||
<?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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -8,12 +8,100 @@ use Illuminate\Support\Str;
|
||||
|
||||
class CalendarSettingsController extends Controller
|
||||
{
|
||||
/* landing page – list of settings choices */
|
||||
/* landing page shows the first settings pane (language/region) */
|
||||
public function index()
|
||||
{
|
||||
return $this->frame('calendar.settings.subscribe');
|
||||
return redirect()->route('calendar.settings.language');
|
||||
}
|
||||
|
||||
/**
|
||||
* Language and region
|
||||
**/
|
||||
|
||||
/* language and region form */
|
||||
public function languageForm(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
$settings = (array) ($user->settings ?? []);
|
||||
|
||||
return $this->frame('calendar.settings.language', [
|
||||
'title' => __('calendar.settings.language_region.title'),
|
||||
|
||||
'values' => [
|
||||
'language' => $user->getSetting('app.language', app()->getLocale()),
|
||||
'region' => $user->getSetting('app.region', 'US'),
|
||||
'date_format' => $user->getSetting('app.date_format', 'mdy'),
|
||||
'time_format' => $user->getSetting('app.time_format', '12'),
|
||||
],
|
||||
|
||||
'options' => [
|
||||
'languages' => [
|
||||
'en' => 'English',
|
||||
'es' => 'Spanish',
|
||||
'fr' => 'French',
|
||||
'de' => 'German',
|
||||
'it' => 'Italian',
|
||||
'pt' => 'Portuguese',
|
||||
'nl' => 'Dutch',
|
||||
],
|
||||
'regions' => [
|
||||
'US' => 'United States',
|
||||
'CA' => 'Canada',
|
||||
'GB' => 'United Kingdom',
|
||||
'AU' => 'Australia',
|
||||
'NZ' => 'New Zealand',
|
||||
'IE' => 'Ireland',
|
||||
'DE' => 'Germany',
|
||||
'FR' => 'France',
|
||||
'ES' => 'Spain',
|
||||
'IT' => 'Italy',
|
||||
'NL' => 'Netherlands',
|
||||
],
|
||||
'date_formats' => [
|
||||
'mdy' => 'MM/DD/YYYY (01/15/2026)',
|
||||
'dmy' => 'DD/MM/YYYY (15/01/2026)',
|
||||
'ymd' => 'YYYY-MM-DD (2026-01-15)',
|
||||
],
|
||||
'time_formats' => [
|
||||
'12' => '12-hour (1:30 PM)',
|
||||
'24' => '24-hour (13:30)',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/* handle POST from language/region pane */
|
||||
public function languageStore(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'language' => ['required', 'string', 'max:10', 'regex:/^[a-z]{2}([-_][A-Z]{2})?$/'],
|
||||
'region' => ['required', 'string', 'size:2', 'regex:/^[A-Z]{2}$/'],
|
||||
'date_format' => ['required', 'in:mdy,dmy,ymd'],
|
||||
'time_format' => ['required', 'in:12,24'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
$user->setSettings([
|
||||
'app.language' => $data['language'],
|
||||
'app.region' => $data['region'],
|
||||
'app.date_format' => $data['date_format'],
|
||||
'app.time_format' => $data['time_format'],
|
||||
]);
|
||||
|
||||
// apply immediately for the current request cycle going forward
|
||||
app()->setLocale($data['language']);
|
||||
|
||||
return redirect()
|
||||
->route('calendar.settings.language')
|
||||
->with('toast', __('Settings saved!'));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Subscribe
|
||||
**/
|
||||
|
||||
/* show “Subscribe to a calendar” form */
|
||||
public function subscribeForm()
|
||||
{
|
||||
@ -50,6 +138,7 @@ class CalendarSettingsController extends Controller
|
||||
->with('toast', __('Subscription added successfully!'));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* content frame handler
|
||||
*/
|
||||
|
||||
40
app/Http/Controllers/Concerns/FlashesToasts.php
Normal file
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Concerns;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
|
||||
trait FlashesToasts
|
||||
{
|
||||
protected function toast(Request $request, string $message, string $type = 'success')
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
return response('', 204)->header('HX-Trigger', json_encode([
|
||||
'toast' => ['message' => $message, 'type' => $type],
|
||||
]));
|
||||
}
|
||||
|
||||
// for normal requests, just flash to session
|
||||
return Redirect::back()->with('toast', [
|
||||
'message' => $message,
|
||||
'type' => $type ]);
|
||||
}
|
||||
|
||||
protected function redirectWithToast(Request $request, string $routeName, string $message, string $type = 'success')
|
||||
{
|
||||
if ($request->header('HX-Request')) {
|
||||
// optionally: you can HX-Redirect and also trigger toast
|
||||
return response('', 204)
|
||||
->header('HX-Redirect', route($routeName))
|
||||
->header('HX-Trigger', json_encode([
|
||||
'toast' => ['message' => $message, 'type' => $type],
|
||||
]));
|
||||
}
|
||||
|
||||
return Redirect::route($routeName)->with('toast', [
|
||||
'message' => $message,
|
||||
'type' => $type ]);
|
||||
}
|
||||
}
|
||||
80
app/Http/Controllers/Concerns/ValidatesHtmx.php
Normal file
@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Concerns;
|
||||
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\ViewErrorBag;
|
||||
|
||||
trait ValidatesHtmx
|
||||
{
|
||||
protected function validateHtmx(Request $request, array $rules, array $options = []): array
|
||||
{
|
||||
$bag = $options['bag'] ?? 'default';
|
||||
|
||||
$validator = Validator::make(
|
||||
$request->all(),
|
||||
$rules,
|
||||
$options['messages'] ?? [],
|
||||
$options['attributes'] ?? [],
|
||||
);
|
||||
|
||||
if ($validator->passes()) {
|
||||
return $validator->validated();
|
||||
}
|
||||
|
||||
// single-message toast by default
|
||||
$toast = $options['toast_message']
|
||||
?? $validator->errors()->first()
|
||||
?? __('Please correct any errors and try again.');
|
||||
$type = $options['toast_type'] ?? 'error';
|
||||
|
||||
// allow disabling toast
|
||||
$toastEnabled = $options['toast'] ?? true;
|
||||
|
||||
if ($request->header('HX-Request')) {
|
||||
$request->session()->flashInput($request->input());
|
||||
|
||||
$errors = new ViewErrorBag();
|
||||
$errors->put($bag, $validator->errors());
|
||||
|
||||
$view = $options['htmx_view'] ?? null;
|
||||
if (!$view) {
|
||||
throw new HttpResponseException(response('', 422));
|
||||
}
|
||||
|
||||
$data = array_merge($options['htmx_data'] ?? [], [
|
||||
'errors' => $errors,
|
||||
]);
|
||||
|
||||
$response = response()->view($view, $data, 422);
|
||||
|
||||
if ($toastEnabled) {
|
||||
$response->header('HX-Trigger', json_encode([
|
||||
'toast' => [
|
||||
'message' => $toast,
|
||||
'type' => $type,
|
||||
],
|
||||
]));
|
||||
}
|
||||
|
||||
throw new HttpResponseException($response);
|
||||
}
|
||||
|
||||
$redirect = $options['redirect'] ?? null;
|
||||
$response = $redirect ? Redirect::route($redirect) : Redirect::back();
|
||||
|
||||
if ($toastEnabled) {
|
||||
$response->with('toast', [
|
||||
'message' => $toast,
|
||||
'type' => $type,
|
||||
]);
|
||||
}
|
||||
|
||||
throw new HttpResponseException(
|
||||
$response->withErrors($validator, $bag)->withInput()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Concerns\FlashesToasts;
|
||||
use App\Http\Controllers\Concerns\ValidatesHtmx;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
@ -9,5 +11,11 @@ use Illuminate\Routing\Controller as BaseController;
|
||||
abstract class Controller
|
||||
{
|
||||
// add authorization requests to the base controller
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
use AuthorizesRequests;
|
||||
use ValidatesRequests;
|
||||
|
||||
// add error handling with HTMX and toast handling
|
||||
// controllers now get $this->validateHtmx(), $this->toast(), $this->redirectWithToast()
|
||||
use ValidatesHtmx;
|
||||
use FlashesToasts;
|
||||
}
|
||||
|
||||
@ -1,172 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\ProfileUpdateRequest;
|
||||
use App\Models\UserAddress;
|
||||
use App\Models\Location;
|
||||
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\Redirect;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
/**
|
||||
* profile index test
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return $this->frame('profile.partials.addresses-form');
|
||||
}
|
||||
|
||||
/**
|
||||
* display user's profile forms
|
||||
*/
|
||||
public function edit(Request $request): View
|
||||
{
|
||||
$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();
|
||||
|
||||
$billing = $user->addresses()->where('kind', 'billing')->where('is_primary', 1)->first()
|
||||
?? $user->addresses()->where('kind', 'billing')->first();
|
||||
|
||||
return view('profile.edit', compact('user', 'home', 'billing'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's profile information.
|
||||
*/
|
||||
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||
{
|
||||
$request->user()->fill($request->validated());
|
||||
|
||||
if ($request->user()->isDirty('email')) {
|
||||
$request->user()->email_verified_at = null;
|
||||
}
|
||||
|
||||
$request->user()->save();
|
||||
|
||||
return Redirect::route('profile.edit')->with('status', 'profile-updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* save or update the user's addresses (/profile/addresses)
|
||||
*/
|
||||
public function saveAddresses(Request $request, Geocoder $geocoder): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$data = $request->validate([
|
||||
'home' => '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',
|
||||
'home.phone' => 'nullable|string|max:32',
|
||||
|
||||
'billing' => 'array',
|
||||
'billing.label' => 'nullable|string|max:100',
|
||||
'billing.line1' => 'nullable|string|max:255',
|
||||
'billing.line2' => 'nullable|string|max:255',
|
||||
'billing.city' => 'nullable|string|max:120',
|
||||
'billing.state' => 'nullable|string|max:64',
|
||||
'billing.postal' => 'nullable|string|max:32',
|
||||
'billing.country' => 'nullable|string|max:64',
|
||||
'billing.phone' => 'nullable|string|max:32',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($user, $data, $geocoder) {
|
||||
|
||||
$save = function (string $kind, array $payload) use ($user, $geocoder) {
|
||||
// short-circuit if nothing filled
|
||||
$filled = collect($payload)->only(['line1','line2','city','state','postal','country','phone'])
|
||||
->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,
|
||||
'phone' => $payload['phone'] ?? null,
|
||||
]
|
||||
);
|
||||
|
||||
// build a singleLine string to geocode
|
||||
$singleLine = collect([
|
||||
$addr->line1, $addr->line2, $addr->city, $addr->state, $addr->postal, $addr->country
|
||||
])->filter()->implode(', ');
|
||||
|
||||
if ($singleLine !== '') {
|
||||
// geocode and link to locations
|
||||
$norm = $geocoder->forward($singleLine);
|
||||
if ($norm) {
|
||||
$loc = Location::findOrCreateNormalized($norm, $singleLine);
|
||||
$addr->location_id = $loc->id;
|
||||
$addr->save();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isset($data['home'])) {
|
||||
$save('home', $data['home']);
|
||||
}
|
||||
if (isset($data['billing'])) {
|
||||
$save('billing', $data['billing']);
|
||||
}
|
||||
});
|
||||
|
||||
return Redirect::route('profile.edit')->with('status', 'addresses-updated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the user's account.
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validateWithBag('userDeletion', [
|
||||
'password' => ['required', 'current_password'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
Auth::logout();
|
||||
|
||||
$user->delete();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return Redirect::to('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* content frame handler
|
||||
*/
|
||||
private function frame(?string $view = null, array $data = [])
|
||||
{
|
||||
return view('profile.index', [
|
||||
'view' => $view,
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
24
app/Http/Middleware/SetUserLocale.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SetUserLocale
|
||||
{
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if ($user) {
|
||||
$locale = $user->getSetting('app.language');
|
||||
|
||||
if (is_string($locale) && $locale !== '') {
|
||||
app()->setLocale($locale);
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,7 @@ use App\Models\User;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ProfileUpdateRequest extends FormRequest
|
||||
class AccountUpdateRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
@ -16,7 +16,9 @@ class ProfileUpdateRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'firstname' => ['nullable', 'string', 'max:255'],
|
||||
'lastname' => ['nullable', 'string', 'max:255'],
|
||||
'displayname' => ['nullable', 'string', 'max:255'],
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
@ -25,6 +27,8 @@ class ProfileUpdateRequest extends FormRequest
|
||||
'max:255',
|
||||
Rule::unique(User::class)->ignore($this->user()->id),
|
||||
],
|
||||
'phone' => ['nullable', 'string', 'max:32'],
|
||||
'timezone' => ['nullable', 'string', 'max:64'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -3,13 +3,14 @@
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use App\Models\UserSetting;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
@ -26,9 +27,12 @@ class User extends Authenticatable
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'firstname',
|
||||
'lastname',
|
||||
'displayname',
|
||||
'email',
|
||||
'password',
|
||||
'timezone',
|
||||
'phone',
|
||||
];
|
||||
|
||||
/**
|
||||
@ -55,7 +59,7 @@ class User extends Authenticatable
|
||||
}
|
||||
|
||||
/**
|
||||
* A user can own many calendars.
|
||||
* user can own many calendars
|
||||
*/
|
||||
public function calendars(): HasMany
|
||||
{
|
||||
@ -88,4 +92,69 @@ class User extends Authenticatable
|
||||
->where('is_primary', 1)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* user can have many settings
|
||||
*/
|
||||
public function userSettings(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserSetting::class, 'user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* get a user setting by key
|
||||
*/
|
||||
public function getSetting(string $key, mixed $default = null): mixed
|
||||
{
|
||||
// avoid repeated queries if the relationship is already eager loaded
|
||||
if ($this->relationLoaded('userSettings')) {
|
||||
$match = $this->userSettings->firstWhere('key', $key);
|
||||
return $match?->value ?? $default;
|
||||
}
|
||||
|
||||
$value = $this->userSettings()
|
||||
->where('key', $key)
|
||||
->value('value');
|
||||
|
||||
return $value ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* set a user setting by key
|
||||
*/
|
||||
public function setSetting(string $key, mixed $value): void
|
||||
{
|
||||
// store everything as a string in the value column
|
||||
$stringValue = is_null($value) ? null : (string) $value;
|
||||
|
||||
$this->userSettings()->updateOrCreate(
|
||||
['key' => $key],
|
||||
['value' => $stringValue],
|
||||
);
|
||||
|
||||
// keep in-memory relation in sync if it was loaded
|
||||
if ($this->relationLoaded('userSettings')) {
|
||||
$existing = $this->userSettings->firstWhere('key', $key);
|
||||
|
||||
if ($existing) {
|
||||
$existing->value = $stringValue;
|
||||
} else {
|
||||
$this->userSettings->push(new UserSetting([
|
||||
'user_id' => $this->getKey(),
|
||||
'key' => $key,
|
||||
'value' => $stringValue,
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* convenience: set many settings at once
|
||||
*/
|
||||
public function setSettings(array $pairs): void
|
||||
{
|
||||
foreach ($pairs as $key => $value) {
|
||||
$this->setSetting($key, $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,6 +30,7 @@ class UserAddress extends Model
|
||||
// simple casts
|
||||
protected $casts = [
|
||||
'is_primary' => 'boolean',
|
||||
'is_billing' => 'boolean',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
22
app/Models/UserSetting.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class UserSetting extends Model
|
||||
{
|
||||
protected $table = 'user_settings';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'key',
|
||||
'value',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\SetUserLocale;
|
||||
use App\Jobs\GeocodeEventLocations;
|
||||
use App\Jobs\SyncSubscriptionsDispatcher;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
@ -16,7 +17,10 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
)
|
||||
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
//
|
||||
// set language/locale
|
||||
$middleware->web(append: [
|
||||
SetUserLocale::class,
|
||||
]);
|
||||
})
|
||||
|
||||
->withSchedule(function (Schedule $schedule) {
|
||||
|
||||
@ -14,6 +14,7 @@ return new class extends Migration
|
||||
$table->string('firstname')->nullable();
|
||||
$table->string('lastname')->nullable();
|
||||
$table->string('displayname')->nullable(); // formerly from sabre principals table
|
||||
$table->string('phone', 32)->nullable(); // user-level phone number
|
||||
//$table->string('name')->nullable(); // custom name if necessary
|
||||
$table->string('email')->unique();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
|
||||
@ -15,10 +15,10 @@ return new class extends Migration
|
||||
Schema::create('user_addresses', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
|
||||
// your users.id is char(26)
|
||||
// users.id is char(26)
|
||||
$table->char('user_id', 26);
|
||||
$table->string('label', 100)->nullable(); // "Home", "Office"
|
||||
$table->string('kind', 20)->nullable(); // "billing", "shipping", "home", "work"
|
||||
$table->string('kind', 20)->nullable(); // "home", "work", etc
|
||||
|
||||
// raw, user-entered fields
|
||||
$table->string('line1', 255)->nullable();
|
||||
@ -27,7 +27,6 @@ return new class extends Migration
|
||||
$table->string('state', 64)->nullable();
|
||||
$table->string('postal', 32)->nullable();
|
||||
$table->string('country', 64)->nullable();
|
||||
$table->string('phone', 32)->nullable();
|
||||
|
||||
// optional normalized location
|
||||
$table->unsignedBigInteger('location_id')->nullable();
|
||||
@ -35,11 +34,15 @@ return new class extends Migration
|
||||
// nullable so unique index allows many NULLs (only 1 with value 1)
|
||||
$table->boolean('is_primary')->nullable()->default(null);
|
||||
|
||||
// flag for whether this is the user's billing address
|
||||
$table->boolean('is_billing')->default(false);
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
// helpful indexes
|
||||
$table->index(['user_id', 'kind']);
|
||||
$table->index('postal');
|
||||
$table->index(['user_id', 'is_billing']);
|
||||
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
|
||||
$table->foreign('location_id')->references('id')->on('locations')->nullOnDelete();
|
||||
|
||||
@ -55,7 +58,6 @@ return new class extends Migration
|
||||
$table->string('country', 64)->nullable()->after('postal');
|
||||
$table->decimal('lat', 9, 6)->nullable()->after('country');
|
||||
$table->decimal('lon', 9, 6)->nullable()->after('lat');
|
||||
|
||||
$table->index('postal');
|
||||
});
|
||||
}
|
||||
@ -76,4 +78,4 @@ return new class extends Migration
|
||||
|
||||
Schema::dropIfExists('user_addresses');
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('user_settings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->char('user_id', 26);
|
||||
$table->string('key', 100);
|
||||
$table->text('value')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'key']);
|
||||
$table->index(['user_id', 'key']);
|
||||
|
||||
$table->foreign('user_id')
|
||||
->references('id')->on('users')
|
||||
->cascadeOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user_settings');
|
||||
}
|
||||
};
|
||||
@ -42,6 +42,32 @@ class DatabaseSeeder extends Seeder
|
||||
'displayname' => $firstname.' '.$lastname,
|
||||
]);
|
||||
|
||||
/**
|
||||
*
|
||||
* global calendar settings (user_settings)
|
||||
*/
|
||||
|
||||
$defaultCalendarSettings = [
|
||||
'app.language' => 'en',
|
||||
'app.region' => 'US',
|
||||
'app.date_format' => 'mdy',
|
||||
'app.time_format' => '12',
|
||||
];
|
||||
|
||||
foreach ($defaultCalendarSettings as $key => $value) {
|
||||
DB::table('user_settings')->updateOrInsert(
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'key' => $key,
|
||||
],
|
||||
[
|
||||
'value' => (string) $value,
|
||||
'updated_at' => now(),
|
||||
'created_at' => now(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* calendar and meta
|
||||
|
||||
64
lang/en/account.php
Normal file
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Account Language Lines
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Account, profile, and user settings language lines.
|
||||
|
|
||||
*/
|
||||
|
||||
// addresses
|
||||
'address' => [
|
||||
'city' => 'City',
|
||||
'country' => 'Country',
|
||||
'home' => 'Home address',
|
||||
'label' => 'Address label',
|
||||
'line1' => 'Address line 1',
|
||||
'line2' => 'Address line 2',
|
||||
'state' => 'State',
|
||||
'work' => 'Work address',
|
||||
'zip' => 'Zip code',
|
||||
],
|
||||
'billing' => [
|
||||
'home' => 'Use your home address for billing',
|
||||
'work' => 'Use your work address for billing',
|
||||
],
|
||||
'delete' => 'Delete account',
|
||||
'delete-your' => 'Delete your account',
|
||||
'delete-confirm' => 'Really delete my account!',
|
||||
'email' => 'Email',
|
||||
'email_address' => 'Email address',
|
||||
'first_name' => 'First name',
|
||||
'last_name' => 'Last name',
|
||||
'phone' => 'Phone number',
|
||||
'settings' => [
|
||||
'addresses' => [
|
||||
'title' => 'Addresses',
|
||||
'subtitle' => 'Manage your home and work addresses and choose which one is used for billing.',
|
||||
],
|
||||
'delete' => [
|
||||
'title' => 'There be dragons here',
|
||||
'subtitle' => 'Delete your account and remove all information from our database. This can\'t be undone, so we recommend exporting your data first and migrating to a new provider.',
|
||||
'explanation' => 'Please note that this is not like other apps that "delete" your data—we\'re not setting <code>is_deleted = 1</code>, we\'re purging it from our database.',
|
||||
],
|
||||
'delete-confirm' => [
|
||||
'title' => 'Confirm account deletion',
|
||||
'subtitle' => 'Please enter your password and confirm that you would like to permanently delete your account.',
|
||||
],
|
||||
'information' => [
|
||||
'title' => 'Account information',
|
||||
'subtitle' => 'Your name, email address, and other primary account details.',
|
||||
],
|
||||
'password' => [
|
||||
'title' => 'Password',
|
||||
'subtitle' => 'Ensure your account is using a long, random password to stay secure. We always recommend a password manager as well!',
|
||||
],
|
||||
'title' => 'Account settings',
|
||||
],
|
||||
'title' => 'Account',
|
||||
|
||||
];
|
||||
20
lang/en/auth.php
Normal file
@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Language Lines
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following language lines are used during authentication for various
|
||||
| messages that we need to display to the user. You are free to modify
|
||||
| these language lines according to your application's requirements.
|
||||
|
|
||||
*/
|
||||
|
||||
'failed' => 'These credentials do not match our records.',
|
||||
'password' => 'The provided password is incorrect.',
|
||||
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
|
||||
|
||||
];
|
||||
28
lang/en/calendar.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Calendar Language Lines
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following language lines are used throughout the calendar app,
|
||||
| including calendar settings and events.
|
||||
|
|
||||
*/
|
||||
|
||||
// settings
|
||||
'settings' => [
|
||||
'language_region' => [
|
||||
'title' => 'Language and region',
|
||||
'subtitle' => 'Choose your default language, region, and formatting preferences for calendars. These affect how dates and times are displayed throughout Kithkin.',
|
||||
],
|
||||
'subscribe' => [
|
||||
'title' => 'Subscribe to a calendar',
|
||||
'subtitle' => 'Add an `.ics` calendar from another service',
|
||||
],
|
||||
'title' => 'Calendar settings',
|
||||
],
|
||||
|
||||
];
|
||||
26
lang/en/common.php
Normal file
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Common words and phrases
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Generic words used throughout the app in more than one location.
|
||||
|
|
||||
*/
|
||||
|
||||
'address' => 'Address',
|
||||
'addresses' => 'Addresses',
|
||||
'calendar' => 'Calendar',
|
||||
'calendars' => 'Calendars',
|
||||
'cancel' => 'Cancel',
|
||||
'cancel_funny' => 'Get me out of here',
|
||||
'event' => 'Event',
|
||||
'events' => 'Events',
|
||||
'password' => 'Password',
|
||||
'save_changes' => 'Save changes',
|
||||
'settings' => 'Settings',
|
||||
|
||||
];
|
||||
19
lang/en/pagination.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Pagination Language Lines
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following language lines are used by the paginator library to build
|
||||
| the simple pagination links. You are free to change them to anything
|
||||
| you want to customize your views to better match your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'previous' => '« Previous',
|
||||
'next' => 'Next »',
|
||||
|
||||
];
|
||||
22
lang/en/passwords.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Reset Language Lines
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following language lines are the default lines which match reasons
|
||||
| that are given by the password broker for a password update attempt
|
||||
| outcome such as failure due to an invalid password / reset token.
|
||||
|
|
||||
*/
|
||||
|
||||
'reset' => 'Your password has been reset.',
|
||||
'sent' => 'We have emailed your password reset link.',
|
||||
'throttled' => 'Please wait before retrying.',
|
||||
'token' => 'This password reset token is invalid.',
|
||||
'user' => "We can't find a user with that email address.",
|
||||
|
||||
];
|
||||
198
lang/en/validation.php
Normal file
@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Validation Language Lines
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following language lines contain the default error messages used by
|
||||
| the validator class. Some of these rules have multiple versions such
|
||||
| as the size rules. Feel free to tweak each of these messages here.
|
||||
|
|
||||
*/
|
||||
|
||||
'accepted' => 'The :attribute field must be accepted.',
|
||||
'accepted_if' => 'The :attribute field must be accepted when :other is :value.',
|
||||
'active_url' => 'The :attribute field must be a valid URL.',
|
||||
'after' => 'The :attribute field must be a date after :date.',
|
||||
'after_or_equal' => 'The :attribute field must be a date after or equal to :date.',
|
||||
'alpha' => 'The :attribute field must only contain letters.',
|
||||
'alpha_dash' => 'The :attribute field must only contain letters, numbers, dashes, and underscores.',
|
||||
'alpha_num' => 'The :attribute field must only contain letters and numbers.',
|
||||
'any_of' => 'The :attribute field is invalid.',
|
||||
'array' => 'The :attribute field must be an array.',
|
||||
'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.',
|
||||
'before' => 'The :attribute field must be a date before :date.',
|
||||
'before_or_equal' => 'The :attribute field must be a date before or equal to :date.',
|
||||
'between' => [
|
||||
'array' => 'The :attribute field must have between :min and :max items.',
|
||||
'file' => 'The :attribute field must be between :min and :max kilobytes.',
|
||||
'numeric' => 'The :attribute field must be between :min and :max.',
|
||||
'string' => 'The :attribute field must be between :min and :max characters.',
|
||||
],
|
||||
'boolean' => 'The :attribute field must be true or false.',
|
||||
'can' => 'The :attribute field contains an unauthorized value.',
|
||||
'confirmed' => 'The :attribute field confirmation does not match.',
|
||||
'contains' => 'The :attribute field is missing a required value.',
|
||||
'current_password' => 'The password you entered is incorrect.',
|
||||
'date' => 'The :attribute field must be a valid date.',
|
||||
'date_equals' => 'The :attribute field must be a date equal to :date.',
|
||||
'date_format' => 'The :attribute field must match the format :format.',
|
||||
'decimal' => 'The :attribute field must have :decimal decimal places.',
|
||||
'declined' => 'The :attribute field must be declined.',
|
||||
'declined_if' => 'The :attribute field must be declined when :other is :value.',
|
||||
'different' => 'The :attribute field and :other must be different.',
|
||||
'digits' => 'The :attribute field must be :digits digits.',
|
||||
'digits_between' => 'The :attribute field must be between :min and :max digits.',
|
||||
'dimensions' => 'The :attribute field has invalid image dimensions.',
|
||||
'distinct' => 'The :attribute field has a duplicate value.',
|
||||
'doesnt_end_with' => 'The :attribute field must not end with one of the following: :values.',
|
||||
'doesnt_start_with' => 'The :attribute field must not start with one of the following: :values.',
|
||||
'email' => 'The :attribute field must be a valid email address.',
|
||||
'ends_with' => 'The :attribute field must end with one of the following: :values.',
|
||||
'enum' => 'The selected :attribute is invalid.',
|
||||
'exists' => 'The selected :attribute is invalid.',
|
||||
'extensions' => 'The :attribute field must have one of the following extensions: :values.',
|
||||
'file' => 'The :attribute field must be a file.',
|
||||
'filled' => 'The :attribute field must have a value.',
|
||||
'gt' => [
|
||||
'array' => 'The :attribute field must have more than :value items.',
|
||||
'file' => 'The :attribute field must be greater than :value kilobytes.',
|
||||
'numeric' => 'The :attribute field must be greater than :value.',
|
||||
'string' => 'The :attribute field must be greater than :value characters.',
|
||||
],
|
||||
'gte' => [
|
||||
'array' => 'The :attribute field must have :value items or more.',
|
||||
'file' => 'The :attribute field must be greater than or equal to :value kilobytes.',
|
||||
'numeric' => 'The :attribute field must be greater than or equal to :value.',
|
||||
'string' => 'The :attribute field must be greater than or equal to :value characters.',
|
||||
],
|
||||
'hex_color' => 'The :attribute field must be a valid hexadecimal color.',
|
||||
'image' => 'The :attribute field must be an image.',
|
||||
'in' => 'The selected :attribute is invalid.',
|
||||
'in_array' => 'The :attribute field must exist in :other.',
|
||||
'in_array_keys' => 'The :attribute field must contain at least one of the following keys: :values.',
|
||||
'integer' => 'The :attribute field must be an integer.',
|
||||
'ip' => 'The :attribute field must be a valid IP address.',
|
||||
'ipv4' => 'The :attribute field must be a valid IPv4 address.',
|
||||
'ipv6' => 'The :attribute field must be a valid IPv6 address.',
|
||||
'json' => 'The :attribute field must be a valid JSON string.',
|
||||
'list' => 'The :attribute field must be a list.',
|
||||
'lowercase' => 'The :attribute field must be lowercase.',
|
||||
'lt' => [
|
||||
'array' => 'The :attribute field must have less than :value items.',
|
||||
'file' => 'The :attribute field must be less than :value kilobytes.',
|
||||
'numeric' => 'The :attribute field must be less than :value.',
|
||||
'string' => 'The :attribute field must be less than :value characters.',
|
||||
],
|
||||
'lte' => [
|
||||
'array' => 'The :attribute field must not have more than :value items.',
|
||||
'file' => 'The :attribute field must be less than or equal to :value kilobytes.',
|
||||
'numeric' => 'The :attribute field must be less than or equal to :value.',
|
||||
'string' => 'The :attribute field must be less than or equal to :value characters.',
|
||||
],
|
||||
'mac_address' => 'The :attribute field must be a valid MAC address.',
|
||||
'max' => [
|
||||
'array' => 'The :attribute field must not have more than :max items.',
|
||||
'file' => 'The :attribute field must not be greater than :max kilobytes.',
|
||||
'numeric' => 'The :attribute field must not be greater than :max.',
|
||||
'string' => 'The :attribute field must not be greater than :max characters.',
|
||||
],
|
||||
'max_digits' => 'The :attribute field must not have more than :max digits.',
|
||||
'mimes' => 'The :attribute field must be a file of type: :values.',
|
||||
'mimetypes' => 'The :attribute field must be a file of type: :values.',
|
||||
'min' => [
|
||||
'array' => 'The :attribute field must have at least :min items.',
|
||||
'file' => 'The :attribute field must be at least :min kilobytes.',
|
||||
'numeric' => 'The :attribute field must be at least :min.',
|
||||
'string' => 'The :attribute field must be at least :min characters.',
|
||||
],
|
||||
'min_digits' => 'The :attribute field must have at least :min digits.',
|
||||
'missing' => 'The :attribute field must be missing.',
|
||||
'missing_if' => 'The :attribute field must be missing when :other is :value.',
|
||||
'missing_unless' => 'The :attribute field must be missing unless :other is :value.',
|
||||
'missing_with' => 'The :attribute field must be missing when :values is present.',
|
||||
'missing_with_all' => 'The :attribute field must be missing when :values are present.',
|
||||
'multiple_of' => 'The :attribute field must be a multiple of :value.',
|
||||
'not_in' => 'The selected :attribute is invalid.',
|
||||
'not_regex' => 'The :attribute field format is invalid.',
|
||||
'numeric' => 'The :attribute field must be a number.',
|
||||
'password' => [
|
||||
'letters' => 'The :attribute field must contain at least one letter.',
|
||||
'mixed' => 'The :attribute field must contain at least one uppercase and one lowercase letter.',
|
||||
'numbers' => 'The :attribute field must contain at least one number.',
|
||||
'symbols' => 'The :attribute field must contain at least one symbol.',
|
||||
'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.',
|
||||
],
|
||||
'present' => 'The :attribute field must be present.',
|
||||
'present_if' => 'The :attribute field must be present when :other is :value.',
|
||||
'present_unless' => 'The :attribute field must be present unless :other is :value.',
|
||||
'present_with' => 'The :attribute field must be present when :values is present.',
|
||||
'present_with_all' => 'The :attribute field must be present when :values are present.',
|
||||
'prohibited' => 'The :attribute field is prohibited.',
|
||||
'prohibited_if' => 'The :attribute field is prohibited when :other is :value.',
|
||||
'prohibited_if_accepted' => 'The :attribute field is prohibited when :other is accepted.',
|
||||
'prohibited_if_declined' => 'The :attribute field is prohibited when :other is declined.',
|
||||
'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.',
|
||||
'prohibits' => 'The :attribute field prohibits :other from being present.',
|
||||
'regex' => 'The :attribute field format is invalid.',
|
||||
'required' => 'The :attribute field is required.',
|
||||
'required_array_keys' => 'The :attribute field must contain entries for: :values.',
|
||||
'required_if' => 'The :attribute field is required when :other is :value.',
|
||||
'required_if_accepted' => 'The :attribute field is required when :other is accepted.',
|
||||
'required_if_declined' => 'The :attribute field is required when :other is declined.',
|
||||
'required_unless' => 'The :attribute field is required unless :other is in :values.',
|
||||
'required_with' => 'The :attribute field is required when :values is present.',
|
||||
'required_with_all' => 'The :attribute field is required when :values are present.',
|
||||
'required_without' => 'The :attribute field is required when :values is not present.',
|
||||
'required_without_all' => 'The :attribute field is required when none of :values are present.',
|
||||
'same' => 'The :attribute field must match :other.',
|
||||
'size' => [
|
||||
'array' => 'The :attribute field must contain :size items.',
|
||||
'file' => 'The :attribute field must be :size kilobytes.',
|
||||
'numeric' => 'The :attribute field must be :size.',
|
||||
'string' => 'The :attribute field must be :size characters.',
|
||||
],
|
||||
'starts_with' => 'The :attribute field must start with one of the following: :values.',
|
||||
'string' => 'The :attribute field must be a string.',
|
||||
'timezone' => 'The :attribute field must be a valid timezone.',
|
||||
'unique' => 'The :attribute has already been taken.',
|
||||
'uploaded' => 'The :attribute failed to upload.',
|
||||
'uppercase' => 'The :attribute field must be uppercase.',
|
||||
'url' => 'The :attribute field must be a valid URL.',
|
||||
'ulid' => 'The :attribute field must be a valid ULID.',
|
||||
'uuid' => 'The :attribute field must be a valid UUID.',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Custom Validation Language Lines
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify custom validation messages for attributes using the
|
||||
| convention "attribute.rule" to name the lines. This makes it quick to
|
||||
| specify a specific custom language line for a given attribute rule.
|
||||
|
|
||||
*/
|
||||
|
||||
'custom' => [
|
||||
'attribute-name' => [
|
||||
'rule-name' => 'custom-message',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Custom Validation Attributes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following language lines are used to swap our attribute placeholder
|
||||
| with something more reader friendly such as "E-Mail Address" instead
|
||||
| of "email". This simply helps us make our message more expressive.
|
||||
|
|
||||
*/
|
||||
|
||||
'attributes' => [],
|
||||
|
||||
];
|
||||
11
lang/it/common.php
Normal file
@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
'calendar' => 'Calendario',
|
||||
'calendars' => 'Calendari',
|
||||
'event' => 'Evento',
|
||||
'events' => 'Eventi',
|
||||
'settings' => 'Impostazioni',
|
||||
|
||||
];
|
||||
@ -13,6 +13,7 @@
|
||||
@import './lib/input.css';
|
||||
@import './lib/mini.css';
|
||||
@import './lib/modal.css';
|
||||
@import './lib/toast.css';
|
||||
|
||||
/** plugins */
|
||||
@plugin '@tailwindcss/forms';
|
||||
|
||||
@ -133,14 +133,14 @@ main {
|
||||
|
||||
a {
|
||||
@apply flex flex-row gap-2 items-center justify-start px-3 h-9 rounded-md;
|
||||
transition: background-color 100ms ease-in-out;
|
||||
/*transition: background-color 100ms ease-in-out; */
|
||||
|
||||
&:hover {
|
||||
@apply bg-cyan-200;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
@apply bg-cyan-400;
|
||||
@apply bg-cyan-400 font-semibold;
|
||||
|
||||
&:hover {
|
||||
@apply bg-cyan-500;
|
||||
@ -193,6 +193,16 @@ main {
|
||||
|
||||
> .content {
|
||||
@apply col-span-4 2xl:col-span-3;
|
||||
|
||||
/* page subtitle section */
|
||||
.description {
|
||||
|
||||
}
|
||||
|
||||
/* everything below the description (i.e., the content pane) */
|
||||
.pane {
|
||||
@apply flex flex-col items-start gap-4 my-8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -16,6 +16,8 @@
|
||||
--color-gray-950: #282828;
|
||||
--color-primary: #151515;
|
||||
--color-primary-hover: #000000;
|
||||
--color-secondary: #555;
|
||||
--color-secondary-hover: #444;
|
||||
--color-cyan-50: oklch(98.97% 0.015 196.79);
|
||||
--color-cyan-100: oklch(97.92% 0.03 196.61);
|
||||
--color-cyan-200: oklch(95.79% 0.063 196.12);
|
||||
@ -40,6 +42,17 @@
|
||||
--color-magenta-800: oklch(47.69% 0.219 328.37);
|
||||
--color-magenta-900: oklch(40.42% 0.186 328.37);
|
||||
--color-magenta-950: oklch(36.79% 0.169 328.37);
|
||||
--color-red-50: oklch(0.975 0.012 23.84);
|
||||
--color-red-100: oklch(0.951 0.024 20.79);
|
||||
--color-red-200: oklch(0.895 0.055 23.81);
|
||||
--color-red-300: oklch(0.845 0.085 23.49);
|
||||
--color-red-400: oklch(0.79 0.121 25.68);
|
||||
--color-red-500: oklch(0.742 0.157 27.48);
|
||||
--color-red-600: oklch(0.686 0.203 26.03);
|
||||
--color-red-700: oklch(0.586 0.1663 26.19);
|
||||
--color-red-800: oklch(0.386 0.136 34.85);
|
||||
--color-red-900: oklch(0.269 0.095 34.78);
|
||||
--color-red-950: oklch(0.205 0.073 34.33);
|
||||
|
||||
--border-width-md: 1.5px;
|
||||
|
||||
|
||||
@ -34,12 +34,17 @@ h2 {
|
||||
@apply font-serif text-2xl font-extrabold leading-tight text-primary;
|
||||
}
|
||||
|
||||
/* section dividers */
|
||||
h3 {
|
||||
@apply text-xl font-semibold leading-tight text-secondary;
|
||||
}
|
||||
|
||||
/* links */
|
||||
a {
|
||||
&.text {
|
||||
@apply underline decoration-inherit underline-offset-2 text-magenta-600;
|
||||
@apply underline decoration-inherit underline-offset-2 text-black font-semibold;
|
||||
text-decoration-thickness: 1.5px;
|
||||
transition: color 125ms ease-in-out;
|
||||
transition: color 100ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
@apply text-magenta-700;
|
||||
@ -58,4 +63,12 @@ p {
|
||||
|
||||
.description { /* contains <p> and uses gap for spacing */
|
||||
@apply space-y-3;
|
||||
|
||||
p {
|
||||
@apply text-lg;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
@apply text-base text-red-600 font-semibold;
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
button,
|
||||
.button {
|
||||
@apply relative inline-flex items-center cursor-pointer gap-2 rounded-md h-11 px-4 text-lg font-medium;
|
||||
transition: background-color 125ms ease-in-out;
|
||||
/*transition: background-color 125ms ease-in-out; */
|
||||
--button-border: var(--color-primary);
|
||||
--button-accent: var(--color-primary-hover);
|
||||
|
||||
@ -22,6 +22,34 @@ button,
|
||||
}
|
||||
}
|
||||
|
||||
&.button--secondary {
|
||||
@apply bg-white border-md border-solid;
|
||||
border-color: var(--button-border);
|
||||
--button-border: var(--color-gray-400);
|
||||
--button-accent: var(--color-gray-100);
|
||||
|
||||
&:hover {
|
||||
@apply bg-gray-100;
|
||||
}
|
||||
}
|
||||
|
||||
&.button--tertiary {
|
||||
@apply px-0;
|
||||
|
||||
&:hover {
|
||||
@apply underline decoration-[1.5px] underline-offset-3;
|
||||
}
|
||||
}
|
||||
|
||||
&.button--danger {
|
||||
@apply bg-red-500 font-bold;
|
||||
--button-accent: var(--color-red-900);
|
||||
|
||||
&:hover {
|
||||
@apply bg-red-600;
|
||||
}
|
||||
}
|
||||
|
||||
&.button--icon {
|
||||
@apply justify-center p-0 h-12 top-px rounded-blob;
|
||||
aspect-ratio: 1 / 1;
|
||||
@ -45,7 +73,7 @@ button,
|
||||
> button {
|
||||
@apply relative flex items-center justify-center h-full pl-3.5 pr-3 cursor-pointer;
|
||||
@apply border-md border-primary border-l-0 font-medium rounded-none;
|
||||
transition: background-color 100ms ease-in-out;
|
||||
/*transition: background-color 100ms ease-in-out; */
|
||||
|
||||
&:hover {
|
||||
@apply bg-cyan-200;
|
||||
|
||||
@ -5,13 +5,52 @@ input[type="email"],
|
||||
input[type="password"],
|
||||
input[type="text"],
|
||||
input[type="url"],
|
||||
input[type="search"] {
|
||||
input[type="search"],
|
||||
select {
|
||||
@apply border-md border-gray-800 bg-white rounded-md shadow-input;
|
||||
@apply focus:border-primary focus:ring-2 focus:ring-offset-2 focus:ring-cyan-600;
|
||||
transition: box-shadow 125ms ease-in-out,
|
||||
border-color 125ms ease-in-out;
|
||||
}
|
||||
|
||||
/**
|
||||
* checkboxes
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* radio buttons
|
||||
*/
|
||||
|
||||
input[type="radio"] {
|
||||
@apply appearance-none p-0 align-middle select-none shrink-0 rounded-full h-6 w-6;
|
||||
@apply text-black border-md border-gray-800 bg-none;
|
||||
@apply focus:border-primary focus:ring-2 focus:ring-offset-2 focus:ring-cyan-600;
|
||||
print-color-adjust: exact;
|
||||
--radio-bg: var(--color-white);
|
||||
|
||||
&:checked {
|
||||
@apply border-black;
|
||||
box-shadow: 0 0 0 3px var(--radio-bg) inset, 0 0 0 3px var(--radio-bg) inset;
|
||||
animation: radio-check 200ms ease-out;
|
||||
}
|
||||
}
|
||||
@keyframes radio-check {
|
||||
0% {
|
||||
box-shadow: 0 0 0 12px var(--radio-bg) inset, 0 0 0 12px var(--radio-bg) inset;
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 2px var(--radio-bg) inset, 0 0 0 2px var(--radio-bg) inset;
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 3px var(--radio-bg) inset, 0 0 0 3px var(--radio-bg) inset;
|
||||
}
|
||||
}
|
||||
.radio-label {
|
||||
@apply flex flex-row gap-2 items-center;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* specific minor types
|
||||
*/
|
||||
@ -30,18 +69,32 @@ input[type="color"] {
|
||||
label.text-label {
|
||||
@apply flex flex-col gap-2;
|
||||
|
||||
> .label {
|
||||
@apply font-semibold text-md;
|
||||
}
|
||||
|
||||
> .description {
|
||||
@apply text-gray-800;
|
||||
}
|
||||
}
|
||||
.label {
|
||||
@apply font-semibold text-md;
|
||||
}
|
||||
|
||||
/**
|
||||
* form layouts
|
||||
*/
|
||||
form {
|
||||
&.settings {
|
||||
@apply mt-8;
|
||||
@apply 2xl:max-w-3xl;
|
||||
}
|
||||
}
|
||||
article.settings {
|
||||
/* scroll detection for use later */
|
||||
animation: settings-scroll;
|
||||
animation-timeline: scroll(self);
|
||||
--can-scroll: 0;
|
||||
}
|
||||
@keyframes settings-scroll {
|
||||
from, to { --can-scroll: 1; }
|
||||
}
|
||||
.form-grid-1 {
|
||||
@apply grid grid-cols-3 gap-6;
|
||||
|
||||
@ -53,5 +106,63 @@ label.text-label {
|
||||
@apply col-span-3 flex flex-row justify-start gap-4 pt-4;
|
||||
}
|
||||
}
|
||||
.input-row {
|
||||
@apply grid gap-4;
|
||||
@apply md:gap-6;
|
||||
|
||||
/* format is a string of slash-separated cols, so `1-2` is 3 cols, 1st spans 1 and 2nd spans 2 */
|
||||
&.input-row--1 {
|
||||
@apply grid-cols-1;
|
||||
}
|
||||
&.input-row--1-1 {
|
||||
@apply grid-cols-1;
|
||||
@apply md:grid-cols-2;
|
||||
}
|
||||
&.input-row--1-1-1-1 {
|
||||
@apply grid-cols-1;
|
||||
@apply md:grid-cols-2;
|
||||
@apply xl:grid-cols-4;
|
||||
}
|
||||
&.input-row--2-1-1 {
|
||||
@apply grid-cols-1;
|
||||
@apply md:grid-cols-4;
|
||||
|
||||
.input-cell:first-child {
|
||||
@apply col-span-2;
|
||||
}
|
||||
}
|
||||
|
||||
/* bottom actions row */
|
||||
&.input-row--actions {
|
||||
@apply pt-6 pb-8 flex flex-row gap-6 justify-between items-center;
|
||||
@apply sticky bottom-0 bg-white border-t-md border-transparent;
|
||||
transition: border-color 100ms ease-in-out;
|
||||
box-shadow: 0 -1rem 1rem white;
|
||||
|
||||
&.input-row--start {
|
||||
@apply justify-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* generic cell and row handling */
|
||||
.input-cell {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
+ .input-row {
|
||||
@apply mt-6;
|
||||
}
|
||||
}
|
||||
h3 + .input-row {
|
||||
@apply mt-6;
|
||||
}
|
||||
@container style(--can-scroll: 1) {
|
||||
.input-row--actions {
|
||||
@apply !border-black;
|
||||
}
|
||||
}
|
||||
@container style(--can-scroll: 0) {
|
||||
.input-row--actions {
|
||||
@apply !border-transparent;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
.close-modal {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
dialog {
|
||||
@apply grid fixed top-0 right-0 bottom-0 left-0 m-0 p-0 pointer-events-none;
|
||||
@apply justify-items-center items-start bg-transparent opacity-0 invisible;
|
||||
@ -20,16 +24,35 @@ dialog {
|
||||
transition: all 150ms cubic-bezier(0,0,.2,1);
|
||||
box-shadow: #00000040 0 1.5rem 4rem -0.5rem;
|
||||
|
||||
> form {
|
||||
@apply absolute top-4 right-4;
|
||||
> .close-modal {
|
||||
@apply block absolute top-4 right-4;
|
||||
}
|
||||
|
||||
> .content {
|
||||
@apply w-full;
|
||||
|
||||
/* modal header */
|
||||
h2 {
|
||||
@apply pr-12;
|
||||
header {
|
||||
@apply px-6 py-6;
|
||||
|
||||
h2 {
|
||||
@apply pr-12;
|
||||
}
|
||||
}
|
||||
|
||||
/* main content pane */
|
||||
section {
|
||||
@apply flex flex-col px-6 pb-8;
|
||||
}
|
||||
|
||||
/* standard form with 1rem gap between rows */
|
||||
form {
|
||||
@apply flex flex-col gap-4;
|
||||
}
|
||||
|
||||
/* footer */
|
||||
footer {
|
||||
@apply px-6 py-4 border-t-md border-gray-400 flex justify-between;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
52
resources/css/lib/toast.css
Normal file
@ -0,0 +1,52 @@
|
||||
dl.toasts {
|
||||
@apply fixed top-4 right-4 z-100;
|
||||
--toast-delay: 7s;
|
||||
|
||||
/* semantic info in a hidden title */
|
||||
dt {
|
||||
@apply h-0 invisible overflow-hidden;
|
||||
|
||||
&.success + dd {
|
||||
@apply bg-green-500 text-white;
|
||||
}
|
||||
|
||||
&.error + dd {
|
||||
@apply bg-red-500 text-primary;
|
||||
}
|
||||
|
||||
&.info + dd {
|
||||
@apply bg-cyan-500 text-primary;
|
||||
}
|
||||
}
|
||||
|
||||
/* toast itself */
|
||||
dd {
|
||||
@apply max-w-96 rounded-md px-4 py-3 translate-y-2 leading-tight font-medium;
|
||||
animation:
|
||||
toast-in 250ms ease-out forwards,
|
||||
toast-out 300ms ease-in forwards;
|
||||
animation-delay:
|
||||
0ms,
|
||||
var(--toast-delay);
|
||||
}
|
||||
}
|
||||
|
||||
.toast__dismiss{
|
||||
color: rgba(255,255,255,.85);
|
||||
text-decoration: underline;
|
||||
font-size: .875rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
to {
|
||||
opacity: 1;
|
||||
translate: 0;
|
||||
}
|
||||
}
|
||||
@keyframes toast-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
translate: 0 -0.5rem;
|
||||
}
|
||||
}
|
||||
@ -15,7 +15,7 @@ document.addEventListener('htmx:configRequest', (evt) => {
|
||||
})
|
||||
|
||||
// calendar toggle
|
||||
// > progressive enhancement on html form with no js
|
||||
// * progressive enhancement on html form with no js
|
||||
document.addEventListener('change', event => {
|
||||
const checkbox = event.target;
|
||||
|
||||
|
||||
1
resources/svg/icons/bomb.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="13" r="9"/><path d="M14.35 4.65 16.3 2.7a2.41 2.41 0 0 1 3.4 0l1.6 1.6a2.4 2.4 0 0 1 0 3.4l-1.95 1.95"/><path d="m22 2-1.5 1.5"/></svg>
|
||||
|
After Width: | Height: | Size: 337 B |
1
resources/svg/icons/info-circle.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||
|
After Width: | Height: | Size: 261 B |
1
resources/svg/icons/key.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z"/><circle cx="16.5" cy="7.5" r=".5" fill="currentColor"/></svg>
|
||||
|
After Width: | Height: | Size: 424 B |
1
resources/svg/icons/pin.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
|
After Width: | Height: | Size: 330 B |
1
resources/svg/icons/solid/bomb.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round" viewBox="0 0 24 24"><circle cx="11" cy="13" r="9" style="stroke:#000;stroke-width:2px"/><path d="M14.35 4.65 16.3 2.7a2.422 2.422 0 0 1 3.4 0l1.6 1.6a2.399 2.399 0 0 1 0 3.4l-1.95 1.95M22 2l-1.5 1.5" style="fill:none;fill-rule:nonzero;stroke:#000;stroke-width:2px"/></svg>
|
||||
|
After Width: | Height: | Size: 420 B |
1
resources/svg/icons/solid/calendar-sync.svg
Normal file
@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M7,5l-2,0c-0.549,0 -1,0.451 -1,1l0,3l3.005,0c0.549,0 0.995,0.446 0.995,0.995l0,6.505c0,1.844 0.667,3.533 1.772,4.839c0.169,0.176 0.272,0.415 0.272,0.679c0,0.542 -0.44,0.982 -0.982,0.982l-4.062,-0c-1.646,0 -3,-1.354 -3,-3l0,-14c0,-1.646 1.354,-3 3,-3l2,0l0,-1c-0,-0.552 0.448,-1 1,-1c0.552,-0 1,0.448 1,1l0,1l6,0l0,-1c-0,-0.552 0.448,-1 1,-1c0.552,-0 1,0.448 1,1l0,1l2,0c1.646,0 3,1.354 3,3l0,2.549c0,0.549 -0.446,0.995 -0.995,0.995c-0.545,0 -0.989,-0.44 -0.996,-0.983l-0,-0.012l-0.009,-2.549c0,-0.549 -0.451,-1 -1,-1l-2,0l0,1c-0,0.552 -0.448,1 -1,1c-0.552,-0 -1,-0.448 -1,-1l0,-1l-6,0l-0,1c-0,0.552 -0.448,1 -1,1c-0.552,-0 -1,-0.448 -1,-1l0,-1Zm3,9l-0,-4c0,-0.552 0.448,-1 1,-1c0.552,0 1,0.448 1,1l-0,1.528c1.098,-0.982 2.522,-1.528 4,-1.528c2.332,0 4.461,1.359 5.442,3.474c0.232,0.501 0.015,1.096 -0.486,1.328c-0.501,0.232 -1.096,0.015 -1.328,-0.486c-0.654,-1.41 -2.074,-2.316 -3.628,-2.316c-0.976,0 -1.917,0.357 -2.646,1l1.646,-0c0.552,-0 1,0.448 1,1c0,0.552 -0.448,1 -1,1l-4,-0c-0.258,0 -0.505,-0.099 -0.691,-0.277c-0.198,-0.189 -0.309,-0.449 -0.309,-0.723Zm10,6.472c-1.098,0.982 -2.522,1.528 -4,1.528c-2.332,0 -4.461,-1.359 -5.442,-3.474c-0.232,-0.501 -0.015,-1.096 0.486,-1.328c0.501,-0.232 1.096,-0.015 1.328,0.486c0.654,1.41 2.074,2.316 3.628,2.316c0.976,0 1.917,-0.357 2.646,-1l-1.646,0c-0.552,0 -1,-0.448 -1,-1c-0,-0.552 0.448,-1 1,-1l4,0c0.258,-0 0.505,0.099 0.691,0.277c0.198,0.189 0.309,0.449 0.309,0.723l0,4c0,0.552 -0.448,1 -1,1c-0.552,0 -1,-0.448 -1,-1l0,-1.528Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
1
resources/svg/icons/solid/calendar.svg
Normal file
@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M17,3l2,0c1.656,0 3,1.344 3,3l0,14c0,1.656 -1.344,3 -3,3l-14,0c-1.656,0 -3,-1.344 -3,-3l0,-14c0,-1.656 1.344,-3 3,-3l2,0l0,-1c-0,-0.552 0.448,-1 1,-1c0.552,-0 1,0.448 1,1l0,1l6,0l0,-1c-0,-0.552 0.448,-1 1,-1c0.552,-0 1,0.448 1,1l0,1Zm-10,2l-2,0c-0.552,0 -1,0.448 -1,1l0,3l16,0l0,-3c0,-0.552 -0.448,-1 -1,-1l-2,0l0,1c-0,0.552 -0.448,1 -1,1c-0.552,-0 -1,-0.448 -1,-1l0,-1l-6,0l0,1c-0,0.552 -0.448,1 -1,1c-0.552,-0 -1,-0.448 -1,-1l0,-1Zm9.01,11.981c-0.562,0 -1.019,0.457 -1.019,1.019c0,0.562 0.457,1.019 1.019,1.019c0.562,0 1.019,-0.457 1.019,-1.019c0,-0.562 -0.457,-1.019 -1.019,-1.019Zm-4.01,-4c-0.562,0 -1.019,0.457 -1.019,1.019c0,0.562 0.457,1.019 1.019,1.019c0.562,0 1.019,-0.457 1.019,-1.019c0,-0.562 -0.457,-1.019 -1.019,-1.019Zm-3.99,0c-0.562,0 -1.019,0.457 -1.019,1.019c0,0.562 0.457,1.019 1.019,1.019c0.562,0 1.019,-0.457 1.019,-1.019c0,-0.562 -0.457,-1.019 -1.019,-1.019Zm3.99,4c-0.562,0 -1.019,0.457 -1.019,1.019c0,0.562 0.457,1.019 1.019,1.019c0.562,0 1.019,-0.457 1.019,-1.019c0,-0.562 -0.457,-1.019 -1.019,-1.019Zm4.01,-4c-0.562,0 -1.019,0.457 -1.019,1.019c0,0.562 0.457,1.019 1.019,1.019c0.562,0 1.019,-0.457 1.019,-1.019c0,-0.562 -0.457,-1.019 -1.019,-1.019Zm-8,4c-0.562,0 -1.019,0.457 -1.019,1.019c0,0.562 0.457,1.019 1.019,1.019c0.562,0 1.019,-0.457 1.019,-1.019c0,-0.562 -0.457,-1.019 -1.019,-1.019Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
resources/svg/icons/solid/globe.svg
Normal file
@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M11.546,22.991c-5.527,-0.225 -10.008,-4.536 -10.501,-9.991l6.012,0c0.221,3.467 1.59,6.872 4.108,9.572l0.38,0.418Zm1.144,-0.267l0.035,-0.034c2.589,-2.718 3.995,-6.172 4.219,-9.69l6.012,0c-0.493,5.457 -4.978,9.77 -10.509,9.991l0.243,-0.267Zm-0.27,-21.716c5.543,0.208 10.042,4.526 10.535,9.992l-6.012,-0c-0.224,-3.518 -1.63,-6.971 -4.219,-9.69l-0.034,-0.034l-0.026,-0.024l-0.244,-0.244Zm-1.144,0.302c-2.589,2.718 -3.995,6.172 -4.219,9.69l-6.012,-0c0.493,-5.456 4.976,-9.768 10.505,-9.991l-0.274,0.301Zm0.724,2.205c1.766,2.187 2.746,4.812 2.942,7.485l-5.883,-0c0.195,-2.673 1.176,-5.298 2.942,-7.485Zm0,16.97c-1.766,-2.187 -2.746,-4.812 -2.942,-7.485l5.883,-0c-0.195,2.673 -1.176,5.298 -2.942,7.485Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
1
resources/svg/icons/solid/info-circle.svg
Normal file
@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M12,1c6.071,0 11,4.929 11,11c0,6.071 -4.929,11 -11,11c-6.071,0 -11,-4.929 -11,-11c0,-6.071 4.929,-11 11,-11Zm1.25,15l0,-4c-0,-0.69 -0.56,-1.25 -1.25,-1.25c-0.69,0 -1.25,0.56 -1.25,1.25l0,4c-0,0.69 0.56,1.25 1.25,1.25c0.69,0 1.25,-0.56 1.25,-1.25Zm-1.252,-9.252c-0.691,0 -1.252,0.561 -1.252,1.252c0,0.691 0.561,1.252 1.252,1.252c0.691,0 1.252,-0.561 1.252,-1.252c0,-0.691 -0.561,-1.252 -1.252,-1.252Z"/></svg>
|
||||
|
After Width: | Height: | Size: 857 B |
1
resources/svg/icons/solid/key.svg
Normal file
@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M11.172,17l-0.172,0l0,1c0,1.097 -0.903,2 -2,2l-1,0l0,1c0,1.097 -0.903,2 -2,2l-3,0c-1.097,0 -2,-0.903 -2,-2l0,-2.172c0,-0.795 0.317,-1.559 0.879,-2.121l6.392,-6.392c-0.154,-0.605 -0.233,-1.228 -0.233,-1.853c0,-4.114 3.386,-7.5 7.5,-7.5c4.114,0 7.5,3.386 7.5,7.5c0,4.114 -3.386,7.5 -7.5,7.5c-0.626,0 -1.249,-0.078 -1.853,-0.233l-0.392,0.392c-0.562,0.562 -1.326,0.879 -2.121,0.879Zm5.311,-11.526c-1.109,0 -2.009,0.9 -2.009,2.009c0,1.109 0.9,2.009 2.009,2.009c1.109,0 2.009,-0.9 2.009,-2.009c0,-1.109 -0.9,-2.009 -2.009,-2.009Z"/></svg>
|
||||
|
After Width: | Height: | Size: 981 B |
1
resources/svg/icons/solid/pin.svg
Normal file
@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;"><path d="M20,10c0,4.993 -5.539,10.193 -7.399,11.799c-0.355,0.267 -0.847,0.267 -1.202,0c-1.86,-1.606 -7.399,-6.806 -7.399,-11.799c-0,-4.389 3.611,-8 8,-8c4.389,-0 8,3.611 8,8Zm-8,-3c-1.656,0 -3,1.344 -3,3c0,1.656 1.344,3 3,3c1.656,0 3,-1.344 3,-3c0,-1.656 -1.344,-3 -3,-3Z" style="stroke:#000;stroke-width:2px;"/></svg>
|
||||
|
After Width: | Height: | Size: 759 B |
@ -9,13 +9,13 @@
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
|
||||
<div class="max-w-xl">
|
||||
@include('profile.partials.update-profile-information-form')
|
||||
@include('account.partials.update-profile-information-form')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
|
||||
<div class="max-w-xl">
|
||||
@include('profile.partials.addresses-form', [
|
||||
@include('account.partials.addresses-form', [
|
||||
'home' => $home ?? null,
|
||||
'billing' => $billing ?? null,
|
||||
])
|
||||
@ -24,13 +24,13 @@
|
||||
|
||||
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
|
||||
<div class="max-w-xl">
|
||||
@include('profile.partials.update-password-form')
|
||||
@include('account.partials.update-password-form')
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
|
||||
<div class="max-w-xl">
|
||||
@include('profile.partials.delete-user-form')
|
||||
@include('account.partials.delete-user-form')
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,10 +1,10 @@
|
||||
<x-app-layout id="profile" class="readable">
|
||||
<x-app-layout id="account" class="readable settings">
|
||||
|
||||
<x-slot name="aside">
|
||||
<h1>
|
||||
{{ __('Profile') }}
|
||||
{{ __('account.title') }}
|
||||
</h1>
|
||||
<x-profile.settings-menu />
|
||||
<x-menu.account-settings />
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="header">
|
||||
@ -12,7 +12,7 @@
|
||||
@isset($data['title'])
|
||||
{{ $data['title'] }}
|
||||
@else
|
||||
{{ __('Settings') }}
|
||||
{{ __('common.settings') }}
|
||||
@endisset
|
||||
</h2>
|
||||
</x-slot>
|
||||
26
resources/views/account/partials/delete-modal.blade.php
Normal file
@ -0,0 +1,26 @@
|
||||
<x-modal.content>
|
||||
<x-modal.title>
|
||||
{{ __('account.settings.delete-confirm.title') }}
|
||||
</x-modal.title>
|
||||
<x-modal.body>
|
||||
<form id="delete-account-form" method="post" action="{{ route('account.destroy') }}">
|
||||
@csrf
|
||||
@method('delete')
|
||||
<p>
|
||||
{{ __('account.settings.delete-confirm.subtitle') }}
|
||||
</p>
|
||||
<div>
|
||||
<x-input.text-label label="{{ __('Password') }}" id="password" name="password" type="password" placeholder="{{ __('Password') }}" />
|
||||
<x-input.error :messages="$errors->userDeletion->get('password')" />
|
||||
</div>
|
||||
</form>
|
||||
</x-modal.body>
|
||||
<x-modal.footer>
|
||||
<x-button variant="secondary" onclick="this.closest('dialog')?.close()">
|
||||
{{ __('common.cancel') }}
|
||||
</x-button>
|
||||
<x-button variant="primary danger" type="submit" form="delete-account-form">
|
||||
{{ __('account.delete-confirm') }}
|
||||
</x-button>
|
||||
</x-modal.footer>
|
||||
</x-modal.content>
|
||||
124
resources/views/account/settings/addresses.blade.php
Normal file
@ -0,0 +1,124 @@
|
||||
<div class="description">
|
||||
<p>
|
||||
{{ $sub }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ route('account.addresses.store') }}" class="settings">
|
||||
@csrf
|
||||
@method('patch')
|
||||
|
||||
{{-- home address --}}
|
||||
<h3>{{ __('account.address.home') }}</h3>
|
||||
<div class="input-row input-row--1-1">
|
||||
<div class="input-cell">
|
||||
<x-input.label for="home_label" :value="__('account.address.label')" />
|
||||
<x-input.text id="home_label" name="home[label]" type="text" placeholder="Home" :value="old('home.label', $home->label ?? '')" />
|
||||
<x-input.error :messages="$errors->get('home.label')" />
|
||||
</div>
|
||||
<div class="input-cell">
|
||||
<x-input.label for="home_country" :value="__('account.address.country')" />
|
||||
<x-input.text id="home_country" name="home[country]" type="text" :value="old('home.country', $home->country ?? '')" />
|
||||
<x-input.error :messages="$errors->get('home.country')" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-row input-row--1-1">
|
||||
<div class="input-cell">
|
||||
<x-input.label for="home_line1" :value="__('account.address.line1')" />
|
||||
<x-input.text id="home_line1" name="home[line1]" type="text" :value="old('home.line1', $home->line1 ?? '')" />
|
||||
<x-input.error :messages="$errors->get('home.line2')" />
|
||||
</div>
|
||||
<div class="input-cell">
|
||||
<x-input.label for="home_line2" :value="__('account.address.line2')" />
|
||||
<x-input.text id="home_line2" name="home[line2]" type="text" :value="old('home.line2', $home->line2 ?? '')" />
|
||||
<x-input.error :messages="$errors->get('home.line2')" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-row input-row--2-1-1">
|
||||
<div class="input-cell">
|
||||
<x-input.label for="home_city" :value="__('account.address.city')" />
|
||||
<x-input.text id="home_city" name="home[city]" type="text" :value="old('home.city', $home->city ?? '')" />
|
||||
<x-input.error :messages="$errors->get('home.city')" />
|
||||
</div>
|
||||
<div class="input-cell">
|
||||
<x-input.label for="home_state" :value="__('account.address.state')" />
|
||||
<x-input.select-state id="home_state" name="home[state]" :selected="$home->state ?? ''" autocomplete="address-level1" />
|
||||
<x-input.error :messages="$errors->get('home.state')" />
|
||||
</div>
|
||||
<div class="input-cell">
|
||||
<x-input.label for="home_postal" :value="__('account.address.zip')" />
|
||||
<x-input.text id="home_postal" name="home[postal]" type="text" :value="old('home.postal', $home->postal ?? '')" />
|
||||
<x-input.error :messages="$errors->get('home.postal')" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-row input-row--1">
|
||||
<div class="input-cell">
|
||||
<x-input.radio-label :label="__('account.billing.home')" id="billing_home" value="home" name="billing" :checked="old('billing', $billing) === 'home'" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- work address --}}
|
||||
<h3 class="mt-12">{{ __('account.address.work') }}</h3>
|
||||
<div class="input-row input-row--1-1">
|
||||
<div class="input-cell">
|
||||
<x-input.label for="work_label" :value="__('account.address.label')" />
|
||||
<x-input.text id="work_label" name="work[label]" type="text" placeholder="Work" :value="old('work.label', $work->label ?? '')" />
|
||||
<x-input.error :messages="$errors->get('work.label')" />
|
||||
</div>
|
||||
<div class="input-cell">
|
||||
<x-input.label for="work_country" :value="__('account.address.country')" />
|
||||
<x-input.text id="work_country" name="work[country]" type="text" :value="old('work.country', $work->country ?? '')" />
|
||||
<x-input.error :messages="$errors->get('work.country')" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-row input-row--1-1">
|
||||
<div class="input-cell">
|
||||
<x-input.label for="work_line1" :value="__('account.address.line1')" />
|
||||
<x-input.text id="work_line1" name="work[line1]" type="text" :value="old('work.line1', $work->line1 ?? '')" />
|
||||
<x-input.error :messages="$errors->get('work.line2')" />
|
||||
</div>
|
||||
<div class="input-cell">
|
||||
<x-input.label for="work_line2" :value="__('account.address.line2')" />
|
||||
<x-input.text id="work_line2" name="work[line2]" type="text" :value="old('work.line2', $work->line2 ?? '')" />
|
||||
<x-input.error :messages="$errors->get('work.line2')" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-row input-row--2-1-1">
|
||||
<div class="input-cell">
|
||||
<x-input.label for="work_city" :value="__('account.address.city')" />
|
||||
<x-input.text id="work_city" name="work[city]" type="text" :value="old('work.city', $work->city ?? '')" />
|
||||
<x-input.error :messages="$errors->get('work.city')" />
|
||||
</div>
|
||||
<div class="input-cell">
|
||||
<x-input.label for="work_state" :value="__('account.address.state')" />
|
||||
<x-input.select-state id="work_state" name="work[state]" :selected="$work->state ?? ''" autocomplete="address-level1" />
|
||||
<x-input.error :messages="$errors->get('work.state')" />
|
||||
</div>
|
||||
<div class="input-cell">
|
||||
<x-input.label for="work_postal" :value="__('account.address.zip')" />
|
||||
<x-input.text id="work_postal" name="work[postal]" type="text" :value="old('work.postal', $work->postal ?? '')" />
|
||||
<x-input.error :messages="$errors->get('work.postal')" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-row input-row--1">
|
||||
<div class="input-cell">
|
||||
<x-input.radio-label :label="__('account.billing.work')" id="billing_work" value="work" name="billing" :checked="old('billing', $billing) === 'work'" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- save --}}
|
||||
<div class="input-row input-row--actions input-row--start sticky-bottom">
|
||||
<x-button type="submit" variant="primary">{{ __('common.save_changes') }}</x-button>
|
||||
<x-button type="anchor" variant="secondary" href="{{ route('account.addresses') }}">{{ __('common.cancel') }}</x-button>
|
||||
|
||||
@if (session('status') === 'addresses-updated')
|
||||
<p
|
||||
x-data="{ show: true }"
|
||||
x-show="show"
|
||||
x-transition
|
||||
x-init="setTimeout(() => show = false, 2000)"
|
||||
class="text-sm text-gray-600"
|
||||
>{{ __('Saved.') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</form>
|
||||
21
resources/views/account/settings/delete-confirm.blade.php
Normal file
@ -0,0 +1,21 @@
|
||||
<div class="description">
|
||||
@isset($sub)
|
||||
<p>{{ $sub }}</p>
|
||||
@endisset
|
||||
</div>
|
||||
<form method="post" action="{{ route('account.destroy') }}" class="settings">
|
||||
@csrf
|
||||
@method('delete')
|
||||
|
||||
<div class="input-row input-row--1-1">
|
||||
<div class="input-cell">
|
||||
<x-input.text-label label="{{ __('Password') }}" id="password" name="password" type="password" placeholder="{{ __('Password') }}" />
|
||||
<x-input.error :messages="$errors->userDeletion->get('password')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-row input-row--actions input-row--start">
|
||||
<x-button type="submit" variant="primary danger">{{ __('account.delete-confirm') }}</x-button>
|
||||
<x-button type="anchor" variant="tertiary" href="{{ route('account.info') }}">{{ __('common.cancel_funny') }}</x-button>
|
||||
</div>
|
||||
</form>
|
||||
31
resources/views/account/settings/delete.blade.php
Normal file
@ -0,0 +1,31 @@
|
||||
<div class="description">
|
||||
<p>
|
||||
{{ $sub }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="pane">
|
||||
|
||||
<h3>
|
||||
{{ __('account.delete-your') }}
|
||||
</h3>
|
||||
|
||||
<p>
|
||||
{!! __('account.settings.delete.explanation') !!}
|
||||
</p>
|
||||
|
||||
<x-button
|
||||
type="anchor"
|
||||
variant="primary danger"
|
||||
class="mt-4"
|
||||
href="{{ route('account.delete.confirm') }}"
|
||||
hx-get="{{ route('account.delete.confirm') }}"
|
||||
hx-target="#modal"
|
||||
hx-swap="innerHTML"
|
||||
hx-push-url="false">
|
||||
{{ __('Delete my account...') }}
|
||||
</x-button>
|
||||
|
||||
@error('password', 'userDeletion')
|
||||
<div class="error">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
70
resources/views/account/settings/info.blade.php
Normal file
@ -0,0 +1,70 @@
|
||||
<div class="description">
|
||||
<p>
|
||||
{{ __("account.settings.information.subtitle") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form id="send-verification" method="post" action="{{ route('verification.send') }}">
|
||||
@csrf
|
||||
</form>
|
||||
|
||||
<form method="post" action="{{ route('account.info.store') }}" class="settings">
|
||||
@csrf
|
||||
@method('patch')
|
||||
|
||||
<div class="input-row input-row--1-1">
|
||||
<div class="input-cell">
|
||||
<x-input.text-label :label="__('account.first_name')" id="firstname" name="firstname" type="text" :value="old('firstname', $user->firstname)" required autofocus autocomplete="name" />
|
||||
<x-input.error :messages="$errors->get('firstname')" />
|
||||
</div>
|
||||
<div class="input-cell">
|
||||
<x-input.text-label :label="__('account.last_name')" id="lastname" name="lastname" type="text" :value="old('lastname', $user->lastname)" required autofocus autocomplete="name" />
|
||||
<x-input.error :messages="$errors->get('lastname')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-row input-row--1-1">
|
||||
<div class="input-cell">
|
||||
<x-input.text-label :label="__('account.email_address')" id="email" name="email" type="text" :value="old('email', $user->email)" required autofocus autocomplete="username" />
|
||||
<x-input.error class="mt-2" :messages="$errors->get('email')" />
|
||||
|
||||
@if ($user instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && ! $user->hasVerifiedEmail())
|
||||
<div>
|
||||
<p class="text-sm mt-2 text-gray-800">
|
||||
{{ __('Your email address is unverified.') }}
|
||||
|
||||
<button form="send-verification" class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
{{ __('Click here to re-send the verification email.') }}
|
||||
</button>
|
||||
</p>
|
||||
|
||||
@if (session('status') === 'verification-link-sent')
|
||||
<p class="mt-2 font-medium text-sm text-green-600">
|
||||
{{ __('A new verification link has been sent to your email address.') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="input-cell">
|
||||
<x-input.label for="phone" :value="__('account.phone')" />
|
||||
<x-input.text id="phone" name="phone" type="text" :value="old('phone', $user->phone ?? '')" />
|
||||
<x-input.error :messages="$errors->get('phone')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-row input-row--actions input-row--start sticky-bottom">
|
||||
<x-button variant="primary" type="submit">{{ __('common.save_changes') }}</x-button>
|
||||
<x-button type="anchor" variant="tertiary" href="{{ route('account.info') }}">{{ __('common.cancel') }}</x-button>
|
||||
|
||||
@if (session('status') === 'account-updated')
|
||||
<p
|
||||
x-data="{ show: true }"
|
||||
x-show="show"
|
||||
x-transition
|
||||
x-init="setTimeout(() => show = false, 2000)"
|
||||
class="text-sm text-gray-600"
|
||||
>{{ __('Saved.') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</form>
|
||||
46
resources/views/account/settings/password.blade.php
Normal file
@ -0,0 +1,46 @@
|
||||
<div class="description">
|
||||
<p>
|
||||
{{ __('account.settings.password.subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="post" action="{{ route('password.update') }}" class="settings">
|
||||
@csrf
|
||||
@method('put')
|
||||
|
||||
<div class="input-row input-row--1">
|
||||
<div class="input-cell">
|
||||
<x-input.text-label :label="__('Current password')" id="current_password" name="current_password" type="password" autocomplete="current-password" />
|
||||
<x-input.error :messages="$errors->updatePassword->get('current_password')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-row input-row--1">
|
||||
<div class="input-cell">
|
||||
<x-input.text-label :label="__('New password')" id="new_password" name="password" type="password" autocomplete="new-password" />
|
||||
<x-input.error :messages="$errors->updatePassword->get('password')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-row input-row--1">
|
||||
<div class="input-cell">
|
||||
<x-input.text-label :label="__('Confirm password')" id="password_confirmation" name="password_confirmation" type="password" autocomplete="new-password" />
|
||||
<x-input.error :messages="$errors->updatePassword->get('password_confirmation')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-row input-row--actions input-row--start">
|
||||
<x-button variant="primary">{{ __('common.save_changes') }}</x-button>
|
||||
<x-button type="anchor" variant="tertiary" href="{{ route('account.info') }}">{{ __('common.cancel') }}</x-button>
|
||||
|
||||
@if (session('status') === 'password-updated')
|
||||
<p
|
||||
x-data="{ show: true }"
|
||||
x-show="show"
|
||||
x-transition
|
||||
x-init="setTimeout(() => show = false, 2000)"
|
||||
class="text-sm text-gray-600"
|
||||
>{{ __('Saved.') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</form>
|
||||
@ -15,7 +15,7 @@
|
||||
name="password"
|
||||
required autocomplete="current-password" />
|
||||
|
||||
<x-input-error :messages="$errors->get('password')" class="mt-2" />
|
||||
<x-input.error :messages="$errors->get('password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
<div>
|
||||
<x-input-label for="email" :value="__('Email')" />
|
||||
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus />
|
||||
<x-input-error :messages="$errors->get('email')" class="mt-2" />
|
||||
<x-input.error :messages="$errors->get('email')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
|
||||
@ -7,21 +7,21 @@
|
||||
|
||||
<!-- Email Address -->
|
||||
<div>
|
||||
<x-input-label for="email" :value="__('Email')" />
|
||||
<x-input.label for="email" :value="__('Email')" />
|
||||
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autofocus autocomplete="username" />
|
||||
<x-input-error :messages="$errors->get('email')" class="mt-2" />
|
||||
<x-input.error :messages="$errors->get('email')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="password" :value="__('Password')" />
|
||||
<x-input.label for="password" :value="__('Password')" />
|
||||
|
||||
<x-text-input id="password" class="block mt-1 w-full"
|
||||
type="password"
|
||||
name="password"
|
||||
required autocomplete="current-password" />
|
||||
|
||||
<x-input-error :messages="$errors->get('password')" class="mt-2" />
|
||||
<x-input.error :messages="$errors->get('password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Remember Me -->
|
||||
@ -34,7 +34,7 @@
|
||||
|
||||
<div class="flex items-center justify-between mt-4 gap-4">
|
||||
@if (Route::has('password.request'))
|
||||
<a href="{{ route('password.request') }}" href="text">
|
||||
<a href="{{ route('password.request') }}" class="text">
|
||||
{{ __('Forgot your password?') }}
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@ -6,14 +6,14 @@
|
||||
<div>
|
||||
<x-input-label for="name" :value="__('Name')" />
|
||||
<x-text-input id="name" class="block mt-1 w-full" type="text" name="name" :value="old('name')" required autofocus autocomplete="name" />
|
||||
<x-input-error :messages="$errors->get('name')" class="mt-2" />
|
||||
<x-input.error :messages="$errors->get('name')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Email Address -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="email" :value="__('Email')" />
|
||||
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autocomplete="username" />
|
||||
<x-input-error :messages="$errors->get('email')" class="mt-2" />
|
||||
<x-input.error :messages="$errors->get('email')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
@ -25,7 +25,7 @@
|
||||
name="password"
|
||||
required autocomplete="new-password" />
|
||||
|
||||
<x-input-error :messages="$errors->get('password')" class="mt-2" />
|
||||
<x-input.error :messages="$errors->get('password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
@ -36,7 +36,7 @@
|
||||
type="password"
|
||||
name="password_confirmation" required autocomplete="new-password" />
|
||||
|
||||
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
|
||||
<x-input.error :messages="$errors->get('password_confirmation')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
|
||||
@ -9,14 +9,14 @@
|
||||
<div>
|
||||
<x-input-label for="email" :value="__('Email')" />
|
||||
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email', $request->email)" required autofocus autocomplete="username" />
|
||||
<x-input-error :messages="$errors->get('email')" class="mt-2" />
|
||||
<x-input.error :messages="$errors->get('email')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="mt-4">
|
||||
<x-input-label for="password" :value="__('Password')" />
|
||||
<x-text-input id="password" class="block mt-1 w-full" type="password" name="password" required autocomplete="new-password" />
|
||||
<x-input-error :messages="$errors->get('password')" class="mt-2" />
|
||||
<x-input.error :messages="$errors->get('password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
@ -27,7 +27,7 @@
|
||||
type="password"
|
||||
name="password_confirmation" required autocomplete="new-password" />
|
||||
|
||||
<x-input-error :messages="$errors->get('password_confirmation')" class="mt-2" />
|
||||
<x-input.error :messages="$errors->get('password_confirmation')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
<x-input-label for="name" :value="__('Name')" />
|
||||
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full"
|
||||
:value="old('name', $instance?->displayname ?? '')" required autofocus />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('name')" />
|
||||
<x-input.error class="mt-2" :messages="$errors->get('name')" />
|
||||
</div>
|
||||
|
||||
{{-- Description --}}
|
||||
@ -19,7 +19,7 @@
|
||||
<x-input-label for="description" :value="__('Description')" />
|
||||
<textarea id="description" name="description" rows="3"
|
||||
class="mt-1 block w-full rounded-md shadow-xs border-gray-300 focus:border-indigo-300 focus:ring-3">{{ old('description', $instance?->description ?? '') }}</textarea>
|
||||
<x-input-error class="mt-2" :messages="$errors->get('description')" />
|
||||
<x-input.error class="mt-2" :messages="$errors->get('description')" />
|
||||
</div>
|
||||
|
||||
{{-- Timezone --}}
|
||||
@ -34,7 +34,7 @@
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<x-input-error class="mt-2" :messages="$errors->get('timezone')" />
|
||||
<x-input.error class="mt-2" :messages="$errors->get('timezone')" />
|
||||
</div>
|
||||
|
||||
{{-- COLOR --}}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
<x-slot name="aside">
|
||||
<h1>
|
||||
{{ __('Calendar') }}
|
||||
{{ __('common.calendar') }}
|
||||
</h1>
|
||||
<div class="content aside-inset">
|
||||
<form id="calendar-toggles"
|
||||
|
||||
@ -2,9 +2,9 @@
|
||||
|
||||
<x-slot name="aside">
|
||||
<h1>
|
||||
{{ __('Calendar') }}
|
||||
{{ __('common.calendar') }}
|
||||
</h1>
|
||||
<x-calendar.settings-menu />
|
||||
<x-menu.calendar-settings />
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="header">
|
||||
@ -12,7 +12,7 @@
|
||||
@isset($data['title'])
|
||||
{{ $data['title'] }}
|
||||
@else
|
||||
{{ __('Settings') }}
|
||||
{{ __('common.settings') }}
|
||||
@endisset
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
78
resources/views/calendar/settings/language.blade.php
Normal file
@ -0,0 +1,78 @@
|
||||
@php
|
||||
$values = $data['values'] ?? [];
|
||||
$options = $data['options'] ?? [];
|
||||
@endphp
|
||||
|
||||
<div class="description">
|
||||
<p>
|
||||
{{ __('calendar.settings.language_region.subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="post"
|
||||
action="{{ route('calendar.settings.language.store') }}"
|
||||
class="form-grid-1 mt-8">
|
||||
@csrf
|
||||
|
||||
<div>
|
||||
<label for="language">{{ __('Language') }}</label>
|
||||
<select id="language" name="language">
|
||||
@foreach(($options['languages'] ?? []) as $value => $label)
|
||||
<option value="{{ $value }}" @selected(old('language', $values['language'] ?? '') === $value)>
|
||||
{{ $label }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('language')
|
||||
<div class="text-danger">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="region">{{ __('Region') }}</label>
|
||||
<select id="region" name="region">
|
||||
@foreach(($options['regions'] ?? []) as $value => $label)
|
||||
<option value="{{ $value }}" @selected(old('region', $values['region'] ?? '') === $value)>
|
||||
{{ $label }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('region')
|
||||
<div class="text-danger">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="date_format">{{ __('Date format') }}</label>
|
||||
<select id="date_format" name="date_format">
|
||||
@foreach(($options['date_formats'] ?? []) as $value => $label)
|
||||
<option value="{{ $value }}" @selected(old('date_format', $values['date_format'] ?? '') === $value)>
|
||||
{{ $label }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('date_format')
|
||||
<div class="text-danger">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="time_format">{{ __('Time format') }}</label>
|
||||
<select id="time_format" name="time_format">
|
||||
@foreach(($options['time_formats'] ?? []) as $value => $label)
|
||||
<option value="{{ $value }}" @selected(old('time_format', $values['time_format'] ?? '') === $value)>
|
||||
{{ $label }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('time_format')
|
||||
<div class="text-danger">{{ $message }}</div>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<x-button variant="primary" type="submit">{{ __('Save') }}</x-button>
|
||||
<a href="{{ route('calendar.index') }}"
|
||||
class="button button--secondary">{{ __('Cancel and go back') }}</a>
|
||||
</div>
|
||||
</form>
|
||||
@ -1,13 +1,26 @@
|
||||
@props(['active'])
|
||||
@props([
|
||||
'active' => false,
|
||||
'label' => null,
|
||||
'icon' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$classes = ($active ?? false)
|
||||
? 'is-active'
|
||||
: '';
|
||||
$isActive = (bool) $active;
|
||||
|
||||
$classes = $isActive ? 'is-active' : null;
|
||||
|
||||
$iconComponent = null;
|
||||
if ($icon) {
|
||||
$iconComponent = $isActive
|
||||
? 'icon-solid.'.$icon
|
||||
: 'icon-'.$icon;
|
||||
}
|
||||
@endphp
|
||||
|
||||
<li class="app-button">
|
||||
<a {{ $attributes->merge(['class' => $classes]) }}>
|
||||
{{ $slot }}
|
||||
<a {{ $attributes->merge(['class' => $classes]) }} aria-label="{{ $label }}">
|
||||
@if ($iconComponent)
|
||||
<x-dynamic-component :component="$iconComponent" class="w-7 h-7" />
|
||||
@endif
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@ -1,11 +1,37 @@
|
||||
@props(['active'])
|
||||
@props([
|
||||
'active' => false,
|
||||
|
||||
// label text (string)
|
||||
'label' => null,
|
||||
|
||||
// icon name only
|
||||
'icon' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$classes = ($active ?? false)
|
||||
? 'is-active'
|
||||
: '';
|
||||
$isActive = (bool) $active;
|
||||
|
||||
$classes = trim(collect([
|
||||
'pagelink',
|
||||
$isActive ? 'is-active' : null,
|
||||
])->filter()->implode(' '));
|
||||
|
||||
$iconComponent = null;
|
||||
if ($icon) {
|
||||
$iconComponent = $isActive
|
||||
? 'icon-solid.'.$icon
|
||||
: 'icon-'.$icon;
|
||||
}
|
||||
@endphp
|
||||
|
||||
<a {{ $attributes->merge(['class' => $classes]) }}>
|
||||
{{ $slot }}
|
||||
@if ($iconComponent)
|
||||
<x-dynamic-component :component="$iconComponent" width="20" />
|
||||
@endif
|
||||
|
||||
@if (!is_null($label))
|
||||
<span>{{ $label }}</span>
|
||||
@else
|
||||
{{ $slot }}
|
||||
@endif
|
||||
</a>
|
||||
|
||||
@ -21,8 +21,8 @@ $sizeClass = match ($size) {
|
||||
|
||||
$element = match ($type) {
|
||||
'anchor' => 'a',
|
||||
'button' => 'button',
|
||||
'submit' => 'button',
|
||||
'button' => 'button type="button"',
|
||||
'submit' => 'button type="submit"',
|
||||
default => 'button',
|
||||
};
|
||||
|
||||
|
||||
@ -1,39 +1,51 @@
|
||||
@props([
|
||||
'variant' => '',
|
||||
'size' => 'default',
|
||||
'type' => 'button',
|
||||
'class' => '',
|
||||
'label' => '',
|
||||
'href' => null ])
|
||||
'variant' => '', // e.g. "primary danger"
|
||||
'size' => 'default', // sm | default | lg
|
||||
'type' => 'button', // anchor | button | submit
|
||||
'class' => '',
|
||||
'label' => '',
|
||||
'href' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$variantClass = match ($variant) {
|
||||
'primary' => 'button--primary',
|
||||
'secondary' => 'button--secondary',
|
||||
default => '',
|
||||
};
|
||||
// allow "primary danger" (space-delimited), or even "primary,danger"
|
||||
$variantTokens = preg_split('/[\s,]+/', trim($variant)) ?: [];
|
||||
|
||||
$sizeClass = match ($size) {
|
||||
'sm' => 'button--sm',
|
||||
'lg' => 'button--lg',
|
||||
default => '',
|
||||
};
|
||||
$variantMap = [
|
||||
'primary' => 'button--primary',
|
||||
'secondary' => 'button--secondary',
|
||||
'tertiary' => 'button--tertiary',
|
||||
'danger' => 'button--danger',
|
||||
];
|
||||
|
||||
$element = match ($type) {
|
||||
'anchor' => 'a',
|
||||
'button' => 'button type="button"',
|
||||
'submit' => 'button type="submit"',
|
||||
default => 'button',
|
||||
};
|
||||
$variantClass = collect($variantTokens)
|
||||
->map(fn ($v) => $variantMap[$v] ?? null)
|
||||
->filter()
|
||||
->implode(' ');
|
||||
|
||||
$type = match ($type) {
|
||||
'anchor' => '',
|
||||
'button' => 'type="button"',
|
||||
'submit' => 'type="submit"',
|
||||
default => '',
|
||||
}
|
||||
$sizeClass = match ($size) {
|
||||
'sm' => 'button--sm',
|
||||
'lg' => 'button--lg',
|
||||
default => '',
|
||||
};
|
||||
|
||||
$isAnchor = $type === 'anchor';
|
||||
$tag = $isAnchor ? 'a' : 'button';
|
||||
$buttonType = $isAnchor ? null : ($type === 'submit' ? 'submit' : 'button');
|
||||
|
||||
$classes = trim("button {$variantClass} {$sizeClass} {$class}");
|
||||
@endphp
|
||||
|
||||
<{{ $element }} {{ $type }} class="button button--icon {{ $variantClass }} {{ $sizeClass }} {{ $class }}" aria-label="{{ $label }}" href="{{ $href }}">
|
||||
{{ $slot }}
|
||||
</{{ $element }}>
|
||||
@if($isAnchor)
|
||||
<a href="{{ $href }}"
|
||||
aria-label="{{ $label }}"
|
||||
{{ $attributes->merge(['class' => $classes]) }}>
|
||||
{{ $slot }}
|
||||
</a>
|
||||
@else
|
||||
<button type="{{ $buttonType }}"
|
||||
aria-label="{{ $label }}"
|
||||
{{ $attributes->merge(['class' => $classes]) }}>
|
||||
{{ $slot }}
|
||||
</button>
|
||||
@endif
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
<div class="drawers aside-inset">
|
||||
<details open>
|
||||
<summary>{{ __('General settings') }}</summary>
|
||||
<menu class="content pagelinks">
|
||||
<li>
|
||||
<x-app.pagelink
|
||||
href="{{ route('calendar.settings.subscribe') }}"
|
||||
:active="request()->routeIs('calendar.settings')">
|
||||
<x-icon-globe width="20" />
|
||||
<span>Language and region</span>
|
||||
</x-app.pagelink>
|
||||
</li>
|
||||
</menu>
|
||||
</details>
|
||||
<details open>
|
||||
<summary>{{ __('Add a calendar') }}</summary>
|
||||
<menu class="content pagelinks">
|
||||
<li>
|
||||
<x-app.pagelink
|
||||
href="{{ route('calendar.settings.subscribe') }}"
|
||||
:active="request()->routeIs('calendar.settings.subscribe')">
|
||||
<x-icon-calendar-sync width="20" />
|
||||
<span>Subscribe to a calendar</span>
|
||||
</x-app.pagelink>
|
||||
</li>
|
||||
</menu>
|
||||
</details>
|
||||
</div>
|
||||
@ -1,9 +1,9 @@
|
||||
@props(['messages'])
|
||||
|
||||
@if ($messages)
|
||||
<ul {{ $attributes->merge(['class' => 'text-sm text-red-600 space-y-1']) }}>
|
||||
<ul {{ $attributes->merge(['class' => 'input-errors']) }}>
|
||||
@foreach ((array) $messages as $message)
|
||||
<li>{{ $message }}</li>
|
||||
<li class="error">{{ $message }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
5
resources/views/components/input/label.blade.php
Normal file
@ -0,0 +1,5 @@
|
||||
@props(['value'])
|
||||
|
||||
<label {{ $attributes->merge(['class' => 'label']) }}>
|
||||
{{ $value ?? $slot }}
|
||||
</label>
|
||||
22
resources/views/components/input/radio-label.blade.php
Normal file
@ -0,0 +1,22 @@
|
||||
@props([
|
||||
'id' => null, // input id
|
||||
'label' => '', // label text
|
||||
'labelclass' => '', // extra CSS classes for the label
|
||||
'checkclass' => '', // radio classes
|
||||
'name' => '', // radio name
|
||||
'value' => '', // radio value
|
||||
'style' => '', // raw style string for the radio
|
||||
'checked' => false // true/false or truthy value
|
||||
])
|
||||
|
||||
<label class="radio-label {{ $labelclass }}">
|
||||
<x-input.radio
|
||||
:id="$id"
|
||||
:name="$name"
|
||||
:value="$value"
|
||||
:class="$checkclass"
|
||||
:style="$style"
|
||||
:checked="$checked"
|
||||
{{ $attributes->except(['id', 'class']) }} />
|
||||
<span>{{ $label }}</span>
|
||||
</label>
|
||||
16
resources/views/components/input/radio.blade.php
Normal file
@ -0,0 +1,16 @@
|
||||
@props([
|
||||
'id' => null, // input id
|
||||
'class' => '', // extra CSS classes
|
||||
'name' => '', // input name
|
||||
'value' => '', // input value
|
||||
'style' => '', // raw style string
|
||||
'checked' => false // true/false or truthy value
|
||||
])
|
||||
|
||||
<input type="radio"
|
||||
@if($id) id="{{ $id }}" @endif
|
||||
name="{{ $name }}"
|
||||
value="{{ $value }}"
|
||||
{{ $attributes->class($class) }}
|
||||
@if($style !== '') style="{{ $style }}" @endif
|
||||
@checked($checked) />
|
||||
74
resources/views/components/input/select-state.blade.php
Normal file
@ -0,0 +1,74 @@
|
||||
@props([
|
||||
'id' => 'select-state',
|
||||
'name' => 'state',
|
||||
'selected' => null,
|
||||
'placeholder' => 'Select a state',
|
||||
'required' => false,
|
||||
'autocomplete' => 'address-level1',
|
||||
])
|
||||
|
||||
@php
|
||||
$states = [
|
||||
'AL' => 'Alabama',
|
||||
'AK' => 'Alaska',
|
||||
'AZ' => 'Arizona',
|
||||
'AR' => 'Arkansas',
|
||||
'CA' => 'California',
|
||||
'CO' => 'Colorado',
|
||||
'CT' => 'Connecticut',
|
||||
'DE' => 'Delaware',
|
||||
'DC' => 'District of Columbia',
|
||||
'FL' => 'Florida',
|
||||
'GA' => 'Georgia',
|
||||
'HI' => 'Hawaii',
|
||||
'ID' => 'Idaho',
|
||||
'IL' => 'Illinois',
|
||||
'IN' => 'Indiana',
|
||||
'IA' => 'Iowa',
|
||||
'KS' => 'Kansas',
|
||||
'KY' => 'Kentucky',
|
||||
'LA' => 'Louisiana',
|
||||
'ME' => 'Maine',
|
||||
'MD' => 'Maryland',
|
||||
'MA' => 'Massachusetts',
|
||||
'MI' => 'Michigan',
|
||||
'MN' => 'Minnesota',
|
||||
'MS' => 'Mississippi',
|
||||
'MO' => 'Missouri',
|
||||
'MT' => 'Montana',
|
||||
'NE' => 'Nebraska',
|
||||
'NV' => 'Nevada',
|
||||
'NH' => 'New Hampshire',
|
||||
'NJ' => 'New Jersey',
|
||||
'NM' => 'New Mexico',
|
||||
'NY' => 'New York',
|
||||
'NC' => 'North Carolina',
|
||||
'ND' => 'North Dakota',
|
||||
'OH' => 'Ohio',
|
||||
'OK' => 'Oklahoma',
|
||||
'OR' => 'Oregon',
|
||||
'PA' => 'Pennsylvania',
|
||||
'RI' => 'Rhode Island',
|
||||
'SC' => 'South Carolina',
|
||||
'SD' => 'South Dakota',
|
||||
'TN' => 'Tennessee',
|
||||
'TX' => 'Texas',
|
||||
'UT' => 'Utah',
|
||||
'VT' => 'Vermont',
|
||||
'VA' => 'Virginia',
|
||||
'WA' => 'Washington',
|
||||
'WV' => 'West Virginia',
|
||||
'WI' => 'Wisconsin',
|
||||
'WY' => 'Wyoming',
|
||||
];
|
||||
@endphp
|
||||
|
||||
<x-input.select
|
||||
:id="$id"
|
||||
:name="$name"
|
||||
:options="$states"
|
||||
:selected="$selected"
|
||||
:placeholder="$placeholder"
|
||||
:required="$required"
|
||||
:autocomplete="$autocomplete"
|
||||
{{ $attributes }} />
|
||||
63
resources/views/components/input/select.blade.php
Normal file
@ -0,0 +1,63 @@
|
||||
@props([
|
||||
'id' => '', // unique ID
|
||||
'class' => '', // extra CSS classes
|
||||
'name' => '', // select name
|
||||
'options' => [], // array of options, optgroups, or rich option defs
|
||||
'selected' => null, // selected value (falls back to old())
|
||||
'placeholder' => '', // optional placeholder option (disabled)
|
||||
'style' => '', // raw style string
|
||||
'required' => false, // true/false or truthy value
|
||||
'autocomplete' => '',
|
||||
])
|
||||
|
||||
@php
|
||||
// prefer old() value if present, otherwise selected prop
|
||||
$current = old($name, $selected);
|
||||
|
||||
$isSelected = function ($value) use ($current) {
|
||||
// cast to string so '1' and 1 match
|
||||
return (string) $value === (string) $current;
|
||||
};
|
||||
|
||||
$renderOption = function ($value, $label, $attrs = []) use ($isSelected) {
|
||||
$attrString = collect($attrs)->map(function ($v, $k) {
|
||||
if (is_bool($v)) return $v ? $k : '';
|
||||
return $k.'="'.e($v).'"';
|
||||
})->filter()->implode(' ');
|
||||
|
||||
$selectedAttr = $isSelected($value) ? ' selected' : '';
|
||||
|
||||
return '<option value="'.e($value).'"'.$selectedAttr.($attrString ? ' '.$attrString : '').'>'.e($label).'</option>';
|
||||
};
|
||||
@endphp
|
||||
|
||||
<select id="{{ $id }}" name="{{ $name }}" autocomplete="{{ $autocomplete }}"
|
||||
{{ $attributes->merge(['class' => 'select '.$class]) }}
|
||||
@if($style !== '') style="{{ $style }}" @endif
|
||||
@required($required)>
|
||||
|
||||
@if($placeholder !== '')
|
||||
<option value="" disabled @selected((string) $current === '')>
|
||||
{{ $placeholder }}
|
||||
</option>
|
||||
@endif
|
||||
|
||||
@foreach($options as $key => $opt)
|
||||
{{-- optgroup: 'Group label' => [ ...options... ] --}}
|
||||
@if(is_array($opt) && !array_key_exists('value', $opt) && !array_key_exists('label', $opt))
|
||||
<optgroup label="{{ $key }}">
|
||||
@foreach($opt as $value => $label)
|
||||
{!! $renderOption($value, $label) !!}
|
||||
@endforeach
|
||||
</optgroup>
|
||||
|
||||
{{-- rich option: [ 'value' => 'en', 'label' => 'English', 'attrs' => ['data-x' => 'y', 'disabled' => true] ] --}}
|
||||
@elseif(is_array($opt) && array_key_exists('value', $opt))
|
||||
{!! $renderOption($opt['value'], $opt['label'] ?? $opt['value'], $opt['attrs'] ?? []) !!}
|
||||
|
||||
{{-- simple options: [ 'en' => 'English', ... ] --}}
|
||||
@else
|
||||
{!! $renderOption($key, $opt) !!}
|
||||
@endif
|
||||
@endforeach
|
||||
</select>
|
||||
@ -12,6 +12,6 @@
|
||||
name="{{ $name }}"
|
||||
value="{{ $value }}"
|
||||
placeholder="{{ $placeholder }}"
|
||||
{{ $attributes->class($class) }}
|
||||
{{ $attributes->merge(['class' => 'text']) }}
|
||||
@if($style !== '') style="{{ $style }}" @endif
|
||||
@required($required) />
|
||||
|
||||
39
resources/views/components/menu/account-settings.blade.php
Normal file
@ -0,0 +1,39 @@
|
||||
<div class="drawers aside-inset">
|
||||
<details open>
|
||||
<summary>{{ __('General settings') }}</summary>
|
||||
<menu class="content pagelinks">
|
||||
<li>
|
||||
<x-app.pagelink
|
||||
href="{{ route('account.info') }}"
|
||||
:active="request()->routeIs('account.info')"
|
||||
:label="__('account.settings.information.title')"
|
||||
icon="info-circle"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<x-app.pagelink
|
||||
href="{{ route('account.password') }}"
|
||||
:active="request()->routeIs('account.password')"
|
||||
:label="__('common.password')"
|
||||
icon="key"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<x-app.pagelink
|
||||
href="{{ route('account.addresses') }}"
|
||||
:active="request()->routeIs('account.addresses')"
|
||||
:label="__('common.addresses')"
|
||||
icon="pin"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<x-app.pagelink
|
||||
href="{{ route('account.delete') }}"
|
||||
:active="request()->routeIs('account.delete', 'account.delete.*')"
|
||||
:label="__('account.delete')"
|
||||
icon="bomb"
|
||||
/>
|
||||
</li>
|
||||
</menu>
|
||||
</details>
|
||||
</div>
|
||||
@ -4,11 +4,11 @@
|
||||
<menu class="content pagelinks">
|
||||
<li>
|
||||
<x-app.pagelink
|
||||
href="{{ route('calendar.settings.subscribe') }}"
|
||||
:active="request()->routeIs('calendar.settings')">
|
||||
<x-icon-globe width="20" />
|
||||
<span>Language and region</span>
|
||||
</x-app.pagelink>
|
||||
href="{{ route('calendar.settings.language') }}"
|
||||
:active="request()->routeIs('calendar.settings.language')"
|
||||
:label="__('calendar.settings.language_region.title')"
|
||||
icon="globe"
|
||||
/>
|
||||
</li>
|
||||
</menu>
|
||||
</details>
|
||||
@ -18,10 +18,10 @@
|
||||
<li>
|
||||
<x-app.pagelink
|
||||
href="{{ route('calendar.settings.subscribe') }}"
|
||||
:active="request()->routeIs('calendar.settings.subscribe')">
|
||||
<x-icon-calendar-sync width="20" />
|
||||
<span>Subscribe to a calendar</span>
|
||||
</x-app.pagelink>
|
||||
:active="request()->routeIs('calendar.settings.subscribe')"
|
||||
:label="__('calendar.settings.subscribe.title')"
|
||||
icon="calendar-sync"
|
||||
/>
|
||||
</li>
|
||||
</menu>
|
||||
</details>
|
||||
@ -1,4 +1,4 @@
|
||||
<form method="dialog">
|
||||
<form method="dialog" class="close-modal">
|
||||
<x-button.icon type="submit" label="Close the modal" autofocus>
|
||||
<x-icon-x />
|
||||
</x-button.icon>
|
||||
|
||||
3
resources/views/components/modal/footer.blade.php
Normal file
@ -0,0 +1,3 @@
|
||||
<footer>
|
||||
{{ $slot }}
|
||||
</footer>
|
||||
@ -1,7 +1,10 @@
|
||||
<dialog>
|
||||
<dialog
|
||||
hx-on:click="if(event.target === this) this.close()"
|
||||
hx-on:close="document.getElementById('modal').innerHTML=''"
|
||||
>
|
||||
<div id="modal"
|
||||
hx-target="this"
|
||||
hx-on::after-swap="document.querySelector('dialog').showModal();"
|
||||
hx-on::after-swap="this.closest('dialog')?.showModal()"
|
||||
hx-swap="innerHTML">
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
'border' => false,
|
||||
])
|
||||
|
||||
<header @class(['px-8 py-6', 'header--with-border' => $border])>
|
||||
<h2 class="text-lg font-semibold">
|
||||
<header @class(['header--with-border' => $border])>
|
||||
<h2>
|
||||
{{ $slot }}
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
<x-input-label for="title" :value="__('Title')" />
|
||||
<x-text-input id="title" name="title" type="text" class="mt-1 block w-full"
|
||||
:value="old('title', $event->meta?->title ?? '')" required autofocus />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('title')" />
|
||||
<x-input.error class="mt-2" :messages="$errors->get('title')" />
|
||||
</div>
|
||||
|
||||
{{-- Description --}}
|
||||
@ -39,7 +39,7 @@
|
||||
<x-input-label for="description" :value="__('Description')" />
|
||||
<textarea id="description" name="description" rows="3"
|
||||
class="mt-1 block w-full rounded-md shadow-xs border-gray-300 focus:border-indigo-300 focus:ring-3">{{ old('description', $event->meta?->description ?? '') }}</textarea>
|
||||
<x-input-error class="mt-2" :messages="$errors->get('description')" />
|
||||
<x-input.error class="mt-2" :messages="$errors->get('description')" />
|
||||
</div>
|
||||
|
||||
{{-- Location --}}
|
||||
@ -70,7 +70,7 @@
|
||||
<input type="hidden" id="loc_lat" name="loc_lat" />
|
||||
<input type="hidden" id="loc_lon" name="loc_lon" />
|
||||
|
||||
<x-input-error class="mt-2" :messages="$errors->get('location')" />
|
||||
<x-input.error class="mt-2" :messages="$errors->get('location')" />
|
||||
</div>
|
||||
|
||||
{{-- Start / End --}}
|
||||
@ -82,7 +82,7 @@
|
||||
class="mt-1 block w-full"
|
||||
:value="old('start_at', $start)"
|
||||
required />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('start_at')" />
|
||||
<x-input.error class="mt-2" :messages="$errors->get('start_at')" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@ -91,7 +91,7 @@
|
||||
class="mt-1 block w-full"
|
||||
:value="old('end_at', $end)"
|
||||
required />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('end_at')" />
|
||||
<x-input.error class="mt-2" :messages="$errors->get('end_at')" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@ -35,20 +35,18 @@
|
||||
|
||||
</main>
|
||||
|
||||
<!-- messages -->
|
||||
<figure>
|
||||
@if (session('toast'))
|
||||
<figcaption
|
||||
x-data="{ open: true }"
|
||||
x-show="open"
|
||||
x-init="setTimeout(() => open = false, 4000)"
|
||||
class="fixed top-4 right-4 bg-green-600 text-white px-4 py-2 rounded-lg shadow-lg"
|
||||
x-transition.opacity.duration.300ms
|
||||
>
|
||||
{{ session('toast') }}
|
||||
</figcaption>
|
||||
<!-- toasts -->
|
||||
<dl class="toasts" role="status" aria-live="polite" aria-atomic="true">
|
||||
@if (session()->has('toast'))
|
||||
@php
|
||||
$toast = session('toast');
|
||||
$message = is_array($toast) ? ($toast['message'] ?? '') : $toast;
|
||||
$type = is_array($toast) ? ($toast['type'] ?? 'success') : 'success';
|
||||
@endphp
|
||||
<dt class="{{ $type }}">{{ $type }}</dt>
|
||||
<dd class="message">{{ $message }}</dd>
|
||||
@endif
|
||||
</figure>
|
||||
</dl>
|
||||
|
||||
<!-- modal -->
|
||||
<x-modal />
|
||||
|
||||
@ -8,15 +8,21 @@
|
||||
|
||||
<!-- app nav -->
|
||||
<menu>
|
||||
<x-app.nav-button :href="route('dashboard')" :active="request()->routeIs('dashboard')">
|
||||
<x-icon-home class="w-7 h-7" />
|
||||
</x-app.nav-button>
|
||||
<x-app.nav-button :href="route('calendar.index')" :active="request()->routeIs('calendar*')">
|
||||
<x-icon-calendar class="w-7 h-7" />
|
||||
</x-app.nav-button>
|
||||
<x-app.nav-button :href="route('book.index')" :active="request()->routeIs('books*')">
|
||||
<x-icon-book-user class="w-7 h-7" />
|
||||
</x-app.nav-button>
|
||||
<x-app.nav-button
|
||||
:href="route('dashboard')"
|
||||
:active="request()->routeIs('dashboard')"
|
||||
icon="home"
|
||||
/>
|
||||
<x-app.nav-button
|
||||
:href="route('calendar.index')"
|
||||
:active="request()->routeIs('calendar*')"
|
||||
icon="calendar"
|
||||
/>
|
||||
<x-app.nav-button
|
||||
:href="route('book.index')"
|
||||
:active="request()->routeIs('book.index')"
|
||||
icon="book-user"
|
||||
/>
|
||||
<menu>
|
||||
</section>
|
||||
|
||||
@ -25,7 +31,7 @@
|
||||
<x-button.icon type="anchor" :href="route('settings')">
|
||||
<x-icon-settings class="w-7 h-7" />
|
||||
</x-button.icon>
|
||||
<x-button.icon type="anchor" :href="route('profile.edit')">
|
||||
<x-button.icon type="anchor" :href="route('account.index')">
|
||||
<x-icon-user-circle class="w-7 h-7" />
|
||||
</x-button.icon>
|
||||
</div>
|
||||
|
||||
@ -1,153 +0,0 @@
|
||||
<section>
|
||||
<header class="mb-4">
|
||||
<h2 class="text-lg font-medium text-gray-900">
|
||||
{{ __('Addresses') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
{{ __('Manage your Home and Billing addresses.') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form method="post" action="{{ route('profile.addresses.save') }}" class="space-y-8">
|
||||
@csrf
|
||||
@method('patch')
|
||||
|
||||
{{-- home address --}}
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-md font-semibold text-gray-800">{{ __('Home Address') }}</h3>
|
||||
|
||||
<div>
|
||||
<x-input-label for="home_label" :value="__('Label')" />
|
||||
<x-text-input id="home_label" name="home[label]" type="text" class="mt-1 block w-full"
|
||||
:value="old('home.label', $home->label ?? 'Home')" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('home.label')" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="home_line1" :value="__('Address line 1')" />
|
||||
<x-text-input id="home_line1" name="home[line1]" type="text" class="mt-1 block w-full"
|
||||
:value="old('home.line1', $home->line1 ?? '')" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('home.line1')" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="home_line2" :value="__('Address line 2')" />
|
||||
<x-text-input id="home_line2" name="home[line2]" type="text" class="mt-1 block w-full"
|
||||
:value="old('home.line2', $home->line2 ?? '')" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('home.line2')" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<x-input-label for="home_city" :value="__('City')" />
|
||||
<x-text-input id="home_city" name="home[city]" type="text" class="mt-1 block w-full"
|
||||
:value="old('home.city', $home->city ?? '')" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('home.city')" />
|
||||
</div>
|
||||
<div>
|
||||
<x-input-label for="home_state" :value="__('State/Region')" />
|
||||
<x-text-input id="home_state" name="home[state]" type="text" class="mt-1 block w-full"
|
||||
:value="old('home.state', $home->state ?? '')" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('home.state')" />
|
||||
</div>
|
||||
<div>
|
||||
<x-input-label for="home_postal" :value="__('Postal code')" />
|
||||
<x-text-input id="home_postal" name="home[postal]" type="text" class="mt-1 block w-full"
|
||||
:value="old('home.postal', $home->postal ?? '')" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('home.postal')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<x-input-label for="home_country" :value="__('Country')" />
|
||||
<x-text-input id="home_country" name="home[country]" type="text" class="mt-1 block w-full"
|
||||
:value="old('home.country', $home->country ?? '')" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('home.country')" />
|
||||
</div>
|
||||
<div>
|
||||
<x-input-label for="home_phone" :value="__('Phone')" />
|
||||
<x-text-input id="home_phone" name="home[phone]" type="text" class="mt-1 block w-full"
|
||||
:value="old('home.phone', $home->phone ?? '')" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('home.phone')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- billing address --}}
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-md font-semibold text-gray-800">{{ __('Billing Address') }}</h3>
|
||||
|
||||
<div>
|
||||
<x-input-label for="bill_label" :value="__('Label')" />
|
||||
<x-text-input id="bill_label" name="billing[label]" type="text" class="mt-1 block w-full"
|
||||
:value="old('billing.label', $billing->label ?? 'Billing')" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('billing.label')" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="bill_line1" :value="__('Address line 1')" />
|
||||
<x-text-input id="bill_line1" name="billing[line1]" type="text" class="mt-1 block w-full"
|
||||
:value="old('billing.line1', $billing->line1 ?? '')" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('billing.line1')" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="bill_line2" :value="__('Address line 2')" />
|
||||
<x-text-input id="bill_line2" name="billing[line2]" type="text" class="mt-1 block w-full"
|
||||
:value="old('billing.line2', $billing->line2 ?? '')" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('billing.line2')" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<x-input-label for="bill_city" :value="__('City')" />
|
||||
<x-text-input id="bill_city" name="billing[city]" type="text" class="mt-1 block w-full"
|
||||
:value="old('billing.city', $billing->city ?? '')" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('billing.city')" />
|
||||
</div>
|
||||
<div>
|
||||
<x-input-label for="bill_state" :value="__('State/Region')" />
|
||||
<x-text-input id="bill_state" name="billing[state]" type="text" class="mt-1 block w-full"
|
||||
:value="old('billing.state', $billing->state ?? '')" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('billing.state')" />
|
||||
</div>
|
||||
<div>
|
||||
<x-input-label for="bill_postal" :value="__('Postal code')" />
|
||||
<x-text-input id="bill_postal" name="billing[postal]" type="text" class="mt-1 block w-full"
|
||||
:value="old('billing.postal', $billing->postal ?? '')" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('billing.postal')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<x-input-label for="bill_country" :value="__('Country')" />
|
||||
<x-text-input id="bill_country" name="billing[country]" type="text" class="mt-1 block w-full"
|
||||
:value="old('billing.country', $billing->country ?? '')" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('billing.country')" />
|
||||
</div>
|
||||
<div>
|
||||
<x-input-label for="bill_phone" :value="__('Phone')" />
|
||||
<x-text-input id="bill_phone" name="billing[phone]" type="text" class="mt-1 block w-full"
|
||||
:value="old('billing.phone', $billing->phone ?? '')" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('billing.phone')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<x-primary-button>{{ __('Save Addresses') }}</x-primary-button>
|
||||
|
||||
@if (session('status') === 'addresses-updated')
|
||||
<p
|
||||
x-data="{ show: true }"
|
||||
x-show="show"
|
||||
x-transition
|
||||
x-init="setTimeout(() => show = false, 2000)"
|
||||
class="text-sm text-gray-600"
|
||||
>{{ __('Saved.') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@ -1,55 +0,0 @@
|
||||
<section class="space-y-6">
|
||||
<header>
|
||||
<h2 class="text-lg font-medium text-gray-900">
|
||||
{{ __('Delete Account') }}
|
||||
</h2>
|
||||
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<x-danger-button
|
||||
x-data=""
|
||||
x-on:click.prevent="$dispatch('open-modal', 'confirm-user-deletion')"
|
||||
>{{ __('Delete Account') }}</x-danger-button>
|
||||
|
||||
<x-modal name="confirm-user-deletion" :show="$errors->userDeletion->isNotEmpty()" focusable>
|
||||
<form method="post" action="{{ route('profile.destroy') }}" class="p-6">
|
||||
@csrf
|
||||
@method('delete')
|
||||
|
||||
<h2 class="text-lg font-medium text-gray-900">
|
||||
{{ __('Are you sure you want to delete your account?') }}
|
||||
</h2>
|
||||
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
{{ __('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.') }}
|
||||
</p>
|
||||
|
||||
<div class="mt-6">
|
||||
<x-input-label for="password" value="{{ __('Password') }}" class="sr-only" />
|
||||
|
||||
<x-text-input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
class="mt-1 block w-3/4"
|
||||
placeholder="{{ __('Password') }}"
|
||||
/>
|
||||
|
||||
<x-input-error :messages="$errors->userDeletion->get('password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<x-secondary-button x-on:click="$dispatch('close')">
|
||||
{{ __('Cancel') }}
|
||||
</x-secondary-button>
|
||||
|
||||
<x-danger-button class="ms-3">
|
||||
{{ __('Delete Account') }}
|
||||
</x-danger-button>
|
||||
</div>
|
||||
</form>
|
||||
</x-modal>
|
||||
</section>
|
||||
@ -1,48 +0,0 @@
|
||||
<section>
|
||||
<header>
|
||||
<h2 class="text-lg font-medium text-gray-900">
|
||||
{{ __('Update Password') }}
|
||||
</h2>
|
||||
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
{{ __('Ensure your account is using a long, random password to stay secure.') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form method="post" action="{{ route('password.update') }}" class="mt-6 space-y-6">
|
||||
@csrf
|
||||
@method('put')
|
||||
|
||||
<div>
|
||||
<x-input-label for="update_password_current_password" :value="__('Current Password')" />
|
||||
<x-text-input id="update_password_current_password" name="current_password" type="password" class="mt-1 block w-full" autocomplete="current-password" />
|
||||
<x-input-error :messages="$errors->updatePassword->get('current_password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="update_password_password" :value="__('New Password')" />
|
||||
<x-text-input id="update_password_password" name="password" type="password" class="mt-1 block w-full" autocomplete="new-password" />
|
||||
<x-input-error :messages="$errors->updatePassword->get('password')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="update_password_password_confirmation" :value="__('Confirm Password')" />
|
||||
<x-text-input id="update_password_password_confirmation" name="password_confirmation" type="password" class="mt-1 block w-full" autocomplete="new-password" />
|
||||
<x-input-error :messages="$errors->updatePassword->get('password_confirmation')" class="mt-2" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<x-primary-button>{{ __('Save') }}</x-primary-button>
|
||||
|
||||
@if (session('status') === 'password-updated')
|
||||
<p
|
||||
x-data="{ show: true }"
|
||||
x-show="show"
|
||||
x-transition
|
||||
x-init="setTimeout(() => show = false, 2000)"
|
||||
class="text-sm text-gray-600"
|
||||
>{{ __('Saved.') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@ -1,64 +0,0 @@
|
||||
<section>
|
||||
<header>
|
||||
<h2 class="text-lg font-medium text-gray-900">
|
||||
{{ __('Profile Information') }}
|
||||
</h2>
|
||||
|
||||
<p class="mt-1 text-sm text-gray-600">
|
||||
{{ __("Update your account's profile information and email address.") }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form id="send-verification" method="post" action="{{ route('verification.send') }}">
|
||||
@csrf
|
||||
</form>
|
||||
|
||||
<form method="post" action="{{ route('profile.update') }}" class="mt-6 space-y-6">
|
||||
@csrf
|
||||
@method('patch')
|
||||
|
||||
<div>
|
||||
<x-input-label for="name" :value="__('Name')" />
|
||||
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full" :value="old('name', $user->name)" required autofocus autocomplete="name" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('name')" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<x-input-label for="email" :value="__('Email')" />
|
||||
<x-text-input id="email" name="email" type="email" class="mt-1 block w-full" :value="old('email', $user->email)" required autocomplete="username" />
|
||||
<x-input-error class="mt-2" :messages="$errors->get('email')" />
|
||||
|
||||
@if ($user instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && ! $user->hasVerifiedEmail())
|
||||
<div>
|
||||
<p class="text-sm mt-2 text-gray-800">
|
||||
{{ __('Your email address is unverified.') }}
|
||||
|
||||
<button form="send-verification" class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
{{ __('Click here to re-send the verification email.') }}
|
||||
</button>
|
||||
</p>
|
||||
|
||||
@if (session('status') === 'verification-link-sent')
|
||||
<p class="mt-2 font-medium text-sm text-green-600">
|
||||
{{ __('A new verification link has been sent to your email address.') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<x-primary-button>{{ __('Save') }}</x-primary-button>
|
||||
|
||||
@if (session('status') === 'profile-updated')
|
||||
<p
|
||||
x-data="{ show: true }"
|
||||
x-show="show"
|
||||
x-transition
|
||||
x-init="setTimeout(() => show = false, 2000)"
|
||||
class="text-sm text-gray-600"
|
||||
>{{ __('Saved.') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use App\Http\Controllers\AccountController;
|
||||
use App\Http\Controllers\BookController;
|
||||
use App\Http\Controllers\CalendarController;
|
||||
use App\Http\Controllers\CalendarSettingsController;
|
||||
@ -23,7 +23,7 @@ Route::view('/', 'welcome');
|
||||
/**
|
||||
*
|
||||
* starter pages
|
||||
* @todo replace thse
|
||||
* @todo replace these
|
||||
*/
|
||||
|
||||
Route::view('/dashboard', 'dashboard')
|
||||
@ -41,12 +41,29 @@ Route::view('/settings', 'settings')
|
||||
|
||||
Route::middleware('auth')->group(function ()
|
||||
{
|
||||
/* user profile */
|
||||
Route::get('/profile/view', [ProfileController::class, 'index'])->name('profile.index');
|
||||
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
|
||||
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
|
||||
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
|
||||
Route::patch('/profile/addresses', [ProfileController::class, 'saveAddresses'])->name('profile.addresses.save');
|
||||
/* user account */
|
||||
Route::middleware(['web', 'auth'])
|
||||
->prefix('account')
|
||||
->name('account.')
|
||||
->group(function () {
|
||||
|
||||
// landing: send them to the first pane
|
||||
Route::get('/', [AccountController::class, 'index'])->name('index');
|
||||
|
||||
// panes
|
||||
Route::get('info', [AccountController::class, 'infoForm'])->name('info');
|
||||
Route::patch('info', [AccountController::class, 'infoStore'])->name('info.store');
|
||||
|
||||
Route::get('addresses', [AccountController::class, 'addressesForm'])->name('addresses');
|
||||
Route::patch('addresses', [AccountController::class, 'addressesStore'])->name('addresses.store');
|
||||
|
||||
Route::get('password', [AccountController::class, 'passwordForm'])->name('password');
|
||||
Route::patch('password', [AccountController::class, 'passwordStore'])->name('password.store');
|
||||
|
||||
Route::get('delete', [AccountController::class, 'deleteForm'])->name('delete');
|
||||
Route::get('delete/confirm', [AccountController::class, 'deleteConfirm'])->name('delete.confirm');
|
||||
Route::delete('/', [AccountController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
/* calendar core */
|
||||
Route::middleware('auth')->group(function () {
|
||||
@ -64,6 +81,10 @@ Route::middleware('auth')->group(function ()
|
||||
// settings landing
|
||||
Route::get('settings', [CalendarSettingsController::class, 'index'])->name('settings');
|
||||
|
||||
// language/region settings
|
||||
Route::get('settings/language', [CalendarSettingsController::class, 'languageForm'])->name('settings.language');
|
||||
Route::post('settings/language', [CalendarSettingsController::class, 'languageStore'])->name('settings.language.store');
|
||||
|
||||
// settings / subscribe to a calendar
|
||||
Route::get('settings/subscribe', [CalendarSettingsController::class, 'subscribeForm'])->name('settings.subscribe');
|
||||
Route::post('settings/subscribe', [CalendarSettingsController::class, 'subscribeStore'])->name('settings.subscribe.store');
|
||||
|
||||