Commit ce5c6e89 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Upgrade event form builder with 20+ new features — radio, uniqueness, maps, conditionals

- Add radio, national_id, location, section_divider, terms field types to FormFieldType enum
- Add field uniqueness constraint (blocks duplicate registrations per unique field)
- Add Google Maps locations support (JSONB column + map embed on public page)
- Add conditional field visibility (show field only when another field equals a value)
- Add min/max length, min/max value, regex pattern validation per field
- Add allow_other option for select/radio/multi_select fields
- Add field description/help text, placeholder, default value
- Add duplicate field action in form builder
- Add section dividers for visual grouping
- Add terms acceptance field type with URL link
- Sanitize form fields before save (auto-fill values, strip invalid options)
- Public registration form uses Alpine.js for conditional visibility
- Character counters shown when max_length is set
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent a26be026
...@@ -12,8 +12,13 @@ ...@@ -12,8 +12,13 @@
case Date = 'date'; case Date = 'date';
case Select = 'select'; case Select = 'select';
case MultiSelect = 'multi_select'; case MultiSelect = 'multi_select';
case Radio = 'radio';
case Checkbox = 'checkbox'; case Checkbox = 'checkbox';
case FileUpload = 'file_upload'; case FileUpload = 'file_upload';
case NationalId = 'national_id';
case Location = 'location';
case SectionDivider = 'section_divider';
case Terms = 'terms';
public function label(): string public function label(): string
{ {
...@@ -24,10 +29,15 @@ public function label(): string ...@@ -24,10 +29,15 @@ public function label(): string
self::Email => 'بريد إلكتروني', self::Email => 'بريد إلكتروني',
self::Phone => 'هاتف', self::Phone => 'هاتف',
self::Date => 'تاريخ', self::Date => 'تاريخ',
self::Select => 'قائمة اختيار', self::Select => 'قائمة منسدلة (اختيار واحد)',
self::MultiSelect => 'اختيار متعدد', self::MultiSelect => 'اختيار متعدد',
self::Radio => 'أزرار اختيار (إجابة واحدة)',
self::Checkbox => 'خانة اختيار', self::Checkbox => 'خانة اختيار',
self::FileUpload => 'رفع ملف', self::FileUpload => 'رفع ملف',
self::NationalId => 'الرقم القومي',
self::Location => 'موقع خريطة',
self::SectionDivider => 'فاصل / عنوان قسم',
self::Terms => 'الموافقة على الشروط',
}; };
} }
...@@ -42,8 +52,13 @@ public function icon(): string ...@@ -42,8 +52,13 @@ public function icon(): string
self::Date => 'calendar', self::Date => 'calendar',
self::Select => 'chevron-down', self::Select => 'chevron-down',
self::MultiSelect => 'list-bullet', self::MultiSelect => 'list-bullet',
self::Checkbox => 'check-circle', self::Radio => 'check-circle',
self::Checkbox => 'check',
self::FileUpload => 'arrow-up-tray', self::FileUpload => 'arrow-up-tray',
self::NationalId => 'identification',
self::Location => 'map-pin',
self::SectionDivider => 'minus',
self::Terms => 'shield-check',
}; };
} }
...@@ -58,8 +73,28 @@ public function baseValidation(): string ...@@ -58,8 +73,28 @@ public function baseValidation(): string
self::Date => 'date', self::Date => 'date',
self::Select => 'string', self::Select => 'string',
self::MultiSelect => 'array', self::MultiSelect => 'array',
self::Checkbox => 'boolean', self::Radio => 'string',
self::Checkbox => 'accepted',
self::FileUpload => 'file|max:5120', self::FileUpload => 'file|max:5120',
self::NationalId => 'string|size:14|regex:/^[0-9]{14}$/',
self::Location => 'string',
self::SectionDivider => '',
self::Terms => 'accepted',
}; };
} }
public function hasOptions(): bool
{
return in_array($this, [self::Select, self::MultiSelect, self::Radio]);
}
public function isInputField(): bool
{
return $this !== self::SectionDivider;
}
public function supportsUnique(): bool
{
return in_array($this, [self::Text, self::Email, self::Phone, self::NationalId, self::Number]);
}
} }
...@@ -34,6 +34,7 @@ class Event extends Model ...@@ -34,6 +34,7 @@ class Event extends Model
'facility_id', 'facility_id',
'location_name', 'location_name',
'location_address', 'location_address',
'map_locations',
'starts_at', 'starts_at',
'ends_at', 'ends_at',
'registration_opens_at', 'registration_opens_at',
...@@ -59,6 +60,7 @@ class Event extends Model ...@@ -59,6 +60,7 @@ class Event extends Model
'registrations_count' => 'integer', 'registrations_count' => 'integer',
'form_fields' => 'array', 'form_fields' => 'array',
'settings' => 'array', 'settings' => 'array',
'map_locations' => 'array',
]; ];
public function facility(): BelongsTo public function facility(): BelongsTo
......
...@@ -23,6 +23,8 @@ public function register(Event $event, array $formData, array $meta): EventRegis ...@@ -23,6 +23,8 @@ public function register(Event $event, array $formData, array $meta): EventRegis
throw new DomainException('تم اكتمال العدد المسموح به'); throw new DomainException('تم اكتمال العدد المسموح به');
} }
$this->checkUniqueness($event, $formData);
return DB::transaction(function () use ($event, $formData, $meta) { return DB::transaction(function () use ($event, $formData, $meta) {
$personId = $this->matchPerson($meta['phone'], $meta['email'] ?? null, $event->academy_id); $personId = $this->matchPerson($meta['phone'], $meta['email'] ?? null, $event->academy_id);
...@@ -126,40 +128,130 @@ public function validateFormData(Event $event, array $submitted): array ...@@ -126,40 +128,130 @@ public function validateFormData(Event $event, array $submitted): array
$messages = []; $messages = [];
foreach ($event->form_fields as $field) { foreach ($event->form_fields as $field) {
$type = FormFieldType::tryFrom($field['type']);
if (! $type || ! $type->isInputField()) {
continue;
}
if ($this->isFieldHiddenByCondition($field, $submitted, $event->form_fields)) {
continue;
}
$key = "fields.{$field['key']}"; $key = "fields.{$field['key']}";
$fieldRules = []; $fieldRules = [];
if ($field['is_required']) { if ($field['is_required'] ?? false) {
$fieldRules[] = 'required'; $fieldRules[] = $field['type'] === 'terms' ? 'accepted' : 'required';
$messages["{$key}.required"] = "حقل {$field['label']} مطلوب"; $messages["{$key}.required"] = "حقل {$field['label']} مطلوب";
$messages["{$key}.accepted"] = "يجب الموافقة على {$field['label']}";
} else { } else {
$fieldRules[] = 'nullable'; $fieldRules[] = 'nullable';
} }
$type = FormFieldType::tryFrom($field['type']); if ($type && $type->baseValidation()) {
if ($type) {
foreach (explode('|', $type->baseValidation()) as $rule) { foreach (explode('|', $type->baseValidation()) as $rule) {
if ($rule === 'accepted' && !($field['is_required'] ?? false)) {
continue;
}
$fieldRules[] = $rule; $fieldRules[] = $rule;
} }
} }
if (in_array($field['type'], ['select']) && ! empty($field['options'])) { if ($type && $type->hasOptions() && !empty($field['options'])) {
$values = array_column($field['options'], 'value'); $values = array_column($field['options'], 'value');
$fieldRules[] = 'in:' . implode(',', $values); if ($field['allow_other'] ?? false) {
// Don't restrict to options if "Other" is allowed
} elseif ($type === FormFieldType::MultiSelect) {
$fieldRules[] = 'array';
$rules["{$key}.*"] = 'in:' . implode(',', $values);
} else {
$fieldRules[] = 'in:' . implode(',', $values);
}
} }
$rules[$key] = implode('|', $fieldRules); if (!empty($field['min_length'])) {
$fieldRules[] = 'min:' . (int) $field['min_length'];
}
if (!empty($field['max_length'])) {
$fieldRules[] = 'max:' . (int) $field['max_length'];
}
if (!empty($field['min_value']) && $type === FormFieldType::Number) {
$fieldRules[] = 'min:' . (float) $field['min_value'];
}
if (!empty($field['max_value']) && $type === FormFieldType::Number) {
$fieldRules[] = 'max:' . (float) $field['max_value'];
}
if (!empty($field['pattern'])) {
$fieldRules[] = 'regex:/' . $field['pattern'] . '/';
$messages["{$key}.regex"] = $field['pattern_message'] ?? "تنسيق {$field['label']} غير صحيح";
}
$rules[$key] = implode('|', array_filter($fieldRules));
} }
return ['rules' => $rules, 'messages' => $messages]; return ['rules' => $rules, 'messages' => $messages];
} }
public function checkUniqueness(Event $event, array $formData): void
{
foreach ($event->form_fields as $field) {
if (empty($field['is_unique'])) {
continue;
}
$value = $formData[$field['key']] ?? null;
if (empty($value)) {
continue;
}
$exists = EventRegistration::withoutGlobalScopes()
->where('event_id', $event->id)
->where('status', '!=', 'cancelled')
->whereRaw("form_data->>? = ?", [$field['key'], $value])
->exists();
if ($exists) {
throw new DomainException("تم التسجيل مسبقاً بنفس {$field['label']}");
}
}
}
private function isFieldHiddenByCondition(array $field, array $submitted, array $allFields): bool
{
if (empty($field['condition_field']) || empty($field['condition_value'])) {
return false;
}
$conditionFieldKey = $field['condition_field'];
$conditionValue = $field['condition_value'];
$submittedValue = $submitted[$conditionFieldKey] ?? null;
if ($submittedValue === null) {
return true;
}
if (is_array($submittedValue)) {
return !in_array($conditionValue, $submittedValue);
}
return (string) $submittedValue !== (string) $conditionValue;
}
public function matchPerson(string $phone, ?string $email, int $academyId): ?int public function matchPerson(string $phone, ?string $email, int $academyId): ?int
{ {
if (empty($phone) && empty($email)) {
return null;
}
$person = Person::withoutGlobalScopes() $person = Person::withoutGlobalScopes()
->where('academy_id', $academyId) ->where('academy_id', $academyId)
->where(function ($q) use ($phone, $email) { ->where(function ($q) use ($phone, $email) {
$q->where('phone', $phone); if ($phone) {
$q->where('phone', $phone);
}
if ($email) { if ($email) {
$q->orWhere('email', $email); $q->orWhere('email', $email);
} }
......
...@@ -38,6 +38,7 @@ class CreateEventWizard extends Component ...@@ -38,6 +38,7 @@ class CreateEventWizard extends Component
public ?int $facilityId = null; public ?int $facilityId = null;
public string $locationName = ''; public string $locationName = '';
public string $locationAddress = ''; public string $locationAddress = '';
public array $mapLocations = [];
public string $startsAt = ''; public string $startsAt = '';
public string $endsAt = ''; public string $endsAt = '';
public string $registrationOpensAt = ''; public string $registrationOpensAt = '';
...@@ -75,6 +76,7 @@ private function fillFromEvent(Event $event): void ...@@ -75,6 +76,7 @@ private function fillFromEvent(Event $event): void
$this->facilityId = $event->facility_id; $this->facilityId = $event->facility_id;
$this->locationName = $event->location_name ?? ''; $this->locationName = $event->location_name ?? '';
$this->locationAddress = $event->location_address ?? ''; $this->locationAddress = $event->location_address ?? '';
$this->mapLocations = $event->map_locations ?: [];
$this->startsAt = $event->starts_at?->format('Y-m-d\TH:i'); $this->startsAt = $event->starts_at?->format('Y-m-d\TH:i');
$this->endsAt = $event->ends_at?->format('Y-m-d\TH:i'); $this->endsAt = $event->ends_at?->format('Y-m-d\TH:i');
$this->registrationOpensAt = $event->registration_opens_at?->format('Y-m-d\TH:i') ?? ''; $this->registrationOpensAt = $event->registration_opens_at?->format('Y-m-d\TH:i') ?? '';
...@@ -86,42 +88,40 @@ private function fillFromEvent(Event $event): void ...@@ -86,42 +88,40 @@ private function fillFromEvent(Event $event): void
private function getDefaultFields(): array private function getDefaultFields(): array
{ {
return [ return [
[ $this->makeField('name', 'text', 'الاسم الكامل', true, 'full', ['label_en' => 'Full Name']),
'key' => 'name', $this->makeField('phone', 'phone', 'رقم الهاتف', true, 'half', ['label_en' => 'Phone', 'placeholder' => '01XXXXXXXXX', 'is_unique' => true]),
'type' => 'text', $this->makeField('email', 'email', 'البريد الإلكتروني', false, 'half', ['label_en' => 'Email']),
'label' => 'الاسم الكامل',
'label_en' => 'Full Name',
'placeholder' => '',
'is_required' => true,
'sort_order' => 1,
'options' => [],
'width' => 'full',
],
[
'key' => 'phone',
'type' => 'phone',
'label' => 'رقم الهاتف',
'label_en' => 'Phone',
'placeholder' => '01XXXXXXXXX',
'is_required' => true,
'sort_order' => 2,
'options' => [],
'width' => 'half',
],
[
'key' => 'email',
'type' => 'email',
'label' => 'البريد الإلكتروني',
'label_en' => 'Email',
'placeholder' => '',
'is_required' => false,
'sort_order' => 3,
'options' => [],
'width' => 'half',
],
]; ];
} }
private function makeField(string $key, string $type, string $label, bool $required, string $width, array $extra = []): array
{
return array_merge([
'key' => $key,
'type' => $type,
'label' => $label,
'label_en' => '',
'placeholder' => '',
'description' => '',
'is_required' => $required,
'is_unique' => false,
'sort_order' => 0,
'options' => [],
'width' => $width,
'allow_other' => false,
'min_length' => null,
'max_length' => null,
'min_value' => null,
'max_value' => null,
'default_value' => '',
'pattern' => '',
'pattern_message' => '',
'condition_field' => '',
'condition_value' => '',
'terms_url' => '',
], $extra);
}
public function nextStep(): void public function nextStep(): void
{ {
$this->validateStep(); $this->validateStep();
...@@ -173,21 +173,25 @@ private function validateStep(): void ...@@ -173,21 +173,25 @@ private function validateStep(): void
} }
} }
// Form Builder Actions // ========== Form Builder Actions ==========
public function addField(): void public function addField(): void
{ {
$order = count($this->formFields) + 1; $order = count($this->formFields) + 1;
$this->formFields[] = [ $this->formFields[] = $this->makeField('field_' . $order, 'text', '', false, 'full');
'key' => 'field_' . $order, $this->reorderFields();
'type' => 'text', }
'label' => '',
'label_en' => '', public function duplicateField(int $index): void
'placeholder' => '', {
'is_required' => false, if (!isset($this->formFields[$index])) return;
'sort_order' => $order,
'options' => [], $clone = $this->formFields[$index];
'width' => 'full', $clone['key'] = $clone['key'] . '_copy';
]; $clone['label'] = $clone['label'] . ' (نسخة)';
array_splice($this->formFields, $index + 1, 0, [$clone]);
$this->reorderFields();
} }
public function removeField(int $index): void public function removeField(int $index): void
...@@ -219,11 +223,13 @@ public function moveFieldDown(int $index): void ...@@ -219,11 +223,13 @@ public function moveFieldDown(int $index): void
public function addOption(int $fieldIndex): void public function addOption(int $fieldIndex): void
{ {
if (!isset($this->formFields[$fieldIndex])) return;
$this->formFields[$fieldIndex]['options'][] = ['value' => '', 'label' => '']; $this->formFields[$fieldIndex]['options'][] = ['value' => '', 'label' => ''];
} }
public function removeOption(int $fieldIndex, int $optionIndex): void public function removeOption(int $fieldIndex, int $optionIndex): void
{ {
if (!isset($this->formFields[$fieldIndex]['options'][$optionIndex])) return;
unset($this->formFields[$fieldIndex]['options'][$optionIndex]); unset($this->formFields[$fieldIndex]['options'][$optionIndex]);
$this->formFields[$fieldIndex]['options'] = array_values($this->formFields[$fieldIndex]['options']); $this->formFields[$fieldIndex]['options'] = array_values($this->formFields[$fieldIndex]['options']);
} }
...@@ -232,13 +238,36 @@ private function reorderFields(): void ...@@ -232,13 +238,36 @@ private function reorderFields(): void
{ {
foreach ($this->formFields as $i => &$field) { foreach ($this->formFields as $i => &$field) {
$field['sort_order'] = $i + 1; $field['sort_order'] = $i + 1;
$field['key'] = $field['key'] ?: 'field_' . ($i + 1); if (empty($field['key'])) {
$field['key'] = 'field_' . ($i + 1);
}
} }
} }
// ========== Map Locations ==========
public function addMapLocation(): void
{
$this->mapLocations[] = [
'title' => '',
'lat' => '',
'lng' => '',
'address' => '',
];
}
public function removeMapLocation(int $index): void
{
unset($this->mapLocations[$index]);
$this->mapLocations = array_values($this->mapLocations);
}
// ========== Save ==========
public function save(EventService $eventService, MediaService $mediaService): void public function save(EventService $eventService, MediaService $mediaService): void
{ {
$this->validateStep(); $this->validateStep();
$this->sanitizeFormFields();
$academyId = app('current_academy')->id; $academyId = app('current_academy')->id;
...@@ -253,6 +282,7 @@ public function save(EventService $eventService, MediaService $mediaService): vo ...@@ -253,6 +282,7 @@ public function save(EventService $eventService, MediaService $mediaService): vo
'facility_id' => $this->locationType === 'facility' ? $this->facilityId : null, 'facility_id' => $this->locationType === 'facility' ? $this->facilityId : null,
'location_name' => $this->locationType === 'external' ? $this->locationName : null, 'location_name' => $this->locationType === 'external' ? $this->locationName : null,
'location_address' => $this->locationType === 'external' ? $this->locationAddress : null, 'location_address' => $this->locationType === 'external' ? $this->locationAddress : null,
'map_locations' => array_filter($this->mapLocations, fn ($loc) => !empty($loc['lat']) && !empty($loc['lng'])),
'starts_at' => $this->startsAt, 'starts_at' => $this->startsAt,
'ends_at' => $this->endsAt, 'ends_at' => $this->endsAt,
'registration_opens_at' => $this->registrationOpensAt ?: null, 'registration_opens_at' => $this->registrationOpensAt ?: null,
...@@ -268,7 +298,6 @@ public function save(EventService $eventService, MediaService $mediaService): vo ...@@ -268,7 +298,6 @@ public function save(EventService $eventService, MediaService $mediaService): vo
$event = $eventService->create($data, auth()->user()); $event = $eventService->create($data, auth()->user());
} }
// Upload cover
if ($this->coverPhoto) { if ($this->coverPhoto) {
if ($event->cover) { if ($event->cover) {
$mediaService->replace($event->cover, $this->coverPhoto); $mediaService->replace($event->cover, $this->coverPhoto);
...@@ -282,7 +311,6 @@ public function save(EventService $eventService, MediaService $mediaService): vo ...@@ -282,7 +311,6 @@ public function save(EventService $eventService, MediaService $mediaService): vo
} }
} }
// Upload gallery
foreach ($this->galleryPhotos as $photo) { foreach ($this->galleryPhotos as $photo) {
$mediaService->upload( $mediaService->upload(
$photo, $photo,
...@@ -302,6 +330,39 @@ public function save(EventService $eventService, MediaService $mediaService): vo ...@@ -302,6 +330,39 @@ public function save(EventService $eventService, MediaService $mediaService): vo
} }
} }
private function sanitizeFormFields(): void
{
foreach ($this->formFields as $i => &$field) {
$field['key'] = $field['key'] ?: 'field_' . ($i + 1);
$field['key'] = preg_replace('/[^a-z0-9_]/', '_', strtolower($field['key']));
$type = FormFieldType::tryFrom($field['type']);
if (!$type || !$type->hasOptions()) {
$field['options'] = [];
$field['allow_other'] = false;
}
if (!$type || !$type->supportsUnique()) {
$field['is_unique'] = false;
}
if ($type === FormFieldType::SectionDivider) {
$field['is_required'] = false;
$field['is_unique'] = false;
}
// Auto-fill option values from labels if empty
foreach ($field['options'] as $oi => &$opt) {
if (empty($opt['value']) && !empty($opt['label'])) {
$opt['value'] = preg_replace('/\s+/', '_', trim($opt['label']));
}
}
// Remove empty options
$field['options'] = array_values(array_filter($field['options'], fn ($o) => !empty($o['label'])));
}
}
public function render() public function render()
{ {
return view('livewire.events.create-event-wizard', [ return view('livewire.events.create-event-wizard', [
......
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('events', function (Blueprint $table) {
$table->jsonb('map_locations')->default('[]')->after('location_address');
});
}
public function down(): void
{
Schema::table('events', function (Blueprint $table) {
$table->dropColumn('map_locations');
});
}
};
...@@ -50,11 +50,11 @@ class="w-full h-full object-cover"> ...@@ -50,11 +50,11 @@ class="w-full h-full object-cover">
@endphp @endphp
<div class="flex flex-wrap items-center gap-3 mb-4"> <div class="flex flex-wrap items-center gap-3 mb-4">
<span class="inline-block px-3 py-1 text-xs font-bold rounded-full {{ $statusColors[$event->status] ?? 'bg-gray-100 text-gray-800' }}"> <span class="inline-block px-3 py-1 text-xs font-bold rounded-full {{ $statusColors[$event->status->value ?? $event->status] ?? 'bg-gray-100 text-gray-800' }}">
{{ $statusLabels[$event->status] ?? $event->status }} {{ $statusLabels[$event->status->value ?? $event->status] ?? $event->status }}
</span> </span>
<span class="inline-block px-3 py-1 text-xs font-medium rounded-full bg-[var(--site-accent)]/10 text-[var(--site-accent)]"> <span class="inline-block px-3 py-1 text-xs font-medium rounded-full bg-[var(--site-accent)]/10 text-[var(--site-accent)]">
{{ $typeLabels[$event->type] ?? $event->type }} {{ $typeLabels[$event->type->value ?? $event->type] ?? $event->type }}
</span> </span>
</div> </div>
...@@ -156,6 +156,33 @@ class="w-full h-full object-cover"> ...@@ -156,6 +156,33 @@ class="w-full h-full object-cover">
</div> </div>
</div> </div>
@endif @endif
{{-- Map Locations --}}
@if(!empty($event->map_locations))
<div>
<h3 class="text-lg font-bold text-gray-900 mb-4">{{ __('الموقع على الخريطة') }}</h3>
<div class="space-y-4">
@foreach($event->map_locations as $loc)
@if(!empty($loc['lat']) && !empty($loc['lng']))
<div class="rounded-xl overflow-hidden border border-gray-200">
@if(!empty($loc['title']))
<div class="bg-gray-50 px-4 py-2 border-b border-gray-200">
<p class="text-sm font-medium text-gray-700">{{ $loc['title'] }}</p>
@if(!empty($loc['address']))
<p class="text-xs text-gray-500">{{ $loc['address'] }}</p>
@endif
</div>
@endif
<iframe
src="https://maps.google.com/maps?q={{ $loc['lat'] }},{{ $loc['lng'] }}&z=15&output=embed"
width="100%" height="250" style="border:0;" allowfullscreen loading="lazy"
referrerpolicy="no-referrer-when-downgrade" class="w-full"></iframe>
</div>
@endif
@endforeach
</div>
</div>
@endif
</div> </div>
{{-- Sidebar: Registration Form --}} {{-- Sidebar: Registration Form --}}
......
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