Long overdue changes to various components, migrations for address and location improvements, new address form and suggestion vies, profile components, and significant model cleanups
This commit is contained in:
parent
80c368525a
commit
b7282865c8
@ -19,7 +19,9 @@ ADMIN_LASTNAME=Admin
|
||||
GEOCODER=arcgis
|
||||
GEOCODER_USER_AGENT="Kithkin/1.0 (amrou@kithk.in)"
|
||||
GEOCODER_TIMEOUT=30
|
||||
GEOCIDER_EMAIL=amrou@kithk.in
|
||||
GEOCODER_EMAIL=amrou@kithk.in
|
||||
GEOCODER_COUNTRY=USA
|
||||
GEOCODER_CATEGORIES=POI,Address
|
||||
ARCGIS_API_KEY=
|
||||
ARCGIS_STORE_RESULTS=true # set to false to not store results
|
||||
|
||||
|
||||
@ -2,41 +2,73 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Models\Calendar;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventMeta;
|
||||
use App\Models\Location;
|
||||
use App\Services\Location\Geocoder;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class EventController extends Controller
|
||||
{
|
||||
/**
|
||||
*
|
||||
* create a new event page
|
||||
*/
|
||||
public function create(Calendar $calendar)
|
||||
public function create(Calendar $calendar, Request $request)
|
||||
{
|
||||
// authorize access to this calendar
|
||||
$this->authorize('update', $calendar);
|
||||
|
||||
// the instance for the signed-in user (provides the uri/slug)
|
||||
$instance = $calendar->instanceForUser();
|
||||
$slug = $instance?->uri ?? $calendar->id; // fallback just in case
|
||||
|
||||
// build a fresh event "shell" with meta defaults (keeps your view happy)
|
||||
$event = new Event;
|
||||
$event->meta = (object) [
|
||||
'title' => '',
|
||||
'title' => '',
|
||||
'description' => '',
|
||||
'location' => '',
|
||||
'start_at' => null,
|
||||
'end_at' => null,
|
||||
'all_day' => false,
|
||||
'category' => '',
|
||||
'location' => '',
|
||||
'start_at' => null,
|
||||
'end_at' => null,
|
||||
'all_day' => false,
|
||||
'category' => '',
|
||||
];
|
||||
$start = $event->start_at;
|
||||
$end = $event->end_at;
|
||||
|
||||
return view('event.form', compact('calendar', 'instance', 'event', 'start', 'end'));
|
||||
// choose a timezone and derive defaults for start/end
|
||||
$tz = auth()->user()->timezone ?? config('app.timezone', 'UTC');
|
||||
|
||||
// if ?date=YYYY-MM-DD is present, start that day at 9am; otherwise "now"
|
||||
$anchor = $request->query('date')
|
||||
? Carbon::parse($request->query('date'), $tz)->startOfDay()->addHours(9)
|
||||
: Carbon::now($tz);
|
||||
|
||||
$anchor->second(0);
|
||||
|
||||
$start_carbon = $anchor->copy();
|
||||
$end_carbon = $anchor->copy()->addHour();
|
||||
|
||||
// format for <input type="datetime-local">
|
||||
$start = $start_carbon->format('Y-m-d\TH:i');
|
||||
$end = $end_carbon->format('Y-m-d\TH:i');
|
||||
|
||||
return view('event.form', compact(
|
||||
'calendar', // bound model (so route() can take the model directly)
|
||||
'instance', // convenience in the view
|
||||
'slug', // if you prefer passing just the slug into route()
|
||||
'event',
|
||||
'start',
|
||||
'end'
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* edit event page
|
||||
*/
|
||||
public function edit(Calendar $calendar, Event $event)
|
||||
@ -58,6 +90,7 @@ class EventController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* single event view handling
|
||||
*
|
||||
* URL: /calendar/{uuid}/event/{event_id}
|
||||
@ -72,8 +105,9 @@ class EventController extends Controller
|
||||
// authorize
|
||||
$this->authorize('view', $event);
|
||||
|
||||
// eager-load meta so the view has everything
|
||||
// eager-load metadata so the view has everything
|
||||
$event->load('meta');
|
||||
$event->load('meta.venue');
|
||||
|
||||
// check for HTML; it sends `HX-Request: true` on every AJAX call
|
||||
$isHtmx = $request->header('HX-Request') === 'true';
|
||||
@ -90,7 +124,7 @@ class EventController extends Controller
|
||||
|
||||
return $isHtmx
|
||||
? view('event.partials.details', $data) // tiny fragment for the modal
|
||||
: view('event.show', $data); // full-page fallback
|
||||
: view('event.show', $data); // full-page fallback
|
||||
}
|
||||
|
||||
|
||||
@ -100,9 +134,10 @@ class EventController extends Controller
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* insert vevent into sabre’s calendarobjects + meta row
|
||||
*/
|
||||
public function store(Request $req, Calendar $calendar)
|
||||
public function store(Request $req, Calendar $calendar, Geocoder $geocoder)
|
||||
{
|
||||
$this->authorize('update', $calendar);
|
||||
|
||||
@ -114,42 +149,49 @@ class EventController extends Controller
|
||||
'location' => 'nullable|string',
|
||||
'all_day' => 'sometimes|boolean',
|
||||
'category' => 'nullable|string|max:50',
|
||||
|
||||
// normalized fields from the suggestions ui (all optional)
|
||||
'loc_display_name' => 'nullable|string',
|
||||
'loc_place_name' => 'nullable|string', // optional if you add this hidden input
|
||||
'loc_street' => 'nullable|string',
|
||||
'loc_city' => 'nullable|string',
|
||||
'loc_state' => 'nullable|string',
|
||||
'loc_postal' => 'nullable|string',
|
||||
'loc_country' => 'nullable|string',
|
||||
'loc_lat' => 'nullable',
|
||||
'loc_lon' => 'nullable',
|
||||
]);
|
||||
|
||||
// prepare payload
|
||||
$uid = Str::uuid() . '@' . parse_url(config('app.url'), PHP_URL_HOST);
|
||||
$uid = Str::uuid() . '@' . parse_url(config('app.url'), PHP_URL_HOST);
|
||||
|
||||
// store events as UTC in the database; convert to calendar time in the view
|
||||
$client_timezone = $calendar->timezone ?? 'UTC';
|
||||
$start = Carbon::createFromFormat('Y-m-d\TH:i', $data['start_at'], $client_timezone)
|
||||
->setTimezone('UTC');
|
||||
$end = Carbon::createFromFormat('Y-m-d\TH:i', $data['end_at'], $client_timezone)
|
||||
->setTimezone('UTC');
|
||||
// parse local -> utc
|
||||
$clientTz = $calendar->timezone ?? 'UTC';
|
||||
$start = Carbon::createFromFormat('Y-m-d\TH:i', $data['start_at'], $clientTz)->utc();
|
||||
$end = Carbon::createFromFormat('Y-m-d\TH:i', $data['end_at'], $clientTz)->utc();
|
||||
|
||||
// prepare strings
|
||||
$description = $data['description'] ?? '';
|
||||
$location = $data['location'] ?? '';
|
||||
$description = str_replace("\n", '\\n', $description);
|
||||
$location = str_replace("\n", '\\n', $location);
|
||||
// normalize description/location for ics
|
||||
$description = str_replace("\n", '\\n', $data['description'] ?? '');
|
||||
$locationStr = str_replace("\n", '\\n', $data['location'] ?? '');
|
||||
|
||||
/* build minimal iCalendar payload */
|
||||
// write dtstart/dtend as utc with "Z"
|
||||
$ical = <<<ICS
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Kithkin//Laravel CalDAV//EN
|
||||
BEGIN:VEVENT
|
||||
UID:$uid
|
||||
DTSTAMP:{$start->utc()->format('Ymd\\THis\\Z')}
|
||||
DTSTART:{$start->format('Ymd\\THis')}
|
||||
DTEND:{$end->format('Ymd\\THis')}
|
||||
SUMMARY:{$data['title']}
|
||||
DESCRIPTION:$description
|
||||
LOCATION:$location
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Kithkin//Laravel CalDAV//EN
|
||||
BEGIN:VEVENT
|
||||
UID:$uid
|
||||
DTSTAMP:{$start->format('Ymd\THis\Z')}
|
||||
DTSTART:{$start->format('Ymd\THis\Z')}
|
||||
DTEND:{$end->format('Ymd\THis\Z')}
|
||||
SUMMARY:{$data['title']}
|
||||
DESCRIPTION:$description
|
||||
LOCATION:$locationStr
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
|
||||
ICS;
|
||||
ICS;
|
||||
|
||||
// create sabre object
|
||||
$event = Event::create([
|
||||
'calendarid' => $calendar->id,
|
||||
'uri' => Str::uuid() . '.ics',
|
||||
@ -161,11 +203,59 @@ ICS;
|
||||
'calendardata' => $ical,
|
||||
]);
|
||||
|
||||
// resolve a location_id
|
||||
$locationId = null;
|
||||
$raw = $data['location'] ?? null;
|
||||
|
||||
// did the user pick a suggestion (hidden normalized fields present)?
|
||||
$hasNormHints = $req->filled('loc_display_name') ||
|
||||
$req->filled('loc_place_name') ||
|
||||
$req->filled('loc_street') ||
|
||||
$req->filled('loc_city') ||
|
||||
$req->filled('loc_state') ||
|
||||
$req->filled('loc_postal') ||
|
||||
$req->filled('loc_country') ||
|
||||
$req->filled('loc_lat') ||
|
||||
$req->filled('loc_lon');
|
||||
|
||||
if ($raw) {
|
||||
if ($hasNormHints) {
|
||||
$norm = [
|
||||
'display_name' => $req->input('loc_display_name') ?: $raw,
|
||||
'place_name' => $req->input('loc_place_name'), // fine if null
|
||||
'raw_address' => $raw,
|
||||
'street' => $req->input('loc_street'),
|
||||
'city' => $req->input('loc_city'),
|
||||
'state' => $req->input('loc_state'),
|
||||
'postal' => $req->input('loc_postal'),
|
||||
'country' => $req->input('loc_country'),
|
||||
'lat' => $req->filled('loc_lat') ? (float) $req->input('loc_lat') : null,
|
||||
'lon' => $req->filled('loc_lon') ? (float) $req->input('loc_lon') : null,
|
||||
];
|
||||
$loc = Location::findOrCreateNormalized($norm, $raw);
|
||||
$locationId = $loc->id;
|
||||
|
||||
} else {
|
||||
// no hints: try geocoding the free-form string
|
||||
$norm = $geocoder->forward($raw);
|
||||
if ($norm) {
|
||||
$loc = Location::findOrCreateNormalized($norm, $raw);
|
||||
$locationId = $loc->id;
|
||||
} else {
|
||||
// label-only fallback so the event still links to a location row
|
||||
$loc = Location::labelOnly($raw);
|
||||
$locationId = $loc->id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// meta row (store raw string and link to normalized location if we have one)
|
||||
$event->meta()->create([
|
||||
'title' => $data['title'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'location' => $data['location'] ?? null,
|
||||
'all_day' => $data['all_day'] ?? false,
|
||||
'location' => $raw,
|
||||
'location_id' => $locationId,
|
||||
'all_day' => (bool) ($data['all_day'] ?? false),
|
||||
'category' => $data['category'] ?? null,
|
||||
'start_at' => $start,
|
||||
'end_at' => $end,
|
||||
@ -175,6 +265,7 @@ ICS;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* update vevent + meta
|
||||
*/
|
||||
public function update(Request $req, Calendar $calendar, Event $event)
|
||||
|
||||
35
app/Http/Controllers/LocationController.php
Normal file
35
app/Http/Controllers/LocationController.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use App\Services\Location\Geocoder;
|
||||
|
||||
class LocationController extends Controller
|
||||
{
|
||||
public function suggest(Request $request, Geocoder $geo)
|
||||
{
|
||||
// accept ?q=… or the "location" field (handy for htmx on the input)
|
||||
$q = trim($request->input('q', $request->input('location', '')));
|
||||
|
||||
// short queries: return empty list (avoid rate limits)
|
||||
if ($q === '' || mb_strlen($q) < 3) {
|
||||
return response()->view('event.partials.suggestions', [
|
||||
'suggestions' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
// pass the current user so the geocoder can bias by zip/centroid
|
||||
// signature: suggestions(string $query, int $limit = 5, ?User $user = null)
|
||||
$suggestions = $geo->suggestions($q, 5, $request->user());
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('location suggest failed', ['q' => $q, 'error' => $e->getMessage()]);
|
||||
$suggestions = [];
|
||||
}
|
||||
|
||||
return view('event.partials.suggestions', [
|
||||
'suggestions' => $suggestions,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -3,22 +3,41 @@
|
||||
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
|
||||
{
|
||||
/**
|
||||
* Display the user's profile form.
|
||||
* profile index test
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return $this->frame('profile.partials.addresses-form');
|
||||
}
|
||||
|
||||
/**
|
||||
* display user's profile forms
|
||||
*/
|
||||
public function edit(Request $request): View
|
||||
{
|
||||
return view('profile.edit', [
|
||||
'user' => $request->user(),
|
||||
]);
|
||||
$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'));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -37,6 +56,87 @@ class ProfileController extends Controller
|
||||
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.
|
||||
*/
|
||||
@ -57,4 +157,16 @@ class ProfileController extends Controller
|
||||
|
||||
return Redirect::to('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* content frame handler
|
||||
*/
|
||||
private function frame(?string $view = null, array $data = [])
|
||||
{
|
||||
return view('profile.index', [
|
||||
'view' => $view,
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -40,16 +40,17 @@ class Calendar extends Model
|
||||
}
|
||||
|
||||
/* get the primary? instance for a user */
|
||||
public function instanceForUser(?User $user = null)
|
||||
public function instanceForUser(?User $user = null): CalendarInstance
|
||||
{
|
||||
$user = $user ?? auth()->user();
|
||||
|
||||
return $this->instances()
|
||||
->where('principaluri', 'principals/' . $user->email)
|
||||
->where('principaluri', $user->principal_uri)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* inbound urls
|
||||
* convert "/calendar/{slug}" into the correct calendar instance (uri column)
|
||||
*
|
||||
* @param mixed $value The URI segment (instance UUID).
|
||||
@ -57,11 +58,28 @@ class Calendar extends Model
|
||||
*/
|
||||
public function resolveRouteBinding($value, $field = null): mixed
|
||||
{
|
||||
return $this->whereHas('instances', function (Builder $q) use ($value) {
|
||||
$user = Auth::user();
|
||||
|
||||
return $this->newQuery()
|
||||
->whereHas('instances', function (Builder $q) use ($value, $user) {
|
||||
$q->where('uri', $value)
|
||||
->where('principaluri', Auth::user()->principal_uri);
|
||||
->where('principaluri', $user->principal_uri);
|
||||
})
|
||||
->with('instances')
|
||||
->with(['instances' => function ($q) use ($user) {
|
||||
$q->where('principaluri', $user->principal_uri);
|
||||
}])
|
||||
->firstOrFail();
|
||||
}
|
||||
/**
|
||||
* outbound urls
|
||||
*/
|
||||
public function getRouteKey(): string
|
||||
{
|
||||
// use the per-user instance slug for URLs; fall back to id if not available
|
||||
$instance = $this->instances()
|
||||
->where('principaluri', Auth::user()->principal_uri)
|
||||
->first();
|
||||
|
||||
return $instance->uri ?? (string) $this->getKey();
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,8 +33,6 @@ class EventMeta extends Model
|
||||
'extra' => 'array',
|
||||
];
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* convenience wrapper that mimics an “UPSERT” for meta rows.
|
||||
*
|
||||
@ -53,7 +51,6 @@ class EventMeta extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* relationships
|
||||
*/
|
||||
|
||||
@ -63,9 +60,16 @@ class EventMeta extends Model
|
||||
return $this->belongsTo(Event::class, 'event_id');
|
||||
}
|
||||
|
||||
/* back-reference to location record */
|
||||
public function location(): BelongsTo
|
||||
/* back-reference to location record; calling this venue so that it doesn't shadow the "location" column */
|
||||
public function venue(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Location::class, 'location_id');
|
||||
}
|
||||
|
||||
/* convenient, safe label to use in views */
|
||||
public function getLocationLabelAttribute(): string
|
||||
{
|
||||
// prefer normalized place name; otherwise fall back to the raw string column
|
||||
return $this->venue?->short_label ?? ($this->location ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ class Location extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'display_name',
|
||||
'place_name',
|
||||
'raw_address',
|
||||
'street',
|
||||
'city',
|
||||
@ -26,4 +27,102 @@ class Location extends Model
|
||||
'lat' => 'float',
|
||||
'lon' => 'float',
|
||||
];
|
||||
|
||||
/**
|
||||
* find an existing location by its normalized components or create it.
|
||||
* backfills lat/lon/raw_address/place_name if the found row is missing them.
|
||||
*/
|
||||
public static function findOrCreateNormalized(array $norm, ?string $raw = null): self
|
||||
{
|
||||
$lookup = [
|
||||
'display_name' => $norm['display_name'] ?? null,
|
||||
'street' => $norm['street'] ?? null,
|
||||
'city' => $norm['city'] ?? null,
|
||||
'state' => $norm['state'] ?? null,
|
||||
'postal' => $norm['postal'] ?? null,
|
||||
'country' => $norm['country'] ?? null,
|
||||
];
|
||||
|
||||
$existing = static::where($lookup)->first();
|
||||
|
||||
// fallback: try matching by the raw label (useful for seeds like "Home")
|
||||
if (!$existing && $raw) {
|
||||
$existing = static::where('display_name', $raw)
|
||||
->orWhere('raw_address', $raw)
|
||||
->first();
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
$changed = false;
|
||||
|
||||
// backfill coords if missing (and we have them)
|
||||
$hasNewCoords = (!empty($norm['lat']) || !empty($norm['lon']));
|
||||
if (($existing->lat === null || $existing->lon === null) && $hasNewCoords) {
|
||||
$existing->lat = $norm['lat'] ?? $existing->lat;
|
||||
$existing->lon = $norm['lon'] ?? $existing->lon;
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
// backfill raw address if missing
|
||||
if ($raw && $existing->raw_address === null) {
|
||||
$existing->raw_address = $raw;
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
// backfill place_name if missing
|
||||
if ($existing->place_name === null && !empty($norm['place_name'])) {
|
||||
$existing->place_name = $norm['place_name'];
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
$existing->save();
|
||||
}
|
||||
|
||||
return $existing;
|
||||
}
|
||||
|
||||
return static::create([
|
||||
'display_name' => $norm['display_name'] ?? ($raw ?: 'Unknown'),
|
||||
'place_name' => $norm['place_name'] ?? null,
|
||||
'raw_address' => $norm['raw_address'] ?? $raw,
|
||||
'street' => $norm['street'] ?? null,
|
||||
'city' => $norm['city'] ?? null,
|
||||
'state' => $norm['state'] ?? null,
|
||||
'postal' => $norm['postal'] ?? null,
|
||||
'country' => $norm['country'] ?? null,
|
||||
'lat' => $norm['lat'] ?? null,
|
||||
'lon' => $norm['lon'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* thin alias for backwards compatibility with older call-sites.
|
||||
* prefer using findOrCreateNormalized() directly.
|
||||
*/
|
||||
public static function firstOrCreateNormalized(array $norm, ?string $raw = null): self
|
||||
{
|
||||
return static::findOrCreateNormalized($norm, $raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* create/find a label-only location (no geocode)
|
||||
*/
|
||||
public static function labelOnly(string $label): self
|
||||
{
|
||||
return static::firstOrCreate(
|
||||
['display_name' => $label],
|
||||
['raw_address' => null]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* accessor: short label for ui (falls back gracefully)
|
||||
*/
|
||||
public function getShortLabelAttribute(): string
|
||||
{
|
||||
return $this->place_name
|
||||
?? $this->display_name
|
||||
?? trim(collect([$this->street, $this->city])->filter()->join(', '));
|
||||
}
|
||||
}
|
||||
|
||||
@ -69,4 +69,23 @@ class User extends Authenticatable
|
||||
{
|
||||
return 'principals/' . $this->email;
|
||||
}
|
||||
|
||||
/**
|
||||
* get all user addresses
|
||||
*/
|
||||
public function addresses()
|
||||
{
|
||||
return $this->hasMany(\App\Models\UserAddress::class, 'user_id', 'id');
|
||||
}
|
||||
|
||||
/**
|
||||
* get the user's billing address
|
||||
*/
|
||||
public function billingAddress()
|
||||
{
|
||||
return $this->addresses()
|
||||
->where('kind', 'billing')
|
||||
->where('is_primary', 1)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
|
||||
97
app/Models/UserAddress.php
Normal file
97
app/Models/UserAddress.php
Normal file
@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class UserAddress extends Model
|
||||
{
|
||||
// table name
|
||||
protected $table = 'user_addresses';
|
||||
|
||||
// mass-assignable columns
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'label',
|
||||
'kind',
|
||||
'line1',
|
||||
'line2',
|
||||
'city',
|
||||
'state',
|
||||
'postal',
|
||||
'country',
|
||||
'phone',
|
||||
'location_id',
|
||||
'is_primary',
|
||||
];
|
||||
|
||||
// simple casts
|
||||
protected $casts = [
|
||||
'is_primary' => 'boolean',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
/* relationships */
|
||||
|
||||
// belongs to a user (users.id is char(26))
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id', 'id');
|
||||
}
|
||||
|
||||
// optional normalized/geocoded location row
|
||||
public function location(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Location::class, 'location_id');
|
||||
}
|
||||
|
||||
/* query scopes */
|
||||
|
||||
// filter by kind (e.g., 'billing', 'shipping', 'home', 'work')
|
||||
public function scopeKind($query, string $kind)
|
||||
{
|
||||
return $query->where('kind', $kind);
|
||||
}
|
||||
|
||||
// filter to primary rows
|
||||
public function scopePrimary($query)
|
||||
{
|
||||
return $query->where('is_primary', 1);
|
||||
}
|
||||
|
||||
/* helpers */
|
||||
|
||||
// set this address as the single primary of its kind for the user
|
||||
public function setAsPrimary(): void
|
||||
{
|
||||
DB::transaction(function () {
|
||||
// clear any existing primary for this user+kind (null is allowed by the unique index)
|
||||
static::where('user_id', $this->user_id)
|
||||
->where('kind', $this->kind)
|
||||
->update(['is_primary' => null]);
|
||||
|
||||
// mark this one as primary
|
||||
$this->is_primary = 1;
|
||||
$this->save();
|
||||
});
|
||||
}
|
||||
|
||||
// quick one-line label useful for dropdowns and summaries
|
||||
public function getOneLineAttribute(): string
|
||||
{
|
||||
$parts = array_filter([
|
||||
$this->label,
|
||||
$this->line1,
|
||||
$this->line2,
|
||||
$this->city,
|
||||
$this->state,
|
||||
$this->postal,
|
||||
$this->country,
|
||||
]);
|
||||
|
||||
return trim(collect($parts)->join(', '));
|
||||
}
|
||||
}
|
||||
@ -2,51 +2,52 @@
|
||||
|
||||
namespace App\Services\Location;
|
||||
|
||||
use \App\Models\User;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class Geocoder
|
||||
{
|
||||
public function __construct(
|
||||
private array $cfg = []
|
||||
) {
|
||||
$this->cfg = config('services.geocoding');
|
||||
public function __construct(private array $cfg = [])
|
||||
{
|
||||
$this->cfg = config("services.geocoding");
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward geocode a free-form string.
|
||||
* Returns a normalized array for your `locations` table or null.
|
||||
* forward geocode with optional per-user bias
|
||||
*/
|
||||
public function forward(string $query): ?array
|
||||
public function forward(string $query, ?User $user = null): ?array
|
||||
{
|
||||
$provider = $this->cfg['provider'] ?? 'arcgis';
|
||||
|
||||
// Treat obvious non-address labels as non-geocodable (e.g., "Home", "Office")
|
||||
if (mb_strlen(trim($query)) < 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match ($provider) {
|
||||
'arcgis' => $this->forwardArcgis($query),
|
||||
return match ($this->cfg['provider'] ?? 'arcgis') {
|
||||
'arcgis' => $this->forwardArcgis($query, $this->biasForUser($user)),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse geocode lon/lat → address. (Optional)
|
||||
* reverse geocode lon/lat → address. (Optional)
|
||||
*/
|
||||
public function reverse(float $lat, float $lon): ?array
|
||||
{
|
||||
$provider = $this->cfg['provider'] ?? 'arcgis';
|
||||
return match ($provider) {
|
||||
return match ($this->cfg['provider'] ?? 'arcgis') {
|
||||
'arcgis' => $this->reverseArcgis($lat, $lon),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------- ArcGIS World Geocoding ---------------- */
|
||||
/*
|
||||
*
|
||||
* ArcGIS World Geocoding
|
||||
*/
|
||||
|
||||
/**
|
||||
* request
|
||||
*/
|
||||
private function http()
|
||||
{
|
||||
return Http::retry(3, 500)
|
||||
@ -56,59 +57,139 @@ class Geocoder
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* get base url
|
||||
*/
|
||||
private function arcgisBase(): string
|
||||
{
|
||||
return rtrim($this->cfg['arcgis']['endpoint'], '/');
|
||||
return rtrim($this->cfg["arcgis"]["endpoint"], "/");
|
||||
}
|
||||
|
||||
private function forwardArcgis(string $query): ?array
|
||||
/**
|
||||
* pull a bias from the user (zip -> centroid) and cache it
|
||||
*/
|
||||
private function biasForUser(?User $user): ?array
|
||||
{
|
||||
if (!$user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// if you store home_lat/home_lon on users, prefer those
|
||||
if (!empty($user->home_lat) && !empty($user->home_lon)) {
|
||||
return ['lat' => (float)$user->home_lat, 'lon' => (float)$user->home_lon, 'radius_km' => 120.0];
|
||||
}
|
||||
|
||||
// else try user zip/postal
|
||||
$zip = $user->postal_code ?? $user->zip ?? null;
|
||||
if (!$zip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cacheKey = "geo:bias:zip:{$zip}";
|
||||
$bias = Cache::remember($cacheKey, now()->addDays(7), function () use ($zip) {
|
||||
$a = $this->cfg['arcgis'];
|
||||
|
||||
$params = [
|
||||
'singleLine' => $zip,
|
||||
'category' => 'Postal',
|
||||
'maxLocations' => 1,
|
||||
'f' => 'pjson',
|
||||
'token' => $a['api_key'],
|
||||
'countryCode' => $a['country_code'] ?? null,
|
||||
];
|
||||
|
||||
$res = $this->http()->get($this->arcgisBase().'/findAddressCandidates', $params);
|
||||
if (!$res->ok()) {
|
||||
Log::info('arcgis zip bias lookup failed', ['zip' => $zip, 'status' => $res->status()]);
|
||||
return null;
|
||||
}
|
||||
|
||||
$cand = Arr::first($res->json('candidates', []));
|
||||
if (!$cand || empty($cand['location'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'lat' => (float)($cand['location']['y'] ?? 0),
|
||||
'lon' => (float)($cand['location']['x'] ?? 0),
|
||||
'radius_km' => 120.0, // reasonable city-scale radius
|
||||
];
|
||||
});
|
||||
|
||||
return $bias ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* compute a bounding box string from center + radius (km)
|
||||
*/
|
||||
private function bboxFromBias(float $lat, float $lon, float $radiusKm): string
|
||||
{
|
||||
$latDelta = $radiusKm / 111.0;
|
||||
$lonDelta = $radiusKm / (111.0 * max(cos(deg2rad($lat)), 0.01));
|
||||
$minx = $lon - $lonDelta;
|
||||
$miny = $lat - $latDelta;
|
||||
$maxx = $lon + $lonDelta;
|
||||
$maxy = $lat + $latDelta;
|
||||
return "{$minx},{$miny},{$maxx},{$maxy}";
|
||||
}
|
||||
|
||||
/**
|
||||
* handle location request with optional bias array
|
||||
*/
|
||||
private function forwardArcgis(string $query, ?array $bias): ?array
|
||||
{
|
||||
$a = $this->cfg['arcgis'];
|
||||
|
||||
$params = [
|
||||
'singleLine' => $query,
|
||||
'outFields' => $this->cfg['arcgis']['out_fields'] ?? '*',
|
||||
'maxLocations'=> $this->cfg['arcgis']['max_results'] ?? 1,
|
||||
'f' => 'pjson',
|
||||
'token' => $this->cfg['arcgis']['api_key'],
|
||||
'singleLine' => $query,
|
||||
'outFields' => $a['out_fields'] ?? '*',
|
||||
'maxLocations' => (int)($a['max_results'] ?? 5),
|
||||
'f' => 'pjson',
|
||||
'token' => $a['api_key'],
|
||||
'category' => $a['categories'] ?? 'POI,Address',
|
||||
'countryCode' => $a['country_code'] ?? null,
|
||||
];
|
||||
|
||||
// If your plan permits/requests it:
|
||||
if (!empty($this->cfg['arcgis']['store'])) {
|
||||
$params['forStorage'] = 'true';
|
||||
if ($bias && $bias['lat'] && $bias['lon']) {
|
||||
$params['location'] = $bias['lon'].','.$bias['lat'];
|
||||
if (!empty($bias['radius_km'])) {
|
||||
$params['searchExtent'] = $this->bboxFromBias($bias['lat'], $bias['lon'], (float)$bias['radius_km']);
|
||||
}
|
||||
}
|
||||
|
||||
$res = $this->http()->get($this->arcgisBase().'/findAddressCandidates', $params);
|
||||
|
||||
if (!$res->ok()) {
|
||||
Log::warning('ArcGIS forward geocode failed', ['status' => $res->status(), 'q' => $query]);
|
||||
Log::warning('arcgis forward geocode failed', ['status' => $res->status(), 'q' => $query]);
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = $res->json();
|
||||
$cand = Arr::first($json['candidates'] ?? []);
|
||||
if (!$cand) {
|
||||
$cands = $res->json('candidates', []);
|
||||
if (!$cands) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->normalizeArcgisCandidate($cand, $query);
|
||||
// simplest “best”: highest arcgis score
|
||||
usort($cands, fn($a,$b) => ($b['score'] <=> $a['score']));
|
||||
$best = $cands[0];
|
||||
|
||||
return $this->normalizeArcgisCandidate($best, $query);
|
||||
}
|
||||
|
||||
/**
|
||||
* lookup based on lat/lon
|
||||
*/
|
||||
private function reverseArcgis(float $lat, float $lon): ?array
|
||||
{
|
||||
$a = $this->cfg['arcgis'];
|
||||
$params = [
|
||||
// ArcGIS expects x=lon, y=lat
|
||||
'location' => "{$lon},{$lat}",
|
||||
'f' => 'pjson',
|
||||
'token' => $this->cfg['arcgis']['api_key'],
|
||||
'token' => $a['api_key'],
|
||||
];
|
||||
|
||||
if (!empty($this->cfg['arcgis']['store'])) {
|
||||
$params['forStorage'] = 'true';
|
||||
}
|
||||
|
||||
$res = $this->http()->get($this->arcgisBase().'/reverseGeocode', $params);
|
||||
|
||||
if (!$res->ok()) {
|
||||
Log::warning('ArcGIS reverse geocode failed', ['status' => $res->status()]);
|
||||
Log::warning('arcgis reverse geocode failed', ['status' => $res->status()]);
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -126,25 +207,21 @@ class Geocoder
|
||||
'state' => $addr['Region'] ?? null,
|
||||
'postal' => $addr['Postal'] ?? null,
|
||||
'country' => $addr['CountryCode'] ?? null,
|
||||
// reverseGeocode returns location as x/y too:
|
||||
'lat' => Arr::get($j, 'location.y'),
|
||||
'lon' => Arr::get($j, 'location.x'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* format the returned data
|
||||
*/
|
||||
private function normalizeArcgisCandidate(array $c, string $query): array
|
||||
{
|
||||
$loc = $c['location'] ?? [];
|
||||
$attr = $c['attributes'] ?? [];
|
||||
$loc = $c['location'] ?? [];
|
||||
$attr = $c['attributes'] ?? [];
|
||||
|
||||
// Prefer LongLabel → address → Place_addr
|
||||
$display = $attr['LongLabel']
|
||||
?? $c['address']
|
||||
?? $attr['Place_addr']
|
||||
?? $query;
|
||||
|
||||
// ArcGIS often returns both 'Address' and 'Place_addr'; use either.
|
||||
$street = $attr['Address'] ?? $attr['Place_addr'] ?? null;
|
||||
$display = $attr['LongLabel'] ?? $c['address'] ?? $attr['Place_addr'] ?? $query;
|
||||
$street = $attr['Address'] ?? $attr['Place_addr'] ?? null;
|
||||
|
||||
return [
|
||||
'display_name' => $display,
|
||||
@ -154,9 +231,65 @@ class Geocoder
|
||||
'state' => $attr['Region'] ?? null,
|
||||
'postal' => $attr['Postal'] ?? null,
|
||||
'country' => $attr['CountryCode'] ?? null,
|
||||
// location.x = lon, location.y = lat
|
||||
'lat' => $loc['y'] ?? null,
|
||||
'lon' => $loc['x'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* get top-n suggestions for a free-form query
|
||||
*/
|
||||
public function suggestions(string $query, int $limit = 5, ?User $user = null): array
|
||||
{
|
||||
$provider = $this->cfg["provider"] ?? "arcgis";
|
||||
|
||||
if (mb_strlen(trim($query)) < 3) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return match ($provider) {
|
||||
"arcgis" => $this->arcgisSuggestions($query, $limit),
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* get the suggestions from arcgis
|
||||
*/
|
||||
private function arcgisSuggestions(string $query, int $limit): array
|
||||
{
|
||||
$params = [
|
||||
"singleLine" => $query,
|
||||
"outFields" => $this->cfg["arcgis"]["out_fields"] ?? "*",
|
||||
"maxLocations" => $limit,
|
||||
// you can bias results with 'countryCode' or 'location' here if desired
|
||||
"f" => "pjson",
|
||||
"token" => $this->cfg["arcgis"]["api_key"],
|
||||
];
|
||||
|
||||
if (!empty($this->cfg["arcgis"]["store"])) {
|
||||
$params["forStorage"] = "true";
|
||||
}
|
||||
|
||||
$res = $this->http()->get(
|
||||
$this->arcgisBase() . "/findAddressCandidates",
|
||||
$params,
|
||||
);
|
||||
if (!$res->ok()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$json = $res->json();
|
||||
$cands = array_slice($json["candidates"] ?? [], 0, $limit);
|
||||
|
||||
// normalize to the same structure used elsewhere
|
||||
return array_values(
|
||||
array_filter(
|
||||
array_map(
|
||||
fn($c) => $this->normalizeArcgisCandidate($c, $query),
|
||||
$cands,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Third Party Services
|
||||
@ -14,40 +13,42 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'postmark' => [
|
||||
'token' => env('POSTMARK_TOKEN'),
|
||||
"postmark" => [
|
||||
"token" => env("POSTMARK_TOKEN"),
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'key' => env('RESEND_KEY'),
|
||||
"resend" => [
|
||||
"key" => env("RESEND_KEY"),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
"ses" => [
|
||||
"key" => env("AWS_ACCESS_KEY_ID"),
|
||||
"secret" => env("AWS_SECRET_ACCESS_KEY"),
|
||||
"region" => env("AWS_DEFAULT_REGION", "us-east-1"),
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'notifications' => [
|
||||
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
|
||||
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
|
||||
"slack" => [
|
||||
"notifications" => [
|
||||
"bot_user_oauth_token" => env("SLACK_BOT_USER_OAUTH_TOKEN"),
|
||||
"channel" => env("SLACK_BOT_USER_DEFAULT_CHANNEL"),
|
||||
],
|
||||
],
|
||||
|
||||
/* custom geocoding service values */
|
||||
'geocoding' => [
|
||||
'provider' => env('GEOCODER', 'arcgis'),
|
||||
'timeout' => (int) env('GEOCODER_TIMEOUT', 20),
|
||||
'user_agent' => env('GEOCODER_USER_AGENT', 'Kithkin/LocalDev'),
|
||||
'arcgis' => [
|
||||
'api_key' => env('ARCGIS_API_KEY'),
|
||||
'store' => (bool) env('ARCGIS_STORE_RESULTS', true),
|
||||
'endpoint' => 'https://geocode-api.arcgis.com/arcgis/rest/services/World/GeocodeServer',
|
||||
// keep these compact and stable
|
||||
'out_fields' => 'Match_addr,Addr_type,PlaceName,Place_addr,Address,City,Region,Postal,CountryCode,LongLabel',
|
||||
'max_results' => 1,
|
||||
"geocoding" => [
|
||||
"provider" => env("GEOCODER", "arcgis"),
|
||||
"timeout" => (int) env("GEOCODER_TIMEOUT", 20),
|
||||
"user_agent" => env("GEOCODER_USER_AGENT", "Kithkin/LocalDev"),
|
||||
"arcgis" => [
|
||||
"api_key" => env("ARCGIS_API_KEY"),
|
||||
"store" => (bool) env("ARCGIS_STORE_RESULTS", true),
|
||||
"endpoint" =>
|
||||
"https://geocode-api.arcgis.com/arcgis/rest/services/World/GeocodeServer",
|
||||
"country_code" => env("GEOCODER_COUNTRY", "USA"),
|
||||
"categories" => env("GEOCODER_CATEGORIES", "POI,Address"),
|
||||
"out_fields" =>
|
||||
"Match_addr,Addr_type,PlaceName,Place_addr,Address,City,Region,Postal,CountryCode,LongLabel",
|
||||
"max_results" => 1,
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
<?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::table('locations', function (Blueprint $t) {
|
||||
$t->string('place_name', 255)->nullable()->after('display_name');
|
||||
$t->index('place_name', 'locations_place_name_idx');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('locations', function (Blueprint $t) {
|
||||
$t->dropIndex('locations_place_name_idx');
|
||||
$t->dropColumn('place_name');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,79 @@
|
||||
<?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
|
||||
{
|
||||
/**
|
||||
* 1) addresses per user (supports billing/shipping/home/work/etc.)
|
||||
* optional link to `locations` for normalized/geocoded data.
|
||||
*/
|
||||
Schema::create('user_addresses', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
|
||||
// your 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"
|
||||
|
||||
// raw, user-entered fields
|
||||
$table->string('line1', 255)->nullable();
|
||||
$table->string('line2', 255)->nullable();
|
||||
$table->string('city', 100)->nullable();
|
||||
$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();
|
||||
|
||||
// nullable so unique index allows many NULLs (only 1 with value 1)
|
||||
$table->boolean('is_primary')->nullable()->default(null);
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
// helpful indexes
|
||||
$table->index(['user_id', 'kind']);
|
||||
$table->index('postal');
|
||||
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
|
||||
$table->foreign('location_id')->references('id')->on('locations')->nullOnDelete();
|
||||
|
||||
// enforce at most one primary per (user,kind)
|
||||
$table->unique(['user_id', 'kind', 'is_primary'], 'user_kind_primary_unique');
|
||||
});
|
||||
|
||||
/**
|
||||
* 2) lightweight geocoder bias on users (postal/country + optional centroid).
|
||||
*/
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('postal', 32)->nullable()->after('timezone');
|
||||
$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');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('users', 'postal')) $table->dropIndex(['postal']);
|
||||
foreach (['postal','country','lat','lon'] as $col) {
|
||||
if (Schema::hasColumn('users', $col)) $table->dropColumn($col);
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('user_addresses', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('user_addresses', 'user_id')) $table->dropForeign(['user_id']);
|
||||
if (Schema::hasColumn('user_addresses', 'location_id')) $table->dropForeign(['location_id']);
|
||||
});
|
||||
|
||||
Schema::dropIfExists('user_addresses');
|
||||
}
|
||||
};
|
||||
1808
package-lock.json
generated
1808
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -11,6 +11,7 @@
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"axios": "^1.8.2",
|
||||
"blade-formatter": "^1.44.2",
|
||||
"concurrently": "^9.0.1",
|
||||
"htmx.org": "^2.0.6",
|
||||
"laravel-vite-plugin": "^1.2.0",
|
||||
|
||||
@ -35,7 +35,7 @@ body {
|
||||
/* bottom items */
|
||||
|
||||
.bottom {
|
||||
@apply pb-6 2xl:pb-8;
|
||||
@apply flex flex-col items-center pb-6 2xl:pb-8;
|
||||
}
|
||||
|
||||
/* app buttons */
|
||||
|
||||
@ -8,8 +8,14 @@ window.htmx = htmx;
|
||||
htmx.config.historyEnabled = true; // HX-Boost back/forward support
|
||||
htmx.logger = console.log; // verbose logging during dev
|
||||
|
||||
// csrf on htmx requests
|
||||
document.addEventListener('htmx:configRequest', (evt) => {
|
||||
const token = document.querySelector('meta[name="csrf-token"]')?.content
|
||||
if (token) evt.detail.headers['X-CSRF-TOKEN'] = token
|
||||
})
|
||||
|
||||
// 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;
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
<x-slot name="aside">
|
||||
<h1>
|
||||
{{ __('Settings') }}
|
||||
{{ __('Calendar') }}
|
||||
</h1>
|
||||
<x-calendar.settings-menu />
|
||||
</x-slot>
|
||||
|
||||
@ -6,12 +6,12 @@
|
||||
</h2>
|
||||
|
||||
<div class="space-x-2">
|
||||
<a href="{{ route('calendars.edit', $calendar) }}"
|
||||
<a href="{{ route('calendar.edit', $calendar) }}"
|
||||
class="inline-flex items-center px-3 py-1.5 bg-gray-200 rounded-md text-sm">
|
||||
{{ __('Edit') }}
|
||||
</a>
|
||||
|
||||
<a href="{{ route('calendars.events.create', $calendar) }}"
|
||||
<a href="{{ route('calendar.event.create', $calendar) }}"
|
||||
class="inline-flex items-center px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm">
|
||||
{{ __('Add Event') }}
|
||||
</a>
|
||||
@ -90,7 +90,7 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<a href="{{ route('calendars.events.edit', [$calendar, $event]) }}" class="text-sm text-indigo-600">{{ __('Edit') }}</a>
|
||||
<a href="{{ route('calendar.event.edit', [$calendar, $event]) }}" class="text-sm text-indigo-600">{{ __('Edit') }}</a>
|
||||
</div>
|
||||
</li>
|
||||
@empty
|
||||
|
||||
@ -3,7 +3,8 @@
|
||||
'size' => 'default',
|
||||
'type' => 'button',
|
||||
'class' => '',
|
||||
'label' => 'Icon button' ])
|
||||
'label' => 'Icon button',
|
||||
'href' => null ])
|
||||
|
||||
@php
|
||||
$variantClass = match ($variant) {
|
||||
@ -17,8 +18,22 @@ $sizeClass = match ($size) {
|
||||
'lg' => 'button--lg',
|
||||
default => '',
|
||||
};
|
||||
|
||||
$element = match ($type) {
|
||||
'anchor' => 'a',
|
||||
'button' => 'button',
|
||||
'submit' => 'button',
|
||||
default => 'button',
|
||||
};
|
||||
|
||||
$type = match ($type) {
|
||||
'anchor' => '',
|
||||
'button' => 'type="button"',
|
||||
'submit' => 'type="submit"',
|
||||
default => '',
|
||||
}
|
||||
@endphp
|
||||
|
||||
<button type="{{ $type }}" class="button button--icon {{ $variantClass }} {{ $sizeClass }} {{ $class }}" aria-label="{{ $label }}">
|
||||
<{{ $element }} {{ $type }} class="button button--icon {{ $variantClass }} {{ $sizeClass }} {{ $class }}" aria-label="{{ $label }}" href="{{ $href }}">
|
||||
{{ $slot }}
|
||||
</button>
|
||||
</{{ $element }}>
|
||||
|
||||
@ -3,7 +3,8 @@
|
||||
'size' => 'default',
|
||||
'type' => 'button',
|
||||
'class' => '',
|
||||
'label' => 'Icon button' ])
|
||||
'label' => '',
|
||||
'href' => null ])
|
||||
|
||||
@php
|
||||
$variantClass = match ($variant) {
|
||||
@ -17,8 +18,22 @@ $sizeClass = match ($size) {
|
||||
'lg' => 'button--lg',
|
||||
default => '',
|
||||
};
|
||||
|
||||
$element = match ($type) {
|
||||
'anchor' => 'a',
|
||||
'button' => 'button type="button"',
|
||||
'submit' => 'button type="submit"',
|
||||
default => 'button',
|
||||
};
|
||||
|
||||
$type = match ($type) {
|
||||
'anchor' => '',
|
||||
'button' => 'type="button"',
|
||||
'submit' => 'type="submit"',
|
||||
default => '',
|
||||
}
|
||||
@endphp
|
||||
|
||||
<button type="{{ $type }}" class="{{ $variantClass }} {{ $sizeClass }} {{ $class }}">
|
||||
<{{ $element }} {{ $type }} class="button button--icon {{ $variantClass }} {{ $sizeClass }} {{ $class }}" aria-label="{{ $label }}" href="{{ $href }}">
|
||||
{{ $slot }}
|
||||
</button>
|
||||
</{{ $element }}>
|
||||
|
||||
@ -21,8 +21,8 @@
|
||||
$color = $event['color'] ?? '#999';
|
||||
@endphp
|
||||
<a class="event{{ $event['visible'] ? '' : ' hidden' }}"
|
||||
href="{{ route('calendar.events.show', [$event['calendar_slug'], $event['id']]) }}"
|
||||
hx-get="{{ route('calendar.events.show', [$event['calendar_slug'], $event['id']]) }}"
|
||||
href="{{ route('calendar.event.show', [$event['calendar_slug'], $event['id']]) }}"
|
||||
hx-get="{{ route('calendar.event.show', [$event['calendar_slug'], $event['id']]) }}"
|
||||
hx-target="#modal"
|
||||
hx-push-url="false"
|
||||
hx-swap="innerHTML"
|
||||
|
||||
28
resources/views/components/profile/settings-menu.blade.php
Normal file
28
resources/views/components/profile/settings-menu.blade.php
Normal file
@ -0,0 +1,28 @@
|
||||
<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>
|
||||
@ -6,7 +6,7 @@
|
||||
</h2>
|
||||
|
||||
{{-- “Back” breadcrumb --}}
|
||||
<a href="{{ route('calendars.show', $calendar) }}"
|
||||
<a href="{{ route('calendar.show', $calendar) }}"
|
||||
class="text-sm text-gray-500 hover:text-gray-700">
|
||||
← {{ $calendar->name }}
|
||||
</a>
|
||||
@ -18,8 +18,8 @@
|
||||
<div class="bg-white shadow-sm sm:rounded-lg p-6">
|
||||
<form method="POST"
|
||||
action="{{ $event->exists
|
||||
? route('calendars.events.update', [$calendar, $event])
|
||||
: route('calendars.events.store', $calendar) }}">
|
||||
? route('calendar.event.update', [$calendar, $event])
|
||||
: route('calendar.event.store', $calendar) }}">
|
||||
|
||||
@csrf
|
||||
@if($event->exists)
|
||||
@ -45,8 +45,31 @@
|
||||
{{-- Location --}}
|
||||
<div class="mb-6">
|
||||
<x-input-label for="location" :value="__('Location')" />
|
||||
<x-text-input id="location" name="location" type="text" class="mt-1 block w-full"
|
||||
:value="old('location', $event->meta?->location ?? '')" />
|
||||
<x-text-input id="location"
|
||||
name="location"
|
||||
type="text"
|
||||
class="mt-1 block w-full"
|
||||
:value="old('location', $event->meta?->location ?? '')"
|
||||
{{-- live suggestions via htmx --}}
|
||||
hx-get="{{ route('location.suggest') }}"
|
||||
hx-trigger="keyup changed delay:300ms"
|
||||
hx-target="#location-suggestions"
|
||||
hx-swap="innerHTML" />
|
||||
|
||||
{{-- suggestion dropdown target --}}
|
||||
<div id="location-suggestions" class="relative z-20"></div>
|
||||
|
||||
{{-- hidden fields (filled when user clicks a suggestion; handy for step #2) --}}
|
||||
<input type="hidden" id="loc_display_name" name="loc_display_name" />
|
||||
<input type="hidden" id="loc_place_name" name="loc_place_name" />
|
||||
<input type="hidden" id="loc_street" name="loc_street" />
|
||||
<input type="hidden" id="loc_city" name="loc_city" />
|
||||
<input type="hidden" id="loc_state" name="loc_state" />
|
||||
<input type="hidden" id="loc_postal" name="loc_postal" />
|
||||
<input type="hidden" id="loc_country" name="loc_country" />
|
||||
<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')" />
|
||||
</div>
|
||||
|
||||
@ -84,7 +107,7 @@
|
||||
|
||||
{{-- Submit --}}
|
||||
<div class="flex justify-end space-x-2">
|
||||
<a href="{{ route('calendars.show', $calendar) }}"
|
||||
<a href="{{ route('calendar.show', $calendar) }}"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-200 rounded-md">
|
||||
{{ __('Cancel') }}
|
||||
</a>
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
</p>
|
||||
|
||||
@if ($event->meta->location)
|
||||
<p><strong>Where:</strong> {{ $event->meta->location }}</p>
|
||||
<p><strong>Where:</strong> {{ $event->meta->location_label }}</p>
|
||||
@endif
|
||||
|
||||
@if ($event->meta->description)
|
||||
|
||||
39
resources/views/event/partials/suggestions.blade.php
Normal file
39
resources/views/event/partials/suggestions.blade.php
Normal file
@ -0,0 +1,39 @@
|
||||
@if (empty($suggestions))
|
||||
<div></div>
|
||||
@else
|
||||
<ul class="mt-2 rounded-md border border-gray-200 shadow-sm bg-white divide-y divide-gray-100">
|
||||
@foreach ($suggestions as $s)
|
||||
<li>
|
||||
<button type="button"
|
||||
class="w-full text-left px-3 py-2 hover:bg-gray-50"
|
||||
hx-on:click="
|
||||
// set the visible input value
|
||||
document.querySelector('#location').value = @js($s['display_name'] ?? '');
|
||||
// set optional hidden fields for later normalization (step 2)
|
||||
const setVal = (id,val)=>{ const el=document.querySelector(id); if(el) el.value = val ?? '' };
|
||||
setVal('#loc_display_name', @js($s['display_name'] ?? ''));
|
||||
setVal('#loc_street', @js($s['street'] ?? ''));
|
||||
setVal('#loc_city', @js($s['city'] ?? ''));
|
||||
setVal('#loc_state', @js($s['state'] ?? ''));
|
||||
setVal('#loc_postal', @js($s['postal'] ?? ''));
|
||||
setVal('#loc_country', @js($s['country'] ?? ''));
|
||||
setVal('#loc_lat', @js($s['lat'] ?? ''));
|
||||
setVal('#loc_lon', @js($s['lon'] ?? ''));
|
||||
// clear the suggestion list
|
||||
this.closest('#location-suggestions').innerHTML = '';
|
||||
">
|
||||
<div class="font-medium text-gray-800">
|
||||
{{ $s['display_name'] ?? '' }}
|
||||
</div>
|
||||
@php
|
||||
$line2 = collect([$s['street'] ?? null, $s['city'] ?? null, $s['state'] ?? null, $s['postal'] ?? null])
|
||||
->filter()->implode(', ');
|
||||
@endphp
|
||||
@if($line2)
|
||||
<div class="text-sm text-gray-500">{{ $line2 }}</div>
|
||||
@endif
|
||||
</button>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@endif
|
||||
@ -4,7 +4,7 @@
|
||||
{{ $event->meta->title ?? '(no title)' }}
|
||||
</h1>
|
||||
|
||||
<a href="{{ route('calendar.events.edit', [$calendar->id, $event->id]) }}"
|
||||
<a href="{{ route('calendar.event.edit', [$calendar->id, $event->id]) }}"
|
||||
class="button button--primary ml-auto">
|
||||
Edit
|
||||
</a>
|
||||
|
||||
@ -22,34 +22,12 @@
|
||||
|
||||
<!-- bottom -->
|
||||
<section class="bottom">
|
||||
<x-button.icon :href="route('settings')">
|
||||
<x-button.icon type="anchor" :href="route('settings')">
|
||||
<x-icon-settings class="w-7 h-7" />
|
||||
</x-button.icon>
|
||||
<x-dropdown align="right">
|
||||
<x-slot name="trigger">
|
||||
<x-button.icon>
|
||||
<x-icon-user-circle class="w-7 h-7" />
|
||||
</x-button.icon>
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="content">
|
||||
<div>{{ Auth::user()->name }}</div>
|
||||
<x-dropdown-link :href="route('profile.edit')">
|
||||
{{ __('Profile') }}
|
||||
</x-dropdown-link>
|
||||
|
||||
<!-- Authentication -->
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
|
||||
<x-dropdown-link :href="route('logout')"
|
||||
onclick="event.preventDefault();
|
||||
this.closest('form').submit();">
|
||||
{{ __('Log Out') }}
|
||||
</x-dropdown-link>
|
||||
</form>
|
||||
</x-slot>
|
||||
</x-dropdown>
|
||||
<x-button.icon type="anchor" :href="route('profile.edit')">
|
||||
<x-icon-user-circle class="w-7 h-7" />
|
||||
</x-button.icon>
|
||||
</div>
|
||||
|
||||
<!-- Hamburger -->
|
||||
|
||||
@ -13,6 +13,15 @@
|
||||
</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', [
|
||||
'home' => $home ?? null,
|
||||
'billing' => $billing ?? null,
|
||||
])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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')
|
||||
|
||||
30
resources/views/profile/index.blade.php
Normal file
30
resources/views/profile/index.blade.php
Normal file
@ -0,0 +1,30 @@
|
||||
<x-app-layout id="profile" class="readable">
|
||||
|
||||
<x-slot name="aside">
|
||||
<h1>
|
||||
{{ __('Profile') }}
|
||||
</h1>
|
||||
<x-profile.settings-menu />
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="header">
|
||||
<h2>
|
||||
@isset($data['title'])
|
||||
{{ $data['title'] }}
|
||||
@else
|
||||
{{ __('Settings') }}
|
||||
@endisset
|
||||
</h2>
|
||||
</x-slot>
|
||||
|
||||
<x-slot name="article">
|
||||
<div class="content">
|
||||
@isset($view)
|
||||
@include($view, $data ?? [])
|
||||
@else
|
||||
<p class="text-muted">{{ __('Pick an option in the sidebar…') }}</p>
|
||||
@endisset
|
||||
<div class="content">
|
||||
</x-slot>
|
||||
|
||||
</x-app-layout>
|
||||
153
resources/views/profile/partials/addresses-form.blade.php
Normal file
153
resources/views/profile/partials/addresses-form.blade.php
Normal file
@ -0,0 +1,153 @@
|
||||
<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>
|
||||
@ -9,22 +9,22 @@ use App\Http\Controllers\CardController;
|
||||
use App\Http\Controllers\DavController;
|
||||
use App\Http\Controllers\EventController;
|
||||
use App\Http\Controllers\IcsController;
|
||||
use App\Http\Controllers\LocationController;
|
||||
use App\Http\Controllers\SubscriptionController;
|
||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Public pages
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
/**
|
||||
*
|
||||
* public unauthenticated pages
|
||||
*/
|
||||
|
||||
Route::view('/', 'welcome');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Breeze starter‑kit pages
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
/**
|
||||
*
|
||||
* starter pages
|
||||
* @todo replace thse
|
||||
*/
|
||||
|
||||
Route::view('/dashboard', 'dashboard')
|
||||
->middleware(['auth', 'verified'])
|
||||
@ -34,11 +34,19 @@ Route::view('/settings', 'settings')
|
||||
->middleware(['auth', 'verified'])
|
||||
->name('settings');
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
/* User profile (generated by Breeze) */
|
||||
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');
|
||||
/**
|
||||
*
|
||||
* main authentication block
|
||||
*/
|
||||
|
||||
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');
|
||||
|
||||
/* calendar core */
|
||||
Route::middleware('auth')->group(function () {
|
||||
@ -48,7 +56,7 @@ Route::middleware('auth')->group(function () {
|
||||
});
|
||||
|
||||
/* calendar other */
|
||||
Route::middleware('auth')
|
||||
Route::middleware(['web','auth'])
|
||||
->prefix('calendar')
|
||||
->name('calendar.')
|
||||
->group(function () {
|
||||
@ -63,22 +71,27 @@ Route::middleware('auth')->group(function () {
|
||||
// remote calendar subscriptions
|
||||
Route::resource('subscriptions', SubscriptionController::class)
|
||||
->except(['show']); // index, create, store, edit, update, destroy
|
||||
|
||||
// events
|
||||
Route::prefix('{calendar}')->whereUuid('calendar')->group(function () {
|
||||
// create & store
|
||||
Route::get ('event/create', [EventController::class, 'create'])->name('events.create');
|
||||
Route::post('event', [EventController::class, 'store' ])->name('events.store');
|
||||
Route::get ('event/create', [EventController::class, 'create'])->name('event.create');
|
||||
Route::post('event', [EventController::class, 'store' ])->name('event.store');
|
||||
// read
|
||||
Route::get ('event/{event}', [EventController::class, 'show' ])->name('events.show');
|
||||
Route::get ('event/{event}', [EventController::class, 'show' ])->name('event.show');
|
||||
// edit & update
|
||||
Route::get ('event/{event}/edit', [EventController::class, 'edit' ])->name('events.edit');
|
||||
Route::put ('event/{event}', [EventController::class, 'update'])->name('events.update');
|
||||
Route::get ('event/{event}/edit', [EventController::class, 'edit' ])->name('event.edit');
|
||||
Route::put ('event/{event}', [EventController::class, 'update'])->name('event.update');
|
||||
// delete
|
||||
Route::delete('event/{event}', [EventController::class, 'destroy'])->name('events.destroy');
|
||||
Route::delete('event/{event}', [EventController::class, 'destroy'])->name('event.destroy');
|
||||
});
|
||||
});
|
||||
|
||||
/** address books */
|
||||
// autocomplete suggestions for event locations
|
||||
Route::get('/location/suggest', [LocationController::class, 'suggest'])
|
||||
->name('location.suggest');
|
||||
|
||||
// address books
|
||||
Route::resource('book', BookController::class)
|
||||
->names('book') // books.index, books.create, …
|
||||
->parameter('book', 'book'); // {book} binding
|
||||
|
||||
Loading…
Reference in New Issue
Block a user