Commit 44c8d8aa authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add Events/Tournaments system — full vertical slice

Complete events module with admin CRUD, public registration, and management:
- Migrations: events, event_registrations, media collections, permissions
- Domain: Event/Enums (5), Models (2), Services (EventService, EventRegistrationService)
- Admin: EventList, CreateEventWizard (4-step with form builder), EventShow, EventRegistrationList
- Public: event listing + detail pages under /site/{slug}/events with Livewire registration form
- Form builder: button-based add/remove/reorder fields stored as JSONB
- Registration: dynamic validation from form_fields, person matching, registration numbers
- Admin registrations: dynamic columns, bulk confirm/cancel, CSV export
- Livewire scripts added to website layout for public forms
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 72872427
<?php
namespace App\Domain\Event\Enums;
enum EventStatus: string
{
case Draft = 'draft';
case Published = 'published';
case RegistrationClosed = 'registration_closed';
case InProgress = 'in_progress';
case Completed = 'completed';
case Cancelled = 'cancelled';
public function label(): string
{
return match ($this) {
self::Draft => 'مسودة',
self::Published => 'منشور',
self::RegistrationClosed => 'التسجيل مغلق',
self::InProgress => 'جارٍ',
self::Completed => 'مكتمل',
self::Cancelled => 'ملغي',
};
}
public function color(): string
{
return match ($this) {
self::Draft => 'gray',
self::Published => 'green',
self::RegistrationClosed => 'yellow',
self::InProgress => 'blue',
self::Completed => 'purple',
self::Cancelled => 'red',
};
}
public function canTransitionTo(self $target): bool
{
return match ($this) {
self::Draft => in_array($target, [self::Published, self::Cancelled]),
self::Published => in_array($target, [self::RegistrationClosed, self::InProgress, self::Cancelled]),
self::RegistrationClosed => in_array($target, [self::InProgress, self::Published, self::Cancelled]),
self::InProgress => in_array($target, [self::Completed, self::Cancelled]),
self::Completed, self::Cancelled => false,
};
}
}
<?php
namespace App\Domain\Event\Enums;
enum EventType: string
{
case Tournament = 'tournament';
case Camp = 'camp';
case Competition = 'competition';
case OpenDay = 'open_day';
case Workshop = 'workshop';
case Friendly = 'friendly';
case Exhibition = 'exhibition';
case Other = 'other';
public function label(): string
{
return match ($this) {
self::Tournament => 'بطولة',
self::Camp => 'معسكر',
self::Competition => 'مسابقة',
self::OpenDay => 'يوم مفتوح',
self::Workshop => 'ورشة عمل',
self::Friendly => 'ودية',
self::Exhibition => 'عرض',
self::Other => 'أخرى',
};
}
public function icon(): string
{
return match ($this) {
self::Tournament => 'trophy',
self::Camp => 'fire',
self::Competition => 'bolt',
self::OpenDay => 'sun',
self::Workshop => 'academic-cap',
self::Friendly => 'hand-raised',
self::Exhibition => 'eye',
self::Other => 'calendar',
};
}
}
<?php
namespace App\Domain\Event\Enums;
enum FormFieldType: string
{
case Text = 'text';
case Textarea = 'textarea';
case Number = 'number';
case Email = 'email';
case Phone = 'phone';
case Date = 'date';
case Select = 'select';
case MultiSelect = 'multi_select';
case Checkbox = 'checkbox';
case FileUpload = 'file_upload';
public function label(): string
{
return match ($this) {
self::Text => 'نص قصير',
self::Textarea => 'نص طويل',
self::Number => 'رقم',
self::Email => 'بريد إلكتروني',
self::Phone => 'هاتف',
self::Date => 'تاريخ',
self::Select => 'قائمة اختيار',
self::MultiSelect => 'اختيار متعدد',
self::Checkbox => 'خانة اختيار',
self::FileUpload => 'رفع ملف',
};
}
public function icon(): string
{
return match ($this) {
self::Text => 'bars-3-bottom-left',
self::Textarea => 'document-text',
self::Number => 'hashtag',
self::Email => 'at-symbol',
self::Phone => 'phone',
self::Date => 'calendar',
self::Select => 'chevron-down',
self::MultiSelect => 'list-bullet',
self::Checkbox => 'check-circle',
self::FileUpload => 'arrow-up-tray',
};
}
public function baseValidation(): string
{
return match ($this) {
self::Text => 'string|max:500',
self::Textarea => 'string|max:5000',
self::Number => 'numeric',
self::Email => 'email|max:255',
self::Phone => 'string|max:20',
self::Date => 'date',
self::Select => 'string',
self::MultiSelect => 'array',
self::Checkbox => 'boolean',
self::FileUpload => 'file|max:5120',
};
}
}
<?php
namespace App\Domain\Event\Enums;
enum LocationType: string
{
case Facility = 'facility';
case External = 'external';
public function label(): string
{
return match ($this) {
self::Facility => 'منشأة داخلية',
self::External => 'موقع خارجي',
};
}
}
<?php
namespace App\Domain\Event\Enums;
enum RegistrationStatus: string
{
case Pending = 'pending';
case Confirmed = 'confirmed';
case Cancelled = 'cancelled';
case Attended = 'attended';
case NoShow = 'no_show';
public function label(): string
{
return match ($this) {
self::Pending => 'قيد المراجعة',
self::Confirmed => 'مؤكد',
self::Cancelled => 'ملغي',
self::Attended => 'حضر',
self::NoShow => 'لم يحضر',
};
}
public function color(): string
{
return match ($this) {
self::Pending => 'yellow',
self::Confirmed => 'green',
self::Cancelled => 'red',
self::Attended => 'blue',
self::NoShow => 'gray',
};
}
}
<?php
namespace App\Domain\Event\Models;
use App\Domain\Event\Enums\EventStatus;
use App\Domain\Event\Enums\EventType;
use App\Domain\Event\Enums\LocationType;
use App\Domain\Facility\Models\Facility;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use App\Domain\Website\Models\Media;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Database\Eloquent\SoftDeletes;
class Event extends Model
{
use BelongsToAcademy, HasUuid, SoftDeletes;
protected $fillable = [
'academy_id',
'title',
'title_en',
'slug',
'description',
'description_en',
'type',
'status',
'location_type',
'facility_id',
'location_name',
'location_address',
'starts_at',
'ends_at',
'registration_opens_at',
'registration_closes_at',
'max_capacity',
'form_fields',
'settings',
'registrations_count',
'published_at',
'created_by',
];
protected $casts = [
'type' => EventType::class,
'status' => EventStatus::class,
'location_type' => LocationType::class,
'starts_at' => 'datetime',
'ends_at' => 'datetime',
'registration_opens_at' => 'datetime',
'registration_closes_at' => 'datetime',
'published_at' => 'datetime',
'max_capacity' => 'integer',
'registrations_count' => 'integer',
'form_fields' => 'array',
'settings' => 'array',
];
public function facility(): BelongsTo
{
return $this->belongsTo(Facility::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function registrations(): HasMany
{
return $this->hasMany(EventRegistration::class);
}
public function cover(): MorphOne
{
return $this->morphOne(Media::class, 'mediable')->where('collection', 'event_cover');
}
public function gallery(): MorphMany
{
return $this->morphMany(Media::class, 'mediable')->where('collection', 'event_gallery')->orderBy('sort_order');
}
public function scopePublished($query)
{
return $query->whereNotIn('status', ['draft', 'cancelled']);
}
public function scopeUpcoming($query)
{
return $query->where('starts_at', '>', now());
}
public function scopeRegistrationOpen($query)
{
return $query->where('status', 'published')
->where(function ($q) {
$q->whereNull('registration_opens_at')->orWhere('registration_opens_at', '<=', now());
})
->where(function ($q) {
$q->whereNull('registration_closes_at')->orWhere('registration_closes_at', '>=', now());
});
}
public function isRegistrationOpen(): bool
{
if ($this->status !== EventStatus::Published) {
return false;
}
if ($this->registration_opens_at && now()->lt($this->registration_opens_at)) {
return false;
}
if ($this->registration_closes_at && now()->gt($this->registration_closes_at)) {
return false;
}
return $this->hasCapacity();
}
public function hasCapacity(): bool
{
if ($this->max_capacity === null) {
return true;
}
return $this->registrations_count < $this->max_capacity;
}
public function spotsRemaining(): ?int
{
if ($this->max_capacity === null) {
return null;
}
return max(0, $this->max_capacity - $this->registrations_count);
}
public function getPublicUrl(): string
{
$academy = $this->academy ?? app('current_academy');
return url("/site/{$academy->slug}/events/{$this->slug}");
}
}
<?php
namespace App\Domain\Event\Models;
use App\Domain\Event\Enums\RegistrationStatus;
use App\Domain\Identity\Models\Person;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class EventRegistration extends Model
{
use BelongsToAcademy, HasUuid, SoftDeletes;
protected $fillable = [
'academy_id',
'event_id',
'person_id',
'registration_number',
'status',
'form_data',
'registrant_name',
'registrant_phone',
'registrant_email',
'admin_notes',
'confirmed_at',
'cancelled_at',
'attended_at',
'ip_address',
];
protected $casts = [
'status' => RegistrationStatus::class,
'form_data' => 'array',
'confirmed_at' => 'datetime',
'cancelled_at' => 'datetime',
'attended_at' => 'datetime',
];
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function person(): BelongsTo
{
return $this->belongsTo(Person::class);
}
}
<?php
namespace App\Domain\Event\Services;
use App\Domain\Event\Enums\FormFieldType;
use App\Domain\Event\Enums\RegistrationStatus;
use App\Domain\Event\Models\Event;
use App\Domain\Event\Models\EventRegistration;
use App\Domain\Identity\Models\Person;
use App\Domain\Shared\Exceptions\DomainException;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class EventRegistrationService
{
public function register(Event $event, array $formData, array $meta): EventRegistration
{
if (! $event->isRegistrationOpen()) {
throw new DomainException('التسجيل مغلق لهذا الحدث');
}
if (! $event->hasCapacity()) {
throw new DomainException('تم اكتمال العدد المسموح به');
}
return DB::transaction(function () use ($event, $formData, $meta) {
$personId = $this->matchPerson($meta['phone'], $meta['email'] ?? null, $event->academy_id);
$registration = EventRegistration::create([
'academy_id' => $event->academy_id,
'event_id' => $event->id,
'person_id' => $personId,
'registration_number' => $this->generateRegistrationNumber($event),
'status' => 'pending',
'form_data' => $formData,
'registrant_name' => $meta['name'],
'registrant_phone' => $meta['phone'],
'registrant_email' => $meta['email'] ?? null,
'ip_address' => $meta['ip'] ?? null,
]);
$event->increment('registrations_count');
return $registration;
});
}
public function confirm(EventRegistration $registration, User $admin): EventRegistration
{
if ($registration->status !== RegistrationStatus::Pending) {
throw new DomainException('لا يمكن تأكيد هذا التسجيل');
}
$registration->update([
'status' => 'confirmed',
'confirmed_at' => now(),
]);
return $registration->fresh();
}
public function cancel(EventRegistration $registration, User $admin, ?string $reason = null): EventRegistration
{
if (in_array($registration->status, [RegistrationStatus::Cancelled, RegistrationStatus::Attended])) {
throw new DomainException('لا يمكن إلغاء هذا التسجيل');
}
DB::transaction(function () use ($registration, $reason) {
$registration->update([
'status' => 'cancelled',
'cancelled_at' => now(),
'admin_notes' => $reason ? ($registration->admin_notes ? $registration->admin_notes . "\n" : '') . "سبب الإلغاء: {$reason}" : $registration->admin_notes,
]);
$registration->event->decrement('registrations_count');
});
return $registration->fresh();
}
public function markAttended(EventRegistration $registration, User $admin): EventRegistration
{
if ($registration->status === RegistrationStatus::Cancelled) {
throw new DomainException('لا يمكن تسجيل حضور تسجيل ملغي');
}
$registration->update([
'status' => 'attended',
'attended_at' => now(),
]);
return $registration->fresh();
}
public function bulkConfirm(array $ids, User $admin): int
{
$count = 0;
$registrations = EventRegistration::whereIn('id', $ids)->where('status', 'pending')->get();
foreach ($registrations as $reg) {
$this->confirm($reg, $admin);
$count++;
}
return $count;
}
public function bulkCancel(array $ids, User $admin): int
{
$count = 0;
$registrations = EventRegistration::whereIn('id', $ids)
->whereNotIn('status', ['cancelled', 'attended'])
->get();
foreach ($registrations as $reg) {
$this->cancel($reg, $admin);
$count++;
}
return $count;
}
public function validateFormData(Event $event, array $submitted): array
{
$rules = [];
$messages = [];
foreach ($event->form_fields as $field) {
$key = "fields.{$field['key']}";
$fieldRules = [];
if ($field['is_required']) {
$fieldRules[] = 'required';
$messages["{$key}.required"] = "حقل {$field['label']} مطلوب";
} else {
$fieldRules[] = 'nullable';
}
$type = FormFieldType::tryFrom($field['type']);
if ($type) {
foreach (explode('|', $type->baseValidation()) as $rule) {
$fieldRules[] = $rule;
}
}
if (in_array($field['type'], ['select']) && ! empty($field['options'])) {
$values = array_column($field['options'], 'value');
$fieldRules[] = 'in:' . implode(',', $values);
}
$rules[$key] = implode('|', $fieldRules);
}
return ['rules' => $rules, 'messages' => $messages];
}
public function matchPerson(string $phone, ?string $email, int $academyId): ?int
{
$person = Person::withoutGlobalScopes()
->where('academy_id', $academyId)
->where(function ($q) use ($phone, $email) {
$q->where('phone', $phone);
if ($email) {
$q->orWhere('email', $email);
}
})
->first();
return $person?->id;
}
public function generateRegistrationNumber(Event $event): string
{
$prefix = 'EVT-' . now()->format('ym');
$lastNumber = EventRegistration::withoutGlobalScopes()
->where('academy_id', $event->academy_id)
->where('registration_number', 'like', $prefix . '%')
->max('registration_number');
if ($lastNumber) {
$seq = (int) substr($lastNumber, -4) + 1;
} else {
$seq = 1;
}
return $prefix . '-' . str_pad($seq, 4, '0', STR_PAD_LEFT);
}
}
<?php
namespace App\Domain\Event\Services;
use App\Domain\Event\Enums\EventStatus;
use App\Domain\Event\Models\Event;
use App\Domain\Shared\Exceptions\DomainException;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class EventService
{
public function create(array $data, User $actor): Event
{
return DB::transaction(function () use ($data, $actor) {
$data['created_by'] = $actor->id;
$data['status'] = 'draft';
$data['slug'] = $this->generateSlug($data['title'], $data['academy_id']);
return Event::create($data);
});
}
public function update(Event $event, array $data, User $actor): Event
{
return DB::transaction(function () use ($event, $data) {
if (isset($data['title']) && $data['title'] !== $event->title) {
$data['slug'] = $this->generateSlug($data['title'], $event->academy_id, $event->id);
}
$event->update($data);
return $event->fresh();
});
}
public function publish(Event $event, User $actor): Event
{
if ($event->status !== EventStatus::Draft) {
throw new DomainException('لا يمكن نشر حدث غير مسودة');
}
return DB::transaction(function () use ($event) {
$event->update([
'status' => 'published',
'published_at' => now(),
]);
return $event->fresh();
});
}
public function changeStatus(Event $event, EventStatus $newStatus, User $actor): Event
{
if (! $event->status->canTransitionTo($newStatus)) {
throw new DomainException("لا يمكن تغيير الحالة من '{$event->status->label()}' إلى '{$newStatus->label()}'");
}
return DB::transaction(function () use ($event, $newStatus) {
$updates = ['status' => $newStatus->value];
if ($newStatus === EventStatus::Published && ! $event->published_at) {
$updates['published_at'] = now();
}
$event->update($updates);
return $event->fresh();
});
}
public function delete(Event $event): void
{
if ($event->registrations_count > 0 && $event->status !== EventStatus::Draft) {
throw new DomainException('لا يمكن حذف حدث به تسجيلات. قم بإلغائه بدلاً من ذلك.');
}
$event->delete();
}
public function duplicate(Event $event, User $actor): Event
{
$data = $event->only([
'academy_id', 'title', 'title_en', 'description', 'description_en',
'type', 'location_type', 'facility_id', 'location_name', 'location_address',
'max_capacity', 'form_fields', 'settings',
]);
$data['title'] = $data['title'] . ' (نسخة)';
return $this->create($data, $actor);
}
public function generateSlug(string $title, int $academyId, ?int $excludeId = null): string
{
$base = Str::slug($title, '-', null) ?: Str::random(8);
$slug = $base;
$counter = 1;
while (
Event::withoutGlobalScopes()
->where('academy_id', $academyId)
->where('slug', $slug)
->when($excludeId, fn ($q) => $q->where('id', '!=', $excludeId))
->exists()
) {
$slug = $base . '-' . $counter;
$counter++;
}
return $slug;
}
}
...@@ -15,6 +15,8 @@ ...@@ -15,6 +15,8 @@
case NewsImage = 'news_image'; case NewsImage = 'news_image';
case SectionImage = 'section_image'; case SectionImage = 'section_image';
case General = 'general'; case General = 'general';
case EventCover = 'event_cover';
case EventGallery = 'event_gallery';
public function dimensions(): array public function dimensions(): array
{ {
...@@ -30,6 +32,8 @@ public function dimensions(): array ...@@ -30,6 +32,8 @@ public function dimensions(): array
self::NewsImage => ['width' => 1200, 'height' => 630], self::NewsImage => ['width' => 1200, 'height' => 630],
self::SectionImage => ['width' => 800, 'height' => 600], self::SectionImage => ['width' => 800, 'height' => 600],
self::General => ['width' => 1200, 'height' => 800], self::General => ['width' => 1200, 'height' => 800],
self::EventCover => ['width' => 1920, 'height' => 600],
self::EventGallery => ['width' => 1200, 'height' => 800],
}; };
} }
...@@ -47,6 +51,8 @@ public function aspectRatio(): string ...@@ -47,6 +51,8 @@ public function aspectRatio(): string
self::NewsImage => '1.91:1', self::NewsImage => '1.91:1',
self::SectionImage => '4:3', self::SectionImage => '4:3',
self::General => '3:2', self::General => '3:2',
self::EventCover => '16:5',
self::EventGallery => '3:2',
}; };
} }
...@@ -64,6 +70,8 @@ public function maxSizeKb(): int ...@@ -64,6 +70,8 @@ public function maxSizeKb(): int
self::NewsImage => 1536, self::NewsImage => 1536,
self::SectionImage => 1024, self::SectionImage => 1024,
self::General => 2048, self::General => 2048,
self::EventCover => 2048,
self::EventGallery => 1536,
}; };
} }
...@@ -81,6 +89,8 @@ public function label(): string ...@@ -81,6 +89,8 @@ public function label(): string
self::NewsImage => 'صورة الخبر', self::NewsImage => 'صورة الخبر',
self::SectionImage => 'صورة القسم', self::SectionImage => 'صورة القسم',
self::General => 'صورة عامة', self::General => 'صورة عامة',
self::EventCover => 'غلاف الحدث',
self::EventGallery => 'معرض صور الحدث',
}; };
} }
} }
<?php
namespace App\Http\Controllers;
use App\Domain\Event\Models\Event;
class PublicEventController extends Controller
{
public function index(string $slug)
{
$academy = app('current_academy');
$events = Event::withoutGlobalScopes()
->where('academy_id', $academy->id)
->whereNotIn('status', ['draft', 'cancelled'])
->with('cover')
->orderByDesc('starts_at')
->paginate(12);
$settings = app('website_settings');
return view('website.events.index', compact('academy', 'events', 'settings'));
}
public function show(string $slug, string $eventSlug)
{
$academy = app('current_academy');
$event = Event::withoutGlobalScopes()
->where('academy_id', $academy->id)
->where('slug', $eventSlug)
->where('status', '!=', 'draft')
->with(['cover', 'gallery', 'facility'])
->firstOrFail();
$settings = app('website_settings');
return view('website.events.show', compact('academy', 'event', 'settings'));
}
}
This diff is collapsed.
<?php
namespace App\Livewire\Events;
use App\Domain\Event\Enums\EventStatus;
use App\Domain\Event\Enums\EventType;
use App\Domain\Event\Models\Event;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.app')]
#[Title('الأحداث')]
class EventList extends Component
{
use WithPagination;
#[Url]
public string $search = '';
#[Url]
public string $status = '';
#[Url]
public string $type = '';
public function mount(): void
{
$this->authorize('events.list');
}
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedStatus(): void
{
$this->resetPage();
}
public function updatedType(): void
{
$this->resetPage();
}
public function render()
{
$events = Event::query()
->when($this->search, fn ($q) => $q->where('title', 'ilike', "%{$this->search}%"))
->when($this->status, fn ($q) => $q->where('status', $this->status))
->when($this->type, fn ($q) => $q->where('type', $this->type))
->withCount('registrations')
->orderByDesc('starts_at')
->paginate(15);
return view('livewire.events.event-list', [
'events' => $events,
'statuses' => EventStatus::cases(),
'types' => EventType::cases(),
]);
}
}
<?php
namespace App\Livewire\Events;
use App\Domain\Event\Models\Event;
use App\Domain\Event\Models\EventRegistration;
use App\Domain\Event\Services\EventRegistrationService;
use App\Domain\Shared\Exceptions\DomainException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
use Symfony\Component\HttpFoundation\StreamedResponse;
#[Layout('layouts.app')]
#[Title('تسجيلات الحدث')]
class EventRegistrationList extends Component
{
use WithPagination;
public Event $event;
#[Url]
public string $search = '';
#[Url]
public string $status = '';
public array $selected = [];
public bool $selectAll = false;
public function mount(Event $event): void
{
$this->authorize('events.view');
$this->event = $event->load('cover');
}
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedStatus(): void
{
$this->resetPage();
}
public function updatedSelectAll(bool $value): void
{
if ($value) {
$this->selected = $this->event->registrations()
->when($this->status, fn ($q) => $q->where('status', $this->status))
->pluck('id')
->map(fn ($id) => (string) $id)
->toArray();
} else {
$this->selected = [];
}
}
public function bulkConfirm(EventRegistrationService $service): void
{
$this->authorize('events.manage');
if (empty($this->selected)) {
return;
}
$count = $service->bulkConfirm($this->selected, auth()->user());
$this->selected = [];
$this->selectAll = false;
session()->flash('success', __("تم تأكيد :count تسجيل", ['count' => $count]));
}
public function bulkCancel(EventRegistrationService $service): void
{
$this->authorize('events.manage');
if (empty($this->selected)) {
return;
}
$count = $service->bulkCancel($this->selected, auth()->user());
$this->selected = [];
$this->selectAll = false;
session()->flash('success', __("تم إلغاء :count تسجيل", ['count' => $count]));
}
public function confirm(int $id, EventRegistrationService $service): void
{
$this->authorize('events.manage');
try {
$registration = EventRegistration::findOrFail($id);
$service->confirm($registration, auth()->user());
session()->flash('success', __('تم تأكيد التسجيل'));
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function cancel(int $id, EventRegistrationService $service): void
{
$this->authorize('events.manage');
try {
$registration = EventRegistration::findOrFail($id);
$service->cancel($registration, auth()->user());
session()->flash('success', __('تم إلغاء التسجيل'));
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function markAttended(int $id, EventRegistrationService $service): void
{
$this->authorize('events.manage');
try {
$registration = EventRegistration::findOrFail($id);
$service->markAttended($registration, auth()->user());
session()->flash('success', __('تم تسجيل الحضور'));
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function export(): StreamedResponse
{
$this->authorize('events.export');
$registrations = $this->event->registrations()
->when($this->status, fn ($q) => $q->where('status', $this->status))
->orderByDesc('created_at')
->get();
$fields = $this->event->form_fields;
return response()->streamDownload(function () use ($registrations, $fields) {
$handle = fopen('php://output', 'w');
fprintf($handle, chr(0xEF) . chr(0xBB) . chr(0xBF));
$headers = ['#', 'رقم التسجيل', 'الاسم', 'الهاتف', 'البريد', 'الحالة', 'تاريخ التسجيل'];
foreach ($fields as $field) {
$headers[] = $field['label'];
}
fputcsv($handle, $headers);
foreach ($registrations as $i => $reg) {
$statusLabels = [
'pending' => 'معلق',
'confirmed' => 'مؤكد',
'cancelled' => 'ملغي',
'attended' => 'حضر',
'no_show' => 'لم يحضر',
];
$row = [
$i + 1,
$reg->registration_number,
$reg->registrant_name,
$reg->registrant_phone,
$reg->registrant_email ?? '-',
$statusLabels[$reg->status] ?? $reg->status,
$reg->created_at->format('Y-m-d H:i'),
];
foreach ($fields as $field) {
$value = $reg->form_data[$field['key']] ?? '-';
if (is_array($value)) {
$value = implode(', ', $value);
}
if (is_bool($value)) {
$value = $value ? 'نعم' : 'لا';
}
$row[] = $value;
}
fputcsv($handle, $row);
}
fclose($handle);
}, "registrations-{$this->event->slug}.csv", [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
public function render()
{
$query = $this->event->registrations()
->when($this->search, function ($q) {
$q->where(function ($q) {
$q->where('registrant_name', 'ilike', "%{$this->search}%")
->orWhere('registrant_phone', 'ilike', "%{$this->search}%")
->orWhere('registrant_email', 'ilike', "%{$this->search}%")
->orWhere('registration_number', 'ilike', "%{$this->search}%");
});
})
->when($this->status, fn ($q) => $q->where('status', $this->status))
->orderByDesc('created_at');
return view('livewire.events.event-registration-list', [
'registrations' => $query->paginate(25),
'formFields' => $this->event->form_fields,
'statusCounts' => [
'all' => $this->event->registrations()->count(),
'pending' => $this->event->registrations()->where('status', 'pending')->count(),
'confirmed' => $this->event->registrations()->where('status', 'confirmed')->count(),
'cancelled' => $this->event->registrations()->where('status', 'cancelled')->count(),
'attended' => $this->event->registrations()->where('status', 'attended')->count(),
],
]);
}
}
<?php
namespace App\Livewire\Events;
use App\Domain\Event\Enums\EventStatus;
use App\Domain\Event\Models\Event;
use App\Domain\Event\Services\EventService;
use App\Domain\Shared\Exceptions\DomainException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('تفاصيل الحدث')]
class EventShow extends Component
{
public Event $event;
public function mount(Event $event): void
{
$this->authorize('events.view');
$this->event = $event->load(['facility', 'creator', 'cover', 'gallery']);
}
public function changeStatus(string $status, EventService $service): void
{
$this->authorize('events.manage');
try {
$newStatus = EventStatus::from($status);
$service->changeStatus($this->event, $newStatus, auth()->user());
$this->event->refresh();
session()->flash('success', __('تم تغيير حالة الحدث'));
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function publish(EventService $service): void
{
$this->authorize('events.manage');
try {
$service->publish($this->event, auth()->user());
$this->event->refresh();
session()->flash('success', __('تم نشر الحدث بنجاح'));
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function deleteEvent(EventService $service): void
{
$this->authorize('events.manage');
try {
$service->delete($this->event);
session()->flash('success', __('تم حذف الحدث'));
$this->redirect(route('events.list'), navigate: true);
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function render()
{
return view('livewire.events.event-show', [
'confirmedCount' => $this->event->registrations()->where('status', 'confirmed')->count(),
'pendingCount' => $this->event->registrations()->where('status', 'pending')->count(),
]);
}
}
<?php
namespace App\Livewire\Public;
use App\Domain\Event\Models\Event;
use App\Domain\Event\Services\EventRegistrationService;
use App\Domain\Shared\Exceptions\DomainException;
use Livewire\Component;
class EventRegistrationForm extends Component
{
public Event $event;
public array $fields = [];
public bool $submitted = false;
public ?string $registrationNumber = null;
public function mount(Event $event): void
{
$this->event = $event;
$this->initFields();
}
private function initFields(): void
{
foreach ($this->event->form_fields as $field) {
$default = match ($field['type']) {
'checkbox' => false,
'multi_select' => [],
default => '',
};
$this->fields[$field['key']] = $default;
}
}
public function submit(EventRegistrationService $service): void
{
$validation = $service->validateFormData($this->event, $this->fields);
$this->validate($validation['rules'], $validation['messages']);
$name = $this->fields['name'] ?? $this->fields['player_name'] ?? $this->fields['participant_name'] ?? ($this->fields['first_name'] ?? '') . ' ' . ($this->fields['last_name'] ?? '');
$phone = $this->fields['phone'] ?? $this->fields['mobile'] ?? '';
$email = $this->fields['email'] ?? null;
if (empty(trim($name))) {
$name = 'مسجل';
}
try {
$registration = $service->register($this->event, $this->fields, [
'name' => trim($name),
'phone' => $phone,
'email' => $email,
'ip' => request()->ip(),
]);
$this->submitted = true;
$this->registrationNumber = $registration->registration_number;
} catch (DomainException $e) {
$this->addError('form', $e->getMessage());
}
}
public function render()
{
return view('livewire.public.event-registration-form');
}
}
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('events', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('academy_id')->constrained('academies');
$table->string('title');
$table->string('title_en')->nullable();
$table->string('slug');
$table->text('description')->nullable();
$table->text('description_en')->nullable();
$table->string('type', 30);
$table->string('status', 30)->default('draft');
$table->string('location_type', 20);
$table->foreignId('facility_id')->nullable()->constrained('facilities')->nullOnDelete();
$table->string('location_name')->nullable();
$table->text('location_address')->nullable();
$table->timestamp('starts_at');
$table->timestamp('ends_at');
$table->timestamp('registration_opens_at')->nullable();
$table->timestamp('registration_closes_at')->nullable();
$table->unsignedInteger('max_capacity')->nullable();
$table->jsonb('form_fields')->default('[]');
$table->jsonb('settings')->default('{}');
$table->unsignedInteger('registrations_count')->default(0);
$table->timestamp('published_at')->nullable();
$table->foreignId('created_by')->constrained('users');
$table->timestamps();
$table->softDeletes();
$table->unique(['academy_id', 'slug']);
$table->index(['academy_id', 'status']);
$table->index(['academy_id', 'starts_at']);
$table->index(['academy_id', 'type']);
});
DB::statement("ALTER TABLE events ADD CONSTRAINT events_type_check CHECK (type IN ('tournament', 'camp', 'competition', 'open_day', 'workshop', 'friendly', 'exhibition', 'other'))");
DB::statement("ALTER TABLE events ADD CONSTRAINT events_status_check CHECK (status IN ('draft', 'published', 'registration_closed', 'in_progress', 'completed', 'cancelled'))");
DB::statement("ALTER TABLE events ADD CONSTRAINT events_location_type_check CHECK (location_type IN ('facility', 'external'))");
}
public function down(): void
{
Schema::dropIfExists('events');
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('event_registrations', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('academy_id')->constrained('academies');
$table->foreignId('event_id')->constrained('events')->cascadeOnDelete();
$table->foreignId('person_id')->nullable()->constrained('people')->nullOnDelete();
$table->string('registration_number', 30);
$table->string('status', 20)->default('pending');
$table->jsonb('form_data')->default('{}');
$table->string('registrant_name');
$table->string('registrant_phone', 20);
$table->string('registrant_email')->nullable();
$table->text('admin_notes')->nullable();
$table->timestamp('confirmed_at')->nullable();
$table->timestamp('cancelled_at')->nullable();
$table->timestamp('attended_at')->nullable();
$table->string('ip_address', 45)->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique(['academy_id', 'registration_number']);
$table->index(['event_id', 'status']);
$table->index(['event_id', 'created_at']);
$table->index(['academy_id', 'registrant_phone']);
});
DB::statement("ALTER TABLE event_registrations ADD CONSTRAINT event_registrations_status_check CHECK (status IN ('pending', 'confirmed', 'cancelled', 'attended', 'no_show'))");
}
public function down(): void
{
Schema::dropIfExists('event_registrations');
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::statement("ALTER TABLE media DROP CONSTRAINT IF EXISTS media_collection_check");
DB::statement("ALTER TABLE media ADD CONSTRAINT media_collection_check CHECK (collection IN (
'academy_logo', 'academy_cover', 'activity_photo', 'branch_photo',
'gallery', 'team_photo', 'testimonial_avatar', 'partner_logo',
'news_image', 'section_image', 'general',
'event_cover', 'event_gallery'
))");
}
public function down(): void
{
DB::statement("ALTER TABLE media DROP CONSTRAINT IF EXISTS media_collection_check");
DB::statement("ALTER TABLE media ADD CONSTRAINT media_collection_check CHECK (collection IN (
'academy_logo', 'academy_cover', 'activity_photo', 'branch_photo',
'gallery', 'team_photo', 'testimonial_avatar', 'partner_logo',
'news_image', 'section_image', 'general'
))");
}
};
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
$permissions = [
['name' => 'events.list', 'module' => 'events', 'action' => 'list'],
['name' => 'events.create', 'module' => 'events', 'action' => 'create'],
['name' => 'events.view', 'module' => 'events', 'action' => 'view'],
['name' => 'events.update', 'module' => 'events', 'action' => 'update'],
['name' => 'events.manage', 'module' => 'events', 'action' => 'manage'],
['name' => 'events.export', 'module' => 'events', 'action' => 'export'],
];
foreach ($permissions as $perm) {
DB::table('permissions')->insertOrIgnore([
'name' => $perm['name'],
'module' => $perm['module'],
'action' => $perm['action'],
'created_at' => now(),
]);
}
}
public function down(): void
{
DB::table('permissions')->whereIn('name', [
'events.list', 'events.create', 'events.view',
'events.update', 'events.manage', 'events.export',
])->delete();
}
};
...@@ -28,6 +28,11 @@ ...@@ -28,6 +28,11 @@
['label' => 'لوحة المدرب', 'route' => 'trainer.dashboard', 'icon' => 'user', 'permission' => 'attendance.mark'], ['label' => 'لوحة المدرب', 'route' => 'trainer.dashboard', 'icon' => 'user', 'permission' => 'attendance.mark'],
]], ]],
['section' => 'الأحداث', 'items' => [
['label' => 'الأحداث', 'route' => 'events.list', 'icon' => 'calendar', 'permission' => 'events.list'],
['label' => 'إنشاء حدث', 'route' => 'events.create', 'icon' => 'plus-circle', 'permission' => 'events.create'],
]],
['section' => 'الموارد البشرية', 'items' => [ ['section' => 'الموارد البشرية', 'items' => [
['label' => 'الموظفين', 'route' => 'employees.list', 'icon' => 'briefcase', 'permission' => 'employees.list'], ['label' => 'الموظفين', 'route' => 'employees.list', 'icon' => 'briefcase', 'permission' => 'employees.list'],
['label' => 'المدربين', 'route' => 'trainers.list', 'icon' => 'academic-cap', 'permission' => 'trainers.list'], ['label' => 'المدربين', 'route' => 'trainers.list', 'icon' => 'academic-cap', 'permission' => 'trainers.list'],
......
This diff is collapsed.
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{{-- Header --}}
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-800">{{ __('الأحداث') }}</h1>
<p class="text-sm text-gray-500 mt-1">{{ __('إدارة البطولات والمعسكرات والأحداث') }}</p>
</div>
@can('events.create')
<a href="{{ route('events.create') }}" wire:navigate
class="inline-flex items-center gap-2 px-4 py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
{{ __('إنشاء حدث') }}
</a>
@endcan
</div>
{{-- Filters --}}
<div class="bg-white rounded-xl border border-gray-200 p-4 mb-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<input type="text" wire:model.live.debounce.300ms="search"
placeholder="{{ __('بحث بالعنوان...') }}"
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<select wire:model.live="status" class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">{{ __('كل الحالات') }}</option>
@foreach($statuses as $s)
<option value="{{ $s->value }}">{{ $s->label() }}</option>
@endforeach
</select>
</div>
<div>
<select wire:model.live="type" class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">{{ __('كل الأنواع') }}</option>
@foreach($types as $t)
<option value="{{ $t->value }}">{{ $t->label() }}</option>
@endforeach
</select>
</div>
</div>
</div>
{{-- Events Grid --}}
<div wire:loading.class="opacity-50 pointer-events-none">
@if($events->isEmpty())
<div class="bg-white rounded-xl border border-gray-200 p-12 text-center">
<svg class="w-16 h-16 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"/></svg>
<h3 class="text-lg font-medium text-gray-600 mb-1">{{ __('لا توجد أحداث') }}</h3>
<p class="text-sm text-gray-400">{{ __('ابدأ بإنشاء حدث جديد') }}</p>
</div>
@else
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@foreach($events as $event)
<a href="{{ route('events.show', $event) }}" wire:navigate
class="bg-white rounded-xl border border-gray-200 overflow-hidden hover:shadow-md transition-shadow group">
{{-- Cover --}}
<div class="h-40 bg-gradient-to-br from-blue-500 to-purple-600 relative">
@if($event->cover)
<img src="{{ $event->cover->url }}" alt="{{ $event->title }}" class="w-full h-full object-cover">
@endif
<div class="absolute top-3 start-3">
<span class="px-2.5 py-1 rounded-full text-xs font-medium
{{ match($event->status->color()) {
'green' => 'bg-green-100 text-green-800',
'yellow' => 'bg-yellow-100 text-yellow-800',
'blue' => 'bg-blue-100 text-blue-800',
'purple' => 'bg-purple-100 text-purple-800',
'red' => 'bg-red-100 text-red-800',
default => 'bg-gray-100 text-gray-800',
} }}">
{{ $event->status->label() }}
</span>
</div>
<div class="absolute top-3 end-3">
<span class="px-2.5 py-1 rounded-full text-xs font-medium bg-white/90 text-gray-700">
{{ $event->type->label() }}
</span>
</div>
</div>
{{-- Content --}}
<div class="p-4">
<h3 class="font-semibold text-gray-800 group-hover:text-blue-600 transition-colors mb-2 line-clamp-1">
{{ $event->title }}
</h3>
<div class="space-y-1.5 text-sm text-gray-500">
<div class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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>
{{ $event->starts_at->translatedFormat('d M Y - H:i') }}
</div>
<div class="flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
{{ $event->registrations_count }} {{ __('مسجل') }}
@if($event->max_capacity)
/ {{ $event->max_capacity }}
@endif
</div>
</div>
</div>
</a>
@endforeach
</div>
<div class="mt-6">
{{ $events->links() }}
</div>
@endif
</div>
</div>
This diff is collapsed.
<div>
@if($submitted)
{{-- Success --}}
<div class="bg-green-50 border border-green-200 rounded-2xl p-8 text-center">
<div class="w-16 h-16 mx-auto bg-green-100 rounded-full flex items-center justify-center mb-4">
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
</div>
<h3 class="text-xl font-bold text-green-800 mb-2">{{ __('تم التسجيل بنجاح!') }}</h3>
<p class="text-green-700 mb-4">{{ __('رقم التسجيل الخاص بك:') }}</p>
<div class="inline-block bg-white border-2 border-green-300 rounded-xl px-6 py-3">
<span class="text-2xl font-mono font-bold text-green-800" dir="ltr">{{ $registrationNumber }}</span>
</div>
<p class="text-sm text-green-600 mt-4">{{ __('يرجى الاحتفاظ بهذا الرقم للمتابعة') }}</p>
</div>
@else
{{-- Registration Form --}}
<form wire:submit="submit" class="space-y-5">
@error('form')
<div class="p-4 bg-red-50 border border-red-200 rounded-xl text-red-700 text-sm">{{ $message }}</div>
@enderror
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@foreach($event->form_fields as $field)
<div class="{{ $field['width'] === 'full' ? 'md:col-span-2' : '' }}">
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ $field['label'] }}
@if($field['is_required']) <span class="text-red-500">*</span> @endif
</label>
@switch($field['type'])
@case('text')
@case('email')
@case('phone')
@case('number')
<input type="{{ $field['type'] === 'phone' ? 'tel' : $field['type'] }}"
wire:model="fields.{{ $field['key'] }}"
placeholder="{{ $field['placeholder'] ?? '' }}"
@if(in_array($field['type'], ['email', 'phone', 'number'])) dir="ltr" @endif
class="w-full px-4 py-3 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('fields.'.$field['key']) border-red-500 @enderror">
@break
@case('textarea')
<textarea wire:model="fields.{{ $field['key'] }}" rows="3"
placeholder="{{ $field['placeholder'] ?? '' }}"
class="w-full px-4 py-3 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('fields.'.$field['key']) border-red-500 @enderror"></textarea>
@break
@case('date')
<input type="date" wire:model="fields.{{ $field['key'] }}" dir="ltr"
class="w-full px-4 py-3 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('fields.'.$field['key']) border-red-500 @enderror">
@break
@case('select')
<select wire:model="fields.{{ $field['key'] }}"
class="w-full px-4 py-3 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('fields.'.$field['key']) border-red-500 @enderror">
<option value="">{{ $field['placeholder'] ?? __('اختر...') }}</option>
@foreach($field['options'] ?? [] as $option)
<option value="{{ $option['value'] }}">{{ $option['label'] }}</option>
@endforeach
</select>
@break
@case('multi_select')
<div class="space-y-2 border border-gray-300 rounded-xl p-3 @error('fields.'.$field['key']) border-red-500 @enderror">
@foreach($field['options'] ?? [] as $option)
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" wire:model="fields.{{ $field['key'] }}" value="{{ $option['value'] }}" class="rounded text-blue-600 focus:ring-blue-500">
<span class="text-sm text-gray-700">{{ $option['label'] }}</span>
</label>
@endforeach
</div>
@break
@case('checkbox')
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" wire:model="fields.{{ $field['key'] }}" class="rounded text-blue-600 focus:ring-blue-500">
<span class="text-sm text-gray-700">{{ $field['placeholder'] ?? $field['label'] }}</span>
</label>
@break
@endswitch
@error('fields.'.$field['key']) <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
</div>
@endforeach
</div>
<button type="submit" wire:loading.attr="disabled" wire:target="submit"
class="w-full py-3.5 bg-blue-600 text-white rounded-xl font-medium hover:bg-blue-700 transition-colors disabled:opacity-50">
<span wire:loading.remove wire:target="submit">{{ __('تسجيل') }}</span>
<span wire:loading wire:target="submit">{{ __('جارٍ التسجيل...') }}</span>
</button>
</form>
@endif
</div>
@extends('website.layout')
@section('content')
<div class="site-section">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="text-center mb-12">
<h1 class="section-title mx-auto">{{ __('الأحداث والبطولات') }}</h1>
<p class="mt-4 text-gray-500 text-lg">{{ __('تابع أحدث الفعاليات والبطولات وسجّل الآن') }}</p>
</div>
@if($events->count())
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
@foreach($events as $event)
@php
$cover = $event->cover;
$statusColors = [
'published' => 'bg-green-100 text-green-800',
'registration_closed' => 'bg-yellow-100 text-yellow-800',
'in_progress' => 'bg-blue-100 text-blue-800',
'completed' => 'bg-gray-100 text-gray-800',
];
$statusLabels = [
'published' => 'التسجيل مفتوح',
'registration_closed' => 'التسجيل مغلق',
'in_progress' => 'جارٍ الآن',
'completed' => 'انتهى',
];
@endphp
<a href="{{ route('website.events.show', ['slug' => $academy->slug, 'eventSlug' => $event->slug]) }}"
class="site-card group block transition-transform hover:-translate-y-1">
<div class="relative overflow-hidden aspect-[16/9] rounded-t-xl">
@if($cover)
<img src="{{ $cover->url }}"
alt="{{ $event->title }}"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300">
@else
<div class="w-full h-full bg-gradient-to-br from-[var(--site-accent)]/20 to-[var(--site-primary)]/10 flex items-center justify-center">
<svg class="w-14 h-14 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
</div>
@endif
{{-- Status Badge --}}
<div class="absolute top-3 start-3">
<span class="inline-block px-2.5 py-1 text-xs font-bold rounded-full {{ $statusColors[$event->status] ?? 'bg-gray-100 text-gray-800' }}">
{{ $statusLabels[$event->status] ?? $event->status }}
</span>
</div>
</div>
<div class="p-5">
<h3 class="font-bold text-lg text-gray-900 group-hover:text-[var(--site-accent)] transition-colors line-clamp-1">
{{ $event->title }}
</h3>
<div class="mt-3 space-y-2 text-sm text-gray-500">
{{-- Date --}}
<div class="flex items-center gap-2">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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>
<span dir="ltr">{{ $event->starts_at->translatedFormat('d M Y') }}</span>
</div>
{{-- Location --}}
@if($event->location_name || $event->facility)
<div class="flex items-center gap-2">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<span class="line-clamp-1">{{ $event->location_name ?? $event->facility?->name_ar }}</span>
</div>
@endif
{{-- Capacity --}}
@if($event->max_capacity)
<div class="flex items-center gap-2">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<span>{{ $event->registrations_count }}/{{ $event->max_capacity }}</span>
</div>
@endif
</div>
</div>
</a>
@endforeach
</div>
<div class="mt-10">
{{ $events->links() }}
</div>
@else
<div class="text-center py-16">
<svg class="w-16 h-16 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
<p class="text-gray-500 text-lg">{{ __('لا توجد أحداث حالياً') }}</p>
</div>
@endif
</div>
</div>
@endsection
This diff is collapsed.
...@@ -23,6 +23,9 @@ ...@@ -23,6 +23,9 @@
{{-- Vite Assets --}} {{-- Vite Assets --}}
@vite(['resources/css/website.css', 'resources/js/website.js']) @vite(['resources/css/website.css', 'resources/js/website.js'])
{{-- Livewire --}}
@livewireStyles
{{-- Dynamic CSS Variables from Academy Branding --}} {{-- Dynamic CSS Variables from Academy Branding --}}
<style> <style>
:root { :root {
...@@ -99,5 +102,7 @@ class="whatsapp-float" ...@@ -99,5 +102,7 @@ class="whatsapp-float"
وضع المعاينة — غير منشور وضع المعاينة — غير منشور
</div> </div>
@endisset @endisset
@livewireScripts
</body> </body>
</html> </html>
...@@ -96,6 +96,8 @@ ...@@ -96,6 +96,8 @@
->group(function () { ->group(function () {
Route::get('/', [PublicWebsiteController::class, 'show'])->name('show'); Route::get('/', [PublicWebsiteController::class, 'show'])->name('show');
Route::post('/contact', [ContactFormController::class, 'submit'])->name('contact.submit'); Route::post('/contact', [ContactFormController::class, 'submit'])->name('contact.submit');
Route::get('/events', [\App\Http\Controllers\PublicEventController::class, 'index'])->name('events.index');
Route::get('/events/{eventSlug}', [\App\Http\Controllers\PublicEventController::class, 'show'])->name('events.show');
}); });
/* /*
...@@ -310,6 +312,18 @@ ...@@ -310,6 +312,18 @@
Route::get('/hr/trainers/{trainer}/edit', \App\Livewire\HR\TrainerForm::class)->name('trainers.edit') Route::get('/hr/trainers/{trainer}/edit', \App\Livewire\HR\TrainerForm::class)->name('trainers.edit')
->middleware('permission:trainers.update'); ->middleware('permission:trainers.update');
// Events
Route::get('/events', \App\Livewire\Events\EventList::class)->name('events.list')
->middleware('permission:events.list');
Route::get('/events/create', \App\Livewire\Events\CreateEventWizard::class)->name('events.create')
->middleware('permission:events.create');
Route::get('/events/{event}', \App\Livewire\Events\EventShow::class)->name('events.show')
->middleware('permission:events.view');
Route::get('/events/{event}/edit', \App\Livewire\Events\CreateEventWizard::class)->name('events.edit')
->middleware('permission:events.update');
Route::get('/events/{event}/registrations', \App\Livewire\Events\EventRegistrationList::class)->name('events.registrations')
->middleware('permission:events.view');
// HR - Payroll // HR - Payroll
Route::get('/hr/payroll', \App\Livewire\HR\PayrollDashboard::class)->name('payroll.dashboard') Route::get('/hr/payroll', \App\Livewire\HR\PayrollDashboard::class)->name('payroll.dashboard')
->middleware('permission:payroll.manage'); ->middleware('permission:payroll.manage');
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment