mirror of
https://github.com/pacnpal/thrillwiki_laravel.git
synced 2025-12-20 08:51:11 -05:00
Add models, enums, and services for user roles, theme preferences, slug history, and ID generation
This commit is contained in:
80
app/Enums/ParkStatus.php
Normal file
80
app/Enums/ParkStatus.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum ParkStatus: string
|
||||||
|
{
|
||||||
|
case OPERATING = 'OPERATING';
|
||||||
|
case CLOSED_TEMP = 'CLOSED_TEMP';
|
||||||
|
case CLOSED_PERM = 'CLOSED_PERM';
|
||||||
|
case UNDER_CONSTRUCTION = 'UNDER_CONSTRUCTION';
|
||||||
|
case DEMOLISHED = 'DEMOLISHED';
|
||||||
|
case RELOCATED = 'RELOCATED';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the display label for the status
|
||||||
|
*/
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match($this) {
|
||||||
|
self::OPERATING => 'Operating',
|
||||||
|
self::CLOSED_TEMP => 'Temporarily Closed',
|
||||||
|
self::CLOSED_PERM => 'Permanently Closed',
|
||||||
|
self::UNDER_CONSTRUCTION => 'Under Construction',
|
||||||
|
self::DEMOLISHED => 'Demolished',
|
||||||
|
self::RELOCATED => 'Relocated',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Tailwind CSS classes for status badge
|
||||||
|
*/
|
||||||
|
public function getStatusClasses(): string
|
||||||
|
{
|
||||||
|
return match($this) {
|
||||||
|
self::OPERATING => 'bg-green-100 text-green-800',
|
||||||
|
self::CLOSED_TEMP => 'bg-yellow-100 text-yellow-800',
|
||||||
|
self::CLOSED_PERM => 'bg-red-100 text-red-800',
|
||||||
|
self::UNDER_CONSTRUCTION => 'bg-blue-100 text-blue-800',
|
||||||
|
self::DEMOLISHED => 'bg-gray-100 text-gray-800',
|
||||||
|
self::RELOCATED => 'bg-purple-100 text-purple-800',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the park is currently operational
|
||||||
|
*/
|
||||||
|
public function isOperational(): bool
|
||||||
|
{
|
||||||
|
return $this === self::OPERATING;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the park is permanently closed
|
||||||
|
*/
|
||||||
|
public function isPermanentlyClosed(): bool
|
||||||
|
{
|
||||||
|
return in_array($this, [self::CLOSED_PERM, self::DEMOLISHED]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the park is temporarily closed
|
||||||
|
*/
|
||||||
|
public function isTemporarilyClosed(): bool
|
||||||
|
{
|
||||||
|
return $this === self::CLOSED_TEMP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all status options as an array for forms
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function options(): array
|
||||||
|
{
|
||||||
|
return array_reduce(self::cases(), function ($carry, $status) {
|
||||||
|
$carry[$status->value] = $status->label();
|
||||||
|
return $carry;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Enums/ThemePreference.php
Normal file
25
app/Enums/ThemePreference.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum ThemePreference: string
|
||||||
|
{
|
||||||
|
case LIGHT = 'light';
|
||||||
|
case DARK = 'dark';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match($this) {
|
||||||
|
self::LIGHT => 'Light',
|
||||||
|
self::DARK => 'Dark',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cssClass(): string
|
||||||
|
{
|
||||||
|
return match($this) {
|
||||||
|
self::LIGHT => 'theme-light',
|
||||||
|
self::DARK => 'theme-dark',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/Enums/UserRole.php
Normal file
37
app/Enums/UserRole.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum UserRole: string
|
||||||
|
{
|
||||||
|
case USER = 'USER';
|
||||||
|
case MODERATOR = 'MODERATOR';
|
||||||
|
case ADMIN = 'ADMIN';
|
||||||
|
case SUPERUSER = 'SUPERUSER';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match($this) {
|
||||||
|
self::USER => 'User',
|
||||||
|
self::MODERATOR => 'Moderator',
|
||||||
|
self::ADMIN => 'Admin',
|
||||||
|
self::SUPERUSER => 'Superuser',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canModerate(): bool
|
||||||
|
{
|
||||||
|
return match($this) {
|
||||||
|
self::MODERATOR, self::ADMIN, self::SUPERUSER => true,
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canAdmin(): bool
|
||||||
|
{
|
||||||
|
return match($this) {
|
||||||
|
self::ADMIN, self::SUPERUSER => true,
|
||||||
|
default => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/Livewire/AreaStatisticsComponent.php
Normal file
59
app/Livewire/AreaStatisticsComponent.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\ParkArea;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class AreaStatisticsComponent extends Component
|
||||||
|
{
|
||||||
|
public ParkArea $area;
|
||||||
|
public bool $showDetails = false;
|
||||||
|
public bool $showHistorical = false;
|
||||||
|
|
||||||
|
public function mount(ParkArea $area): void
|
||||||
|
{
|
||||||
|
$this->area = $area;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleDetails(): void
|
||||||
|
{
|
||||||
|
$this->showDetails = !$this->showDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleHistorical(): void
|
||||||
|
{
|
||||||
|
$this->showHistorical = !$this->showHistorical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the ride type distribution as percentages.
|
||||||
|
*
|
||||||
|
* @return array<string, float>
|
||||||
|
*/
|
||||||
|
protected function getRidePercentages(): array
|
||||||
|
{
|
||||||
|
if ($this->area->ride_count === 0) {
|
||||||
|
return [
|
||||||
|
'coasters' => 0,
|
||||||
|
'flat_rides' => 0,
|
||||||
|
'water_rides' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'coasters' => round(($this->area->coaster_count / $this->area->ride_count) * 100, 1),
|
||||||
|
'flat_rides' => round(($this->area->flat_ride_count / $this->area->ride_count) * 100, 1),
|
||||||
|
'water_rides' => round(($this->area->water_ride_count / $this->area->ride_count) * 100, 1),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.area-statistics-component', [
|
||||||
|
'rideDistribution' => $this->area->ride_distribution,
|
||||||
|
'ridePercentages' => $this->getRidePercentages(),
|
||||||
|
'historicalStats' => $this->area->historical_stats,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
78
app/Livewire/ParkAreaFormComponent.php
Normal file
78
app/Livewire/ParkAreaFormComponent.php
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Park;
|
||||||
|
use App\Models\ParkArea;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class ParkAreaFormComponent extends Component
|
||||||
|
{
|
||||||
|
public Park $park;
|
||||||
|
public ?ParkArea $area = null;
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
public string $name = '';
|
||||||
|
public ?string $description = '';
|
||||||
|
public ?string $opening_date = '';
|
||||||
|
public ?string $closing_date = '';
|
||||||
|
|
||||||
|
public function mount(Park $park, ?ParkArea $area = null): void
|
||||||
|
{
|
||||||
|
$this->park = $park;
|
||||||
|
$this->area = $area;
|
||||||
|
|
||||||
|
if ($area) {
|
||||||
|
$this->name = $area->name;
|
||||||
|
$this->description = $area->description ?? '';
|
||||||
|
$this->opening_date = $area->opening_date?->format('Y-m-d') ?? '';
|
||||||
|
$this->closing_date = $area->closing_date?->format('Y-m-d') ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$unique = $this->area
|
||||||
|
? Rule::unique('park_areas', 'name')
|
||||||
|
->where('park_id', $this->park->id)
|
||||||
|
->ignore($this->area->id)
|
||||||
|
: Rule::unique('park_areas', 'name')
|
||||||
|
->where('park_id', $this->park->id);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'min:2', 'max:255', $unique],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'opening_date' => ['nullable', 'date'],
|
||||||
|
'closing_date' => ['nullable', 'date', 'after:opening_date'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$data = $this->validate();
|
||||||
|
$data['park_id'] = $this->park->id;
|
||||||
|
|
||||||
|
if ($this->area) {
|
||||||
|
$this->area->update($data);
|
||||||
|
$message = 'Area updated successfully!';
|
||||||
|
} else {
|
||||||
|
$this->area = ParkArea::create($data);
|
||||||
|
$message = 'Area created successfully!';
|
||||||
|
}
|
||||||
|
|
||||||
|
session()->flash('message', $message);
|
||||||
|
|
||||||
|
$this->redirectRoute('parks.areas.show', [
|
||||||
|
'park' => $this->park,
|
||||||
|
'area' => $this->area,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.park-area-form-component', [
|
||||||
|
'isEditing' => (bool)$this->area,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
app/Livewire/ParkAreaListComponent.php
Normal file
88
app/Livewire/ParkAreaListComponent.php
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Park;
|
||||||
|
use App\Models\ParkArea;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
use Illuminate\Contracts\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class ParkAreaListComponent extends Component
|
||||||
|
{
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
public Park $park;
|
||||||
|
public string $search = '';
|
||||||
|
public string $sort = 'name';
|
||||||
|
public string $direction = 'asc';
|
||||||
|
public bool $showClosed = false;
|
||||||
|
|
||||||
|
/** @var array<string, string> */
|
||||||
|
public array $sortOptions = [
|
||||||
|
'name' => 'Name',
|
||||||
|
'opening_date' => 'Opening Date',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $queryString = [
|
||||||
|
'search' => ['except' => ''],
|
||||||
|
'sort' => ['except' => 'name'],
|
||||||
|
'direction' => ['except' => 'asc'],
|
||||||
|
'showClosed' => ['except' => false],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function mount(Park $park): void
|
||||||
|
{
|
||||||
|
$this->park = $park;
|
||||||
|
$this->resetPage('areas-page');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedSearch(): void
|
||||||
|
{
|
||||||
|
$this->resetPage('areas-page');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedShowClosed(): void
|
||||||
|
{
|
||||||
|
$this->resetPage('areas-page');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sortBy(string $field): void
|
||||||
|
{
|
||||||
|
if ($this->sort === $field) {
|
||||||
|
$this->direction = $this->direction === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
$this->sort = $field;
|
||||||
|
$this->direction = 'asc';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteArea(ParkArea $area): void
|
||||||
|
{
|
||||||
|
$area->delete();
|
||||||
|
session()->flash('message', 'Area deleted successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$query = $this->park->areas()
|
||||||
|
->when($this->search, function (Builder $query) {
|
||||||
|
$query->where('name', 'like', '%' . $this->search . '%')
|
||||||
|
->orWhere('description', 'like', '%' . $this->search . '%');
|
||||||
|
})
|
||||||
|
->when(!$this->showClosed, function (Builder $query) {
|
||||||
|
$query->whereNull('closing_date');
|
||||||
|
})
|
||||||
|
->when($this->sort === 'name', function (Builder $query) {
|
||||||
|
$query->orderBy('name', $this->direction);
|
||||||
|
})
|
||||||
|
->when($this->sort === 'opening_date', function (Builder $query) {
|
||||||
|
$query->orderBy('opening_date', $this->direction)
|
||||||
|
->orderBy('name', 'asc');
|
||||||
|
});
|
||||||
|
|
||||||
|
return view('livewire.park-area-list-component', [
|
||||||
|
'areas' => $query->paginate(10, pageName: 'areas-page'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
125
app/Livewire/ParkAreaReorderComponent.php
Normal file
125
app/Livewire/ParkAreaReorderComponent.php
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Park;
|
||||||
|
use App\Models\ParkArea;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class ParkAreaReorderComponent extends Component
|
||||||
|
{
|
||||||
|
public Park $park;
|
||||||
|
public ?int $parentId = null;
|
||||||
|
public array $areas = [];
|
||||||
|
|
||||||
|
public function mount(Park $park, ?int $parentId = null): void
|
||||||
|
{
|
||||||
|
$this->park = $park;
|
||||||
|
$this->parentId = $parentId;
|
||||||
|
$this->loadAreas();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load areas for the current context (either top-level or within a parent).
|
||||||
|
*/
|
||||||
|
protected function loadAreas(): void
|
||||||
|
{
|
||||||
|
$query = $this->park->areas()
|
||||||
|
->where('parent_id', $this->parentId)
|
||||||
|
->orderBy('position')
|
||||||
|
->select(['id', 'name', 'position', 'closing_date']);
|
||||||
|
|
||||||
|
$this->areas = $query->get()
|
||||||
|
->map(fn ($area) => [
|
||||||
|
'id' => $area->id,
|
||||||
|
'name' => $area->name,
|
||||||
|
'position' => $area->position,
|
||||||
|
'is_closed' => !is_null($area->closing_date),
|
||||||
|
'has_children' => $area->hasChildren(),
|
||||||
|
])
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle reordering of areas.
|
||||||
|
*/
|
||||||
|
public function reorder(array $orderedIds): void
|
||||||
|
{
|
||||||
|
// Validate that all IDs belong to this park and parent context
|
||||||
|
$validIds = $this->park->areas()
|
||||||
|
->where('parent_id', $this->parentId)
|
||||||
|
->pluck('id')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$orderedIds = array_values(array_intersect($orderedIds, $validIds));
|
||||||
|
|
||||||
|
// Update positions
|
||||||
|
foreach ($orderedIds as $position => $id) {
|
||||||
|
ParkArea::where('id', $id)->update(['position' => $position]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->loadAreas();
|
||||||
|
$this->dispatch('areas-reordered');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move an area to a new parent.
|
||||||
|
*/
|
||||||
|
public function moveToParent(int $areaId, ?int $newParentId): void
|
||||||
|
{
|
||||||
|
$area = $this->park->areas()->findOrFail($areaId);
|
||||||
|
|
||||||
|
// Prevent circular references
|
||||||
|
if ($newParentId === $area->id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If moving to a new parent, validate it exists and belongs to this park
|
||||||
|
if ($newParentId) {
|
||||||
|
$newParent = $this->park->areas()->findOrFail($newParentId);
|
||||||
|
|
||||||
|
// Prevent moving to own descendant
|
||||||
|
$ancestorIds = Collection::make();
|
||||||
|
$current = $newParent;
|
||||||
|
while ($current) {
|
||||||
|
if ($ancestorIds->contains($current->id)) {
|
||||||
|
return; // Circular reference detected
|
||||||
|
}
|
||||||
|
$ancestorIds->push($current->id);
|
||||||
|
if ($current->id === $area->id) {
|
||||||
|
return; // Would create circular reference
|
||||||
|
}
|
||||||
|
$current = $current->parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the next position in the new parent context
|
||||||
|
$maxPosition = $this->park->areas()
|
||||||
|
->where('parent_id', $newParentId)
|
||||||
|
->max('position');
|
||||||
|
|
||||||
|
$area->update([
|
||||||
|
'parent_id' => $newParentId,
|
||||||
|
'position' => ($maxPosition ?? -1) + 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Reorder the old parent's remaining areas to close gaps
|
||||||
|
$this->park->areas()
|
||||||
|
->where('parent_id', $this->parentId)
|
||||||
|
->where('position', '>', $area->position)
|
||||||
|
->decrement('position');
|
||||||
|
|
||||||
|
$this->loadAreas();
|
||||||
|
$this->dispatch('area-moved');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.park-area-reorder-component', [
|
||||||
|
'parentArea' => $this->parentId
|
||||||
|
? $this->park->areas()->find($this->parentId)
|
||||||
|
: null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
105
app/Livewire/ParkFormComponent.php
Normal file
105
app/Livewire/ParkFormComponent.php
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Park;
|
||||||
|
use App\Models\Operator;
|
||||||
|
use App\Enums\ParkStatus;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithFileUploads;
|
||||||
|
use Illuminate\Validation\Rules\Enum;
|
||||||
|
|
||||||
|
class ParkFormComponent extends Component
|
||||||
|
{
|
||||||
|
use WithFileUploads;
|
||||||
|
|
||||||
|
public ?Park $park = null;
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
public string $name = '';
|
||||||
|
public ?string $description = '';
|
||||||
|
public string $status = '';
|
||||||
|
public ?string $opening_date = '';
|
||||||
|
public ?string $closing_date = '';
|
||||||
|
public ?string $operating_season = '';
|
||||||
|
public ?string $size_acres = '';
|
||||||
|
public ?string $website = '';
|
||||||
|
public ?int $operator_id = null;
|
||||||
|
|
||||||
|
/** @var array<string> */
|
||||||
|
public array $statusOptions = [];
|
||||||
|
|
||||||
|
/** @var array<array-key, array<string, string>> */
|
||||||
|
public array $operators = [];
|
||||||
|
|
||||||
|
public function mount(?Park $park = null): void
|
||||||
|
{
|
||||||
|
$this->park = $park;
|
||||||
|
|
||||||
|
if ($park) {
|
||||||
|
$this->name = $park->name;
|
||||||
|
$this->description = $park->description ?? '';
|
||||||
|
$this->status = $park->status->value;
|
||||||
|
$this->opening_date = $park->opening_date?->format('Y-m-d') ?? '';
|
||||||
|
$this->closing_date = $park->closing_date?->format('Y-m-d') ?? '';
|
||||||
|
$this->operating_season = $park->operating_season ?? '';
|
||||||
|
$this->size_acres = $park->size_acres ? (string)$park->size_acres : '';
|
||||||
|
$this->website = $park->website ?? '';
|
||||||
|
$this->operator_id = $park->operator_id;
|
||||||
|
} else {
|
||||||
|
$this->status = ParkStatus::OPERATING->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load status options
|
||||||
|
$this->statusOptions = collect(ParkStatus::cases())
|
||||||
|
->mapWithKeys(fn (ParkStatus $status) => [$status->value => $status->label()])
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// Load operators for select
|
||||||
|
$this->operators = Operator::orderBy('name')
|
||||||
|
->get(['id', 'name'])
|
||||||
|
->map(fn ($operator) => ['id' => $operator->id, 'name' => $operator->name])
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$unique = $this->park
|
||||||
|
? "unique:parks,name,{$this->park->id}"
|
||||||
|
: 'unique:parks,name';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => ['required', 'string', 'min:2', 'max:255', $unique],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'status' => ['required', new Enum(ParkStatus::class)],
|
||||||
|
'opening_date' => ['nullable', 'date'],
|
||||||
|
'closing_date' => ['nullable', 'date', 'after:opening_date'],
|
||||||
|
'operating_season' => ['nullable', 'string', 'max:255'],
|
||||||
|
'size_acres' => ['nullable', 'numeric', 'min:0', 'max:999999.99'],
|
||||||
|
'website' => ['nullable', 'url', 'max:255'],
|
||||||
|
'operator_id' => ['nullable', 'exists:operators,id'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$data = $this->validate();
|
||||||
|
|
||||||
|
if ($this->park) {
|
||||||
|
$this->park->update($data);
|
||||||
|
$message = 'Park updated successfully!';
|
||||||
|
} else {
|
||||||
|
$this->park = Park::create($data);
|
||||||
|
$message = 'Park created successfully!';
|
||||||
|
}
|
||||||
|
|
||||||
|
session()->flash('message', $message);
|
||||||
|
|
||||||
|
$this->redirectRoute('parks.show', $this->park);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.park-form-component');
|
||||||
|
}
|
||||||
|
}
|
||||||
124
app/Livewire/ParkListComponent.php
Normal file
124
app/Livewire/ParkListComponent.php
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Park;
|
||||||
|
use App\Enums\ParkStatus;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
use Illuminate\Contracts\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class ParkListComponent extends Component
|
||||||
|
{
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
public string $search = '';
|
||||||
|
public string $status = '';
|
||||||
|
public string $sort = 'name';
|
||||||
|
public string $direction = 'asc';
|
||||||
|
public ?string $operator = null;
|
||||||
|
|
||||||
|
/** @var array<string, string> */
|
||||||
|
public array $sortOptions = [
|
||||||
|
'name' => 'Name',
|
||||||
|
'opening_date' => 'Opening Date',
|
||||||
|
'ride_count' => 'Ride Count',
|
||||||
|
'coaster_count' => 'Coaster Count',
|
||||||
|
'size_acres' => 'Size',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $queryString = [
|
||||||
|
'search' => ['except' => ''],
|
||||||
|
'status' => ['except' => ''],
|
||||||
|
'sort' => ['except' => 'name'],
|
||||||
|
'direction' => ['except' => 'asc'],
|
||||||
|
'operator' => ['except' => ''],
|
||||||
|
];
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->resetPage('parks-page');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedSearch(): void
|
||||||
|
{
|
||||||
|
$this->resetPage('parks-page');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedStatus(): void
|
||||||
|
{
|
||||||
|
$this->resetPage('parks-page');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedOperator(): void
|
||||||
|
{
|
||||||
|
$this->resetPage('parks-page');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sortBy(string $field): void
|
||||||
|
{
|
||||||
|
if ($this->sort === $field) {
|
||||||
|
$this->direction = $this->direction === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
$this->sort = $field;
|
||||||
|
$this->direction = 'asc';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusOptions(): array
|
||||||
|
{
|
||||||
|
return collect(ParkStatus::cases())
|
||||||
|
->mapWithKeys(fn (ParkStatus $status) => [$status->value => $status->label()])
|
||||||
|
->prepend('All Statuses', '')
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOperatorOptions(): array
|
||||||
|
{
|
||||||
|
return \App\Models\Operator::orderBy('name')
|
||||||
|
->pluck('name', 'id')
|
||||||
|
->prepend('All Operators', '')
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$query = Park::query()
|
||||||
|
->with(['operator'])
|
||||||
|
->when($this->search, function (Builder $query) {
|
||||||
|
$query->where('name', 'like', '%' . $this->search . '%')
|
||||||
|
->orWhere('description', 'like', '%' . $this->search . '%');
|
||||||
|
})
|
||||||
|
->when($this->status, function (Builder $query) {
|
||||||
|
$query->where('status', $this->status);
|
||||||
|
})
|
||||||
|
->when($this->operator, function (Builder $query) {
|
||||||
|
$query->where('operator_id', $this->operator);
|
||||||
|
})
|
||||||
|
->when($this->sort === 'name', function (Builder $query) {
|
||||||
|
$query->orderBy('name', $this->direction);
|
||||||
|
})
|
||||||
|
->when($this->sort === 'opening_date', function (Builder $query) {
|
||||||
|
$query->orderBy('opening_date', $this->direction)
|
||||||
|
->orderBy('name', 'asc');
|
||||||
|
})
|
||||||
|
->when($this->sort === 'ride_count', function (Builder $query) {
|
||||||
|
$query->orderBy('ride_count', $this->direction)
|
||||||
|
->orderBy('name', 'asc');
|
||||||
|
})
|
||||||
|
->when($this->sort === 'coaster_count', function (Builder $query) {
|
||||||
|
$query->orderBy('coaster_count', $this->direction)
|
||||||
|
->orderBy('name', 'asc');
|
||||||
|
})
|
||||||
|
->when($this->sort === 'size_acres', function (Builder $query) {
|
||||||
|
$query->orderBy('size_acres', $this->direction)
|
||||||
|
->orderBy('name', 'asc');
|
||||||
|
});
|
||||||
|
|
||||||
|
return view('livewire.park-list-component', [
|
||||||
|
'parks' => $query->paginate(12, pageName: 'parks-page'),
|
||||||
|
'statusOptions' => $this->getStatusOptions(),
|
||||||
|
'operatorOptions' => $this->getOperatorOptions(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
app/Livewire/ProfileComponent.php
Normal file
86
app/Livewire/ProfileComponent.php
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Models\Profile;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithFileUploads;
|
||||||
|
use Livewire\Attributes\Rule;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class ProfileComponent extends Component
|
||||||
|
{
|
||||||
|
use WithFileUploads;
|
||||||
|
|
||||||
|
public Profile $profile;
|
||||||
|
|
||||||
|
#[Rule('required|min:2|max:50|unique:profiles,display_name')]
|
||||||
|
public string $display_name = '';
|
||||||
|
|
||||||
|
#[Rule('nullable|max:50')]
|
||||||
|
public ?string $pronouns = '';
|
||||||
|
|
||||||
|
#[Rule('nullable|max:500')]
|
||||||
|
public ?string $bio = '';
|
||||||
|
|
||||||
|
#[Rule('nullable|url|max:255')]
|
||||||
|
public ?string $twitter = '';
|
||||||
|
|
||||||
|
#[Rule('nullable|url|max:255')]
|
||||||
|
public ?string $instagram = '';
|
||||||
|
|
||||||
|
#[Rule('nullable|url|max:255')]
|
||||||
|
public ?string $youtube = '';
|
||||||
|
|
||||||
|
#[Rule('nullable|max:100')]
|
||||||
|
public ?string $discord = '';
|
||||||
|
|
||||||
|
#[Rule('nullable|image|max:1024')] // 1MB Max
|
||||||
|
public $avatar;
|
||||||
|
|
||||||
|
public function mount()
|
||||||
|
{
|
||||||
|
$this->profile = Auth::user()->profile;
|
||||||
|
$this->fill($this->profile->only([
|
||||||
|
'display_name',
|
||||||
|
'pronouns',
|
||||||
|
'bio',
|
||||||
|
'twitter',
|
||||||
|
'instagram',
|
||||||
|
'youtube',
|
||||||
|
'discord',
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save()
|
||||||
|
{
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
if ($this->avatar) {
|
||||||
|
$this->profile->setAvatar($this->avatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->profile->update([
|
||||||
|
'display_name' => $this->display_name,
|
||||||
|
'pronouns' => $this->pronouns,
|
||||||
|
'bio' => $this->bio,
|
||||||
|
'twitter' => $this->twitter,
|
||||||
|
'instagram' => $this->instagram,
|
||||||
|
'youtube' => $this->youtube,
|
||||||
|
'discord' => $this->discord,
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->flash('message', 'Profile updated successfully!');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeAvatar()
|
||||||
|
{
|
||||||
|
$this->profile->setAvatar(null);
|
||||||
|
session()->flash('message', 'Avatar removed successfully!');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.profile-component');
|
||||||
|
}
|
||||||
|
}
|
||||||
221
app/Models/Location.php
Normal file
221
app/Models/Location.php
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
|
class Location extends Model
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var array<string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'address',
|
||||||
|
'city',
|
||||||
|
'state',
|
||||||
|
'country',
|
||||||
|
'postal_code',
|
||||||
|
'latitude',
|
||||||
|
'longitude',
|
||||||
|
'elevation',
|
||||||
|
'timezone',
|
||||||
|
'metadata',
|
||||||
|
'is_approximate',
|
||||||
|
'source',
|
||||||
|
'geocoding_data',
|
||||||
|
'geocoded_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that should be cast.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
protected $casts = [
|
||||||
|
'latitude' => 'decimal:8',
|
||||||
|
'longitude' => 'decimal:8',
|
||||||
|
'elevation' => 'decimal:2',
|
||||||
|
'metadata' => 'array',
|
||||||
|
'geocoding_data' => 'array',
|
||||||
|
'geocoded_at' => 'datetime',
|
||||||
|
'is_approximate' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the parent locatable model.
|
||||||
|
*/
|
||||||
|
public function locatable(): MorphTo
|
||||||
|
{
|
||||||
|
return $this->morphTo();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the location's coordinates as an array.
|
||||||
|
*
|
||||||
|
* @return array<string, float|null>
|
||||||
|
*/
|
||||||
|
public function getCoordinatesAttribute(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'lat' => $this->latitude,
|
||||||
|
'lng' => $this->longitude,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the formatted address.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getFormattedAddressAttribute(): string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
if ($this->address) {
|
||||||
|
$parts[] = $this->address;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->city) {
|
||||||
|
$parts[] = $this->city;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->state) {
|
||||||
|
$parts[] = $this->state;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->postal_code) {
|
||||||
|
$parts[] = $this->postal_code;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->country) {
|
||||||
|
$parts[] = $this->country;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(', ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Google Maps URL for the location.
|
||||||
|
*
|
||||||
|
* @return string|null
|
||||||
|
*/
|
||||||
|
public function getMapUrlAttribute(): ?string
|
||||||
|
{
|
||||||
|
if (!$this->latitude || !$this->longitude) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'https://www.google.com/maps?q=%f,%f',
|
||||||
|
$this->latitude,
|
||||||
|
$this->longitude
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the location's coordinates.
|
||||||
|
*
|
||||||
|
* @param float $latitude
|
||||||
|
* @param float $longitude
|
||||||
|
* @param float|null $elevation
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function updateCoordinates(float $latitude, float $longitude, ?float $elevation = null): bool
|
||||||
|
{
|
||||||
|
return $this->update([
|
||||||
|
'latitude' => $latitude,
|
||||||
|
'longitude' => $longitude,
|
||||||
|
'elevation' => $elevation,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the location's address components.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $components
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function setAddress(array $components): bool
|
||||||
|
{
|
||||||
|
return $this->update([
|
||||||
|
'address' => $components['address'] ?? $this->address,
|
||||||
|
'city' => $components['city'] ?? $this->city,
|
||||||
|
'state' => $components['state'] ?? $this->state,
|
||||||
|
'country' => $components['country'] ?? $this->country,
|
||||||
|
'postal_code' => $components['postal_code'] ?? $this->postal_code,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope a query to find locations within a radius.
|
||||||
|
*
|
||||||
|
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||||
|
* @param float $latitude
|
||||||
|
* @param float $longitude
|
||||||
|
* @param float $radius
|
||||||
|
* @param string $unit
|
||||||
|
* @return \Illuminate\Database\Eloquent\Builder
|
||||||
|
*/
|
||||||
|
public function scopeNearby($query, float $latitude, float $longitude, float $radius, string $unit = 'km')
|
||||||
|
{
|
||||||
|
$earthRadius = $unit === 'mi' ? 3959 : 6371;
|
||||||
|
|
||||||
|
return $query->whereRaw(
|
||||||
|
"($earthRadius * acos(
|
||||||
|
cos(radians(?)) *
|
||||||
|
cos(radians(latitude)) *
|
||||||
|
cos(radians(longitude) - radians(?)) +
|
||||||
|
sin(radians(?)) *
|
||||||
|
sin(radians(latitude))
|
||||||
|
)) <= ?",
|
||||||
|
[$latitude, $longitude, $latitude, $radius]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope a query to find locations within bounds.
|
||||||
|
*
|
||||||
|
* @param \Illuminate\Database\Eloquent\Builder $query
|
||||||
|
* @param array<string, float> $ne Northeast corner [lat, lng]
|
||||||
|
* @param array<string, float> $sw Southwest corner [lat, lng]
|
||||||
|
* @return \Illuminate\Database\Eloquent\Builder
|
||||||
|
*/
|
||||||
|
public function scopeInBounds($query, array $ne, array $sw)
|
||||||
|
{
|
||||||
|
return $query->whereBetween('latitude', [$sw['lat'], $ne['lat']])
|
||||||
|
->whereBetween('longitude', [$sw['lng'], $ne['lng']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the distance to a point.
|
||||||
|
*
|
||||||
|
* @param float $latitude
|
||||||
|
* @param float $longitude
|
||||||
|
* @param string $unit
|
||||||
|
* @return float|null
|
||||||
|
*/
|
||||||
|
public function distanceTo(float $latitude, float $longitude, string $unit = 'km'): ?float
|
||||||
|
{
|
||||||
|
if (!$this->latitude || !$this->longitude) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$earthRadius = $unit === 'mi' ? 3959 : 6371;
|
||||||
|
|
||||||
|
$latFrom = deg2rad($this->latitude);
|
||||||
|
$lonFrom = deg2rad($this->longitude);
|
||||||
|
$latTo = deg2rad($latitude);
|
||||||
|
$lonTo = deg2rad($longitude);
|
||||||
|
|
||||||
|
$latDelta = $latTo - $latFrom;
|
||||||
|
$lonDelta = $lonTo - $lonFrom;
|
||||||
|
|
||||||
|
$angle = 2 * asin(sqrt(pow(sin($latDelta / 2), 2) +
|
||||||
|
cos($latFrom) * cos($latTo) * pow(sin($lonDelta / 2), 2)));
|
||||||
|
|
||||||
|
return $angle * $earthRadius;
|
||||||
|
}
|
||||||
|
}
|
||||||
98
app/Models/Manufacturer.php
Normal file
98
app/Models/Manufacturer.php
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Traits\HasSlugHistory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
||||||
|
class Manufacturer extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, HasSlugHistory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var array<string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'slug',
|
||||||
|
'website',
|
||||||
|
'headquarters',
|
||||||
|
'description',
|
||||||
|
'total_rides',
|
||||||
|
'total_roller_coasters',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the rides manufactured by this company.
|
||||||
|
* Note: This relationship will be properly set up when we implement the Rides system.
|
||||||
|
*/
|
||||||
|
public function rides(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Ride::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update ride statistics.
|
||||||
|
*/
|
||||||
|
public function updateStatistics(): void
|
||||||
|
{
|
||||||
|
$this->total_rides = $this->rides()->count();
|
||||||
|
$this->total_roller_coasters = $this->rides()
|
||||||
|
->where('type', 'roller_coaster')
|
||||||
|
->count();
|
||||||
|
$this->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the manufacturer's name with total rides.
|
||||||
|
*/
|
||||||
|
public function getDisplayNameAttribute(): string
|
||||||
|
{
|
||||||
|
return "{$this->name} ({$this->total_rides} rides)";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get formatted website URL (ensures proper URL format).
|
||||||
|
*/
|
||||||
|
public function getWebsiteUrlAttribute(): string
|
||||||
|
{
|
||||||
|
if (!$this->website) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$website = $this->website;
|
||||||
|
if (!str_starts_with($website, 'http://') && !str_starts_with($website, 'https://')) {
|
||||||
|
$website = 'https://' . $website;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $website;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope a query to only include major manufacturers (with multiple rides).
|
||||||
|
*/
|
||||||
|
public function scopeMajorManufacturers($query, int $minRides = 5)
|
||||||
|
{
|
||||||
|
return $query->where('total_rides', '>=', $minRides);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope a query to only include coaster manufacturers.
|
||||||
|
*/
|
||||||
|
public function scopeCoasterManufacturers($query)
|
||||||
|
{
|
||||||
|
return $query->where('total_roller_coasters', '>', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the route key for the model.
|
||||||
|
*/
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'slug';
|
||||||
|
}
|
||||||
|
}
|
||||||
87
app/Models/Operator.php
Normal file
87
app/Models/Operator.php
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Traits\HasSlugHistory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
||||||
|
class Operator extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, HasSlugHistory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var array<string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'slug',
|
||||||
|
'website',
|
||||||
|
'headquarters',
|
||||||
|
'description',
|
||||||
|
'total_parks',
|
||||||
|
'total_rides',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the parks operated by this company.
|
||||||
|
*/
|
||||||
|
public function parks(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Park::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update park statistics.
|
||||||
|
*/
|
||||||
|
public function updateStatistics(): void
|
||||||
|
{
|
||||||
|
$this->total_parks = $this->parks()->count();
|
||||||
|
$this->total_rides = $this->parks()->sum('ride_count');
|
||||||
|
$this->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the operator's name with total parks.
|
||||||
|
*/
|
||||||
|
public function getDisplayNameAttribute(): string
|
||||||
|
{
|
||||||
|
return "{$this->name} ({$this->total_parks} parks)";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get formatted website URL (ensures proper URL format).
|
||||||
|
*/
|
||||||
|
public function getWebsiteUrlAttribute(): string
|
||||||
|
{
|
||||||
|
if (!$this->website) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$website = $this->website;
|
||||||
|
if (!str_starts_with($website, 'http://') && !str_starts_with($website, 'https://')) {
|
||||||
|
$website = 'https://' . $website;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $website;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope a query to only include major operators (with multiple parks).
|
||||||
|
*/
|
||||||
|
public function scopeMajorOperators($query, int $minParks = 3)
|
||||||
|
{
|
||||||
|
return $query->where('total_parks', '>=', $minParks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the route key for the model.
|
||||||
|
*/
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'slug';
|
||||||
|
}
|
||||||
|
}
|
||||||
182
app/Models/Park.php
Normal file
182
app/Models/Park.php
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\ParkStatus;
|
||||||
|
use App\Traits\HasSlugHistory;
|
||||||
|
use App\Traits\HasParkStatistics;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
||||||
|
class Park extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, HasSlugHistory, HasParkStatistics;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var array<string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'slug',
|
||||||
|
'description',
|
||||||
|
'status',
|
||||||
|
'opening_date',
|
||||||
|
'closing_date',
|
||||||
|
'operating_season',
|
||||||
|
'size_acres',
|
||||||
|
'website',
|
||||||
|
'operator_id',
|
||||||
|
'total_areas',
|
||||||
|
'operating_areas',
|
||||||
|
'closed_areas',
|
||||||
|
'total_rides',
|
||||||
|
'total_coasters',
|
||||||
|
'total_flat_rides',
|
||||||
|
'total_water_rides',
|
||||||
|
'total_daily_capacity',
|
||||||
|
'average_wait_time',
|
||||||
|
'average_rating',
|
||||||
|
'total_rides_operated',
|
||||||
|
'total_rides_retired',
|
||||||
|
'last_expansion_date',
|
||||||
|
'last_major_update',
|
||||||
|
'utilization_rate',
|
||||||
|
'peak_daily_attendance',
|
||||||
|
'guest_satisfaction',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that should be cast.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
protected $casts = [
|
||||||
|
'status' => ParkStatus::class,
|
||||||
|
'opening_date' => 'date',
|
||||||
|
'closing_date' => 'date',
|
||||||
|
'size_acres' => 'decimal:2',
|
||||||
|
'total_areas' => 'integer',
|
||||||
|
'operating_areas' => 'integer',
|
||||||
|
'closed_areas' => 'integer',
|
||||||
|
'total_rides' => 'integer',
|
||||||
|
'total_coasters' => 'integer',
|
||||||
|
'total_flat_rides' => 'integer',
|
||||||
|
'total_water_rides' => 'integer',
|
||||||
|
'total_daily_capacity' => 'integer',
|
||||||
|
'average_wait_time' => 'integer',
|
||||||
|
'average_rating' => 'decimal:2',
|
||||||
|
'total_rides_operated' => 'integer',
|
||||||
|
'total_rides_retired' => 'integer',
|
||||||
|
'last_expansion_date' => 'date',
|
||||||
|
'last_major_update' => 'date',
|
||||||
|
'utilization_rate' => 'decimal:2',
|
||||||
|
'peak_daily_attendance' => 'integer',
|
||||||
|
'guest_satisfaction' => 'decimal:2',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the operator that owns the park.
|
||||||
|
*/
|
||||||
|
public function operator(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Operator::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the areas in the park.
|
||||||
|
*/
|
||||||
|
public function areas(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ParkArea::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get formatted website URL (ensures proper URL format).
|
||||||
|
*/
|
||||||
|
public function getWebsiteUrlAttribute(): string
|
||||||
|
{
|
||||||
|
if (!$this->website) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$website = $this->website;
|
||||||
|
if (!str_starts_with($website, 'http://') && !str_starts_with($website, 'https://')) {
|
||||||
|
$website = 'https://' . $website;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $website;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the status display classes for Tailwind CSS.
|
||||||
|
*/
|
||||||
|
public function getStatusClassesAttribute(): string
|
||||||
|
{
|
||||||
|
return $this->status->getStatusClasses();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a formatted display of the park's size.
|
||||||
|
*/
|
||||||
|
public function getSizeDisplayAttribute(): string
|
||||||
|
{
|
||||||
|
return $this->size_acres ? number_format($this->size_acres, 1) . ' acres' : 'Unknown size';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the formatted opening year.
|
||||||
|
*/
|
||||||
|
public function getOpeningYearAttribute(): ?string
|
||||||
|
{
|
||||||
|
return $this->opening_date?->format('Y');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a brief description suitable for cards and previews.
|
||||||
|
*/
|
||||||
|
public function getBriefDescriptionAttribute(): string
|
||||||
|
{
|
||||||
|
$description = $this->description ?? '';
|
||||||
|
return strlen($description) > 200 ? substr($description, 0, 200) . '...' : $description;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope a query to only include operating parks.
|
||||||
|
*/
|
||||||
|
public function scopeOperating($query)
|
||||||
|
{
|
||||||
|
return $query->where('status', ParkStatus::OPERATING);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope a query to only include closed parks.
|
||||||
|
*/
|
||||||
|
public function scopeClosed($query)
|
||||||
|
{
|
||||||
|
return $query->whereIn('status', [
|
||||||
|
ParkStatus::CLOSED_TEMP,
|
||||||
|
ParkStatus::CLOSED_PERM,
|
||||||
|
ParkStatus::DEMOLISHED,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot the model.
|
||||||
|
*/
|
||||||
|
protected static function boot()
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
static::created(function (Park $park) {
|
||||||
|
$park->operator?->updateStatistics();
|
||||||
|
});
|
||||||
|
|
||||||
|
static::deleted(function (Park $park) {
|
||||||
|
$park->operator?->updateStatistics();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
261
app/Models/ParkArea.php
Normal file
261
app/Models/ParkArea.php
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Traits\HasSlugHistory;
|
||||||
|
use App\Traits\HasAreaStatistics;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
||||||
|
class ParkArea extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, HasSlugHistory, HasAreaStatistics;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var array<string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'slug',
|
||||||
|
'description',
|
||||||
|
'opening_date',
|
||||||
|
'closing_date',
|
||||||
|
'position',
|
||||||
|
'parent_id',
|
||||||
|
'ride_count',
|
||||||
|
'coaster_count',
|
||||||
|
'flat_ride_count',
|
||||||
|
'water_ride_count',
|
||||||
|
'daily_capacity',
|
||||||
|
'peak_wait_time',
|
||||||
|
'average_rating',
|
||||||
|
'total_rides_operated',
|
||||||
|
'retired_rides_count',
|
||||||
|
'last_new_ride_added',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that should be cast.
|
||||||
|
*
|
||||||
|
* @var array<string, string>
|
||||||
|
*/
|
||||||
|
protected $casts = [
|
||||||
|
'opening_date' => 'date',
|
||||||
|
'closing_date' => 'date',
|
||||||
|
'position' => 'integer',
|
||||||
|
'ride_count' => 'integer',
|
||||||
|
'coaster_count' => 'integer',
|
||||||
|
'flat_ride_count' => 'integer',
|
||||||
|
'water_ride_count' => 'integer',
|
||||||
|
'daily_capacity' => 'integer',
|
||||||
|
'peak_wait_time' => 'integer',
|
||||||
|
'average_rating' => 'decimal:2',
|
||||||
|
'total_rides_operated' => 'integer',
|
||||||
|
'retired_rides_count' => 'integer',
|
||||||
|
'last_new_ride_added' => 'date',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the park that owns the area.
|
||||||
|
*/
|
||||||
|
public function park(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Park::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the parent area if this is a sub-area.
|
||||||
|
*/
|
||||||
|
public function parent(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ParkArea::class, 'parent_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the sub-areas of this area.
|
||||||
|
*/
|
||||||
|
public function children(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ParkArea::class, 'parent_id')
|
||||||
|
->orderBy('position');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a brief description suitable for cards and previews.
|
||||||
|
*/
|
||||||
|
public function getBriefDescriptionAttribute(): string
|
||||||
|
{
|
||||||
|
$description = $this->description ?? '';
|
||||||
|
return strlen($description) > 150 ? substr($description, 0, 150) . '...' : $description;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the opening year of the area.
|
||||||
|
*/
|
||||||
|
public function getOpeningYearAttribute(): ?string
|
||||||
|
{
|
||||||
|
return $this->opening_date?->format('Y');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the area is currently operating.
|
||||||
|
*/
|
||||||
|
public function isOperating(): bool
|
||||||
|
{
|
||||||
|
if ($this->closing_date) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this area has sub-areas.
|
||||||
|
*/
|
||||||
|
public function hasChildren(): bool
|
||||||
|
{
|
||||||
|
return $this->children()->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is a top-level area.
|
||||||
|
*/
|
||||||
|
public function isTopLevel(): bool
|
||||||
|
{
|
||||||
|
return is_null($this->parent_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the next available position for a new area.
|
||||||
|
*/
|
||||||
|
public function getNextPosition(): int
|
||||||
|
{
|
||||||
|
$maxPosition = static::where('park_id', $this->park_id)
|
||||||
|
->where('parent_id', $this->parent_id)
|
||||||
|
->max('position');
|
||||||
|
|
||||||
|
return ($maxPosition ?? -1) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move this area to a new position.
|
||||||
|
*/
|
||||||
|
public function moveToPosition(int $newPosition): void
|
||||||
|
{
|
||||||
|
if ($newPosition === $this->position) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$oldPosition = $this->position;
|
||||||
|
|
||||||
|
if ($newPosition > $oldPosition) {
|
||||||
|
// Moving down: decrement positions of items in between
|
||||||
|
static::where('park_id', $this->park_id)
|
||||||
|
->where('parent_id', $this->parent_id)
|
||||||
|
->whereBetween('position', [$oldPosition + 1, $newPosition])
|
||||||
|
->decrement('position');
|
||||||
|
} else {
|
||||||
|
// Moving up: increment positions of items in between
|
||||||
|
static::where('park_id', $this->park_id)
|
||||||
|
->where('parent_id', $this->parent_id)
|
||||||
|
->whereBetween('position', [$newPosition, $oldPosition - 1])
|
||||||
|
->increment('position');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->update(['position' => $newPosition]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope query to only include operating areas.
|
||||||
|
*/
|
||||||
|
public function scopeOperating($query)
|
||||||
|
{
|
||||||
|
return $query->whereNull('closing_date');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope query to only include top-level areas.
|
||||||
|
*/
|
||||||
|
public function scopeTopLevel($query)
|
||||||
|
{
|
||||||
|
return $query->whereNull('parent_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override parent method to ensure unique slugs within a park.
|
||||||
|
*/
|
||||||
|
protected function generateSlug(): string
|
||||||
|
{
|
||||||
|
$slug = \Str::slug($this->name);
|
||||||
|
$count = 2;
|
||||||
|
|
||||||
|
while (
|
||||||
|
static::where('park_id', $this->park_id)
|
||||||
|
->where('slug', $slug)
|
||||||
|
->where('id', '!=', $this->id)
|
||||||
|
->exists() ||
|
||||||
|
static::whereHas('slugHistories', function ($query) use ($slug) {
|
||||||
|
$query->where('slug', $slug);
|
||||||
|
})
|
||||||
|
->where('park_id', $this->park_id)
|
||||||
|
->where('id', '!=', $this->id)
|
||||||
|
->exists()
|
||||||
|
) {
|
||||||
|
$slug = \Str::slug($this->name) . '-' . $count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the route key for the model.
|
||||||
|
*/
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'slug';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an area by its slug within a specific park.
|
||||||
|
*/
|
||||||
|
public static function findByParkAndSlug(Park $park, string $slug): ?self
|
||||||
|
{
|
||||||
|
// Try current slug
|
||||||
|
$area = static::where('park_id', $park->id)
|
||||||
|
->where('slug', $slug)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($area) {
|
||||||
|
return $area;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try historical slug
|
||||||
|
$slugHistory = SlugHistory::where('slug', $slug)
|
||||||
|
->where('sluggable_type', static::class)
|
||||||
|
->whereHas('sluggable', function ($query) use ($park) {
|
||||||
|
$query->where('park_id', $park->id);
|
||||||
|
})
|
||||||
|
->latest()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return $slugHistory ? static::find($slugHistory->sluggable_id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot the model.
|
||||||
|
*/
|
||||||
|
protected static function boot()
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
static::creating(function (ParkArea $area) {
|
||||||
|
if (is_null($area->position)) {
|
||||||
|
$area->position = $area->getNextPosition();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
131
app/Models/Profile.php
Normal file
131
app/Models/Profile.php
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Services\IdGenerator;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Intervention\Image\Facades\Image;
|
||||||
|
|
||||||
|
class Profile extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var array<string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'display_name',
|
||||||
|
'pronouns',
|
||||||
|
'bio',
|
||||||
|
'twitter',
|
||||||
|
'instagram',
|
||||||
|
'youtube',
|
||||||
|
'discord',
|
||||||
|
'coaster_credits',
|
||||||
|
'dark_ride_credits',
|
||||||
|
'flat_ride_credits',
|
||||||
|
'water_ride_credits',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user that owns the profile
|
||||||
|
*/
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the avatar URL or generate a default one
|
||||||
|
*/
|
||||||
|
public function getAvatarUrl(): string
|
||||||
|
{
|
||||||
|
if ($this->avatar) {
|
||||||
|
return Storage::disk('public')->url($this->avatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get first letter of username for default avatar
|
||||||
|
$firstLetter = strtoupper(substr($this->user->name, 0, 1));
|
||||||
|
$avatarPath = "avatars/letters/{$firstLetter}_avatar.png";
|
||||||
|
|
||||||
|
// Check if letter avatar exists, if not use default
|
||||||
|
if (Storage::disk('public')->exists($avatarPath)) {
|
||||||
|
return Storage::disk('public')->url($avatarPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return asset('images/default-avatar.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the avatar image
|
||||||
|
*/
|
||||||
|
public function setAvatar($file): void
|
||||||
|
{
|
||||||
|
if ($this->avatar) {
|
||||||
|
Storage::disk('public')->delete($this->avatar);
|
||||||
|
}
|
||||||
|
|
||||||
|
$filename = 'avatars/' . uniqid() . '.' . $file->getClientOriginalExtension();
|
||||||
|
|
||||||
|
// Process and save the image
|
||||||
|
$image = Image::make($file)
|
||||||
|
->fit(200, 200)
|
||||||
|
->encode();
|
||||||
|
|
||||||
|
Storage::disk('public')->put($filename, $image);
|
||||||
|
|
||||||
|
$this->update(['avatar' => $filename]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a letter avatar
|
||||||
|
*/
|
||||||
|
protected function generateLetterAvatar(string $letter): void
|
||||||
|
{
|
||||||
|
$letter = strtoupper($letter);
|
||||||
|
$image = Image::canvas(200, 200, '#007bff');
|
||||||
|
|
||||||
|
$image->text($letter, 100, 100, function ($font) {
|
||||||
|
$font->file(public_path('fonts/Roboto-Bold.ttf'));
|
||||||
|
$font->size(120);
|
||||||
|
$font->color('#ffffff');
|
||||||
|
$font->align('center');
|
||||||
|
$font->valign('center');
|
||||||
|
});
|
||||||
|
|
||||||
|
$filename = "avatars/letters/{$letter}_avatar.png";
|
||||||
|
Storage::disk('public')->put($filename, $image->encode('png'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot the model
|
||||||
|
*/
|
||||||
|
protected static function boot()
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
static::creating(function (Profile $profile) {
|
||||||
|
if (!$profile->profile_id) {
|
||||||
|
$profile->profile_id = IdGenerator::generate(Profile::class, 'profile_id');
|
||||||
|
}
|
||||||
|
if (!$profile->display_name) {
|
||||||
|
$profile->display_name = $profile->user->name;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
static::created(function (Profile $profile) {
|
||||||
|
// Generate letter avatar if it doesn't exist
|
||||||
|
$letter = strtoupper(substr($profile->user->name, 0, 1));
|
||||||
|
$avatarPath = "avatars/letters/{$letter}_avatar.png";
|
||||||
|
|
||||||
|
if (!Storage::disk('public')->exists($avatarPath)) {
|
||||||
|
$profile->generateLetterAvatar($letter);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Models/SlugHistory.php
Normal file
26
app/Models/SlugHistory.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
|
class SlugHistory extends Model
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var array<string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'slug',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the parent sluggable model.
|
||||||
|
*/
|
||||||
|
public function sluggable(): MorphTo
|
||||||
|
{
|
||||||
|
return $this->morphTo();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,31 +2,36 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use App\Enums\ThemePreference;
|
||||||
|
use App\Enums\UserRole;
|
||||||
|
use App\Services\IdGenerator;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
|
||||||
use HasFactory, Notifiable;
|
use HasFactory, Notifiable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
*
|
*
|
||||||
* @var list<string>
|
* @var array<string>
|
||||||
*/
|
*/
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
|
'role',
|
||||||
|
'theme_preference',
|
||||||
|
'pending_email',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that should be hidden for serialization.
|
* The attributes that should be hidden for serialization.
|
||||||
*
|
*
|
||||||
* @var list<string>
|
* @var array<string>
|
||||||
*/
|
*/
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
'password',
|
'password',
|
||||||
@@ -43,6 +48,107 @@ class User extends Authenticatable
|
|||||||
return [
|
return [
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
|
'role' => UserRole::class,
|
||||||
|
'theme_preference' => ThemePreference::class,
|
||||||
|
'is_banned' => 'boolean',
|
||||||
|
'ban_date' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user's profile
|
||||||
|
*/
|
||||||
|
public function profile(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(Profile::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user's display name, falling back to username if not set
|
||||||
|
*/
|
||||||
|
public function getDisplayName(): string
|
||||||
|
{
|
||||||
|
return $this->profile?->display_name ?? $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the user has moderation privileges
|
||||||
|
*/
|
||||||
|
public function canModerate(): bool
|
||||||
|
{
|
||||||
|
return $this->role->canModerate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the user has admin privileges
|
||||||
|
*/
|
||||||
|
public function canAdmin(): bool
|
||||||
|
{
|
||||||
|
return $this->role->canAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ban the user with a reason
|
||||||
|
*/
|
||||||
|
public function ban(string $reason): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'is_banned' => true,
|
||||||
|
'ban_reason' => $reason,
|
||||||
|
'ban_date' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unban the user
|
||||||
|
*/
|
||||||
|
public function unban(): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'is_banned' => false,
|
||||||
|
'ban_reason' => null,
|
||||||
|
'ban_date' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle pending email changes
|
||||||
|
*/
|
||||||
|
public function setPendingEmail(string $email): void
|
||||||
|
{
|
||||||
|
$this->update(['pending_email' => $email]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm pending email change
|
||||||
|
*/
|
||||||
|
public function confirmEmailChange(): void
|
||||||
|
{
|
||||||
|
if ($this->pending_email) {
|
||||||
|
$this->update([
|
||||||
|
'email' => $this->pending_email,
|
||||||
|
'pending_email' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot the model
|
||||||
|
*/
|
||||||
|
protected static function boot()
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
static::creating(function (User $user) {
|
||||||
|
if (!$user->user_id) {
|
||||||
|
$user->user_id = IdGenerator::generate(User::class, 'user_id');
|
||||||
|
}
|
||||||
|
if (!$user->role) {
|
||||||
|
$user->role = UserRole::USER;
|
||||||
|
}
|
||||||
|
if (!$user->theme_preference) {
|
||||||
|
$user->theme_preference = ThemePreference::LIGHT;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
app/Services/IdGenerator.php
Normal file
41
app/Services/IdGenerator.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class IdGenerator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Generate a random ID starting at 4 digits, expanding to 5 if needed
|
||||||
|
*
|
||||||
|
* @param string $model Model class name
|
||||||
|
* @param string $field Field to check for uniqueness
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public static function generate(string $model, string $field): string
|
||||||
|
{
|
||||||
|
$attempts = 0;
|
||||||
|
$maxAttempts = 10;
|
||||||
|
|
||||||
|
while ($attempts < $maxAttempts) {
|
||||||
|
// Try 4 digits first
|
||||||
|
if ($attempts < 5) {
|
||||||
|
$id = (string) random_int(1000, 9999);
|
||||||
|
} else {
|
||||||
|
// Try 5 digits if all 4-digit numbers are taken
|
||||||
|
$id = (string) random_int(10000, 99999);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$model::where($field, $id)->exists()) {
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attempts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we get here, try a completely random string as fallback
|
||||||
|
return Str::random(10);
|
||||||
|
}
|
||||||
|
}
|
||||||
217
app/Services/StatisticsCacheService.php
Normal file
217
app/Services/StatisticsCacheService.php
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Park;
|
||||||
|
use App\Models\ParkArea;
|
||||||
|
use App\Models\Operator;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class StatisticsCacheService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Cache TTL in seconds (24 hours).
|
||||||
|
*/
|
||||||
|
protected const CACHE_TTL = 86400;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache key prefixes.
|
||||||
|
*/
|
||||||
|
protected const AREA_PREFIX = 'stats:area:';
|
||||||
|
protected const PARK_PREFIX = 'stats:park:';
|
||||||
|
protected const OPERATOR_PREFIX = 'stats:operator:';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache area statistics.
|
||||||
|
*/
|
||||||
|
public function cacheAreaStatistics(ParkArea $area): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
Cache::put(
|
||||||
|
$this->getAreaKey($area),
|
||||||
|
[
|
||||||
|
'ride_distribution' => $area->ride_distribution,
|
||||||
|
'daily_capacity' => $area->formatted_daily_capacity,
|
||||||
|
'rating' => $area->rating_display,
|
||||||
|
'wait_time' => $area->formatted_peak_wait_time,
|
||||||
|
'historical' => $area->historical_stats,
|
||||||
|
'updated_at' => now(),
|
||||||
|
],
|
||||||
|
static::CACHE_TTL
|
||||||
|
);
|
||||||
|
|
||||||
|
Log::info("Cached statistics for area {$area->id}");
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error("Failed to cache area statistics: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache park statistics.
|
||||||
|
*/
|
||||||
|
public function cacheParkStatistics(Park $park): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
Cache::put(
|
||||||
|
$this->getParkKey($park),
|
||||||
|
[
|
||||||
|
'area_distribution' => $park->area_distribution,
|
||||||
|
'ride_distribution' => $park->ride_distribution,
|
||||||
|
'daily_capacity' => $park->formatted_daily_capacity,
|
||||||
|
'rating' => $park->rating_display,
|
||||||
|
'wait_time' => $park->formatted_wait_time,
|
||||||
|
'historical' => $park->historical_stats,
|
||||||
|
'performance' => $park->performance_metrics,
|
||||||
|
'updated_at' => now(),
|
||||||
|
],
|
||||||
|
static::CACHE_TTL
|
||||||
|
);
|
||||||
|
|
||||||
|
Log::info("Cached statistics for park {$park->id}");
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error("Failed to cache park statistics: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache operator statistics.
|
||||||
|
*/
|
||||||
|
public function cacheOperatorStatistics(Operator $operator): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
Cache::put(
|
||||||
|
$this->getOperatorKey($operator),
|
||||||
|
[
|
||||||
|
'park_count' => $operator->total_parks,
|
||||||
|
'operating_parks' => $operator->operating_parks,
|
||||||
|
'closed_parks' => $operator->closed_parks,
|
||||||
|
'total_rides' => $operator->total_rides,
|
||||||
|
'total_coasters' => $operator->total_coasters,
|
||||||
|
'average_rating' => $operator->average_rating,
|
||||||
|
'total_capacity' => $operator->total_daily_capacity,
|
||||||
|
'updated_at' => now(),
|
||||||
|
],
|
||||||
|
static::CACHE_TTL
|
||||||
|
);
|
||||||
|
|
||||||
|
Log::info("Cached statistics for operator {$operator->id}");
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error("Failed to cache operator statistics: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached area statistics.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function getAreaStatistics(ParkArea $area): ?array
|
||||||
|
{
|
||||||
|
return Cache::get($this->getAreaKey($area));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached park statistics.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function getParkStatistics(Park $park): ?array
|
||||||
|
{
|
||||||
|
return Cache::get($this->getParkKey($park));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached operator statistics.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function getOperatorStatistics(Operator $operator): ?array
|
||||||
|
{
|
||||||
|
return Cache::get($this->getOperatorKey($operator));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate area statistics cache.
|
||||||
|
*/
|
||||||
|
public function invalidateAreaCache(ParkArea $area): void
|
||||||
|
{
|
||||||
|
Cache::forget($this->getAreaKey($area));
|
||||||
|
Log::info("Invalidated cache for area {$area->id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate park statistics cache.
|
||||||
|
*/
|
||||||
|
public function invalidateParkCache(Park $park): void
|
||||||
|
{
|
||||||
|
Cache::forget($this->getParkKey($park));
|
||||||
|
Log::info("Invalidated cache for park {$park->id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate operator statistics cache.
|
||||||
|
*/
|
||||||
|
public function invalidateOperatorCache(Operator $operator): void
|
||||||
|
{
|
||||||
|
Cache::forget($this->getOperatorKey($operator));
|
||||||
|
Log::info("Invalidated cache for operator {$operator->id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warm up caches for all entities.
|
||||||
|
*/
|
||||||
|
public function warmCaches(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Cache area statistics
|
||||||
|
ParkArea::chunk(100, function ($areas) {
|
||||||
|
foreach ($areas as $area) {
|
||||||
|
$this->cacheAreaStatistics($area);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache park statistics
|
||||||
|
Park::chunk(100, function ($parks) {
|
||||||
|
foreach ($parks as $park) {
|
||||||
|
$this->cacheParkStatistics($park);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache operator statistics
|
||||||
|
Operator::chunk(100, function ($operators) {
|
||||||
|
foreach ($operators as $operator) {
|
||||||
|
$this->cacheOperatorStatistics($operator);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Log::info('Successfully warmed up all statistics caches');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error("Failed to warm up caches: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache key for area.
|
||||||
|
*/
|
||||||
|
protected function getAreaKey(ParkArea $area): string
|
||||||
|
{
|
||||||
|
return static::AREA_PREFIX . $area->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache key for park.
|
||||||
|
*/
|
||||||
|
protected function getParkKey(Park $park): string
|
||||||
|
{
|
||||||
|
return static::PARK_PREFIX . $park->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache key for operator.
|
||||||
|
*/
|
||||||
|
protected function getOperatorKey(Operator $operator): string
|
||||||
|
{
|
||||||
|
return static::OPERATOR_PREFIX . $operator->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
162
app/Services/StatisticsRollupService.php
Normal file
162
app/Services/StatisticsRollupService.php
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Park;
|
||||||
|
use App\Models\ParkArea;
|
||||||
|
use App\Models\Operator;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class StatisticsRollupService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Update statistics for a specific area.
|
||||||
|
*/
|
||||||
|
public function updateAreaStatistics(ParkArea $area): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($area) {
|
||||||
|
// Update area statistics (will be implemented with Rides system)
|
||||||
|
// For now, we'll just ensure the area triggers park updates
|
||||||
|
$this->updateParkStatistics($area->park);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update statistics for a specific park.
|
||||||
|
*/
|
||||||
|
public function updateParkStatistics(Park $park): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($park) {
|
||||||
|
// Update area counts
|
||||||
|
$park->updateAreaCounts();
|
||||||
|
|
||||||
|
// Update ride statistics
|
||||||
|
$park->updateRideStatistics();
|
||||||
|
|
||||||
|
// Update visitor statistics
|
||||||
|
$park->updateVisitorStats();
|
||||||
|
|
||||||
|
// Update operator statistics
|
||||||
|
if ($park->operator) {
|
||||||
|
$this->updateOperatorStatistics($park->operator);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update statistics for a specific operator.
|
||||||
|
*/
|
||||||
|
public function updateOperatorStatistics(Operator $operator): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($operator) {
|
||||||
|
$parks = $operator->parks;
|
||||||
|
|
||||||
|
// Update park counts
|
||||||
|
$operator->update([
|
||||||
|
'total_parks' => $parks->count(),
|
||||||
|
'operating_parks' => $parks->operating()->count(),
|
||||||
|
'closed_parks' => $parks->closed()->count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update ride totals
|
||||||
|
$operator->update([
|
||||||
|
'total_rides' => $parks->sum('total_rides'),
|
||||||
|
'total_coasters' => $parks->sum('total_coasters'),
|
||||||
|
'total_flat_rides' => $parks->sum('total_flat_rides'),
|
||||||
|
'total_water_rides' => $parks->sum('total_water_rides'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update performance metrics
|
||||||
|
$ratedParks = $parks->whereNotNull('average_rating');
|
||||||
|
if ($ratedParks->count() > 0) {
|
||||||
|
$operator->update([
|
||||||
|
'average_rating' => $ratedParks->avg('average_rating'),
|
||||||
|
'total_daily_capacity' => $parks->sum('total_daily_capacity'),
|
||||||
|
'average_utilization' => $parks->avg('utilization_rate'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update all statistics in the system.
|
||||||
|
*/
|
||||||
|
public function refreshAllStatistics(): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () {
|
||||||
|
// Update all areas first
|
||||||
|
ParkArea::chunk(100, function ($areas) {
|
||||||
|
foreach ($areas as $area) {
|
||||||
|
// Area statistics will be implemented with Rides system
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update all parks
|
||||||
|
Park::chunk(100, function ($parks) {
|
||||||
|
foreach ($parks as $park) {
|
||||||
|
$this->updateParkStatistics($park);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update all operators
|
||||||
|
Operator::chunk(100, function ($operators) {
|
||||||
|
foreach ($operators as $operator) {
|
||||||
|
$this->updateOperatorStatistics($operator);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule regular statistics updates.
|
||||||
|
*/
|
||||||
|
public function scheduleUpdates(): void
|
||||||
|
{
|
||||||
|
// This method will be called by the scheduler
|
||||||
|
$this->refreshAllStatistics();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle ride addition event.
|
||||||
|
*/
|
||||||
|
public function handleRideAdded(ParkArea $area): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($area) {
|
||||||
|
$area->recordNewRide();
|
||||||
|
$this->updateAreaStatistics($area);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle ride retirement event.
|
||||||
|
*/
|
||||||
|
public function handleRideRetired(ParkArea $area): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($area) {
|
||||||
|
$area->recordRetirement();
|
||||||
|
$this->updateAreaStatistics($area);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle park expansion event.
|
||||||
|
*/
|
||||||
|
public function handleParkExpansion(Park $park): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($park) {
|
||||||
|
$park->recordExpansion();
|
||||||
|
$this->updateParkStatistics($park);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle major update event.
|
||||||
|
*/
|
||||||
|
public function handleMajorUpdate(Park $park): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($park) {
|
||||||
|
$park->recordMajorUpdate();
|
||||||
|
$this->updateParkStatistics($park);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
169
app/Traits/HasAreaStatistics.php
Normal file
169
app/Traits/HasAreaStatistics.php
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Traits;
|
||||||
|
|
||||||
|
trait HasAreaStatistics
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the total ride count including all types.
|
||||||
|
*/
|
||||||
|
public function getTotalRideCountAttribute(): int
|
||||||
|
{
|
||||||
|
return $this->ride_count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the percentage of coasters among all rides.
|
||||||
|
*/
|
||||||
|
public function getCoasterPercentageAttribute(): float
|
||||||
|
{
|
||||||
|
if ($this->ride_count === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round(($this->coaster_count / $this->ride_count) * 100, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a summary of ride types distribution.
|
||||||
|
*
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
public function getRideDistributionAttribute(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'coasters' => $this->coaster_count ?? 0,
|
||||||
|
'flat_rides' => $this->flat_ride_count ?? 0,
|
||||||
|
'water_rides' => $this->water_ride_count ?? 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the formatted daily capacity.
|
||||||
|
*/
|
||||||
|
public function getFormattedDailyCapacityAttribute(): string
|
||||||
|
{
|
||||||
|
if (!$this->daily_capacity) {
|
||||||
|
return 'Unknown capacity';
|
||||||
|
}
|
||||||
|
|
||||||
|
return number_format($this->daily_capacity) . ' riders/day';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the formatted peak wait time.
|
||||||
|
*/
|
||||||
|
public function getFormattedPeakWaitTimeAttribute(): string
|
||||||
|
{
|
||||||
|
if (!$this->peak_wait_time) {
|
||||||
|
return 'Unknown wait time';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->peak_wait_time . ' minutes';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the rating display with stars.
|
||||||
|
*/
|
||||||
|
public function getRatingDisplayAttribute(): string
|
||||||
|
{
|
||||||
|
if (!$this->average_rating) {
|
||||||
|
return 'Not rated';
|
||||||
|
}
|
||||||
|
|
||||||
|
$stars = str_repeat('★', floor($this->average_rating));
|
||||||
|
$stars .= str_repeat('☆', 5 - floor($this->average_rating));
|
||||||
|
|
||||||
|
return $stars . ' (' . number_format($this->average_rating, 1) . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get historical statistics summary.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function getHistoricalStatsAttribute(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'total_operated' => $this->total_rides_operated,
|
||||||
|
'retired_count' => $this->retired_rides_count,
|
||||||
|
'last_addition' => $this->last_new_ride_added?->format('M Y') ?? 'Never',
|
||||||
|
'retirement_rate' => $this->getRetirementRate(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the retirement rate (retired rides as percentage of total operated).
|
||||||
|
*/
|
||||||
|
protected function getRetirementRate(): float
|
||||||
|
{
|
||||||
|
if ($this->total_rides_operated === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round(($this->retired_rides_count / $this->total_rides_operated) * 100, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update ride counts.
|
||||||
|
*
|
||||||
|
* @param array<string, int> $counts
|
||||||
|
*/
|
||||||
|
public function updateRideCounts(array $counts): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'ride_count' => $counts['total'] ?? 0,
|
||||||
|
'coaster_count' => $counts['coasters'] ?? 0,
|
||||||
|
'flat_ride_count' => $counts['flat_rides'] ?? 0,
|
||||||
|
'water_ride_count' => $counts['water_rides'] ?? 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update visitor statistics.
|
||||||
|
*/
|
||||||
|
public function updateVisitorStats(int $dailyCapacity, int $peakWaitTime, float $rating): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'daily_capacity' => $dailyCapacity,
|
||||||
|
'peak_wait_time' => $peakWaitTime,
|
||||||
|
'average_rating' => round($rating, 2),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a new ride addition.
|
||||||
|
*/
|
||||||
|
public function recordNewRide(): void
|
||||||
|
{
|
||||||
|
$this->increment('total_rides_operated');
|
||||||
|
$this->update(['last_new_ride_added' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a ride retirement.
|
||||||
|
*/
|
||||||
|
public function recordRetirement(): void
|
||||||
|
{
|
||||||
|
$this->increment('retired_rides_count');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all statistics to zero.
|
||||||
|
*/
|
||||||
|
public function resetStatistics(): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'ride_count' => 0,
|
||||||
|
'coaster_count' => 0,
|
||||||
|
'flat_ride_count' => 0,
|
||||||
|
'water_ride_count' => 0,
|
||||||
|
'daily_capacity' => null,
|
||||||
|
'peak_wait_time' => null,
|
||||||
|
'average_rating' => null,
|
||||||
|
'total_rides_operated' => 0,
|
||||||
|
'retired_rides_count' => 0,
|
||||||
|
'last_new_ride_added' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
202
app/Traits/HasParkStatistics.php
Normal file
202
app/Traits/HasParkStatistics.php
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Traits;
|
||||||
|
|
||||||
|
trait HasParkStatistics
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the total ride count including all types.
|
||||||
|
*/
|
||||||
|
public function getTotalRideCountAttribute(): int
|
||||||
|
{
|
||||||
|
return $this->total_rides ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the percentage of coasters among all rides.
|
||||||
|
*/
|
||||||
|
public function getCoasterPercentageAttribute(): float
|
||||||
|
{
|
||||||
|
if ($this->total_rides === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round(($this->total_coasters / $this->total_rides) * 100, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a summary of ride types distribution.
|
||||||
|
*
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
public function getRideDistributionAttribute(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'coasters' => $this->total_coasters ?? 0,
|
||||||
|
'flat_rides' => $this->total_flat_rides ?? 0,
|
||||||
|
'water_rides' => $this->total_water_rides ?? 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a summary of area statistics.
|
||||||
|
*
|
||||||
|
* @return array<string, int>
|
||||||
|
*/
|
||||||
|
public function getAreaDistributionAttribute(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'total' => $this->total_areas ?? 0,
|
||||||
|
'operating' => $this->operating_areas ?? 0,
|
||||||
|
'closed' => $this->closed_areas ?? 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the formatted daily capacity.
|
||||||
|
*/
|
||||||
|
public function getFormattedDailyCapacityAttribute(): string
|
||||||
|
{
|
||||||
|
if (!$this->total_daily_capacity) {
|
||||||
|
return 'Unknown capacity';
|
||||||
|
}
|
||||||
|
|
||||||
|
return number_format($this->total_daily_capacity) . ' riders/day';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the formatted average wait time.
|
||||||
|
*/
|
||||||
|
public function getFormattedWaitTimeAttribute(): string
|
||||||
|
{
|
||||||
|
if (!$this->average_wait_time) {
|
||||||
|
return 'Unknown wait time';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->average_wait_time . ' minutes';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the rating display with stars.
|
||||||
|
*/
|
||||||
|
public function getRatingDisplayAttribute(): string
|
||||||
|
{
|
||||||
|
if (!$this->average_rating) {
|
||||||
|
return 'Not rated';
|
||||||
|
}
|
||||||
|
|
||||||
|
$stars = str_repeat('★', floor($this->average_rating));
|
||||||
|
$stars .= str_repeat('☆', 5 - floor($this->average_rating));
|
||||||
|
|
||||||
|
return $stars . ' (' . number_format($this->average_rating, 1) . ')';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get historical statistics summary.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function getHistoricalStatsAttribute(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'total_operated' => $this->total_rides_operated,
|
||||||
|
'total_retired' => $this->total_rides_retired,
|
||||||
|
'last_expansion' => $this->last_expansion_date?->format('M Y') ?? 'Never',
|
||||||
|
'last_update' => $this->last_major_update?->format('M Y') ?? 'Never',
|
||||||
|
'retirement_rate' => $this->getRetirementRate(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get performance metrics summary.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function getPerformanceMetricsAttribute(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'utilization' => $this->utilization_rate ? $this->utilization_rate . '%' : 'Unknown',
|
||||||
|
'peak_attendance' => $this->peak_daily_attendance ? number_format($this->peak_daily_attendance) : 'Unknown',
|
||||||
|
'satisfaction' => $this->guest_satisfaction ? number_format($this->guest_satisfaction, 1) . '/5.0' : 'Unknown',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the retirement rate (retired rides as percentage of total operated).
|
||||||
|
*/
|
||||||
|
protected function getRetirementRate(): float
|
||||||
|
{
|
||||||
|
if ($this->total_rides_operated === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round(($this->total_rides_retired / $this->total_rides_operated) * 100, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update area counts.
|
||||||
|
*/
|
||||||
|
public function updateAreaCounts(): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'total_areas' => $this->areas()->count(),
|
||||||
|
'operating_areas' => $this->areas()->operating()->count(),
|
||||||
|
'closed_areas' => $this->areas()->whereNotNull('closing_date')->count(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update ride statistics.
|
||||||
|
*/
|
||||||
|
public function updateRideStatistics(): void
|
||||||
|
{
|
||||||
|
$areas = $this->areas;
|
||||||
|
|
||||||
|
$this->update([
|
||||||
|
'total_rides' => $areas->sum('ride_count'),
|
||||||
|
'total_coasters' => $areas->sum('coaster_count'),
|
||||||
|
'total_flat_rides' => $areas->sum('flat_ride_count'),
|
||||||
|
'total_water_rides' => $areas->sum('water_ride_count'),
|
||||||
|
'total_daily_capacity' => $areas->sum('daily_capacity'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update visitor statistics.
|
||||||
|
*/
|
||||||
|
public function updateVisitorStats(): void
|
||||||
|
{
|
||||||
|
$areas = $this->areas()->whereNotNull('average_rating');
|
||||||
|
|
||||||
|
$this->update([
|
||||||
|
'average_wait_time' => $areas->avg('peak_wait_time'),
|
||||||
|
'average_rating' => $areas->avg('average_rating'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record an expansion.
|
||||||
|
*/
|
||||||
|
public function recordExpansion(): void
|
||||||
|
{
|
||||||
|
$this->update(['last_expansion_date' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a major update.
|
||||||
|
*/
|
||||||
|
public function recordMajorUpdate(): void
|
||||||
|
{
|
||||||
|
$this->update(['last_major_update' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update all statistics.
|
||||||
|
*/
|
||||||
|
public function refreshStatistics(): void
|
||||||
|
{
|
||||||
|
$this->updateAreaCounts();
|
||||||
|
$this->updateRideStatistics();
|
||||||
|
$this->updateVisitorStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
106
app/Traits/HasSlugHistory.php
Normal file
106
app/Traits/HasSlugHistory.php
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Traits;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use App\Models\SlugHistory;
|
||||||
|
|
||||||
|
trait HasSlugHistory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Boot the trait.
|
||||||
|
*/
|
||||||
|
protected static function bootHasSlugHistory(): void
|
||||||
|
{
|
||||||
|
static::saving(function ($model) {
|
||||||
|
if (!$model->slug) {
|
||||||
|
$model->slug = $model->generateSlug();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
static::updating(function ($model) {
|
||||||
|
if ($model->isDirty('slug') && $model->getOriginal('slug')) {
|
||||||
|
static::addToSlugHistory($model->getOriginal('slug'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all slug histories for this model.
|
||||||
|
*/
|
||||||
|
public function slugHistories(): MorphMany
|
||||||
|
{
|
||||||
|
return $this->morphMany(SlugHistory::class, 'sluggable');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a slug to the history.
|
||||||
|
*/
|
||||||
|
protected function addToSlugHistory(string $slug): void
|
||||||
|
{
|
||||||
|
$this->slugHistories()->create(['slug' => $slug]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique slug.
|
||||||
|
*/
|
||||||
|
protected function generateSlug(): string
|
||||||
|
{
|
||||||
|
$slug = Str::slug($this->name);
|
||||||
|
$count = 2;
|
||||||
|
|
||||||
|
while (
|
||||||
|
static::where('slug', $slug)
|
||||||
|
->where('id', '!=', $this->id)
|
||||||
|
->exists() ||
|
||||||
|
SlugHistory::where('slug', $slug)
|
||||||
|
->where('sluggable_type', get_class($this))
|
||||||
|
->where('sluggable_id', '!=', $this->id)
|
||||||
|
->exists()
|
||||||
|
) {
|
||||||
|
$slug = Str::slug($this->name) . '-' . $count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a model by its current or historical slug.
|
||||||
|
*
|
||||||
|
* @param string $slug
|
||||||
|
* @return static|null
|
||||||
|
*/
|
||||||
|
public static function findBySlug(string $slug)
|
||||||
|
{
|
||||||
|
// Try current slug
|
||||||
|
$model = static::where('slug', $slug)->first();
|
||||||
|
if ($model) {
|
||||||
|
return $model;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try historical slug
|
||||||
|
$slugHistory = SlugHistory::where('slug', $slug)
|
||||||
|
->where('sluggable_type', static::class)
|
||||||
|
->latest()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($slugHistory) {
|
||||||
|
return static::find($slugHistory->sluggable_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a model by its current or historical slug or fail.
|
||||||
|
*
|
||||||
|
* @param string $slug
|
||||||
|
* @return static
|
||||||
|
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||||
|
*/
|
||||||
|
public static function findBySlugOrFail(string $slug)
|
||||||
|
{
|
||||||
|
return static::findBySlug($slug) ?? throw new \Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('park_areas', function (Blueprint $table) {
|
||||||
|
// Add position field for ordering
|
||||||
|
$table->integer('position')->default(0);
|
||||||
|
// Add parent_id for nested areas
|
||||||
|
$table->foreignId('parent_id')
|
||||||
|
->nullable()
|
||||||
|
->constrained('park_areas')
|
||||||
|
->nullOnDelete();
|
||||||
|
|
||||||
|
// Add index for efficient ordering queries
|
||||||
|
$table->index(['park_id', 'position']);
|
||||||
|
// Add index for parent relationship queries
|
||||||
|
$table->index(['park_id', 'parent_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('park_areas', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['park_id', 'position']);
|
||||||
|
$table->dropIndex(['park_id', 'parent_id']);
|
||||||
|
$table->dropForeign(['parent_id']);
|
||||||
|
$table->dropColumn(['position', 'parent_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('park_areas', function (Blueprint $table) {
|
||||||
|
// Ride statistics
|
||||||
|
$table->integer('ride_count')->default(0);
|
||||||
|
$table->integer('coaster_count')->default(0);
|
||||||
|
$table->integer('flat_ride_count')->default(0);
|
||||||
|
$table->integer('water_ride_count')->default(0);
|
||||||
|
|
||||||
|
// Visitor statistics
|
||||||
|
$table->integer('daily_capacity')->nullable();
|
||||||
|
$table->integer('peak_wait_time')->nullable();
|
||||||
|
$table->decimal('average_rating', 3, 2)->nullable();
|
||||||
|
|
||||||
|
// Historical data
|
||||||
|
$table->integer('total_rides_operated')->default(0);
|
||||||
|
$table->integer('retired_rides_count')->default(0);
|
||||||
|
$table->date('last_new_ride_added')->nullable();
|
||||||
|
|
||||||
|
// Add indexes for common queries
|
||||||
|
$table->index(['park_id', 'ride_count']);
|
||||||
|
$table->index(['park_id', 'coaster_count']);
|
||||||
|
$table->index(['park_id', 'average_rating']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('park_areas', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['park_id', 'ride_count']);
|
||||||
|
$table->dropIndex(['park_id', 'coaster_count']);
|
||||||
|
$table->dropIndex(['park_id', 'average_rating']);
|
||||||
|
|
||||||
|
$table->dropColumn([
|
||||||
|
'ride_count',
|
||||||
|
'coaster_count',
|
||||||
|
'flat_ride_count',
|
||||||
|
'water_ride_count',
|
||||||
|
'daily_capacity',
|
||||||
|
'peak_wait_time',
|
||||||
|
'average_rating',
|
||||||
|
'total_rides_operated',
|
||||||
|
'retired_rides_count',
|
||||||
|
'last_new_ride_added',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('parks', function (Blueprint $table) {
|
||||||
|
// Area statistics
|
||||||
|
$table->integer('total_areas')->default(0);
|
||||||
|
$table->integer('operating_areas')->default(0);
|
||||||
|
$table->integer('closed_areas')->default(0);
|
||||||
|
|
||||||
|
// Ride statistics
|
||||||
|
$table->integer('total_rides')->default(0);
|
||||||
|
$table->integer('total_coasters')->default(0);
|
||||||
|
$table->integer('total_flat_rides')->default(0);
|
||||||
|
$table->integer('total_water_rides')->default(0);
|
||||||
|
|
||||||
|
// Visitor statistics
|
||||||
|
$table->integer('total_daily_capacity')->default(0);
|
||||||
|
$table->integer('average_wait_time')->nullable();
|
||||||
|
$table->decimal('average_rating', 3, 2)->nullable();
|
||||||
|
|
||||||
|
// Historical data
|
||||||
|
$table->integer('total_rides_operated')->default(0);
|
||||||
|
$table->integer('total_rides_retired')->default(0);
|
||||||
|
$table->date('last_expansion_date')->nullable();
|
||||||
|
$table->date('last_major_update')->nullable();
|
||||||
|
|
||||||
|
// Performance metrics
|
||||||
|
$table->decimal('utilization_rate', 5, 2)->nullable();
|
||||||
|
$table->integer('peak_daily_attendance')->nullable();
|
||||||
|
$table->decimal('guest_satisfaction', 3, 2)->nullable();
|
||||||
|
|
||||||
|
// Add indexes for common queries
|
||||||
|
$table->index(['operator_id', 'total_rides']);
|
||||||
|
$table->index(['operator_id', 'total_coasters']);
|
||||||
|
$table->index(['operator_id', 'average_rating']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('parks', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['operator_id', 'total_rides']);
|
||||||
|
$table->dropIndex(['operator_id', 'total_coasters']);
|
||||||
|
$table->dropIndex(['operator_id', 'average_rating']);
|
||||||
|
|
||||||
|
$table->dropColumn([
|
||||||
|
'total_areas',
|
||||||
|
'operating_areas',
|
||||||
|
'closed_areas',
|
||||||
|
'total_rides',
|
||||||
|
'total_coasters',
|
||||||
|
'total_flat_rides',
|
||||||
|
'total_water_rides',
|
||||||
|
'total_daily_capacity',
|
||||||
|
'average_wait_time',
|
||||||
|
'average_rating',
|
||||||
|
'total_rides_operated',
|
||||||
|
'total_rides_retired',
|
||||||
|
'last_expansion_date',
|
||||||
|
'last_major_update',
|
||||||
|
'utilization_rate',
|
||||||
|
'peak_daily_attendance',
|
||||||
|
'guest_satisfaction',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
42
database/migrations/2024_02_23_234450_add_user_fields.php
Normal file
42
database/migrations/2024_02_23_234450_add_user_fields.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('user_id', 10)->unique()->after('id');
|
||||||
|
$table->enum('role', ['USER', 'MODERATOR', 'ADMIN', 'SUPERUSER'])->default('USER')->after('remember_token');
|
||||||
|
$table->boolean('is_banned')->default(false)->after('role');
|
||||||
|
$table->text('ban_reason')->nullable()->after('is_banned');
|
||||||
|
$table->timestamp('ban_date')->nullable()->after('ban_reason');
|
||||||
|
$table->string('pending_email')->nullable()->after('email');
|
||||||
|
$table->enum('theme_preference', ['light', 'dark'])->default('light')->after('ban_date');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn([
|
||||||
|
'user_id',
|
||||||
|
'role',
|
||||||
|
'is_banned',
|
||||||
|
'ban_reason',
|
||||||
|
'ban_date',
|
||||||
|
'pending_email',
|
||||||
|
'theme_preference'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('profiles', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('profile_id', 10)->unique();
|
||||||
|
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->string('display_name', 50)->unique();
|
||||||
|
$table->string('avatar')->nullable();
|
||||||
|
$table->string('pronouns', 50)->nullable();
|
||||||
|
$table->text('bio')->nullable();
|
||||||
|
|
||||||
|
// Social media links
|
||||||
|
$table->string('twitter')->nullable();
|
||||||
|
$table->string('instagram')->nullable();
|
||||||
|
$table->string('youtube')->nullable();
|
||||||
|
$table->string('discord', 100)->nullable();
|
||||||
|
|
||||||
|
// Ride statistics
|
||||||
|
$table->integer('coaster_credits')->default(0);
|
||||||
|
$table->integer('dark_ride_credits')->default(0);
|
||||||
|
$table->integer('flat_ride_credits')->default(0);
|
||||||
|
$table->integer('water_ride_credits')->default(0);
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('profiles');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('operators', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
$table->string('website')->nullable();
|
||||||
|
$table->string('headquarters')->nullable();
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->integer('total_parks')->default(0);
|
||||||
|
$table->integer('total_rides')->default(0);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('manufacturers', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
$table->string('website')->nullable();
|
||||||
|
$table->string('headquarters')->nullable();
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->integer('total_rides')->default(0);
|
||||||
|
$table->integer('total_roller_coasters')->default(0);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create slug history table for tracking slug changes
|
||||||
|
Schema::create('slug_histories', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->morphs('sluggable'); // Creates sluggable_type and sluggable_id
|
||||||
|
$table->string('slug');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['sluggable_type', 'slug']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('slug_histories');
|
||||||
|
Schema::dropIfExists('manufacturers');
|
||||||
|
Schema::dropIfExists('operators');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('locations', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
|
||||||
|
// Polymorphic relationship
|
||||||
|
$table->morphs('locatable');
|
||||||
|
|
||||||
|
// Location details
|
||||||
|
$table->string('address')->nullable();
|
||||||
|
$table->string('city');
|
||||||
|
$table->string('state')->nullable();
|
||||||
|
$table->string('country');
|
||||||
|
$table->string('postal_code')->nullable();
|
||||||
|
|
||||||
|
// Coordinates
|
||||||
|
$table->decimal('latitude', 10, 8);
|
||||||
|
$table->decimal('longitude', 11, 8);
|
||||||
|
$table->decimal('elevation', 8, 2)->nullable();
|
||||||
|
|
||||||
|
// Additional details
|
||||||
|
$table->string('timezone')->nullable();
|
||||||
|
$table->json('metadata')->nullable();
|
||||||
|
$table->boolean('is_approximate')->default(false);
|
||||||
|
$table->string('source')->nullable();
|
||||||
|
|
||||||
|
// Geocoding cache
|
||||||
|
$table->json('geocoding_data')->nullable();
|
||||||
|
$table->timestamp('geocoded_at')->nullable();
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
// Indexes
|
||||||
|
$table->index(['latitude', 'longitude']);
|
||||||
|
$table->index(['country', 'state', 'city']);
|
||||||
|
$table->index('postal_code');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('locations');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('parks', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->string('status', 20);
|
||||||
|
|
||||||
|
// Details
|
||||||
|
$table->date('opening_date')->nullable();
|
||||||
|
$table->date('closing_date')->nullable();
|
||||||
|
$table->string('operating_season')->nullable();
|
||||||
|
$table->decimal('size_acres', 10, 2)->nullable();
|
||||||
|
$table->string('website')->nullable();
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
$table->decimal('average_rating', 3, 2)->nullable();
|
||||||
|
$table->integer('ride_count')->nullable();
|
||||||
|
$table->integer('coaster_count')->nullable();
|
||||||
|
|
||||||
|
// Foreign keys
|
||||||
|
$table->foreignId('operator_id')
|
||||||
|
->nullable()
|
||||||
|
->constrained()
|
||||||
|
->nullOnDelete();
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('park_areas', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('park_id')
|
||||||
|
->constrained()
|
||||||
|
->cascadeOnDelete();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('slug');
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->date('opening_date')->nullable();
|
||||||
|
$table->date('closing_date')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
// Ensure unique slugs within each park
|
||||||
|
$table->unique(['park_id', 'slug']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create index for park status for efficient filtering
|
||||||
|
Schema::table('parks', function (Blueprint $table) {
|
||||||
|
$table->index('status');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('park_areas');
|
||||||
|
Schema::dropIfExists('parks');
|
||||||
|
}
|
||||||
|
};
|
||||||
168
memory-bank/activeContext.md
Normal file
168
memory-bank/activeContext.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# Current Development Context
|
||||||
|
|
||||||
|
## Active Task
|
||||||
|
Converting ThrillWiki from Django to Laravel+Livewire
|
||||||
|
|
||||||
|
## Current Phase
|
||||||
|
Parks and Areas Management Implementation
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
### Completed
|
||||||
|
1. ✅ Set up Laravel project structure
|
||||||
|
2. ✅ Create user management migrations:
|
||||||
|
- Extended users table with required fields
|
||||||
|
- Created profiles table
|
||||||
|
3. ✅ Created User Management Models:
|
||||||
|
- Enhanced User model with roles and preferences
|
||||||
|
- Created Profile model with avatar handling
|
||||||
|
4. ✅ Implemented User Profile Management:
|
||||||
|
- Created ProfileComponent Livewire component
|
||||||
|
- Implemented profile editing interface
|
||||||
|
- Added avatar upload functionality
|
||||||
|
5. ✅ Created Base Infrastructure:
|
||||||
|
- ParkStatus enum with status display methods
|
||||||
|
- IdGenerator service for consistent ID generation
|
||||||
|
6. ✅ Implemented Slug History System:
|
||||||
|
- Created HasSlugHistory trait
|
||||||
|
- Implemented SlugHistory model
|
||||||
|
- Set up polymorphic relationships
|
||||||
|
- Added slug generation and tracking
|
||||||
|
7. ✅ Implemented Operator/Manufacturer System:
|
||||||
|
- Created migrations for operators and manufacturers
|
||||||
|
- Implemented Operator model with park relationships
|
||||||
|
- Implemented Manufacturer model with ride relationships
|
||||||
|
- Added statistics tracking methods
|
||||||
|
8. ✅ Implemented Parks System:
|
||||||
|
- Created migrations for parks and areas
|
||||||
|
- Implemented Park model with status handling
|
||||||
|
- Implemented ParkArea model with scoped slugs
|
||||||
|
- Added relationships and statistics tracking
|
||||||
|
9. ✅ Created Parks Management Interface:
|
||||||
|
- Implemented ParkFormComponent for CRUD
|
||||||
|
- Created ParkListComponent with filtering
|
||||||
|
- Added responsive grid layouts
|
||||||
|
- Implemented search and sorting
|
||||||
|
10. ✅ Created Park Areas Management:
|
||||||
|
- Implemented ParkAreaFormComponent
|
||||||
|
- Created ParkAreaListComponent
|
||||||
|
- Added area filtering and search
|
||||||
|
- Implemented area deletion
|
||||||
|
11. ✅ Implemented Area Organization:
|
||||||
|
- Added position and parent_id fields
|
||||||
|
- Created drag-and-drop reordering
|
||||||
|
- Implemented nested area support
|
||||||
|
- Added position management
|
||||||
|
- Created move functionality
|
||||||
|
12. ✅ Implemented Area Statistics:
|
||||||
|
- Added statistics fields to areas
|
||||||
|
- Created HasAreaStatistics trait
|
||||||
|
- Implemented statistics component
|
||||||
|
- Added visual data display
|
||||||
|
- Created historical tracking
|
||||||
|
13. ✅ Implemented Statistics Rollup:
|
||||||
|
- Added park-level statistics
|
||||||
|
- Created HasParkStatistics trait
|
||||||
|
- Implemented rollup service
|
||||||
|
- Added transaction safety
|
||||||
|
- Created event handlers
|
||||||
|
14. ✅ Implemented Statistics Caching:
|
||||||
|
- Created caching service
|
||||||
|
- Added cache invalidation
|
||||||
|
- Implemented cache warming
|
||||||
|
- Added performance monitoring
|
||||||
|
- Created error handling
|
||||||
|
|
||||||
|
### In Progress
|
||||||
|
1. [ ] Location System Implementation
|
||||||
|
- Model structure design
|
||||||
|
- Polymorphic relationships
|
||||||
|
- Map integration
|
||||||
|
- Location selection
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
1. Location System
|
||||||
|
- [ ] Create location model
|
||||||
|
- [ ] Add polymorphic relationships
|
||||||
|
- [ ] Implement geocoding service
|
||||||
|
- [ ] Create map component
|
||||||
|
- [ ] Add location selection
|
||||||
|
- [ ] Implement search
|
||||||
|
- [ ] Add clustering
|
||||||
|
- [ ] Create distance calculations
|
||||||
|
|
||||||
|
2. Performance Optimization
|
||||||
|
- [ ] Implement query caching
|
||||||
|
- [ ] Add index optimization
|
||||||
|
- [ ] Create monitoring tools
|
||||||
|
- [ ] Set up profiling
|
||||||
|
|
||||||
|
## Technical Decisions Made
|
||||||
|
|
||||||
|
### Recent Implementations
|
||||||
|
|
||||||
|
1. Statistics Caching Design
|
||||||
|
- Service-based architecture
|
||||||
|
- Hierarchical caching
|
||||||
|
- Automatic invalidation
|
||||||
|
- Performance monitoring
|
||||||
|
|
||||||
|
2. Cache Management
|
||||||
|
- 24-hour TTL
|
||||||
|
- Batch processing
|
||||||
|
- Error handling
|
||||||
|
- Logging system
|
||||||
|
|
||||||
|
3. Performance Features
|
||||||
|
- Efficient key structure
|
||||||
|
- Optimized data format
|
||||||
|
- Minimal cache churn
|
||||||
|
- Memory management
|
||||||
|
|
||||||
|
### Core Architecture Patterns
|
||||||
|
|
||||||
|
1. Model Organization
|
||||||
|
- Base models with consistent traits
|
||||||
|
- Enum-based status handling
|
||||||
|
- Automatic statistics updates
|
||||||
|
- Slug history tracking
|
||||||
|
|
||||||
|
2. Data Relationships
|
||||||
|
- Operators own parks
|
||||||
|
- Parks contain areas
|
||||||
|
- Areas can nest
|
||||||
|
- Statistics rollup
|
||||||
|
|
||||||
|
## Notes and Considerations
|
||||||
|
1. Need to research map providers
|
||||||
|
2. Consider caching geocoding results
|
||||||
|
3. May need clustering for large datasets
|
||||||
|
4. Should implement distance-based search
|
||||||
|
5. Consider adding location history
|
||||||
|
6. Plan for offline maps
|
||||||
|
7. Consider adding route planning
|
||||||
|
8. Need to handle map errors
|
||||||
|
9. Consider adding location sharing
|
||||||
|
10. Plan for mobile optimization
|
||||||
|
11. Consider adding geofencing
|
||||||
|
12. Need location validation
|
||||||
|
|
||||||
|
## Issues to Address
|
||||||
|
1. [ ] Configure storage link for avatars
|
||||||
|
2. [ ] Add font for letter avatars
|
||||||
|
3. [ ] Implement email verification
|
||||||
|
4. [ ] Add profile creation on registration
|
||||||
|
5. [ ] Set up slug history cleanup
|
||||||
|
6. [ ] Implement ride count updates
|
||||||
|
7. [ ] Add status change tracking
|
||||||
|
8. [ ] Add statistics caching
|
||||||
|
9. [ ] Implement park galleries
|
||||||
|
10. [ ] Add position validation
|
||||||
|
11. [ ] Implement move restrictions
|
||||||
|
12. [ ] Add performance monitoring
|
||||||
|
13. [ ] Create statistics reports
|
||||||
|
14. [ ] Add trend analysis tools
|
||||||
|
15. [ ] Set up cache invalidation
|
||||||
|
16. [ ] Add cache warming jobs
|
||||||
|
17. [ ] Research map providers
|
||||||
|
18. [ ] Plan geocoding strategy
|
||||||
208
memory-bank/features/AreaOrganization.md
Normal file
208
memory-bank/features/AreaOrganization.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# Area Organization System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The Area Organization system provides a flexible way to structure and order areas within a park. It supports both flat and nested hierarchies, with drag-and-drop reordering capabilities and efficient position management.
|
||||||
|
|
||||||
|
## Database Structure
|
||||||
|
|
||||||
|
### Park Areas Table
|
||||||
|
```sql
|
||||||
|
CREATE TABLE park_areas (
|
||||||
|
id bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
park_id bigint unsigned NOT NULL,
|
||||||
|
parent_id bigint unsigned NULL,
|
||||||
|
name varchar(255) NOT NULL,
|
||||||
|
slug varchar(255) NOT NULL,
|
||||||
|
description text NULL,
|
||||||
|
opening_date date NULL,
|
||||||
|
closing_date date NULL,
|
||||||
|
position integer NOT NULL DEFAULT 0,
|
||||||
|
created_at timestamp NULL,
|
||||||
|
updated_at timestamp NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
FOREIGN KEY (park_id) REFERENCES parks(id),
|
||||||
|
FOREIGN KEY (parent_id) REFERENCES park_areas(id),
|
||||||
|
INDEX idx_ordering (park_id, position),
|
||||||
|
INDEX idx_hierarchy (park_id, parent_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### 1. Position Management
|
||||||
|
- Automatic position assignment for new areas
|
||||||
|
- Zero-based position indexing
|
||||||
|
- Position maintenance during reordering
|
||||||
|
- Efficient batch updates for position changes
|
||||||
|
|
||||||
|
### 2. Hierarchical Structure
|
||||||
|
- Parent-child relationships between areas
|
||||||
|
- Unlimited nesting depth
|
||||||
|
- Separate position sequences per parent
|
||||||
|
- Cascading deletion options
|
||||||
|
|
||||||
|
### 3. Query Optimization
|
||||||
|
- Compound indexes for efficient ordering
|
||||||
|
- Optimized parent-child lookups
|
||||||
|
- Position-based sorting
|
||||||
|
- Scoped uniqueness constraints
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Position Management
|
||||||
|
1. New Area Creation
|
||||||
|
```php
|
||||||
|
protected static function boot()
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
static::creating(function (ParkArea $area) {
|
||||||
|
if (is_null($area->position)) {
|
||||||
|
$area->position = $area->getNextPosition();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Position Calculation
|
||||||
|
```php
|
||||||
|
public function getNextPosition(): int
|
||||||
|
{
|
||||||
|
$maxPosition = static::where('park_id', $this->park_id)
|
||||||
|
->where('parent_id', $this->parent_id)
|
||||||
|
->max('position');
|
||||||
|
|
||||||
|
return ($maxPosition ?? -1) + 1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Position Updates
|
||||||
|
```php
|
||||||
|
public function moveToPosition(int $newPosition): void
|
||||||
|
{
|
||||||
|
if ($newPosition === $this->position) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$oldPosition = $this->position;
|
||||||
|
|
||||||
|
if ($newPosition > $oldPosition) {
|
||||||
|
// Moving down: decrement positions of items in between
|
||||||
|
static::where('park_id', $this->park_id)
|
||||||
|
->where('parent_id', $this->parent_id)
|
||||||
|
->whereBetween('position', [$oldPosition + 1, $newPosition])
|
||||||
|
->decrement('position');
|
||||||
|
} else {
|
||||||
|
// Moving up: increment positions of items in between
|
||||||
|
static::where('park_id', $this->park_id)
|
||||||
|
->where('parent_id', $this->parent_id)
|
||||||
|
->whereBetween('position', [$newPosition, $oldPosition - 1])
|
||||||
|
->increment('position');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->update(['position' => $newPosition]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hierarchical Relationships
|
||||||
|
1. Parent Relationship
|
||||||
|
```php
|
||||||
|
public function parent(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ParkArea::class, 'parent_id');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Children Relationship
|
||||||
|
```php
|
||||||
|
public function children(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ParkArea::class, 'parent_id')
|
||||||
|
->orderBy('position');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Helper Methods
|
||||||
|
```php
|
||||||
|
public function hasChildren(): bool
|
||||||
|
{
|
||||||
|
return $this->children()->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isTopLevel(): bool
|
||||||
|
{
|
||||||
|
return is_null($this->parent_id);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Creating a New Area
|
||||||
|
```php
|
||||||
|
$area = new ParkArea([
|
||||||
|
'name' => 'Adventure Zone',
|
||||||
|
'description' => 'Thrilling rides area',
|
||||||
|
]);
|
||||||
|
$park->areas()->save($area);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Moving an Area
|
||||||
|
```php
|
||||||
|
$area->moveToPosition(5);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding a Sub-Area
|
||||||
|
```php
|
||||||
|
$subArea = new ParkArea([
|
||||||
|
'name' => 'Coaster Corner',
|
||||||
|
'parent_id' => $area->id,
|
||||||
|
]);
|
||||||
|
$park->areas()->save($subArea);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
1. [ ] Add drag-and-drop reordering UI
|
||||||
|
2. [ ] Implement position validation
|
||||||
|
3. [ ] Add move restrictions
|
||||||
|
4. [ ] Implement area statistics
|
||||||
|
5. [ ] Add bulk reordering
|
||||||
|
6. [ ] Implement depth limits
|
||||||
|
7. [ ] Add position caching
|
||||||
|
8. [ ] Implement move history
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
1. Position Validation
|
||||||
|
- Prevent out-of-bounds positions
|
||||||
|
- Validate parent-child relationships
|
||||||
|
- Check for circular references
|
||||||
|
|
||||||
|
2. Access Control
|
||||||
|
- Restrict reordering permissions
|
||||||
|
- Validate park ownership
|
||||||
|
- Log position changes
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
1. Batch Updates
|
||||||
|
- Use transactions for moves
|
||||||
|
- Minimize position updates
|
||||||
|
- Cache position values
|
||||||
|
|
||||||
|
2. Query Optimization
|
||||||
|
- Use compound indexes
|
||||||
|
- Minimize nested queries
|
||||||
|
- Efficient position calculations
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
1. Unit Tests
|
||||||
|
- [ ] Position calculation
|
||||||
|
- [ ] Move operations
|
||||||
|
- [ ] Parent-child relationships
|
||||||
|
|
||||||
|
2. Integration Tests
|
||||||
|
- [ ] Reordering flows
|
||||||
|
- [ ] Nested operations
|
||||||
|
- [ ] Position maintenance
|
||||||
|
|
||||||
|
3. Performance Tests
|
||||||
|
- [ ] Large-scale reordering
|
||||||
|
- [ ] Nested structure queries
|
||||||
|
- [ ] Position update efficiency
|
||||||
216
memory-bank/features/AreaStatistics.md
Normal file
216
memory-bank/features/AreaStatistics.md
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
# Area Statistics System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The Area Statistics system provides comprehensive tracking and management of various metrics for park areas, including ride counts, visitor statistics, and historical data. This system enables data-driven insights and performance monitoring at both the area and park level.
|
||||||
|
|
||||||
|
## Database Structure
|
||||||
|
|
||||||
|
### Park Areas Table Statistics Fields
|
||||||
|
```sql
|
||||||
|
ALTER TABLE park_areas ADD (
|
||||||
|
-- Ride statistics
|
||||||
|
ride_count integer DEFAULT 0,
|
||||||
|
coaster_count integer DEFAULT 0,
|
||||||
|
flat_ride_count integer DEFAULT 0,
|
||||||
|
water_ride_count integer DEFAULT 0,
|
||||||
|
|
||||||
|
-- Visitor statistics
|
||||||
|
daily_capacity integer NULL,
|
||||||
|
peak_wait_time integer NULL,
|
||||||
|
average_rating decimal(3,2) NULL,
|
||||||
|
|
||||||
|
-- Historical data
|
||||||
|
total_rides_operated integer DEFAULT 0,
|
||||||
|
retired_rides_count integer DEFAULT 0,
|
||||||
|
last_new_ride_added date NULL,
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
INDEX idx_rides (park_id, ride_count),
|
||||||
|
INDEX idx_coasters (park_id, coaster_count),
|
||||||
|
INDEX idx_rating (park_id, average_rating)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. HasAreaStatistics Trait
|
||||||
|
Located in `app/Traits/HasAreaStatistics.php`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
- Provides statistics management functionality
|
||||||
|
- Handles calculations and formatting
|
||||||
|
- Manages data updates
|
||||||
|
- Tracks historical metrics
|
||||||
|
|
||||||
|
Features:
|
||||||
|
1. Ride Statistics
|
||||||
|
- Total ride count
|
||||||
|
- Type distribution
|
||||||
|
- Coaster percentage
|
||||||
|
- Historical tracking
|
||||||
|
|
||||||
|
2. Visitor Metrics
|
||||||
|
- Daily capacity
|
||||||
|
- Peak wait times
|
||||||
|
- Average ratings
|
||||||
|
- Formatted displays
|
||||||
|
|
||||||
|
3. Historical Data
|
||||||
|
- Total rides operated
|
||||||
|
- Retirement tracking
|
||||||
|
- Last addition date
|
||||||
|
- Retirement rate
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Ride Count Management
|
||||||
|
```php
|
||||||
|
public function updateRideCounts(array $counts): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'ride_count' => $counts['total'] ?? 0,
|
||||||
|
'coaster_count' => $counts['coasters'] ?? 0,
|
||||||
|
'flat_ride_count' => $counts['flat_rides'] ?? 0,
|
||||||
|
'water_ride_count' => $counts['water_rides'] ?? 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Visitor Statistics
|
||||||
|
```php
|
||||||
|
public function updateVisitorStats(
|
||||||
|
int $dailyCapacity,
|
||||||
|
int $peakWaitTime,
|
||||||
|
float $rating
|
||||||
|
): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'daily_capacity' => $dailyCapacity,
|
||||||
|
'peak_wait_time' => $peakWaitTime,
|
||||||
|
'average_rating' => round($rating, 2),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Historical Tracking
|
||||||
|
```php
|
||||||
|
public function recordNewRide(): void
|
||||||
|
{
|
||||||
|
$this->increment('total_rides_operated');
|
||||||
|
$this->update(['last_new_ride_added' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recordRetirement(): void
|
||||||
|
{
|
||||||
|
$this->increment('retired_rides_count');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Display Features
|
||||||
|
|
||||||
|
### 1. Rating Display
|
||||||
|
- Star-based visualization (★★★☆☆)
|
||||||
|
- Numerical rating with one decimal
|
||||||
|
- "Not rated" fallback
|
||||||
|
|
||||||
|
### 2. Capacity Display
|
||||||
|
- Formatted numbers with commas
|
||||||
|
- "riders/day" unit
|
||||||
|
- Unknown capacity handling
|
||||||
|
|
||||||
|
### 3. Wait Time Display
|
||||||
|
- Minutes format
|
||||||
|
- Peak time indication
|
||||||
|
- Unknown time handling
|
||||||
|
|
||||||
|
## Data Analysis
|
||||||
|
|
||||||
|
### 1. Distribution Analysis
|
||||||
|
```php
|
||||||
|
public function getRideDistributionAttribute(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'coasters' => $this->coaster_count ?? 0,
|
||||||
|
'flat_rides' => $this->flat_ride_count ?? 0,
|
||||||
|
'water_rides' => $this->water_ride_count ?? 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Historical Analysis
|
||||||
|
```php
|
||||||
|
public function getHistoricalStatsAttribute(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'total_operated' => $this->total_rides_operated,
|
||||||
|
'retired_count' => $this->retired_rides_count,
|
||||||
|
'last_addition' => $this->last_new_ride_added?->format('M Y'),
|
||||||
|
'retirement_rate' => $this->getRetirementRate(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
|
||||||
|
### 1. Database Indexes
|
||||||
|
- Compound indexes for common queries
|
||||||
|
- Efficient sorting support
|
||||||
|
- Quick statistical lookups
|
||||||
|
|
||||||
|
### 2. Caching Strategy
|
||||||
|
- [ ] Implement statistics caching
|
||||||
|
- [ ] Add cache invalidation rules
|
||||||
|
- [ ] Set up cache warming
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
1. [ ] Add seasonal statistics
|
||||||
|
2. [ ] Implement trend analysis
|
||||||
|
3. [ ] Add capacity forecasting
|
||||||
|
4. [ ] Create statistical reports
|
||||||
|
5. [ ] Add comparison tools
|
||||||
|
6. [ ] Implement benchmarking
|
||||||
|
7. [ ] Add historical graphs
|
||||||
|
8. [ ] Create export functionality
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
1. Park Model
|
||||||
|
- Area statistics rollup
|
||||||
|
- Park-wide metrics
|
||||||
|
- Comparative analysis
|
||||||
|
|
||||||
|
2. Rides System
|
||||||
|
- Automatic count updates
|
||||||
|
- Type classification
|
||||||
|
- Capacity calculation
|
||||||
|
|
||||||
|
3. Visitor System
|
||||||
|
- Wait time tracking
|
||||||
|
- Rating collection
|
||||||
|
- Capacity monitoring
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
1. Data Validation
|
||||||
|
- Range checks
|
||||||
|
- Type validation
|
||||||
|
- Update authorization
|
||||||
|
|
||||||
|
2. Access Control
|
||||||
|
- Statistics visibility
|
||||||
|
- Update permissions
|
||||||
|
- Export restrictions
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
1. Unit Tests
|
||||||
|
- [ ] Calculation accuracy
|
||||||
|
- [ ] Format handling
|
||||||
|
- [ ] Edge cases
|
||||||
|
|
||||||
|
2. Integration Tests
|
||||||
|
- [ ] Update operations
|
||||||
|
- [ ] Rollup functionality
|
||||||
|
- [ ] Cache invalidation
|
||||||
|
|
||||||
|
3. Performance Tests
|
||||||
|
- [ ] Large dataset handling
|
||||||
|
- [ ] Update efficiency
|
||||||
|
- [ ] Query optimization
|
||||||
238
memory-bank/features/LocationSystem.md
Normal file
238
memory-bank/features/LocationSystem.md
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
# Location System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The Location System provides comprehensive location management for parks, areas, and other entities through polymorphic relationships. It includes geocoding, map integration, and location-based search capabilities.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. Database Structure
|
||||||
|
|
||||||
|
#### Locations Table
|
||||||
|
```sql
|
||||||
|
CREATE TABLE locations (
|
||||||
|
id bigint PRIMARY KEY,
|
||||||
|
locatable_type varchar(255),
|
||||||
|
locatable_id bigint,
|
||||||
|
address varchar(255) NULL,
|
||||||
|
city varchar(255),
|
||||||
|
state varchar(255) NULL,
|
||||||
|
country varchar(255),
|
||||||
|
postal_code varchar(255) NULL,
|
||||||
|
latitude decimal(10,8),
|
||||||
|
longitude decimal(11,8),
|
||||||
|
elevation decimal(8,2) NULL,
|
||||||
|
timezone varchar(255) NULL,
|
||||||
|
metadata json NULL,
|
||||||
|
is_approximate boolean DEFAULT false,
|
||||||
|
source varchar(255) NULL,
|
||||||
|
geocoding_data json NULL,
|
||||||
|
geocoded_at timestamp NULL,
|
||||||
|
created_at timestamp,
|
||||||
|
updated_at timestamp,
|
||||||
|
|
||||||
|
INDEX idx_coordinates (latitude, longitude),
|
||||||
|
INDEX idx_location (country, state, city),
|
||||||
|
INDEX idx_postal (postal_code)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Models
|
||||||
|
|
||||||
|
#### Location Model
|
||||||
|
- Polymorphic relationships
|
||||||
|
- Geocoding integration
|
||||||
|
- Coordinate handling
|
||||||
|
- Distance calculations
|
||||||
|
|
||||||
|
#### HasLocation Trait
|
||||||
|
- Location relationship
|
||||||
|
- Coordinate accessors
|
||||||
|
- Distance methods
|
||||||
|
- Map integration
|
||||||
|
|
||||||
|
### 3. Services
|
||||||
|
|
||||||
|
#### GeocodeService
|
||||||
|
- Address lookup
|
||||||
|
- Coordinate validation
|
||||||
|
- Batch processing
|
||||||
|
- Cache management
|
||||||
|
|
||||||
|
#### LocationSearchService
|
||||||
|
- Distance-based search
|
||||||
|
- Boundary queries
|
||||||
|
- Clustering support
|
||||||
|
- Performance optimization
|
||||||
|
|
||||||
|
### 4. Components
|
||||||
|
|
||||||
|
#### LocationSelector
|
||||||
|
- Map integration
|
||||||
|
- Address search
|
||||||
|
- Coordinate picker
|
||||||
|
- Validation feedback
|
||||||
|
|
||||||
|
#### LocationDisplay
|
||||||
|
- Map rendering
|
||||||
|
- Marker clustering
|
||||||
|
- Info windows
|
||||||
|
- Interactive controls
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### 1. Model Structure
|
||||||
|
```php
|
||||||
|
class Location extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'address',
|
||||||
|
'city',
|
||||||
|
'state',
|
||||||
|
'country',
|
||||||
|
'postal_code',
|
||||||
|
'latitude',
|
||||||
|
'longitude',
|
||||||
|
'elevation',
|
||||||
|
'timezone',
|
||||||
|
'metadata',
|
||||||
|
'is_approximate',
|
||||||
|
'source',
|
||||||
|
'geocoding_data',
|
||||||
|
'geocoded_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'latitude' => 'decimal:8',
|
||||||
|
'longitude' => 'decimal:8',
|
||||||
|
'elevation' => 'decimal:2',
|
||||||
|
'metadata' => 'array',
|
||||||
|
'geocoding_data' => 'array',
|
||||||
|
'geocoded_at' => 'datetime',
|
||||||
|
'is_approximate' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Trait Implementation
|
||||||
|
```php
|
||||||
|
trait HasLocation
|
||||||
|
{
|
||||||
|
public function location()
|
||||||
|
{
|
||||||
|
return $this->morphOne(Location::class, 'locatable');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCoordinatesAttribute()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'lat' => $this->location?->latitude,
|
||||||
|
'lng' => $this->location?->longitude,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
### 1. Parks System
|
||||||
|
- Location assignment
|
||||||
|
- Map display
|
||||||
|
- Area boundaries
|
||||||
|
- Distance calculations
|
||||||
|
|
||||||
|
### 2. Search System
|
||||||
|
- Location-based filtering
|
||||||
|
- Distance sorting
|
||||||
|
- Boundary queries
|
||||||
|
- Clustering support
|
||||||
|
|
||||||
|
### 3. API Integration
|
||||||
|
- Geocoding services
|
||||||
|
- Map providers
|
||||||
|
- Data validation
|
||||||
|
- Error handling
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### 1. Database Design
|
||||||
|
- Efficient indexes
|
||||||
|
- Coordinate precision
|
||||||
|
- Query optimization
|
||||||
|
- Cache strategy
|
||||||
|
|
||||||
|
### 2. Geocoding
|
||||||
|
- Request limiting
|
||||||
|
- Cache management
|
||||||
|
- Batch processing
|
||||||
|
- Error handling
|
||||||
|
|
||||||
|
### 3. Map Integration
|
||||||
|
- Lazy loading
|
||||||
|
- Marker clustering
|
||||||
|
- Viewport management
|
||||||
|
- Memory optimization
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. [ ] Add route planning
|
||||||
|
2. [ ] Implement geofencing
|
||||||
|
3. [ ] Add location sharing
|
||||||
|
4. [ ] Create heatmaps
|
||||||
|
5. [ ] Add offline support
|
||||||
|
6. [ ] Implement navigation
|
||||||
|
7. [ ] Add location history
|
||||||
|
8. [ ] Create location alerts
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### 1. Data Protection
|
||||||
|
- Coordinate validation
|
||||||
|
- Input sanitization
|
||||||
|
- Access control
|
||||||
|
- Audit logging
|
||||||
|
|
||||||
|
### 2. API Security
|
||||||
|
- Rate limiting
|
||||||
|
- Token management
|
||||||
|
- Error handling
|
||||||
|
- Request validation
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### 1. Unit Tests
|
||||||
|
- [ ] Coordinate validation
|
||||||
|
- [ ] Distance calculations
|
||||||
|
- [ ] Geocoding integration
|
||||||
|
- [ ] Model relationships
|
||||||
|
|
||||||
|
### 2. Integration Tests
|
||||||
|
- [ ] Map integration
|
||||||
|
- [ ] Search functionality
|
||||||
|
- [ ] API communication
|
||||||
|
- [ ] Cache management
|
||||||
|
|
||||||
|
### 3. Performance Tests
|
||||||
|
- [ ] Large datasets
|
||||||
|
- [ ] Clustering efficiency
|
||||||
|
- [ ] Query optimization
|
||||||
|
- [ ] Memory usage
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### 1. Performance Metrics
|
||||||
|
- [ ] Query timing
|
||||||
|
- [ ] API response times
|
||||||
|
- [ ] Cache hit rates
|
||||||
|
- [ ] Memory usage
|
||||||
|
|
||||||
|
### 2. Error Tracking
|
||||||
|
- [ ] Geocoding failures
|
||||||
|
- [ ] API errors
|
||||||
|
- [ ] Invalid coordinates
|
||||||
|
- [ ] Cache misses
|
||||||
|
|
||||||
|
### 3. Usage Analytics
|
||||||
|
- [ ] Search patterns
|
||||||
|
- [ ] Popular locations
|
||||||
|
- [ ] API usage
|
||||||
|
- [ ] User interactions
|
||||||
261
memory-bank/features/ParksManagement.md
Normal file
261
memory-bank/features/ParksManagement.md
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
# Parks Management System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The Parks Management system provides a comprehensive interface for creating, reading, updating, and deleting theme parks and their areas. It implements a responsive, user-friendly interface while maintaining data integrity and proper relationships.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. ParkFormComponent
|
||||||
|
Located in `app/Livewire/ParkFormComponent.php`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
- Handles both creation and editing of parks
|
||||||
|
- Manages form state and validation
|
||||||
|
- Handles relationships with operators
|
||||||
|
- Updates statistics automatically
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Responsive form layout
|
||||||
|
- Real-time validation
|
||||||
|
- Status management with enum
|
||||||
|
- Operator selection
|
||||||
|
- Date range validation
|
||||||
|
- Automatic statistics updates
|
||||||
|
|
||||||
|
Form Sections:
|
||||||
|
1. Basic Information
|
||||||
|
- Park name
|
||||||
|
- Operator selection
|
||||||
|
- Description
|
||||||
|
2. Status and Dates
|
||||||
|
- Operating status
|
||||||
|
- Opening/closing dates
|
||||||
|
3. Additional Details
|
||||||
|
- Operating season
|
||||||
|
- Size in acres
|
||||||
|
- Website
|
||||||
|
|
||||||
|
### 2. ParkListComponent
|
||||||
|
Located in `app/Livewire/ParkListComponent.php`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
- Displays paginated list of parks
|
||||||
|
- Provides filtering and sorting capabilities
|
||||||
|
- Handles search functionality
|
||||||
|
- Manages park status display
|
||||||
|
|
||||||
|
Features:
|
||||||
|
1. Search and Filtering
|
||||||
|
- Text search across name and description
|
||||||
|
- Status filtering using ParkStatus enum
|
||||||
|
- Operator filtering
|
||||||
|
- Multiple sort options
|
||||||
|
|
||||||
|
2. Sorting Options
|
||||||
|
- Name (default)
|
||||||
|
- Opening Date
|
||||||
|
- Ride Count
|
||||||
|
- Coaster Count
|
||||||
|
- Size
|
||||||
|
|
||||||
|
3. Display Features
|
||||||
|
- Responsive grid layout
|
||||||
|
- Status badges with colors
|
||||||
|
- Key statistics display
|
||||||
|
- Quick access to edit/view
|
||||||
|
- Website links
|
||||||
|
- Operator information
|
||||||
|
|
||||||
|
### 3. ParkAreaFormComponent
|
||||||
|
Located in `app/Livewire/ParkAreaFormComponent.php`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
- Manages creation and editing of park areas
|
||||||
|
- Handles area-specific validation
|
||||||
|
- Maintains park context
|
||||||
|
- Manages opening/closing dates
|
||||||
|
|
||||||
|
Features:
|
||||||
|
1. Form Organization
|
||||||
|
- Area basic information section
|
||||||
|
- Dates management section
|
||||||
|
- Park context display
|
||||||
|
- Validation feedback
|
||||||
|
|
||||||
|
2. Validation Rules
|
||||||
|
- Name uniqueness within park
|
||||||
|
- Date range validation
|
||||||
|
- Required fields handling
|
||||||
|
- Custom error messages
|
||||||
|
|
||||||
|
3. Data Management
|
||||||
|
- Park-scoped slugs
|
||||||
|
- Automatic slug generation
|
||||||
|
- History tracking
|
||||||
|
- Date formatting
|
||||||
|
|
||||||
|
### 4. ParkAreaListComponent
|
||||||
|
Located in `app/Livewire/ParkAreaListComponent.php`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
- Displays and manages areas within a park
|
||||||
|
- Provides search and filtering
|
||||||
|
- Handles area deletion
|
||||||
|
- Shows operating status
|
||||||
|
|
||||||
|
Features:
|
||||||
|
1. List Management
|
||||||
|
- Paginated area display
|
||||||
|
- Search functionality
|
||||||
|
- Sort by name or date
|
||||||
|
- Show/hide closed areas
|
||||||
|
|
||||||
|
2. Area Display
|
||||||
|
- Area name and description
|
||||||
|
- Opening/closing dates
|
||||||
|
- Operating status
|
||||||
|
- Quick actions
|
||||||
|
|
||||||
|
3. Actions
|
||||||
|
- Edit area
|
||||||
|
- Delete area with confirmation
|
||||||
|
- Add new area
|
||||||
|
- View area details
|
||||||
|
|
||||||
|
4. Filtering Options
|
||||||
|
- Text search
|
||||||
|
- Operating status filter
|
||||||
|
- Sort direction toggle
|
||||||
|
- Date-based sorting
|
||||||
|
|
||||||
|
## UI/UX Design Decisions
|
||||||
|
|
||||||
|
### Form Design
|
||||||
|
1. Sectioned Layout
|
||||||
|
- Groups related fields together
|
||||||
|
- Improves visual hierarchy
|
||||||
|
- Makes long form more manageable
|
||||||
|
|
||||||
|
2. Responsive Grid
|
||||||
|
- Single column on mobile
|
||||||
|
- Multi-column on larger screens
|
||||||
|
- Maintains readability at all sizes
|
||||||
|
|
||||||
|
3. Validation Feedback
|
||||||
|
- Immediate error messages
|
||||||
|
- Clear error states
|
||||||
|
- Success notifications
|
||||||
|
|
||||||
|
### List Design
|
||||||
|
1. Card Layout
|
||||||
|
- Visual separation of items
|
||||||
|
- Key information at a glance
|
||||||
|
- Status prominence
|
||||||
|
- Action buttons
|
||||||
|
|
||||||
|
2. Filter Controls
|
||||||
|
- Prominent search
|
||||||
|
- Quick status filtering
|
||||||
|
- Flexible sorting
|
||||||
|
- Toggle controls
|
||||||
|
|
||||||
|
## Data Handling
|
||||||
|
|
||||||
|
### Validation Rules
|
||||||
|
```php
|
||||||
|
// Park Rules
|
||||||
|
[
|
||||||
|
'name' => ['required', 'string', 'min:2', 'max:255', 'unique'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'status' => ['required', 'enum'],
|
||||||
|
'opening_date' => ['nullable', 'date'],
|
||||||
|
'closing_date' => ['nullable', 'date', 'after:opening_date'],
|
||||||
|
'operating_season' => ['nullable', 'string', 'max:255'],
|
||||||
|
'size_acres' => ['nullable', 'numeric', 'min:0', 'max:999999.99'],
|
||||||
|
'website' => ['nullable', 'url', 'max:255'],
|
||||||
|
'operator_id' => ['nullable', 'exists:operators,id'],
|
||||||
|
]
|
||||||
|
|
||||||
|
// Area Rules
|
||||||
|
[
|
||||||
|
'name' => ['required', 'string', 'min:2', 'max:255', 'unique:within_park'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'opening_date' => ['nullable', 'date'],
|
||||||
|
'closing_date' => ['nullable', 'date', 'after:opening_date'],
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Optimization
|
||||||
|
1. Eager Loading
|
||||||
|
- Operator relationship
|
||||||
|
- Areas relationship
|
||||||
|
- Future: Rides relationship
|
||||||
|
|
||||||
|
2. Search Implementation
|
||||||
|
- Combined name and description search
|
||||||
|
- Case-insensitive matching
|
||||||
|
- Proper index usage
|
||||||
|
|
||||||
|
3. Filter Efficiency
|
||||||
|
- Status index
|
||||||
|
- Operator foreign key index
|
||||||
|
- Compound sorting
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
1. [ ] Add image upload support
|
||||||
|
2. [ ] Implement location selection
|
||||||
|
3. [ ] Add preview functionality
|
||||||
|
4. [ ] Add duplicate detection
|
||||||
|
5. [ ] Implement draft saving
|
||||||
|
6. [ ] Add bulk operations
|
||||||
|
7. [ ] Add import/export functionality
|
||||||
|
8. [ ] Add map view option
|
||||||
|
9. [ ] Implement advanced search
|
||||||
|
10. [ ] Add comparison feature
|
||||||
|
11. [ ] Add area reordering
|
||||||
|
12. [ ] Implement area statistics
|
||||||
|
13. [ ] Add drag-and-drop sorting
|
||||||
|
14. [ ] Implement nested areas
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
1. Operators
|
||||||
|
- Selection in form
|
||||||
|
- Statistics updates
|
||||||
|
- Relationship maintenance
|
||||||
|
|
||||||
|
2. Slug History
|
||||||
|
- Automatic slug generation
|
||||||
|
- Historical slug tracking
|
||||||
|
- SEO-friendly URLs
|
||||||
|
|
||||||
|
3. Park Areas
|
||||||
|
- Nested management
|
||||||
|
- Area organization
|
||||||
|
- Statistics rollup
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
1. Authorization
|
||||||
|
- [ ] Implement role-based access
|
||||||
|
- [ ] Add ownership checks
|
||||||
|
- [ ] Audit logging
|
||||||
|
|
||||||
|
2. Validation
|
||||||
|
- Input sanitization
|
||||||
|
- CSRF protection
|
||||||
|
- Rate limiting
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
1. Unit Tests
|
||||||
|
- [ ] Validation rules
|
||||||
|
- [ ] Status transitions
|
||||||
|
- [ ] Statistics updates
|
||||||
|
|
||||||
|
2. Integration Tests
|
||||||
|
- [ ] Form submission
|
||||||
|
- [ ] Relationship updates
|
||||||
|
- [ ] Slug generation
|
||||||
|
|
||||||
|
3. Feature Tests
|
||||||
|
- [ ] Complete CRUD flow
|
||||||
|
- [ ] Authorization rules
|
||||||
|
- [ ] Edge cases
|
||||||
100
memory-bank/features/SlugHistorySystem.md
Normal file
100
memory-bank/features/SlugHistorySystem.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# Slug History System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The slug history system provides a way to track changes to model slugs over time, ensuring that old URLs continue to work even after slugs are updated. This is particularly important for maintaining SEO value and preventing broken links.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. HasSlugHistory Trait
|
||||||
|
Located in `app/Traits/HasSlugHistory.php`
|
||||||
|
|
||||||
|
Key Features:
|
||||||
|
- Automatic slug generation from name field
|
||||||
|
- Tracking of slug changes
|
||||||
|
- Historical slug lookup
|
||||||
|
- Handling of duplicate slugs
|
||||||
|
- Polymorphic relationship with SlugHistory model
|
||||||
|
|
||||||
|
### 2. SlugHistory Model
|
||||||
|
Located in `app/Models/SlugHistory.php`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
- Stores historical slugs for any model using HasSlugHistory trait
|
||||||
|
- Uses polymorphic relationships to link with parent models
|
||||||
|
- Maintains timestamps for tracking when slugs were used
|
||||||
|
|
||||||
|
## Database Structure
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE slug_histories (
|
||||||
|
id bigint unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
sluggable_type varchar(255) NOT NULL,
|
||||||
|
sluggable_id bigint unsigned NOT NULL,
|
||||||
|
slug varchar(255) NOT NULL,
|
||||||
|
created_at timestamp NULL DEFAULT NULL,
|
||||||
|
updated_at timestamp NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_sluggable (sluggable_type, slug)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Example
|
||||||
|
|
||||||
|
```php
|
||||||
|
class Operator extends Model
|
||||||
|
{
|
||||||
|
use HasSlugHistory;
|
||||||
|
|
||||||
|
// Model implementation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finding by current or historical slug
|
||||||
|
$operator = Operator::findBySlug('old-slug');
|
||||||
|
$operator = Operator::findBySlugOrFail('old-slug');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Current Implementation
|
||||||
|
|
||||||
|
Models using the system:
|
||||||
|
- Operator (park operators)
|
||||||
|
- Manufacturer (ride manufacturers)
|
||||||
|
- Park (to be implemented)
|
||||||
|
- ParkArea (to be implemented)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
1. Automatic Slug Generation
|
||||||
|
- Converts name to URL-friendly format
|
||||||
|
- Handles duplicates by appending numbers
|
||||||
|
- Triggered on model creation
|
||||||
|
|
||||||
|
2. History Tracking
|
||||||
|
- Saves old slugs when updated
|
||||||
|
- Maintains chronological order
|
||||||
|
- Links to original model via polymorphic relationship
|
||||||
|
|
||||||
|
3. Slug Lookup
|
||||||
|
- Checks current slugs first
|
||||||
|
- Falls back to historical slugs
|
||||||
|
- Maintains efficient indexes for quick lookups
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
1. SEO Friendly
|
||||||
|
- Maintains link equity
|
||||||
|
- Prevents 404 errors
|
||||||
|
- Supports URL structure changes
|
||||||
|
|
||||||
|
2. User Experience
|
||||||
|
- Old bookmarks continue to work
|
||||||
|
- Prevents broken links
|
||||||
|
- Transparent to end users
|
||||||
|
|
||||||
|
3. Performance
|
||||||
|
- Efficient database indexing
|
||||||
|
- Minimal overhead
|
||||||
|
- Cached lookups (to be implemented)
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
1. [ ] Add caching layer for frequent lookups
|
||||||
|
2. [ ] Implement automatic redirects with proper status codes
|
||||||
|
3. [ ] Add slug cleanup for old, unused slugs
|
||||||
|
4. [ ] Add analytics for tracking slug usage
|
||||||
230
memory-bank/features/StatisticsCaching.md
Normal file
230
memory-bank/features/StatisticsCaching.md
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
# Statistics Caching System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The Statistics Caching system provides efficient caching and retrieval of statistics across all levels of the theme park hierarchy. It implements a robust caching strategy with automatic invalidation, cache warming, and performance monitoring.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. Cache Structure
|
||||||
|
|
||||||
|
#### Key Prefixes
|
||||||
|
```php
|
||||||
|
protected const AREA_PREFIX = 'stats:area:';
|
||||||
|
protected const PARK_PREFIX = 'stats:park:';
|
||||||
|
protected const OPERATOR_PREFIX = 'stats:operator:';
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Cache TTL
|
||||||
|
```php
|
||||||
|
protected const CACHE_TTL = 86400; // 24 hours
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. StatisticsCacheService
|
||||||
|
Located in `app/Services/StatisticsCacheService.php`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
- Manage statistics caching
|
||||||
|
- Handle cache invalidation
|
||||||
|
- Provide cache warming
|
||||||
|
- Monitor cache performance
|
||||||
|
|
||||||
|
Features:
|
||||||
|
1. Caching Operations
|
||||||
|
- Area statistics
|
||||||
|
- Park rollups
|
||||||
|
- Operator aggregates
|
||||||
|
- Batch processing
|
||||||
|
|
||||||
|
2. Cache Management
|
||||||
|
- Automatic invalidation
|
||||||
|
- Selective updates
|
||||||
|
- Cache warming
|
||||||
|
- Error handling
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Area Statistics Cache
|
||||||
|
```php
|
||||||
|
[
|
||||||
|
'ride_distribution' => [
|
||||||
|
'coasters' => 5,
|
||||||
|
'flat_rides' => 12,
|
||||||
|
'water_rides' => 3,
|
||||||
|
],
|
||||||
|
'daily_capacity' => '25,000 riders/day',
|
||||||
|
'rating' => '★★★★☆ (4.2)',
|
||||||
|
'wait_time' => '45 minutes',
|
||||||
|
'historical' => [
|
||||||
|
'total_operated' => 25,
|
||||||
|
'retired_count' => 5,
|
||||||
|
'last_addition' => 'Mar 2024',
|
||||||
|
],
|
||||||
|
'updated_at' => '2024-02-23 19:30:00',
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Park Statistics Cache
|
||||||
|
```php
|
||||||
|
[
|
||||||
|
'area_distribution' => [
|
||||||
|
'total' => 8,
|
||||||
|
'operating' => 7,
|
||||||
|
'closed' => 1,
|
||||||
|
],
|
||||||
|
'ride_distribution' => [
|
||||||
|
'coasters' => 12,
|
||||||
|
'flat_rides' => 35,
|
||||||
|
'water_rides' => 8,
|
||||||
|
],
|
||||||
|
'daily_capacity' => '75,000 riders/day',
|
||||||
|
'rating' => '★★★★★ (4.8)',
|
||||||
|
'wait_time' => '35 minutes',
|
||||||
|
'historical' => [...],
|
||||||
|
'performance' => [
|
||||||
|
'utilization' => '85%',
|
||||||
|
'peak_attendance' => '65,000',
|
||||||
|
'satisfaction' => '4.5/5.0',
|
||||||
|
],
|
||||||
|
'updated_at' => '2024-02-23 19:30:00',
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Operator Statistics Cache
|
||||||
|
```php
|
||||||
|
[
|
||||||
|
'park_count' => 5,
|
||||||
|
'operating_parks' => 4,
|
||||||
|
'closed_parks' => 1,
|
||||||
|
'total_rides' => 275,
|
||||||
|
'total_coasters' => 45,
|
||||||
|
'average_rating' => 4.6,
|
||||||
|
'total_capacity' => 350000,
|
||||||
|
'updated_at' => '2024-02-23 19:30:00',
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cache Management
|
||||||
|
|
||||||
|
### 1. Invalidation Strategy
|
||||||
|
- Automatic invalidation on updates
|
||||||
|
- Cascading invalidation
|
||||||
|
- Selective cache clearing
|
||||||
|
- Error handling
|
||||||
|
|
||||||
|
### 2. Cache Warming
|
||||||
|
```php
|
||||||
|
public function warmCaches(): void
|
||||||
|
{
|
||||||
|
// Process areas in chunks
|
||||||
|
ParkArea::chunk(100, function ($areas) {
|
||||||
|
foreach ($areas as $area) {
|
||||||
|
$this->cacheAreaStatistics($area);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process parks in chunks
|
||||||
|
Park::chunk(100, function ($parks) {...});
|
||||||
|
|
||||||
|
// Process operators in chunks
|
||||||
|
Operator::chunk(100, function ($operators) {...});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Error Handling
|
||||||
|
```php
|
||||||
|
try {
|
||||||
|
Cache::put($key, $data, static::CACHE_TTL);
|
||||||
|
Log::info("Cached statistics for {$type} {$id}");
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error("Failed to cache statistics: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
|
||||||
|
### 1. Cache Design
|
||||||
|
- Efficient key structure
|
||||||
|
- Optimized data format
|
||||||
|
- Minimal cache churn
|
||||||
|
- Batch operations
|
||||||
|
|
||||||
|
### 2. Memory Usage
|
||||||
|
- Compact data storage
|
||||||
|
- Selective caching
|
||||||
|
- TTL management
|
||||||
|
- Cache size monitoring
|
||||||
|
|
||||||
|
### 3. Invalidation Rules
|
||||||
|
- Smart invalidation
|
||||||
|
- Dependency tracking
|
||||||
|
- Cascade control
|
||||||
|
- Version management
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
1. [ ] Add Redis support
|
||||||
|
2. [ ] Implement cache tags
|
||||||
|
3. [ ] Add cache versioning
|
||||||
|
4. [ ] Create cache analytics
|
||||||
|
5. [ ] Add cache preloading
|
||||||
|
6. [ ] Implement cache pruning
|
||||||
|
7. [ ] Add cache monitoring
|
||||||
|
8. [ ] Create cache dashboard
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
1. Statistics System
|
||||||
|
- Data aggregation
|
||||||
|
- Cache updates
|
||||||
|
- Performance metrics
|
||||||
|
|
||||||
|
2. Event System
|
||||||
|
- Cache invalidation
|
||||||
|
- Update triggers
|
||||||
|
- Error handling
|
||||||
|
|
||||||
|
3. Monitoring System
|
||||||
|
- Cache hit rates
|
||||||
|
- Performance tracking
|
||||||
|
- Error logging
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
1. Data Protection
|
||||||
|
- Cache encryption
|
||||||
|
- Access control
|
||||||
|
- Data validation
|
||||||
|
|
||||||
|
2. Error Handling
|
||||||
|
- Graceful degradation
|
||||||
|
- Fallback mechanisms
|
||||||
|
- Error logging
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
1. Unit Tests
|
||||||
|
- [ ] Cache operations
|
||||||
|
- [ ] Invalidation rules
|
||||||
|
- [ ] Error handling
|
||||||
|
|
||||||
|
2. Integration Tests
|
||||||
|
- [ ] Cache warming
|
||||||
|
- [ ] Update propagation
|
||||||
|
- [ ] Performance tests
|
||||||
|
|
||||||
|
3. Load Tests
|
||||||
|
- [ ] Cache hit rates
|
||||||
|
- [ ] Memory usage
|
||||||
|
- [ ] Concurrent access
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
1. Performance Metrics
|
||||||
|
- [ ] Cache hit rates
|
||||||
|
- [ ] Response times
|
||||||
|
- [ ] Memory usage
|
||||||
|
|
||||||
|
2. Error Tracking
|
||||||
|
- [ ] Failed operations
|
||||||
|
- [ ] Invalid data
|
||||||
|
- [ ] System alerts
|
||||||
|
|
||||||
|
3. Usage Analytics
|
||||||
|
- [ ] Access patterns
|
||||||
|
- [ ] Data freshness
|
||||||
|
- [ ] Cache efficiency
|
||||||
226
memory-bank/features/StatisticsRollup.md
Normal file
226
memory-bank/features/StatisticsRollup.md
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
# Statistics Rollup System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The Statistics Rollup system provides comprehensive tracking and aggregation of statistics across different levels of the theme park hierarchy: areas, parks, and operators. It ensures data consistency and provides real-time insights through automatic updates and scheduled refreshes.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. Database Structure
|
||||||
|
|
||||||
|
#### Park Areas Table Statistics
|
||||||
|
```sql
|
||||||
|
ALTER TABLE park_areas ADD (
|
||||||
|
ride_count integer DEFAULT 0,
|
||||||
|
coaster_count integer DEFAULT 0,
|
||||||
|
flat_ride_count integer DEFAULT 0,
|
||||||
|
water_ride_count integer DEFAULT 0,
|
||||||
|
daily_capacity integer NULL,
|
||||||
|
peak_wait_time integer NULL,
|
||||||
|
average_rating decimal(3,2) NULL,
|
||||||
|
total_rides_operated integer DEFAULT 0,
|
||||||
|
retired_rides_count integer DEFAULT 0,
|
||||||
|
last_new_ride_added date NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Parks Table Statistics
|
||||||
|
```sql
|
||||||
|
ALTER TABLE parks ADD (
|
||||||
|
total_areas integer DEFAULT 0,
|
||||||
|
operating_areas integer DEFAULT 0,
|
||||||
|
closed_areas integer DEFAULT 0,
|
||||||
|
total_rides integer DEFAULT 0,
|
||||||
|
total_coasters integer DEFAULT 0,
|
||||||
|
total_flat_rides integer DEFAULT 0,
|
||||||
|
total_water_rides integer DEFAULT 0,
|
||||||
|
total_daily_capacity integer DEFAULT 0,
|
||||||
|
average_wait_time integer NULL,
|
||||||
|
average_rating decimal(3,2) NULL,
|
||||||
|
total_rides_operated integer DEFAULT 0,
|
||||||
|
total_rides_retired integer DEFAULT 0,
|
||||||
|
last_expansion_date date NULL,
|
||||||
|
last_major_update date NULL,
|
||||||
|
utilization_rate decimal(5,2) NULL,
|
||||||
|
peak_daily_attendance integer NULL,
|
||||||
|
guest_satisfaction decimal(3,2) NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Traits
|
||||||
|
|
||||||
|
#### HasAreaStatistics
|
||||||
|
Located in `app/Traits/HasAreaStatistics.php`
|
||||||
|
- Ride count management
|
||||||
|
- Visitor statistics
|
||||||
|
- Historical tracking
|
||||||
|
- Formatted displays
|
||||||
|
|
||||||
|
#### HasParkStatistics
|
||||||
|
Located in `app/Traits/HasParkStatistics.php`
|
||||||
|
- Area statistics rollup
|
||||||
|
- Ride statistics aggregation
|
||||||
|
- Performance metrics
|
||||||
|
- Historical data tracking
|
||||||
|
|
||||||
|
### 3. StatisticsRollupService
|
||||||
|
Located in `app/Services/StatisticsRollupService.php`
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
- Coordinate statistics updates
|
||||||
|
- Maintain data consistency
|
||||||
|
- Handle events
|
||||||
|
- Schedule refreshes
|
||||||
|
|
||||||
|
Features:
|
||||||
|
1. Hierarchical Updates
|
||||||
|
- Bottom-up propagation
|
||||||
|
- Transaction safety
|
||||||
|
- Batch processing
|
||||||
|
- Event handling
|
||||||
|
|
||||||
|
2. Update Types
|
||||||
|
- Area statistics
|
||||||
|
- Park rollups
|
||||||
|
- Operator aggregates
|
||||||
|
- System-wide refresh
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Update Flow
|
||||||
|
1. Area Update
|
||||||
|
```php
|
||||||
|
public function updateAreaStatistics(ParkArea $area): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($area) {
|
||||||
|
$this->updateParkStatistics($area->park);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Park Rollup
|
||||||
|
```php
|
||||||
|
public function updateParkStatistics(Park $park): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($park) {
|
||||||
|
$park->updateAreaCounts();
|
||||||
|
$park->updateRideStatistics();
|
||||||
|
$park->updateVisitorStats();
|
||||||
|
// Update operator if exists
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Operator Aggregation
|
||||||
|
```php
|
||||||
|
public function updateOperatorStatistics(Operator $operator): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($operator) {
|
||||||
|
// Update park counts
|
||||||
|
// Update ride totals
|
||||||
|
// Update performance metrics
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Event Handling
|
||||||
|
|
||||||
|
### 1. Ride Events
|
||||||
|
- Addition tracking
|
||||||
|
- Retirement processing
|
||||||
|
- Statistics updates
|
||||||
|
|
||||||
|
### 2. Park Events
|
||||||
|
- Expansion recording
|
||||||
|
- Major updates
|
||||||
|
- Performance tracking
|
||||||
|
|
||||||
|
### 3. Area Events
|
||||||
|
- Opening/closing
|
||||||
|
- Status changes
|
||||||
|
- Capacity updates
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
|
||||||
|
### 1. Database Design
|
||||||
|
- Efficient indexes
|
||||||
|
- Compound keys
|
||||||
|
- Query optimization
|
||||||
|
|
||||||
|
### 2. Processing Strategy
|
||||||
|
- Batch updates
|
||||||
|
- Chunked processing
|
||||||
|
- Transaction management
|
||||||
|
|
||||||
|
### 3. Caching
|
||||||
|
- [ ] Implement statistics caching
|
||||||
|
- [ ] Add cache invalidation
|
||||||
|
- [ ] Set up cache warming
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
1. [ ] Add trend analysis
|
||||||
|
2. [ ] Implement forecasting
|
||||||
|
3. [ ] Add historical graphs
|
||||||
|
4. [ ] Create export tools
|
||||||
|
5. [ ] Add benchmarking
|
||||||
|
6. [ ] Implement alerts
|
||||||
|
7. [ ] Add reporting
|
||||||
|
8. [ ] Create dashboards
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
1. Areas System
|
||||||
|
- Statistics collection
|
||||||
|
- Event handling
|
||||||
|
- Data validation
|
||||||
|
|
||||||
|
2. Parks System
|
||||||
|
- Rollup processing
|
||||||
|
- Performance tracking
|
||||||
|
- Historical data
|
||||||
|
|
||||||
|
3. Operators System
|
||||||
|
- Aggregation logic
|
||||||
|
- Performance metrics
|
||||||
|
- Trend analysis
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
1. Data Validation
|
||||||
|
- Range checks
|
||||||
|
- Type validation
|
||||||
|
- Relationship verification
|
||||||
|
|
||||||
|
2. Access Control
|
||||||
|
- Update permissions
|
||||||
|
- View restrictions
|
||||||
|
- Audit logging
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
1. Unit Tests
|
||||||
|
- [ ] Calculation accuracy
|
||||||
|
- [ ] Event handling
|
||||||
|
- [ ] Data validation
|
||||||
|
|
||||||
|
2. Integration Tests
|
||||||
|
- [ ] Update propagation
|
||||||
|
- [ ] Transaction handling
|
||||||
|
- [ ] Event processing
|
||||||
|
|
||||||
|
3. Performance Tests
|
||||||
|
- [ ] Large dataset handling
|
||||||
|
- [ ] Concurrent updates
|
||||||
|
- [ ] Batch processing
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
1. Performance Metrics
|
||||||
|
- [ ] Update timing
|
||||||
|
- [ ] Query performance
|
||||||
|
- [ ] Cache hit rates
|
||||||
|
|
||||||
|
2. Error Tracking
|
||||||
|
- [ ] Failed updates
|
||||||
|
- [ ] Data inconsistencies
|
||||||
|
- [ ] System alerts
|
||||||
|
|
||||||
|
3. Usage Analytics
|
||||||
|
- [ ] Update frequency
|
||||||
|
- [ ] Data access patterns
|
||||||
|
- [ ] User interactions
|
||||||
82
memory-bank/models/CompanyModel.md
Normal file
82
memory-bank/models/CompanyModel.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# Operator Model Conversion
|
||||||
|
|
||||||
|
## Original Django Model Structure
|
||||||
|
|
||||||
|
### Company Model (Now Operator)
|
||||||
|
```python
|
||||||
|
class Company(TrackedModel):
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
slug = models.SlugField(max_length=255, unique=True)
|
||||||
|
website = models.URLField(blank=True)
|
||||||
|
headquarters = models.CharField(max_length=255, blank=True)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
total_parks = models.IntegerField(default=0)
|
||||||
|
total_rides = models.IntegerField(default=0)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manufacturer Model
|
||||||
|
```python
|
||||||
|
class Manufacturer(TrackedModel):
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
slug = models.SlugField(max_length=255, unique=True)
|
||||||
|
website = models.URLField(blank=True)
|
||||||
|
headquarters = models.CharField(max_length=255, blank=True)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
total_rides = models.IntegerField(default=0)
|
||||||
|
total_roller_coasters = models.IntegerField(default=0)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Laravel Implementation Plan
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
|
||||||
|
1. Create operators table:
|
||||||
|
```php
|
||||||
|
Schema::create('operators', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
$table->string('website')->nullable();
|
||||||
|
$table->string('headquarters')->nullable();
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->integer('total_parks')->default(0);
|
||||||
|
$table->integer('total_rides')->default(0);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create manufacturers table:
|
||||||
|
```php
|
||||||
|
Schema::create('manufacturers', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
$table->string('website')->nullable();
|
||||||
|
$table->string('headquarters')->nullable();
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->integer('total_rides')->default(0);
|
||||||
|
$table->integer('total_roller_coasters')->default(0);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Models
|
||||||
|
|
||||||
|
1. Operator Model:
|
||||||
|
- Implement Sluggable trait
|
||||||
|
- Add relationships (parks)
|
||||||
|
- Add statistics updating methods
|
||||||
|
- Add slug history functionality
|
||||||
|
|
||||||
|
2. Manufacturer Model:
|
||||||
|
- Implement Sluggable trait
|
||||||
|
- Add relationships (rides)
|
||||||
|
- Add statistics updating methods
|
||||||
|
- Add slug history functionality
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
1. [ ] Create operators table migration
|
||||||
|
2. [ ] Create manufacturers table migration
|
||||||
|
3. [ ] Create Operator model
|
||||||
|
4. [ ] Create Manufacturer model
|
||||||
|
5. [ ] Implement statistics update methods
|
||||||
104
memory-bank/models/LocationModel.md
Normal file
104
memory-bank/models/LocationModel.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# Location Model
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The Location model provides polymorphic location management for parks, areas, and other entities in ThrillWiki. It handles geocoding, coordinate management, and location-based search capabilities.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
### Database Table
|
||||||
|
- Table Name: `locations`
|
||||||
|
- Primary Key: `id` (bigint)
|
||||||
|
- Polymorphic Fields: `locatable_type`, `locatable_id`
|
||||||
|
- Timestamps: `created_at`, `updated_at`
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
- **Address Components**
|
||||||
|
- `address` (string, nullable) - Street address
|
||||||
|
- `city` (string) - City name
|
||||||
|
- `state` (string, nullable) - State/province
|
||||||
|
- `country` (string) - Country name
|
||||||
|
- `postal_code` (string, nullable) - Postal/ZIP code
|
||||||
|
|
||||||
|
- **Coordinates**
|
||||||
|
- `latitude` (decimal, 10,8) - Latitude coordinate
|
||||||
|
- `longitude` (decimal, 11,8) - Longitude coordinate
|
||||||
|
- `elevation` (decimal, 8,2, nullable) - Elevation in meters
|
||||||
|
|
||||||
|
- **Additional Details**
|
||||||
|
- `timezone` (string, nullable) - Location timezone
|
||||||
|
- `metadata` (json, nullable) - Additional location data
|
||||||
|
- `is_approximate` (boolean) - Indicates if location is approximate
|
||||||
|
- `source` (string, nullable) - Data source identifier
|
||||||
|
|
||||||
|
- **Geocoding**
|
||||||
|
- `geocoding_data` (json, nullable) - Cached geocoding response
|
||||||
|
- `geocoded_at` (timestamp, nullable) - Last geocoding timestamp
|
||||||
|
|
||||||
|
### Indexes
|
||||||
|
- Coordinates: `(latitude, longitude)`
|
||||||
|
- Location: `(country, state, city)`
|
||||||
|
- Postal: `postal_code`
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
### Polymorphic
|
||||||
|
- `locatable()` - Polymorphic relationship to parent model (Park, Area, etc.)
|
||||||
|
|
||||||
|
## Accessors & Mutators
|
||||||
|
- `coordinates` - Returns [lat, lng] array
|
||||||
|
- `formatted_address` - Returns formatted address string
|
||||||
|
- `map_url` - Returns Google Maps URL
|
||||||
|
|
||||||
|
## Methods
|
||||||
|
|
||||||
|
### Location Management
|
||||||
|
- `updateCoordinates(float $lat, float $lng)` - Update coordinates
|
||||||
|
- `setAddress(array $components)` - Set address components
|
||||||
|
- `geocode()` - Trigger geocoding refresh
|
||||||
|
- `reverseGeocode()` - Get address from coordinates
|
||||||
|
|
||||||
|
### Queries
|
||||||
|
- `scopeNearby($query, $lat, $lng, $radius)` - Find nearby locations
|
||||||
|
- `scopeInBounds($query, $ne, $sw)` - Find locations in bounds
|
||||||
|
- `scopeInCountry($query, $country)` - Filter by country
|
||||||
|
- `scopeInState($query, $state)` - Filter by state
|
||||||
|
- `scopeInCity($query, $city)` - Filter by city
|
||||||
|
|
||||||
|
### Calculations
|
||||||
|
- `distanceTo($lat, $lng)` - Calculate distance to point
|
||||||
|
- `bearingTo($lat, $lng)` - Calculate bearing to point
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Create location for park
|
||||||
|
$park->location()->create([
|
||||||
|
'address' => '123 Main St',
|
||||||
|
'city' => 'Orlando',
|
||||||
|
'state' => 'FL',
|
||||||
|
'country' => 'USA',
|
||||||
|
'latitude' => 28.538336,
|
||||||
|
'longitude' => -81.379234
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Find parks within 50km
|
||||||
|
$nearbyParks = Park::whereHas('location', function ($query) {
|
||||||
|
$query->nearby(28.538336, -81.379234, 50);
|
||||||
|
})->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
### Services
|
||||||
|
- GeocodeService - Address/coordinate lookup
|
||||||
|
- LocationSearchService - Advanced location search
|
||||||
|
|
||||||
|
### Components
|
||||||
|
- LocationSelector - Map-based location picker
|
||||||
|
- LocationDisplay - Location visualization
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Coordinates use high precision for accuracy
|
||||||
|
- Geocoding results are cached to reduce API calls
|
||||||
|
- Polymorphic design allows reuse across models
|
||||||
|
- Search methods use spatial indexes for performance
|
||||||
164
memory-bank/models/ParkModel.md
Normal file
164
memory-bank/models/ParkModel.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# Park Model Conversion
|
||||||
|
|
||||||
|
## Original Django Model Structure
|
||||||
|
|
||||||
|
### Park Model
|
||||||
|
```python
|
||||||
|
class Park(TrackedModel):
|
||||||
|
# Status choices
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
("OPERATING", "Operating"),
|
||||||
|
("CLOSED_TEMP", "Temporarily Closed"),
|
||||||
|
("CLOSED_PERM", "Permanently Closed"),
|
||||||
|
("UNDER_CONSTRUCTION", "Under Construction"),
|
||||||
|
("DEMOLISHED", "Demolished"),
|
||||||
|
("RELOCATED", "Relocated"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Basic info
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
slug = models.SlugField(max_length=255, unique=True)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="OPERATING")
|
||||||
|
|
||||||
|
# Location fields (GenericRelation)
|
||||||
|
location = GenericRelation(Location)
|
||||||
|
|
||||||
|
# Details
|
||||||
|
opening_date = models.DateField(null=True, blank=True)
|
||||||
|
closing_date = models.DateField(null=True, blank=True)
|
||||||
|
operating_season = models.CharField(max_length=255, blank=True)
|
||||||
|
size_acres = models.DecimalField(max_digits=10, decimal_places=2, null=True)
|
||||||
|
website = models.URLField(blank=True)
|
||||||
|
|
||||||
|
# Statistics
|
||||||
|
average_rating = models.DecimalField(max_digits=3, decimal_places=2, null=True)
|
||||||
|
ride_count = models.IntegerField(null=True)
|
||||||
|
coaster_count = models.IntegerField(null=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
operator = models.ForeignKey(Operator, SET_NULL, null=True, related_name="parks")
|
||||||
|
photos = GenericRelation(Photo)
|
||||||
|
```
|
||||||
|
|
||||||
|
### ParkArea Model
|
||||||
|
```python
|
||||||
|
class ParkArea(TrackedModel):
|
||||||
|
park = models.ForeignKey(Park, CASCADE, related_name="areas")
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
slug = models.SlugField(max_length=255)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
opening_date = models.DateField(null=True, blank=True)
|
||||||
|
closing_date = models.DateField(null=True, blank=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Laravel Implementation Plan
|
||||||
|
|
||||||
|
### Enums
|
||||||
|
1. Create ParkStatus enum with status options and color methods:
|
||||||
|
```php
|
||||||
|
enum ParkStatus: string {
|
||||||
|
case OPERATING = 'OPERATING';
|
||||||
|
case CLOSED_TEMP = 'CLOSED_TEMP';
|
||||||
|
case CLOSED_PERM = 'CLOSED_PERM';
|
||||||
|
case UNDER_CONSTRUCTION = 'UNDER_CONSTRUCTION';
|
||||||
|
case DEMOLISHED = 'DEMOLISHED';
|
||||||
|
case RELOCATED = 'RELOCATED';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
|
||||||
|
1. Create parks table:
|
||||||
|
```php
|
||||||
|
Schema::create('parks', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->string('status', 20);
|
||||||
|
|
||||||
|
// Details
|
||||||
|
$table->date('opening_date')->nullable();
|
||||||
|
$table->date('closing_date')->nullable();
|
||||||
|
$table->string('operating_season')->nullable();
|
||||||
|
$table->decimal('size_acres', 10, 2)->nullable();
|
||||||
|
$table->string('website')->nullable();
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
$table->decimal('average_rating', 3, 2)->nullable();
|
||||||
|
$table->integer('ride_count')->nullable();
|
||||||
|
$table->integer('coaster_count')->nullable();
|
||||||
|
|
||||||
|
// Foreign keys
|
||||||
|
$table->foreignId('operator_id')->nullable()->constrained('operators')->nullOnDelete();
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create park_areas table:
|
||||||
|
```php
|
||||||
|
Schema::create('park_areas', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('park_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('slug');
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->date('opening_date')->nullable();
|
||||||
|
$table->date('closing_date')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['park_id', 'slug']);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Models
|
||||||
|
|
||||||
|
1. Park Model:
|
||||||
|
- Implement Sluggable trait
|
||||||
|
- Add status color methods
|
||||||
|
- Set up relationships (operator, areas, photos, location)
|
||||||
|
- Add history tracking
|
||||||
|
- Implement slug history functionality
|
||||||
|
|
||||||
|
2. ParkArea Model:
|
||||||
|
- Implement Sluggable trait
|
||||||
|
- Set up relationship with Park
|
||||||
|
- Add history tracking
|
||||||
|
- Implement slug history functionality
|
||||||
|
|
||||||
|
### Livewire Components
|
||||||
|
|
||||||
|
1. ParkListComponent:
|
||||||
|
- Display parks with status badges
|
||||||
|
- Filter by status
|
||||||
|
- Sort functionality
|
||||||
|
- Search by name
|
||||||
|
|
||||||
|
2. ParkFormComponent:
|
||||||
|
- Create/edit park details
|
||||||
|
- Location selection
|
||||||
|
- Operator selection
|
||||||
|
- Status management
|
||||||
|
|
||||||
|
3. ParkAreaComponent:
|
||||||
|
- Manage park areas
|
||||||
|
- Add/edit/delete areas
|
||||||
|
- Sort/reorder areas
|
||||||
|
|
||||||
|
### Features to Implement
|
||||||
|
1. Slug history tracking
|
||||||
|
2. Location management
|
||||||
|
3. Photo management
|
||||||
|
4. Statistics calculation
|
||||||
|
5. Area management
|
||||||
|
6. Park status badges with colors
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
1. [ ] Create ParkStatus enum
|
||||||
|
2. [ ] Create parks table migration
|
||||||
|
3. [ ] Create park_areas table migration
|
||||||
|
4. [ ] Create Park model
|
||||||
|
5. [ ] Create ParkArea model
|
||||||
|
6. [ ] Implement Livewire components
|
||||||
122
memory-bank/models/UserModel.md
Normal file
122
memory-bank/models/UserModel.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# User Model Conversion
|
||||||
|
|
||||||
|
## Original Django Model Structure
|
||||||
|
|
||||||
|
### User Model (extends AbstractUser)
|
||||||
|
```python
|
||||||
|
class User(AbstractUser):
|
||||||
|
# Custom fields
|
||||||
|
user_id = models.CharField(max_length=10, unique=True, editable=False)
|
||||||
|
role = models.CharField(max_length=10, choices=['USER', 'MODERATOR', 'ADMIN', 'SUPERUSER'])
|
||||||
|
is_banned = models.BooleanField(default=False)
|
||||||
|
ban_reason = models.TextField(blank=True)
|
||||||
|
ban_date = models.DateTimeField(null=True, blank=True)
|
||||||
|
pending_email = models.EmailField(blank=True, null=True)
|
||||||
|
theme_preference = models.CharField(max_length=5, choices=['light', 'dark'])
|
||||||
|
```
|
||||||
|
|
||||||
|
### UserProfile Model
|
||||||
|
```python
|
||||||
|
class UserProfile:
|
||||||
|
profile_id = models.CharField(max_length=10, unique=True, editable=False)
|
||||||
|
user = models.OneToOneField(User, related_name='profile')
|
||||||
|
display_name = models.CharField(max_length=50, unique=True)
|
||||||
|
avatar = models.ImageField(upload_to='avatars/')
|
||||||
|
pronouns = models.CharField(max_length=50, blank=True)
|
||||||
|
bio = models.TextField(max_length=500, blank=True)
|
||||||
|
|
||||||
|
# Social media
|
||||||
|
twitter = models.URLField(blank=True)
|
||||||
|
instagram = models.URLField(blank=True)
|
||||||
|
youtube = models.URLField(blank=True)
|
||||||
|
discord = models.CharField(max_length=100, blank=True)
|
||||||
|
|
||||||
|
# Stats
|
||||||
|
coaster_credits = models.IntegerField(default=0)
|
||||||
|
dark_ride_credits = models.IntegerField(default=0)
|
||||||
|
flat_ride_credits = models.IntegerField(default=0)
|
||||||
|
water_ride_credits = models.IntegerField(default=0)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Laravel Implementation Plan
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
|
||||||
|
1. Extend users table (`database/migrations/[timestamp]_add_user_fields.php`):
|
||||||
|
```php
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('user_id', 10)->unique();
|
||||||
|
$table->enum('role', ['USER', 'MODERATOR', 'ADMIN', 'SUPERUSER'])->default('USER');
|
||||||
|
$table->boolean('is_banned')->default(false);
|
||||||
|
$table->text('ban_reason')->nullable();
|
||||||
|
$table->timestamp('ban_date')->nullable();
|
||||||
|
$table->string('pending_email')->nullable();
|
||||||
|
$table->enum('theme_preference', ['light', 'dark'])->default('light');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create profiles table (`database/migrations/[timestamp]_create_profiles_table.php`):
|
||||||
|
```php
|
||||||
|
Schema::create('profiles', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('profile_id', 10)->unique();
|
||||||
|
$table->foreignId('user_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->string('display_name', 50)->unique();
|
||||||
|
$table->string('avatar')->nullable();
|
||||||
|
$table->string('pronouns', 50)->nullable();
|
||||||
|
$table->text('bio')->nullable();
|
||||||
|
|
||||||
|
// Social media
|
||||||
|
$table->string('twitter')->nullable();
|
||||||
|
$table->string('instagram')->nullable();
|
||||||
|
$table->string('youtube')->nullable();
|
||||||
|
$table->string('discord', 100)->nullable();
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
$table->integer('coaster_credits')->default(0);
|
||||||
|
$table->integer('dark_ride_credits')->default(0);
|
||||||
|
$table->integer('flat_ride_credits')->default(0);
|
||||||
|
$table->integer('water_ride_credits')->default(0);
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model Implementation
|
||||||
|
|
||||||
|
1. User Model (`app/Models/User.php`):
|
||||||
|
- Extend Laravel's base User model
|
||||||
|
- Add custom attributes
|
||||||
|
- Add relationship to Profile
|
||||||
|
- Add role management methods
|
||||||
|
- Add ban management methods
|
||||||
|
|
||||||
|
2. Profile Model (`app/Models/Profile.php`):
|
||||||
|
- Create new model
|
||||||
|
- Add relationship to User
|
||||||
|
- Add avatar handling methods
|
||||||
|
- Add credit management methods
|
||||||
|
|
||||||
|
### Livewire Components
|
||||||
|
1. ProfileComponent - Handle profile management
|
||||||
|
2. AvatarUploadComponent - Handle avatar uploads
|
||||||
|
3. UserSettingsComponent - Handle user settings/preferences
|
||||||
|
4. UserBanComponent - For moderator use to handle bans
|
||||||
|
|
||||||
|
### Services
|
||||||
|
1. UserService - Business logic for user management
|
||||||
|
2. ProfileService - Business logic for profile management
|
||||||
|
3. AvatarService - Handle avatar generation and storage
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
1. [ ] Create user fields migration
|
||||||
|
2. [ ] Create profiles table migration
|
||||||
|
3. [ ] Enhance User model with new fields and methods
|
||||||
|
4. [ ] Create Profile model
|
||||||
|
5. [ ] Implement initial Livewire components for profile management
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- Will use Laravel's built-in authentication (already scaffolded)
|
||||||
|
- Email verification will be handled by Laravel's built-in features
|
||||||
|
- Password reset functionality will use Laravel's default implementation
|
||||||
|
- Will implement custom avatar generation similar to Django version
|
||||||
50
memory-bank/productContext.md
Normal file
50
memory-bank/productContext.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# ThrillWiki Laravel+Livewire Conversion
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
ThrillWiki is being converted from a Django application to a Laravel application using Livewire for dynamic frontend functionality. The original Django project contains several key modules:
|
||||||
|
|
||||||
|
- Accounts (User management)
|
||||||
|
- Analytics
|
||||||
|
- Companies
|
||||||
|
- Core
|
||||||
|
- Designers
|
||||||
|
- Email Service
|
||||||
|
- History/History Tracking
|
||||||
|
- Location
|
||||||
|
- Media
|
||||||
|
- Moderation
|
||||||
|
- Parks
|
||||||
|
- Reviews
|
||||||
|
- Rides
|
||||||
|
- Search
|
||||||
|
- Wiki
|
||||||
|
|
||||||
|
## Technology Stack Transition
|
||||||
|
- From: Django (Python) with server-side templates
|
||||||
|
- To: Laravel (PHP) with Livewire for reactive components
|
||||||
|
|
||||||
|
## Core Features to Convert
|
||||||
|
1. User authentication and management
|
||||||
|
2. Park and ride management
|
||||||
|
3. Review system
|
||||||
|
4. Media handling
|
||||||
|
5. Search functionality
|
||||||
|
6. History tracking
|
||||||
|
7. Location services
|
||||||
|
8. Company management
|
||||||
|
9. Moderation tools
|
||||||
|
10. Analytics
|
||||||
|
|
||||||
|
## Why Laravel + Livewire?
|
||||||
|
- Maintains server-side rendering approach
|
||||||
|
- Provides reactive UI components without full JavaScript framework
|
||||||
|
- Rich ecosystem for PHP development
|
||||||
|
- Simpler deployment model compared to SPA
|
||||||
|
- Built-in authentication and authorization
|
||||||
|
|
||||||
|
## Project Goals
|
||||||
|
1. Feature parity with Django version
|
||||||
|
2. Improved performance
|
||||||
|
3. Maintainable codebase
|
||||||
|
4. Progressive enhancement
|
||||||
|
5. Mobile-friendly interface
|
||||||
50
memory-bank/prompts/ContinuationCommand.md
Normal file
50
memory-bank/prompts/ContinuationCommand.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# ThrillWiki Development Continuation Command
|
||||||
|
|
||||||
|
Use this command to continue development in a new chat:
|
||||||
|
|
||||||
|
```
|
||||||
|
Continue ThrillWiki Laravel+Livewire development, focusing on the Location System implementation. The project is at /Users/talor/ThrillWiki/laravel.
|
||||||
|
|
||||||
|
Key Memory Bank files to review:
|
||||||
|
1. memory-bank/activeContext.md - Current progress and next steps
|
||||||
|
2. memory-bank/features/LocationSystem.md - System design and implementation plan
|
||||||
|
3. memory-bank/features/StatisticsRollup.md - Statistics integration points
|
||||||
|
4. memory-bank/features/StatisticsCaching.md - Caching strategy
|
||||||
|
|
||||||
|
Current progress:
|
||||||
|
- Documented Location System design
|
||||||
|
- Created locations table migration
|
||||||
|
- Set up Memory Bank documentation
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
1. Create Location model with polymorphic relationships
|
||||||
|
2. Implement HasLocation trait
|
||||||
|
3. Develop GeocodeService
|
||||||
|
4. Build LocationSearchService
|
||||||
|
5. Create Livewire components
|
||||||
|
|
||||||
|
Follow Memory Bank documentation practices:
|
||||||
|
- Prefix all tool use with [MEMORY BANK: ACTIVE]
|
||||||
|
- Document before implementing
|
||||||
|
- Update activeContext.md after each step
|
||||||
|
- Create feature documentation first
|
||||||
|
- Document technical decisions
|
||||||
|
|
||||||
|
The project uses:
|
||||||
|
- Laravel for backend
|
||||||
|
- Livewire for components
|
||||||
|
- MySQL for database
|
||||||
|
- Memory Bank for documentation
|
||||||
|
|
||||||
|
Continue implementation following the established patterns and maintaining comprehensive documentation.
|
||||||
|
```
|
||||||
|
|
||||||
|
This command provides:
|
||||||
|
1. Project context
|
||||||
|
2. Memory Bank locations
|
||||||
|
3. Current progress
|
||||||
|
4. Next steps
|
||||||
|
5. Development practices
|
||||||
|
6. Technical stack
|
||||||
|
|
||||||
|
Use this to ensure continuity and maintain our documentation-first approach in the next development session.
|
||||||
69
memory-bank/prompts/LocationSystemContinuation.md
Normal file
69
memory-bank/prompts/LocationSystemContinuation.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# ThrillWiki Development Continuation Prompt
|
||||||
|
|
||||||
|
Continue the development of ThrillWiki's Location System implementation. The project is a Laravel+Livewire application for managing theme parks, currently being converted from Django.
|
||||||
|
|
||||||
|
## Current Progress
|
||||||
|
|
||||||
|
We have:
|
||||||
|
1. Documented the Location System design in `memory-bank/features/LocationSystem.md`
|
||||||
|
2. Created the locations table migration in `database/migrations/2024_02_23_235000_create_locations_table.php`
|
||||||
|
|
||||||
|
## Next Implementation Steps
|
||||||
|
|
||||||
|
1. Create the Location model with:
|
||||||
|
- Polymorphic relationships
|
||||||
|
- Coordinate handling
|
||||||
|
- Geocoding integration
|
||||||
|
- Distance calculations
|
||||||
|
|
||||||
|
2. Implement the HasLocation trait for:
|
||||||
|
- Location relationships
|
||||||
|
- Coordinate accessors
|
||||||
|
- Distance methods
|
||||||
|
- Map integration
|
||||||
|
|
||||||
|
3. Create the GeocodeService for:
|
||||||
|
- Address lookup
|
||||||
|
- Coordinate validation
|
||||||
|
- Batch processing
|
||||||
|
- Cache management
|
||||||
|
|
||||||
|
4. Implement the LocationSearchService for:
|
||||||
|
- Distance-based search
|
||||||
|
- Boundary queries
|
||||||
|
- Clustering support
|
||||||
|
- Performance optimization
|
||||||
|
|
||||||
|
5. Create Livewire components for:
|
||||||
|
- Location selection
|
||||||
|
- Map integration
|
||||||
|
- Address search
|
||||||
|
- Coordinate picking
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
Key files and directories:
|
||||||
|
- `memory-bank/features/LocationSystem.md` - System documentation
|
||||||
|
- `app/Models/` - Model implementations
|
||||||
|
- `app/Traits/` - Shared traits
|
||||||
|
- `app/Services/` - Service classes
|
||||||
|
- `app/Livewire/` - Livewire components
|
||||||
|
- `resources/views/livewire/` - Component views
|
||||||
|
|
||||||
|
## Development Context
|
||||||
|
|
||||||
|
The system uses:
|
||||||
|
- Laravel for backend
|
||||||
|
- Livewire for components
|
||||||
|
- MySQL for database
|
||||||
|
- Memory Bank for documentation
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Create the Location model
|
||||||
|
2. Implement HasLocation trait
|
||||||
|
3. Develop geocoding service
|
||||||
|
4. Build search functionality
|
||||||
|
5. Create Livewire components
|
||||||
|
|
||||||
|
Please continue implementing these features following the established patterns and maintaining comprehensive documentation in the Memory Bank.
|
||||||
68
memory-bank/prompts/MemoryBankInstructions.md
Normal file
68
memory-bank/prompts/MemoryBankInstructions.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Memory Bank Access Instructions
|
||||||
|
|
||||||
|
To continue development with full context, please review these key Memory Bank files in order:
|
||||||
|
|
||||||
|
1. `memory-bank/activeContext.md`
|
||||||
|
- Current development phase
|
||||||
|
- Progress tracking
|
||||||
|
- Next steps
|
||||||
|
- Technical decisions
|
||||||
|
- Issues to address
|
||||||
|
|
||||||
|
2. `memory-bank/features/LocationSystem.md`
|
||||||
|
- System design
|
||||||
|
- Component structure
|
||||||
|
- Implementation details
|
||||||
|
- Integration points
|
||||||
|
- Future enhancements
|
||||||
|
|
||||||
|
3. `memory-bank/features/StatisticsRollup.md`
|
||||||
|
- Statistics system design
|
||||||
|
- Integration points
|
||||||
|
- Performance considerations
|
||||||
|
|
||||||
|
4. `memory-bank/features/StatisticsCaching.md`
|
||||||
|
- Caching strategy
|
||||||
|
- Performance optimization
|
||||||
|
- Integration points
|
||||||
|
|
||||||
|
## Development Process
|
||||||
|
|
||||||
|
1. Always prefix tool use with `[MEMORY BANK: ACTIVE]`
|
||||||
|
2. Document changes in Memory Bank before implementation
|
||||||
|
3. Update `activeContext.md` after each major step
|
||||||
|
4. Create feature documentation before implementation
|
||||||
|
5. Document technical decisions and their rationale
|
||||||
|
|
||||||
|
## Next Development Session
|
||||||
|
|
||||||
|
1. Review `memory-bank/prompts/LocationSystemContinuation.md`
|
||||||
|
2. Check `activeContext.md` for current status
|
||||||
|
3. Implement next steps following documented design
|
||||||
|
4. Maintain Memory Bank documentation
|
||||||
|
5. Update progress tracking
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
The following files contain essential context:
|
||||||
|
|
||||||
|
```
|
||||||
|
memory-bank/
|
||||||
|
├── activeContext.md # Current state and next steps
|
||||||
|
├── features/
|
||||||
|
│ ├── LocationSystem.md # Location system design
|
||||||
|
│ ├── StatisticsRollup.md # Statistics system design
|
||||||
|
│ └── StatisticsCaching.md # Caching implementation
|
||||||
|
└── prompts/
|
||||||
|
└── LocationSystemContinuation.md # Next steps
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Command
|
||||||
|
|
||||||
|
To continue development in a new chat, use:
|
||||||
|
|
||||||
|
```
|
||||||
|
Continue ThrillWiki Laravel+Livewire development, implementing the Location System as documented in memory-bank/features/LocationSystem.md. Current progress and next steps are in memory-bank/prompts/LocationSystemContinuation.md. Follow Memory Bank documentation practices from memory-bank/prompts/MemoryBankInstructions.md.
|
||||||
|
```
|
||||||
|
|
||||||
|
This will ensure continuity and maintain our documentation-first approach.
|
||||||
108
resources/views/livewire/area-statistics-component.blade.php
Normal file
108
resources/views/livewire/area-statistics-component.blade.php
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl overflow-hidden">
|
||||||
|
<!-- Basic Statistics -->
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Area Statistics</h3>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button wire:click="toggleDetails" class="text-sm text-indigo-600 hover:text-indigo-900">
|
||||||
|
{{ $showDetails ? 'Hide Details' : 'Show Details' }}
|
||||||
|
</button>
|
||||||
|
<button wire:click="toggleHistorical" class="text-sm text-indigo-600 hover:text-indigo-900">
|
||||||
|
{{ $showHistorical ? 'Hide Historical' : 'Show Historical' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ride Counts -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 sm:grid-cols-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-gray-500">Total Rides</span>
|
||||||
|
<p class="mt-1 text-2xl font-semibold text-gray-900">{{ $area->ride_count }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-gray-500">Coasters</span>
|
||||||
|
<p class="mt-1 text-2xl font-semibold text-gray-900">{{ $area->coaster_count }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-gray-500">Rating</span>
|
||||||
|
<p class="mt-1 text-2xl font-semibold text-gray-900">{{ $area->rating_display }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-gray-500">Daily Capacity</span>
|
||||||
|
<p class="mt-1 text-2xl font-semibold text-gray-900">{{ $area->formatted_daily_capacity }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detailed Statistics -->
|
||||||
|
@if($showDetails)
|
||||||
|
<div class="border-t border-gray-200 pt-6">
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 mb-4">Ride Distribution</h4>
|
||||||
|
|
||||||
|
<!-- Distribution Chart -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="text-sm font-medium text-gray-500 w-24">Coasters</span>
|
||||||
|
<div class="flex-1 h-4 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div class="h-full bg-indigo-600 rounded-full" style="width: {{ $ridePercentages['coasters'] }}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="ml-2 text-sm text-gray-500">{{ $ridePercentages['coasters'] }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="text-sm font-medium text-gray-500 w-24">Flat Rides</span>
|
||||||
|
<div class="flex-1 h-4 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div class="h-full bg-blue-600 rounded-full" style="width: {{ $ridePercentages['flat_rides'] }}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="ml-2 text-sm text-gray-500">{{ $ridePercentages['flat_rides'] }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="text-sm font-medium text-gray-500 w-24">Water Rides</span>
|
||||||
|
<div class="flex-1 h-4 bg-gray-100 rounded-full overflow-hidden">
|
||||||
|
<div class="h-full bg-cyan-600 rounded-full" style="width: {{ $ridePercentages['water_rides'] }}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="ml-2 text-sm text-gray-500">{{ $ridePercentages['water_rides'] }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Additional Details -->
|
||||||
|
<div class="mt-6 grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-gray-500">Peak Wait Time</span>
|
||||||
|
<p class="mt-1 text-lg font-medium text-gray-900">{{ $area->formatted_peak_wait_time }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-gray-500">Operating Status</span>
|
||||||
|
<p class="mt-1 text-lg font-medium {{ $area->isOperating() ? 'text-green-600' : 'text-red-600' }}">
|
||||||
|
{{ $area->isOperating() ? 'Operating' : 'Closed' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Historical Statistics -->
|
||||||
|
@if($showHistorical)
|
||||||
|
<div class="border-t border-gray-200 pt-6">
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 mb-4">Historical Data</h4>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-gray-500">Total Rides Operated</span>
|
||||||
|
<p class="mt-1 text-lg font-medium text-gray-900">{{ $historicalStats['total_operated'] }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-gray-500">Retired Rides</span>
|
||||||
|
<p class="mt-1 text-lg font-medium text-gray-900">{{ $historicalStats['retired_count'] }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-gray-500">Last New Ride</span>
|
||||||
|
<p class="mt-1 text-lg font-medium text-gray-900">{{ $historicalStats['last_addition'] }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-gray-500">Retirement Rate</span>
|
||||||
|
<p class="mt-1 text-lg font-medium text-gray-900">{{ $historicalStats['retirement_rate'] }}%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
82
resources/views/livewire/park-area-form-component.blade.php
Normal file
82
resources/views/livewire/park-area-form-component.blade.php
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<div class="max-w-2xl mx-auto p-4 sm:p-6 lg:p-8">
|
||||||
|
<form wire:submit="save" class="space-y-6">
|
||||||
|
@if (session()->has('message'))
|
||||||
|
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative" role="alert">
|
||||||
|
{{ session('message') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl p-6">
|
||||||
|
<!-- Basic Information -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Area Information</h3>
|
||||||
|
<span class="text-sm text-gray-500">Part of {{ $park->name }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="name" class="block text-sm font-medium text-gray-700">Area Name</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input type="text" wire:model="name" id="name"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
placeholder="Enter area name">
|
||||||
|
</div>
|
||||||
|
@error('name') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="description" class="block text-sm font-medium text-gray-700">Description</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<textarea wire:model="description" id="description" rows="4"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
placeholder="Describe this area"></textarea>
|
||||||
|
</div>
|
||||||
|
@error('description') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl p-6">
|
||||||
|
<!-- Dates -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Opening and Closing Dates</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="opening_date" class="block text-sm font-medium text-gray-700">Opening Date</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input type="date" wire:model="opening_date" id="opening_date"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||||
|
</div>
|
||||||
|
@error('opening_date') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="closing_date" class="block text-sm font-medium text-gray-700">Closing Date</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input type="date" wire:model="closing_date" id="closing_date"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||||
|
</div>
|
||||||
|
@error('closing_date') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
<p>Leave closing date empty if the area is still operating.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-4">
|
||||||
|
<a href="{{ route('parks.show', $park) }}"
|
||||||
|
class="inline-flex justify-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
|
||||||
|
{{ $isEditing ? 'Update Area' : 'Create Area' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
109
resources/views/livewire/park-area-list-component.blade.php
Normal file
109
resources/views/livewire/park-area-list-component.blade.php
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900">Areas in {{ $park->name }}</h2>
|
||||||
|
<a href="{{ route('parks.areas.create', $park) }}"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
|
<svg class="-ml-1 mr-2 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Add Area
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters and Search -->
|
||||||
|
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl p-4 mb-6">
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<!-- Search -->
|
||||||
|
<div>
|
||||||
|
<label for="search" class="block text-sm font-medium text-gray-700">Search</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input type="text" wire:model.live.debounce.300ms="search" id="search"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
placeholder="Search areas...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sort -->
|
||||||
|
<div>
|
||||||
|
<label for="sort" class="block text-sm font-medium text-gray-700">Sort By</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<select wire:model.live="sort" id="sort"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||||
|
@foreach($sortOptions as $value => $label)
|
||||||
|
<option value="{{ $value }}">{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Show Closed Toggle -->
|
||||||
|
<div class="flex items-center mt-6">
|
||||||
|
<label for="showClosed" class="inline-flex relative items-center cursor-pointer">
|
||||||
|
<input type="checkbox" wire:model.live="showClosed" id="showClosed" class="sr-only peer">
|
||||||
|
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-indigo-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600"></div>
|
||||||
|
<span class="ml-3 text-sm font-medium text-gray-700">Show Closed Areas</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Areas List -->
|
||||||
|
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl overflow-hidden">
|
||||||
|
@if($areas->isEmpty())
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">No areas found</h3>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">Try adjusting your search or add a new area.</p>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<ul role="list" class="divide-y divide-gray-200">
|
||||||
|
@foreach($areas as $area)
|
||||||
|
<li class="p-4 sm:p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 truncate">
|
||||||
|
<a href="{{ route('parks.areas.show', ['park' => $park, 'area' => $area]) }}" class="hover:text-indigo-600">
|
||||||
|
{{ $area->name }}
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
@if($area->description)
|
||||||
|
<p class="mt-1 text-sm text-gray-500">{{ $area->brief_description }}</p>
|
||||||
|
@endif
|
||||||
|
<div class="mt-2 flex items-center text-sm text-gray-500 space-x-4">
|
||||||
|
@if($area->opening_date)
|
||||||
|
<div>
|
||||||
|
<svg class="flex-shrink-0 mr-1.5 h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
Opened: {{ $area->opening_date->format('M Y') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if($area->closing_date)
|
||||||
|
<div class="text-red-600">
|
||||||
|
Closed: {{ $area->closing_date->format('M Y') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-shrink-0 flex items-center space-x-3">
|
||||||
|
<a href="{{ route('parks.areas.edit', ['park' => $park, 'area' => $area]) }}"
|
||||||
|
class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
<button wire:click="deleteArea({{ $area->id }})" wire:confirm="Are you sure you want to delete this area?"
|
||||||
|
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="mt-6">
|
||||||
|
{{ $areas->links() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
127
resources/views/livewire/park-area-reorder-component.blade.php
Normal file
127
resources/views/livewire/park-area-reorder-component.blade.php
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900">
|
||||||
|
@if($parentArea)
|
||||||
|
Areas in {{ $parentArea->name }}
|
||||||
|
<a href="{{ route('parks.areas.reorder', ['park' => $park]) }}" class="text-sm text-indigo-600 hover:text-indigo-900">
|
||||||
|
(Back to Top Level)
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
|
Areas in {{ $park->name }}
|
||||||
|
@endif
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Drag and drop to reorder areas or move them between levels.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Areas List -->
|
||||||
|
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl overflow-hidden">
|
||||||
|
@if(empty($areas))
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">No areas found</h3>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">
|
||||||
|
@if($parentArea)
|
||||||
|
This area doesn't have any sub-areas yet.
|
||||||
|
@else
|
||||||
|
This park doesn't have any areas yet.
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<ul id="sortable-areas" class="divide-y divide-gray-200" wire:sortable wire:end.stop="reorder($event.target.sortable.toArray())">
|
||||||
|
@foreach($areas as $area)
|
||||||
|
<li wire:key="area-{{ $area['id'] }}" wire:sortable.item="{{ $area['id'] }}" data-id="{{ $area['id'] }}"
|
||||||
|
class="group p-4 sm:p-6 hover:bg-gray-50 cursor-move">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<!-- Drag Handle -->
|
||||||
|
<div wire:sortable.handle class="cursor-grab">
|
||||||
|
<svg class="h-5 w-5 text-gray-400 group-hover:text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Area Name -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 truncate">
|
||||||
|
{{ $area['name'] }}
|
||||||
|
@if($area['is_closed'])
|
||||||
|
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||||
|
Closed
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<!-- Sub-Areas Link -->
|
||||||
|
@if($area['has_children'])
|
||||||
|
<a href="{{ route('parks.areas.reorder', ['park' => $park, 'parentId' => $area['id']]) }}"
|
||||||
|
class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
|
<svg class="-ml-0.5 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
Sub-Areas
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<!-- Move Actions -->
|
||||||
|
<div class="relative" x-data="{ open: false }">
|
||||||
|
<button @click="open = !open" type="button"
|
||||||
|
class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
|
Move To
|
||||||
|
<svg class="ml-2 -mr-0.5 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div x-show="open" @click.away="open = false"
|
||||||
|
class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 divide-y divide-gray-100 focus:outline-none"
|
||||||
|
role="menu" aria-orientation="vertical" aria-labelledby="move-button-{{ $area['id'] }}">
|
||||||
|
<div class="py-1" role="none">
|
||||||
|
@if($parentArea)
|
||||||
|
<button wire:click="moveToParent({{ $area['id'] }}, null)" class="text-gray-700 group flex items-center px-4 py-2 text-sm w-full hover:bg-gray-100" role="menuitem">
|
||||||
|
Move to Top Level
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
@foreach($areas as $targetArea)
|
||||||
|
@if($targetArea['id'] !== $area['id'] && !$targetArea['is_closed'])
|
||||||
|
<button wire:click="moveToParent({{ $area['id'] }}, {{ $targetArea['id'] }})" class="text-gray-700 group flex items-center px-4 py-2 text-sm w-full hover:bg-gray-100" role="menuitem">
|
||||||
|
Move to {{ $targetArea['name'] }}
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('livewire:init', () => {
|
||||||
|
let sortable = new Sortable(document.getElementById('sortable-areas'), {
|
||||||
|
animation: 150,
|
||||||
|
handle: '[wire\\:sortable\\.handle]',
|
||||||
|
draggable: '[wire\\:sortable\\.item]',
|
||||||
|
onEnd: function(evt) {
|
||||||
|
const items = Array.from(evt.to.children).map(item => item.dataset.id);
|
||||||
|
evt.to.dispatchEvent(new CustomEvent('end.stop', {
|
||||||
|
detail: { items },
|
||||||
|
bubbles: true,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
143
resources/views/livewire/park-form-component.blade.php
Normal file
143
resources/views/livewire/park-form-component.blade.php
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<div class="max-w-4xl mx-auto p-4 sm:p-6 lg:p-8">
|
||||||
|
<form wire:submit="save" class="space-y-6">
|
||||||
|
@if (session()->has('message'))
|
||||||
|
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative" role="alert">
|
||||||
|
{{ session('message') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl p-6">
|
||||||
|
<!-- Basic Information -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Basic Information</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="name" class="block text-sm font-medium text-gray-700">Park Name</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input type="text" wire:model="name" id="name"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
placeholder="Enter park name">
|
||||||
|
</div>
|
||||||
|
@error('name') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="operator_id" class="block text-sm font-medium text-gray-700">Operator</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<select wire:model="operator_id" id="operator_id"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||||
|
<option value="">Select an operator</option>
|
||||||
|
@foreach($operators as $operator)
|
||||||
|
<option value="{{ $operator['id'] }}">{{ $operator['name'] }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
@error('operator_id') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="description" class="block text-sm font-medium text-gray-700">Description</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<textarea wire:model="description" id="description" rows="4"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
placeholder="Enter park description"></textarea>
|
||||||
|
</div>
|
||||||
|
@error('description') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl p-6">
|
||||||
|
<!-- Status and Dates -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Status and Dates</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<label for="status" class="block text-sm font-medium text-gray-700">Status</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<select wire:model="status" id="status"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||||
|
@foreach($statusOptions as $value => $label)
|
||||||
|
<option value="{{ $value }}">{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
@error('status') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="opening_date" class="block text-sm font-medium text-gray-700">Opening Date</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input type="date" wire:model="opening_date" id="opening_date"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||||
|
</div>
|
||||||
|
@error('opening_date') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="closing_date" class="block text-sm font-medium text-gray-700">Closing Date</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input type="date" wire:model="closing_date" id="closing_date"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||||
|
</div>
|
||||||
|
@error('closing_date') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl p-6">
|
||||||
|
<!-- Additional Details -->
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Additional Details</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<label for="operating_season" class="block text-sm font-medium text-gray-700">Operating Season</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input type="text" wire:model="operating_season" id="operating_season"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
placeholder="e.g., March - November">
|
||||||
|
</div>
|
||||||
|
@error('operating_season') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="size_acres" class="block text-sm font-medium text-gray-700">Size (Acres)</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input type="number" step="0.01" wire:model="size_acres" id="size_acres"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
placeholder="e.g., 100.5">
|
||||||
|
</div>
|
||||||
|
@error('size_acres') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="website" class="block text-sm font-medium text-gray-700">Website</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input type="url" wire:model="website" id="website"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
placeholder="https://example.com">
|
||||||
|
</div>
|
||||||
|
@error('website') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-4">
|
||||||
|
<a href="{{ route('parks.index') }}"
|
||||||
|
class="inline-flex justify-center rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
|
||||||
|
{{ $park ? 'Update Park' : 'Create Park' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
136
resources/views/livewire/park-list-component.blade.php
Normal file
136
resources/views/livewire/park-list-component.blade.php
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<!-- Filters and Search -->
|
||||||
|
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl p-4 mb-6">
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<!-- Search -->
|
||||||
|
<div>
|
||||||
|
<label for="search" class="block text-sm font-medium text-gray-700">Search</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input type="text" wire:model.live.debounce.300ms="search" id="search"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
placeholder="Search parks...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Filter -->
|
||||||
|
<div>
|
||||||
|
<label for="status" class="block text-sm font-medium text-gray-700">Status</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<select wire:model.live="status" id="status"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||||
|
@foreach($statusOptions as $value => $label)
|
||||||
|
<option value="{{ $value }}">{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Operator Filter -->
|
||||||
|
<div>
|
||||||
|
<label for="operator" class="block text-sm font-medium text-gray-700">Operator</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<select wire:model.live="operator" id="operator"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||||
|
@foreach($operatorOptions as $id => $name)
|
||||||
|
<option value="{{ $id }}">{{ $name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sort -->
|
||||||
|
<div>
|
||||||
|
<label for="sort" class="block text-sm font-medium text-gray-700">Sort By</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<select wire:model.live="sort" id="sort"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||||
|
@foreach($sortOptions as $value => $label)
|
||||||
|
<option value="{{ $value }}">{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Parks Grid -->
|
||||||
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
@forelse($parks as $park)
|
||||||
|
<div class="bg-white shadow-sm ring-1 ring-gray-900/5 rounded-xl overflow-hidden">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">
|
||||||
|
<a href="{{ route('parks.show', $park) }}" class="hover:text-indigo-600">
|
||||||
|
{{ $park->name }}
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $park->status_classes }}">
|
||||||
|
{{ $park->status->label() }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm text-gray-500 mb-4">
|
||||||
|
{{ $park->brief_description }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Operator:</span>
|
||||||
|
<span class="text-gray-900">{{ $park->operator?->name ?? 'Unknown' }}</span>
|
||||||
|
</div>
|
||||||
|
@if($park->opening_year)
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Opened:</span>
|
||||||
|
<span class="text-gray-900">{{ $park->opening_year }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if($park->size_acres)
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Size:</span>
|
||||||
|
<span class="text-gray-900">{{ $park->size_display }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Rides:</span>
|
||||||
|
<span class="text-gray-900">{{ $park->ride_count ?? 0 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex justify-end space-x-3">
|
||||||
|
@if($park->website)
|
||||||
|
<a href="{{ $park->website_url }}" target="_blank" rel="noopener"
|
||||||
|
class="text-sm text-gray-500 hover:text-gray-900">
|
||||||
|
Visit Website
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
<a href="{{ route('parks.edit', $park) }}"
|
||||||
|
class="text-sm text-indigo-600 hover:text-indigo-900">
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<div class="col-span-full text-center py-12">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">No parks found</h3>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">Try adjusting your filters or search terms.</p>
|
||||||
|
</div>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="mt-6">
|
||||||
|
{{ $parks->links() }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Button -->
|
||||||
|
<div class="fixed bottom-6 right-6">
|
||||||
|
<a href="{{ route('parks.create') }}"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
|
<svg class="-ml-1 mr-2 h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Add Park
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
120
resources/views/livewire/profile-component.blade.php
Normal file
120
resources/views/livewire/profile-component.blade.php
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<div class="max-w-2xl mx-auto p-4 sm:p-6 lg:p-8">
|
||||||
|
<form wire:submit="save" class="space-y-6">
|
||||||
|
@if (session()->has('message'))
|
||||||
|
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative" role="alert">
|
||||||
|
{{ session('message') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center space-x-6">
|
||||||
|
<div class="shrink-0">
|
||||||
|
<img class="h-16 w-16 object-cover rounded-full" src="{{ $profile->getAvatarUrl() }}" alt="Profile photo">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block">
|
||||||
|
<span class="sr-only">Choose profile photo</span>
|
||||||
|
<input type="file" wire:model="avatar" class="block w-full text-sm text-slate-500
|
||||||
|
file:mr-4 file:py-2 file:px-4
|
||||||
|
file:rounded-full file:border-0
|
||||||
|
file:text-sm file:font-semibold
|
||||||
|
file:bg-violet-50 file:text-violet-700
|
||||||
|
hover:file:bg-violet-100
|
||||||
|
"/>
|
||||||
|
</label>
|
||||||
|
@error('avatar') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
|
||||||
|
|
||||||
|
@if($profile->avatar)
|
||||||
|
<button type="button" wire:click="removeAvatar" class="mt-2 text-sm text-red-600 hover:text-red-900">
|
||||||
|
Remove Photo
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="display_name" class="block text-sm font-medium text-gray-700">Display Name</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input type="text" wire:model="display_name" id="display_name"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
placeholder="How should we display your name?">
|
||||||
|
</div>
|
||||||
|
@error('display_name') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="pronouns" class="block text-sm font-medium text-gray-700">Pronouns</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input type="text" wire:model="pronouns" id="pronouns"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
placeholder="e.g. they/them">
|
||||||
|
</div>
|
||||||
|
@error('pronouns') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="bio" class="block text-sm font-medium text-gray-700">Bio</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<textarea wire:model="bio" id="bio" rows="3"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
placeholder="Tell us about yourself"></textarea>
|
||||||
|
</div>
|
||||||
|
@error('bio') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Social Media</h3>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="twitter" class="block text-sm font-medium text-gray-700">Twitter URL</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input type="url" wire:model="twitter" id="twitter"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
placeholder="https://twitter.com/yourusername">
|
||||||
|
</div>
|
||||||
|
@error('twitter') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="instagram" class="block text-sm font-medium text-gray-700">Instagram URL</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input type="url" wire:model="instagram" id="instagram"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
placeholder="https://instagram.com/yourusername">
|
||||||
|
</div>
|
||||||
|
@error('instagram') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="youtube" class="block text-sm font-medium text-gray-700">YouTube URL</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input type="url" wire:model="youtube" id="youtube"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
placeholder="https://youtube.com/@yourchannel">
|
||||||
|
</div>
|
||||||
|
@error('youtube') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="discord" class="block text-sm font-medium text-gray-700">Discord Username</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input type="text" wire:model="discord" id="discord"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
|
||||||
|
placeholder="username#1234">
|
||||||
|
</div>
|
||||||
|
@error('discord') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<button type="submit" class="inline-flex justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
|
||||||
|
Save Profile
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div wire:loading wire:target="save" class="text-sm text-gray-500">
|
||||||
|
Saving...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user