| @@ -3,8 +3,9 @@ | ||
| 3 | 3 | namespace FluentBooking\App\Services; |
| 4 | 4 | |
| 5 | 5 | use FluentBooking\App\Models\Booking; |
| 6 | 6 | use FluentBooking\App\Models\CalendarSlot; |
| 7 | +use FluentBooking\App\Services\SanitizeService; | |
| 7 | 8 | use FluentBooking\Framework\Support\Arr; |
| 8 | 9 | |
| 9 | 10 | class BookingFieldService |
| 10 | 11 | { |
| @@ -17,22 +18,28 @@ | ||
| 17 | 18 | $formattedValues = []; |
| 18 | 19 | |
| 19 | 20 | foreach ($customFields as $fieldKey => $customField) { |
| 20 | 21 | $value = wp_unslash(Arr::get($postedData, $fieldKey)); |
| 21 | - if (!$value && Arr::isTrue($customField, 'required')) { | |
| 22 | - // translators: %s is the label of the required field | |
| 23 | - $errors[$fieldKey . '.required'] = sprintf(__('%s is required', 'fluent-booking'), $customField['label']); | |
| 24 | - continue; | |
| 22 | + if (Arr::isTrue($customField, 'required')) { | |
| 23 | + $isTerms = $customField['type'] === 'terms-and-conditions'; | |
| 24 | + $isCheckbox = $customField['type'] === 'checkbox'; | |
| 25 | + if (!$value || ($isCheckbox && $value !== 'Yes') || ($isTerms && $value !== 'Accepted')) { | |
| 26 | + /* translators: %s: Field label */ | |
| 27 | + $errors[$fieldKey . '.required'] = sprintf(__('%s is required', 'fluent-booking'), $customField['label']); | |
| 28 | + continue; | |
| 29 | + } | |
| 25 | 30 | } |
| 26 | 31 | |
| 27 | 32 | if (is_array($value)) { |
| 28 | 33 | if ($customField['type'] === 'multi-select') { |
| 29 | - $value = array_map( | |
| 30 | - function ($item) { | |
| 31 | - return sanitize_text_field(Arr::get($item, 'value')); | |
| 32 | - }, | |
| 33 | - $value | |
| 34 | - ); | |
| 34 | + $value = array_map(function ($item) { | |
| 35 | + $val = is_array($item) ? Arr::get($item, 'value') : $item; | |
| 36 | + return sanitize_text_field($val); | |
| 37 | + },$value); | |
| 38 | + } else if ($customField['type'] === 'file') { | |
| 39 | + $maxField = Arr::get($customField, 'max_file_allow', 1); | |
| 40 | + $value = array_slice($value, 0, $maxField); | |
| 41 | + $value = array_map('sanitize_text_field', $value); | |
| 35 | 42 | } else { |
| 36 | 43 | $value = array_map('sanitize_text_field', $value); |
| 37 | 44 | } |
| 38 | 45 | } else if ($customField['type'] == 'textarea') { |
| @@ -40,8 +47,14 @@ | ||
| 40 | 47 | } else { |
| 41 | 48 | $value = sanitize_text_field($value); |
| 42 | 49 | } |
| 43 | 50 | |
| 51 | + if ($customField['type'] === 'phone' && $value && !Helper::isValidPhoneNumber($value)) { | |
| 52 | + /* translators: %s: Field label */ | |
| 53 | + $errors[$fieldKey . '.valid_phone_number'] = sprintf(__('%s is not a valid phone number', 'fluent-booking'), $customField['label']); | |
| 54 | + continue; | |
| 55 | + } | |
| 56 | + | |
| 44 | 57 | $formattedValues[$fieldKey] = $value; |
| 45 | 58 | } |
| 46 | 59 | |
| 47 | 60 | if ($errors) { |
| @@ -50,10 +63,16 @@ | ||
| 50 | 63 | |
| 51 | 64 | return $formattedValues; |
| 52 | 65 | } |
| 53 | 66 | |
| 54 | - public static function getBookingFields(CalendarSlot $calendarSlot) | |
| 67 | + public static function getBookingFields(CalendarSlot $calendarSlot, $cached = false) | |
| 55 | 68 | { |
| 69 | + static $bookingFields = null; | |
| 70 | + | |
| 71 | + if ($cached && $bookingFields) { | |
| 72 | + return $bookingFields; | |
| 73 | + } | |
| 74 | + | |
| 56 | 75 | $requiredIndexes = ['name', 'email', 'message', 'cancellation_reason', 'rescheduling_reason']; |
| 57 | 76 | |
| 58 | 77 | $defaultFields = [ |
| 59 | 78 | 'name' => [ |
| @@ -132,8 +151,9 @@ | ||
| 132 | 151 | 'system_defined' => true, |
| 133 | 152 | 'disable_alter' => false |
| 134 | 153 | ]; |
| 135 | 154 | } |
| 155 | + | |
| 136 | 156 | if ($calendarSlot->isLocationFieldRequired()) { |
| 137 | 157 | $requiredIndexes[] = 'location'; |
| 138 | 158 | $defaultFields['location'] = [ |
| 139 | 159 | 'index' => 7, |
| @@ -222,16 +242,18 @@ | ||
| 222 | 242 | } |
| 223 | 243 | |
| 224 | 244 | $existingFields['email']['disabled'] = false; |
| 225 | 245 | |
| 226 | - return array_values($existingFields); | |
| 246 | + $bookingFields = apply_filters('fluent_booking/booking_fields', array_values($existingFields), $calendarSlot); | |
| 247 | + | |
| 248 | + return $bookingFields; | |
| 227 | 249 | } |
| 228 | 250 | |
| 229 | 251 | public static function getBookingFieldLabels(CalendarSlot $calendarSlot, $enabledOnly = false) |
| 230 | 252 | { |
| 231 | - $fields = self::getBookingFields($calendarSlot); | |
| 253 | + $fields = self::getBookingFields($calendarSlot, true); | |
| 254 | + | |
| 232 | 255 | $labels = []; |
| 233 | - | |
| 234 | 256 | foreach ($fields as $field) { |
| 235 | 257 | if ($enabledOnly && !Arr::isTrue($field, 'enabled')) { |
| 236 | 258 | continue; |
| 237 | 259 | } |
| @@ -243,9 +265,10 @@ | ||
| 243 | 265 | } |
| 244 | 266 | |
| 245 | 267 | public static function generateFieldName($calendarEvent, $fieldLabel) |
| 246 | 268 | { |
| 247 | - $fieldName = 'custom_' . sanitize_title($fieldLabel); | |
| 269 | + $fieldLabel = preg_replace('/[^A-Za-z0-9]/', '_', $fieldLabel); | |
| 270 | + $fieldName = 'custom_' . strtolower($fieldLabel); | |
| 248 | 271 | $bookingFields = self::getBookingFields($calendarEvent); |
| 249 | 272 | |
| 250 | 273 | $matched = 0; |
| 251 | 274 | foreach ($bookingFields as $field) { |
| @@ -259,10 +282,23 @@ | ||
| 259 | 282 | } |
| 260 | 283 | return $fieldName; |
| 261 | 284 | } |
| 262 | 285 | |
| 263 | - public static function getFormattedCustomBookingData(Booking $booking) | |
| 286 | + public static function maybeGenerateFieldName($calendarEvent, $fieldValue) | |
| 264 | 287 | { |
| 288 | + $bookingFields = self::getBookingFields($calendarEvent); | |
| 289 | + | |
| 290 | + foreach ($bookingFields as $field) { | |
| 291 | + if ($field['name'] == $fieldValue['name'] && $field['index'] != $fieldValue['index']) { | |
| 292 | + return self::generateFieldName($calendarEvent, $fieldValue['label']); | |
| 293 | + } | |
| 294 | + } | |
| 295 | + | |
| 296 | + return sanitize_text_field($fieldValue['name']); | |
| 297 | + } | |
| 298 | + | |
| 299 | + public static function getFormattedCustomBookingData(Booking $booking, $htmlSupport = true, $isPublic = false) | |
| 300 | + { | |
| 265 | 301 | $customFormData = $booking->getMeta('custom_fields_data', []); |
| 266 | 302 | if (!$customFormData) { |
| 267 | 303 | return []; |
| 268 | 304 | } |
| @@ -271,16 +307,29 @@ | ||
| 271 | 307 | |
| 272 | 308 | $formattedData = []; |
| 273 | 309 | |
| 274 | 310 | foreach ($customFormData as $dataKey => $value) { |
| 275 | - if (isset($labels[$dataKey])) { | |
| 276 | - $label = $labels[$dataKey]; | |
| 277 | - } else { | |
| 278 | - $label = $dataKey; | |
| 311 | + $label = $labels[$dataKey] ?? $dataKey; | |
| 312 | + | |
| 313 | + $formattedValue = is_array($value) ? implode(', ', $value) : $value; | |
| 314 | + | |
| 315 | + $field = self::getBookingFieldByName($booking->calendar_event, $dataKey); | |
| 316 | + | |
| 317 | + $fieldType = Arr::get($field, 'type'); | |
| 318 | + | |
| 319 | + if ($fieldType == 'file' && is_array($value)) { | |
| 320 | + $formattedValue = self::getUploadedFiles($value, $htmlSupport); | |
| 279 | 321 | } |
| 322 | + | |
| 323 | + if ($fieldType == 'hidden') { | |
| 324 | + if ($isPublic) continue; | |
| 325 | + $formattedValue = EditorShortcodeParser::parse($formattedValue, $booking); | |
| 326 | + } | |
| 327 | + | |
| 280 | 328 | $formattedData[$dataKey] = [ |
| 281 | 329 | 'label' => $label, |
| 282 | - 'value' => is_array($value) ? implode(', ', $value) : $value | |
| 330 | + 'type' => $fieldType, | |
| 331 | + 'value' => $formattedValue | |
| 283 | 332 | ]; |
| 284 | 333 | } |
| 285 | 334 | |
| 286 | 335 | return $formattedData; |
| @@ -311,9 +360,9 @@ | ||
| 311 | 360 | } |
| 312 | 361 | |
| 313 | 362 | public static function getBookingFieldByName($calendarEvent, $name) |
| 314 | 363 | { |
| 315 | - $fields = self::getBookingFields($calendarEvent); | |
| 364 | + $fields = self::getBookingFields($calendarEvent, true); | |
| 316 | 365 | |
| 317 | 366 | foreach ($fields as $field) { |
| 318 | 367 | if (Arr::get($field, 'name') == $name) { |
| 319 | 368 | return $field; |
| @@ -342,6 +391,116 @@ | ||
| 342 | 391 | } |
| 343 | 392 | } |
| 344 | 393 | } |
| 345 | 394 | return false; |
| 395 | + } | |
| 396 | + | |
| 397 | + public static function getUploadedFiles($fieldValue, $htmlSupport = true) | |
| 398 | + { | |
| 399 | + if (empty($fieldValue)) { | |
| 400 | + return ''; | |
| 401 | + } | |
| 402 | + | |
| 403 | + $files = array_map(function($file) use ($htmlSupport) { | |
| 404 | + if ($htmlSupport) { | |
| 405 | + return '<a href="' . esc_url($file) . '" target="_blank" download="' . esc_attr(basename($file)) . '">' . esc_html(basename($file)) . '</a>'; | |
| 406 | + } | |
| 407 | + return $file; | |
| 408 | + }, $fieldValue); | |
| 409 | + | |
| 410 | + $separator = $htmlSupport ? '<br>' : PHP_EOL; | |
| 411 | + | |
| 412 | + return implode($separator, $files); | |
| 413 | + } | |
| 414 | + | |
| 415 | + public static function validateDateFields($customFieldsData, $calendarEvent) | |
| 416 | + { | |
| 417 | + foreach ($customFieldsData as $fieldKey => $fieldValue) { | |
| 418 | + $field = self::getBookingFieldByName($calendarEvent, $fieldKey); | |
| 419 | + if ($fieldValue && Arr::get($field, 'type') == 'date') { | |
| 420 | + $minDate = Arr::get($field, 'min_date'); | |
| 421 | + $maxDate = Arr::get($field, 'max_date'); | |
| 422 | + | |
| 423 | + $fieldValue = DateTimeHelper::getFormattedDate($fieldValue, Arr::get($field, 'date_format')); | |
| 424 | + $minDate = gmdate('Y-m-d', strtotime($minDate ?: '1900-01-01')); | |
| 425 | + $maxDate = gmdate('Y-m-d', strtotime($maxDate ?: gmdate('Y-12-31'))); | |
| 426 | + | |
| 427 | + if ($minDate && $fieldValue < $minDate) { | |
| 428 | + /* translators: %1$s: Field label, %2$s: Minimum date */ | |
| 429 | + return new \WP_Error('invalid_date', sprintf(__('The date for %1$s cannot be earlier than %2$s.', 'fluent-booking'), $field['label'], $minDate)); | |
| 430 | + } | |
| 431 | + if ($maxDate && $fieldValue > $maxDate) { | |
| 432 | + /* translators: %1$s: Field label, %2$s: Maximum date */ | |
| 433 | + return new \WP_Error('invalid_date', sprintf(__('The date for %1$s cannot be later than %2$s.', 'fluent-booking'), $field['label'], $maxDate)); | |
| 434 | + } | |
| 435 | + } | |
| 436 | + } | |
| 437 | + return true; | |
| 438 | + } | |
| 439 | + | |
| 440 | + /** | |
| 441 | + * Format booking fields (text sanitized, terms-and-conditions kses'd). Shared | |
| 442 | + * by the admin save and calendar import so neither path can store raw HTML. | |
| 443 | + * $calendarEvent is null-safe (import skips name generation). | |
| 444 | + */ | |
| 445 | + public static function sanitizeBookingFields($bookingFields, $calendarEvent = null) | |
| 446 | + { | |
| 447 | + if (!is_array($bookingFields)) { | |
| 448 | + return []; | |
| 449 | + } | |
| 450 | + | |
| 451 | + $optionRequiredFields = ['dropdown', 'radio', 'checkbox-group', 'multi-select']; | |
| 452 | + $textFields = ['type', 'name', 'label', 'placeholder', 'limit', 'help_text', 'date_format', 'min_date', 'max_date']; | |
| 453 | + $booleanFields = ['enabled', 'required', 'system_defined', 'disable_alter', 'is_sms_number']; | |
| 454 | + | |
| 455 | + $formattedFields = []; | |
| 456 | + | |
| 457 | + foreach ($bookingFields as $value) { | |
| 458 | + if (!is_array($value)) { | |
| 459 | + continue; | |
| 460 | + } | |
| 461 | + | |
| 462 | + if ($calendarEvent) { | |
| 463 | + if (empty($value['name'])) { | |
| 464 | + $value['name'] = self::generateFieldName($calendarEvent, Arr::get($value, 'label', '')); | |
| 465 | + } else { | |
| 466 | + $value['name'] = self::maybeGenerateFieldName($calendarEvent, $value); | |
| 467 | + } | |
| 468 | + } else { | |
| 469 | + $value['name'] = sanitize_text_field(Arr::get($value, 'name', '')); | |
| 470 | + } | |
| 471 | + | |
| 472 | + $textValues = array_map('sanitize_text_field', Arr::only($value, $textFields)); | |
| 473 | + | |
| 474 | + $booleanValues = array_map(function ($valueItem) { | |
| 475 | + return $valueItem === true || $valueItem === 'true' || $valueItem == 1; | |
| 476 | + }, Arr::only($value, $booleanFields)); | |
| 477 | + | |
| 478 | + $formattedField = array_merge($textValues, $booleanValues); | |
| 479 | + | |
| 480 | + $fieldType = Arr::get($value, 'type'); | |
| 481 | + | |
| 482 | + $formattedField['index'] = (int) Arr::get($value, 'index'); | |
| 483 | + if (in_array($fieldType, $optionRequiredFields)) { | |
| 484 | + $formattedField['options'] = array_map('sanitize_text_field', (array) Arr::get($value, 'options', [])); | |
| 485 | + } | |
| 486 | + if ($fieldType == 'file') { | |
| 487 | + $formattedField['max_file_allow'] = intval(Arr::get($value, 'max_file_allow')); | |
| 488 | + $formattedField['allow_file_types'] = array_map('sanitize_text_field', (array) Arr::get($value, 'allow_file_types', [])); | |
| 489 | + $formattedField['file_size_value'] = intval(Arr::get($value, 'file_size_value')); | |
| 490 | + $formattedField['file_size_unit'] = SanitizeService::checkCollection(Arr::get($value, 'file_size_unit'), ['kb', 'mb']); | |
| 491 | + } | |
| 492 | + if ($fieldType == 'hidden') { | |
| 493 | + $formattedField['default_value'] = sanitize_text_field(Arr::get($value, 'default_value')); | |
| 494 | + } | |
| 495 | + if ($fieldType == 'terms-and-conditions') { | |
| 496 | + $formattedField['terms_and_conditions'] = wp_kses_post(Arr::get($value, 'terms_and_conditions')); | |
| 497 | + } | |
| 498 | + | |
| 499 | + $formattedField = apply_filters('fluent_booking/save_event_booking_field_' . $fieldType, $formattedField, $value, $calendarEvent); | |
| 500 | + | |
| 501 | + $formattedFields[] = $formattedField; | |
| 502 | + } | |
| 503 | + | |
| 504 | + return $formattedFields; | |
| 346 | 505 | } |
| 347 | 506 | } |