mirror of
https://github.com/pacnpal/thrillwiki_laravel.git
synced 2025-12-20 10:11:11 -05:00
- Implemented ParksLocationSearch component with loading state and refresh functionality. - Created ParksMapView component with similar structure and functionality. - Added RegionalParksListing component for displaying regional parks. - Developed RidesListingUniversal component for universal listing integration. - Established ManufacturersListing view with navigation and Livewire integration. - Added feature tests for various Livewire components including OperatorHierarchyView, OperatorParksListing, OperatorPortfolioCard, OperatorsListing, OperatorsRoleFilter, ParksListing, ParksLocationSearch, ParksMapView, and RegionalParksListing to ensure proper rendering and adherence to patterns.
476 lines
15 KiB
PHP
476 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire;
|
|
|
|
use App\Models\Park;
|
|
use App\Models\Operator;
|
|
use Livewire\Component;
|
|
use Livewire\WithPagination;
|
|
use Livewire\Attributes\Url;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
|
|
class ParksListing extends Component
|
|
{
|
|
use WithPagination;
|
|
|
|
// Search and Filter Properties with URL binding
|
|
#[Url(as: 'q')]
|
|
public string $search = '';
|
|
|
|
#[Url(as: 'operator')]
|
|
public string $operatorId = '';
|
|
|
|
#[Url(as: 'region')]
|
|
public string $region = '';
|
|
|
|
#[Url(as: 'country')]
|
|
public string $country = '';
|
|
|
|
#[Url(as: 'type')]
|
|
public string $parkType = '';
|
|
|
|
#[Url(as: 'year_from')]
|
|
public string $openingYearFrom = '';
|
|
|
|
#[Url(as: 'year_to')]
|
|
public string $openingYearTo = '';
|
|
|
|
#[Url(as: 'min_area')]
|
|
public string $minArea = '';
|
|
|
|
#[Url(as: 'max_area')]
|
|
public string $maxArea = '';
|
|
|
|
#[Url(as: 'min_rides')]
|
|
public string $minRides = '';
|
|
|
|
#[Url(as: 'max_distance')]
|
|
public string $maxDistance = '';
|
|
|
|
#[Url(as: 'sort')]
|
|
public string $sortBy = 'name';
|
|
|
|
#[Url(as: 'dir')]
|
|
public string $sortDirection = 'asc';
|
|
|
|
// Location Properties
|
|
public ?array $userLocation = null;
|
|
public bool $locationEnabled = false;
|
|
public bool $locationLoading = false;
|
|
|
|
// UI State
|
|
public bool $showFilters = false;
|
|
public bool $isLoading = false;
|
|
|
|
/**
|
|
* Component initialization
|
|
*/
|
|
public function mount(): void
|
|
{
|
|
// Initialize component state
|
|
$this->resetPage();
|
|
}
|
|
|
|
/**
|
|
* Updated hook for reactive properties
|
|
*/
|
|
public function updated($property): void
|
|
{
|
|
if (in_array($property, [
|
|
'search', 'operatorId', 'region', 'country', 'parkType',
|
|
'openingYearFrom', 'openingYearTo', 'minArea', 'maxArea',
|
|
'minRides', 'maxDistance', 'sortBy', 'sortDirection'
|
|
])) {
|
|
$this->resetPage();
|
|
$this->invalidateCache('parks');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enable location services
|
|
*/
|
|
public function enableLocation(): void
|
|
{
|
|
$this->locationLoading = true;
|
|
$this->dispatch('request-location');
|
|
}
|
|
|
|
/**
|
|
* Handle location received from JavaScript
|
|
*/
|
|
public function locationReceived(array $location): void
|
|
{
|
|
$this->userLocation = $location;
|
|
$this->locationEnabled = true;
|
|
$this->locationLoading = false;
|
|
$this->resetPage();
|
|
$this->invalidateCache('parks');
|
|
}
|
|
|
|
/**
|
|
* Handle location error
|
|
*/
|
|
public function locationError(string $error): void
|
|
{
|
|
$this->locationLoading = false;
|
|
$this->dispatch('location-error', message: $error);
|
|
}
|
|
|
|
/**
|
|
* Clear all filters
|
|
*/
|
|
public function clearFilters(): void
|
|
{
|
|
$this->reset([
|
|
'search', 'operatorId', 'region', 'country', 'parkType',
|
|
'openingYearFrom', 'openingYearTo', 'minArea', 'maxArea',
|
|
'minRides', 'maxDistance'
|
|
]);
|
|
$this->userLocation = null;
|
|
$this->locationEnabled = false;
|
|
$this->resetPage();
|
|
$this->invalidateCache('parks');
|
|
}
|
|
|
|
/**
|
|
* Toggle filters visibility
|
|
*/
|
|
public function toggleFilters(): void
|
|
{
|
|
$this->showFilters = !$this->showFilters;
|
|
}
|
|
|
|
/**
|
|
* Sort parks by given field
|
|
*/
|
|
public function sortBy(string $field): void
|
|
{
|
|
if ($this->sortBy === $field) {
|
|
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
$this->sortBy = $field;
|
|
$this->sortDirection = 'asc';
|
|
}
|
|
$this->resetPage();
|
|
$this->invalidateCache('parks');
|
|
}
|
|
|
|
/**
|
|
* Get parks with location-aware search and filtering
|
|
*/
|
|
public function getParksProperty()
|
|
{
|
|
return $this->remember('parks', function () {
|
|
return $this->buildParksQuery()
|
|
->paginate(12, ['*'], 'page', $this->getPage());
|
|
}, 1200); // 20-minute location-aware caching
|
|
}
|
|
|
|
/**
|
|
* Build the parks query with Django parity search and filtering
|
|
*/
|
|
protected function buildParksQuery(): Builder
|
|
{
|
|
$query = Park::query();
|
|
|
|
// Location-aware search functionality (Django parity)
|
|
if (!empty($this->search)) {
|
|
$query = $this->locationAwareSearch($query, $this->search, $this->userLocation);
|
|
}
|
|
|
|
// Apply advanced filters with geographic context
|
|
$query = $this->applyFilters($query, [
|
|
'operator_id' => $this->operatorId,
|
|
'region' => $this->region,
|
|
'country' => $this->country,
|
|
'park_type' => $this->parkType,
|
|
'opening_year_from' => $this->openingYearFrom,
|
|
'opening_year_to' => $this->openingYearTo,
|
|
'min_area' => $this->minArea,
|
|
'max_area' => $this->maxArea,
|
|
'min_rides' => $this->minRides,
|
|
'max_distance' => $this->maxDistance,
|
|
], $this->userLocation);
|
|
|
|
// Apply sorting with location-aware options
|
|
$query = $this->applySorting($query);
|
|
|
|
// Eager load relationships for performance
|
|
$query->with(['location', 'operator', 'photos', 'statistics'])
|
|
->withCount(['rides', 'reviews']);
|
|
|
|
return $query;
|
|
}
|
|
|
|
/**
|
|
* Location-aware search functionality matching Django implementation
|
|
*/
|
|
protected function locationAwareSearch(Builder $query, string $searchQuery, ?array $userLocation = null): Builder
|
|
{
|
|
$terms = explode(' ', trim($searchQuery));
|
|
|
|
foreach ($terms as $term) {
|
|
$term = trim($term);
|
|
if (empty($term)) continue;
|
|
|
|
$query->where(function ($subQuery) use ($term) {
|
|
$subQuery->where('name', 'ilike', "%{$term}%")
|
|
->orWhere('description', 'ilike', "%{$term}%")
|
|
->orWhere('park_type', 'ilike', "%{$term}%")
|
|
->orWhereHas('location', function($locQuery) use ($term) {
|
|
$locQuery->where('city', 'ilike', "%{$term}%")
|
|
->orWhere('state', 'ilike', "%{$term}%")
|
|
->orWhere('country', 'ilike', "%{$term}%");
|
|
})
|
|
->orWhereHas('operator', fn($opQuery) =>
|
|
$opQuery->where('name', 'ilike', "%{$term}%"));
|
|
});
|
|
}
|
|
|
|
// Add distance-based ordering for location-aware results
|
|
if ($userLocation) {
|
|
$query->selectRaw('parks.*,
|
|
(6371 * acos(cos(radians(?)) * cos(radians(locations.latitude)) *
|
|
cos(radians(locations.longitude) - radians(?)) +
|
|
sin(radians(?)) * sin(radians(locations.latitude)))) AS distance',
|
|
[$userLocation['lat'], $userLocation['lng'], $userLocation['lat']])
|
|
->join('locations', 'parks.location_id', '=', 'locations.id')
|
|
->orderBy('distance');
|
|
}
|
|
|
|
return $query;
|
|
}
|
|
|
|
/**
|
|
* Apply advanced filters with geographic context
|
|
*/
|
|
protected function applyFilters(Builder $query, array $filters, ?array $userLocation = null): Builder
|
|
{
|
|
return $query
|
|
->when($filters['operator_id'] ?? null, fn($q, $operatorId) =>
|
|
$q->where('operator_id', $operatorId))
|
|
->when($filters['region'] ?? null, fn($q, $region) =>
|
|
$q->whereHas('location', fn($locQ) => $locQ->where('state', $region)))
|
|
->when($filters['country'] ?? null, fn($q, $country) =>
|
|
$q->whereHas('location', fn($locQ) => $locQ->where('country', $country)))
|
|
->when($filters['park_type'] ?? null, fn($q, $type) =>
|
|
$q->where('park_type', $type))
|
|
->when($filters['opening_year_from'] ?? null, fn($q, $year) =>
|
|
$q->where('opening_date', '>=', "{$year}-01-01"))
|
|
->when($filters['opening_year_to'] ?? null, fn($q, $year) =>
|
|
$q->where('opening_date', '<=', "{$year}-12-31"))
|
|
->when($filters['min_area'] ?? null, fn($q, $area) =>
|
|
$q->where('area_acres', '>=', $area))
|
|
->when($filters['max_area'] ?? null, fn($q, $area) =>
|
|
$q->where('area_acres', '<=', $area))
|
|
->when($filters['min_rides'] ?? null, fn($q, $count) =>
|
|
$q->whereHas('rides', fn($rideQ) => $rideQ->havingRaw('COUNT(*) >= ?', [$count])))
|
|
->when($filters['max_distance'] ?? null && $userLocation, function($q) use ($filters, $userLocation) {
|
|
$q->whereRaw('(6371 * acos(cos(radians(?)) * cos(radians(locations.latitude)) *
|
|
cos(radians(locations.longitude) - radians(?)) +
|
|
sin(radians(?)) * sin(radians(locations.latitude)))) <= ?',
|
|
[$userLocation['lat'], $userLocation['lng'], $userLocation['lat'], $filters['max_distance']]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Apply sorting with location-aware options
|
|
*/
|
|
protected function applySorting(Builder $query): Builder
|
|
{
|
|
switch ($this->sortBy) {
|
|
case 'distance':
|
|
if ($this->userLocation) {
|
|
// Distance sorting already applied in locationAwareSearch
|
|
return $query;
|
|
}
|
|
// Fallback to name if no location
|
|
return $query->orderBy('name', $this->sortDirection);
|
|
|
|
case 'rides_count':
|
|
return $query->orderBy('rides_count', $this->sortDirection);
|
|
|
|
case 'reviews_count':
|
|
return $query->orderBy('reviews_count', $this->sortDirection);
|
|
|
|
case 'opening_date':
|
|
return $query->orderBy('opening_date', $this->sortDirection);
|
|
|
|
case 'area_acres':
|
|
return $query->orderBy('area_acres', $this->sortDirection);
|
|
|
|
case 'name':
|
|
default:
|
|
return $query->orderBy('name', $this->sortDirection);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get available operators for filter dropdown
|
|
*/
|
|
public function getOperatorsProperty()
|
|
{
|
|
return $this->remember('operators', function () {
|
|
return Operator::select('id', 'name')
|
|
->whereHas('parks')
|
|
->orderBy('name')
|
|
->get();
|
|
}, 3600);
|
|
}
|
|
|
|
/**
|
|
* Get available regions for filter dropdown
|
|
*/
|
|
public function getRegionsProperty()
|
|
{
|
|
return $this->remember('regions', function () {
|
|
return DB::table('locations')
|
|
->join('parks', 'locations.id', '=', 'parks.location_id')
|
|
->select('locations.state')
|
|
->distinct()
|
|
->whereNotNull('locations.state')
|
|
->orderBy('locations.state')
|
|
->pluck('state');
|
|
}, 3600);
|
|
}
|
|
|
|
/**
|
|
* Get available countries for filter dropdown
|
|
*/
|
|
public function getCountriesProperty()
|
|
{
|
|
return $this->remember('countries', function () {
|
|
return DB::table('locations')
|
|
->join('parks', 'locations.id', '=', 'parks.location_id')
|
|
->select('locations.country')
|
|
->distinct()
|
|
->whereNotNull('locations.country')
|
|
->orderBy('locations.country')
|
|
->pluck('country');
|
|
}, 3600);
|
|
}
|
|
|
|
/**
|
|
* Get available park types for filter dropdown
|
|
*/
|
|
public function getParkTypesProperty()
|
|
{
|
|
return $this->remember('park_types', function () {
|
|
return Park::select('park_type')
|
|
->distinct()
|
|
->whereNotNull('park_type')
|
|
->orderBy('park_type')
|
|
->pluck('park_type');
|
|
}, 3600);
|
|
}
|
|
|
|
/**
|
|
* Get filter summary for display
|
|
*/
|
|
public function getActiveFiltersProperty(): array
|
|
{
|
|
$filters = [];
|
|
|
|
if (!empty($this->search)) {
|
|
$filters[] = "Search: {$this->search}";
|
|
}
|
|
|
|
if (!empty($this->operatorId)) {
|
|
$operator = $this->operators->find($this->operatorId);
|
|
if ($operator) {
|
|
$filters[] = "Operator: {$operator->name}";
|
|
}
|
|
}
|
|
|
|
if (!empty($this->region)) {
|
|
$filters[] = "Region: {$this->region}";
|
|
}
|
|
|
|
if (!empty($this->country)) {
|
|
$filters[] = "Country: {$this->country}";
|
|
}
|
|
|
|
if (!empty($this->parkType)) {
|
|
$filters[] = "Type: {$this->parkType}";
|
|
}
|
|
|
|
if (!empty($this->openingYearFrom) || !empty($this->openingYearTo)) {
|
|
$yearRange = $this->openingYearFrom . ' - ' . $this->openingYearTo;
|
|
$filters[] = "Years: {$yearRange}";
|
|
}
|
|
|
|
if (!empty($this->minArea) || !empty($this->maxArea)) {
|
|
$areaRange = $this->minArea . ' - ' . $this->maxArea . ' acres';
|
|
$filters[] = "Area: {$areaRange}";
|
|
}
|
|
|
|
if (!empty($this->minRides)) {
|
|
$filters[] = "Min Rides: {$this->minRides}";
|
|
}
|
|
|
|
if (!empty($this->maxDistance) && $this->locationEnabled) {
|
|
$filters[] = "Within: {$this->maxDistance} km";
|
|
}
|
|
|
|
return $filters;
|
|
}
|
|
|
|
/**
|
|
* Render the component
|
|
*/
|
|
public function render()
|
|
{
|
|
$this->isLoading = false;
|
|
|
|
return view('livewire.parks-listing', [
|
|
'parks' => $this->parks,
|
|
'operators' => $this->operators,
|
|
'regions' => $this->regions,
|
|
'countries' => $this->countries,
|
|
'parkTypes' => $this->parkTypes,
|
|
'activeFilters' => $this->activeFilters,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Get cache key for this component
|
|
*/
|
|
protected function getCacheKey(string $suffix = ''): string
|
|
{
|
|
$locationKey = $this->userLocation ?
|
|
md5(json_encode($this->userLocation)) : 'no-location';
|
|
|
|
$filterKey = md5(serialize([
|
|
$this->search, $this->operatorId, $this->region, $this->country,
|
|
$this->parkType, $this->openingYearFrom, $this->openingYearTo,
|
|
$this->minArea, $this->maxArea, $this->minRides, $this->maxDistance,
|
|
$this->sortBy, $this->sortDirection
|
|
]));
|
|
|
|
return 'thrillwiki.' . class_basename(static::class) . '.' .
|
|
$locationKey . '.' . $filterKey . '.' . $suffix;
|
|
}
|
|
|
|
/**
|
|
* Remember data with caching
|
|
*/
|
|
protected function remember(string $key, $callback, int $ttl = 3600)
|
|
{
|
|
return Cache::remember($this->getCacheKey($key), $ttl, $callback);
|
|
}
|
|
|
|
/**
|
|
* Invalidate component cache
|
|
*/
|
|
protected function invalidateCache(string $key = null): void
|
|
{
|
|
if ($key) {
|
|
Cache::forget($this->getCacheKey($key));
|
|
} else {
|
|
// Clear all cache for this component
|
|
Cache::flush();
|
|
}
|
|
}
|
|
} |