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:
Andrew Gioia 2026-01-15 16:14:00 -05:00
parent 80c368525a
commit b7282865c8
Signed by: andrew
GPG Key ID: FC09694A000800C8
32 changed files with 3038 additions and 213 deletions

View File

@ -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

View File

@ -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 sabres 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)

View 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,
]);
}
}

View File

@ -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,
]);
}
}

View File

@ -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();
}
}

View File

@ -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 ?? '');
}
}

View File

@ -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(', '));
}
}

View File

@ -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();
}
}

View 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(', '));
}
}

View File

@ -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,
),
),
);
}
}

View File

@ -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,
],
],
];

View File

@ -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');
});
}
};

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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 */

View File

@ -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;

View File

@ -2,7 +2,7 @@
<x-slot name="aside">
<h1>
{{ __('Settings') }}
{{ __('Calendar') }}
</h1>
<x-calendar.settings-menu />
</x-slot>

View File

@ -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

View File

@ -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 }}>

View File

@ -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 }}>

View File

@ -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"

View 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>

View File

@ -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>

View File

@ -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)

View 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

View File

@ -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>

View File

@ -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 -->

View File

@ -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')

View 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>

View 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>

View File

@ -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 starterkit 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