| @@ -3,13 +3,18 @@ | ||
| 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\Libs\FileSystem; | |
| 8 | +use FluentBooking\App\Services\SanitizeService; | |
| 7 | 9 | use FluentBooking\Framework\Support\Arr; |
| 8 | 10 | |
| 9 | 11 | class BookingFieldService |
| 10 | 12 | { |
| 11 | - public static function getCustomFieldsData($postedData, CalendarSlot $slot) | |
| 13 | + /** | |
| 14 | + * @param array|null $mappedKeys Fluent Forms mapped fields: read and sanitized only, the form owns validation. | |
| 15 | + */ | |
| 16 | + public static function getCustomFieldsData($postedData, CalendarSlot $slot, $mappedKeys = null) | |
| 12 | 17 | { |
| 13 | 18 | $customFields = self::getCustomFields($slot, true); |
| 14 | 19 | |
| 15 | 20 | $errors = []; |
| @@ -16,23 +21,40 @@ | ||
| 16 | 21 | |
| 17 | 22 | $formattedValues = []; |
| 18 | 23 | |
| 19 | 24 | foreach ($customFields as $fieldKey => $customField) { |
| 25 | + $isMapped = $mappedKeys !== null; | |
| 26 | + | |
| 27 | + if ($isMapped && !in_array($fieldKey, $mappedKeys, true)) { | |
| 28 | + continue; | |
| 29 | + } | |
| 30 | + | |
| 20 | 31 | $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; | |
| 32 | + | |
| 33 | + if ($customField['type'] === 'file' && $value) { | |
| 34 | + // A posted external URL would render as a trusted download link, and | |
| 35 | + // pro deletes stored basenames from the upload folder with the booking. | |
| 36 | + // Slice first so a crafted array can't force a check per entry. | |
| 37 | + $value = array_slice((array) $value, 0, (int) Arr::get($customField, 'max_file_allow', 1)); | |
| 38 | + $value = array_values(array_filter($value, [FileSystem::class, 'isUploadedFileUrl'])); | |
| 25 | 39 | } |
| 26 | 40 | |
| 41 | + if (!$isMapped && Arr::isTrue($customField, 'required')) { | |
| 42 | + $isTerms = $customField['type'] === 'terms-and-conditions'; | |
| 43 | + $isCheckbox = $customField['type'] === 'checkbox'; | |
| 44 | + if (!$value || ($isCheckbox && $value !== 'Yes') || ($isTerms && $value !== 'Accepted')) { | |
| 45 | + /* translators: %s: Field label */ | |
| 46 | + $errors[$fieldKey . '.required'] = sprintf(__('%s is required', 'fluent-booking'), $customField['label']); | |
| 47 | + continue; | |
| 48 | + } | |
| 49 | + } | |
| 50 | + | |
| 27 | 51 | if (is_array($value)) { |
| 28 | 52 | 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 | - ); | |
| 53 | + $value = array_map(function ($item) { | |
| 54 | + $val = is_array($item) ? Arr::get($item, 'value') : $item; | |
| 55 | + return sanitize_text_field($val); | |
| 56 | + },$value); | |
| 35 | 57 | } else { |
| 36 | 58 | $value = array_map('sanitize_text_field', $value); |
| 37 | 59 | } |
| 38 | 60 | } else if ($customField['type'] == 'textarea') { |
| @@ -40,8 +62,14 @@ | ||
| 40 | 62 | } else { |
| 41 | 63 | $value = sanitize_text_field($value); |
| 42 | 64 | } |
| 43 | 65 | |
| 66 | + if (!$isMapped && $customField['type'] === 'phone' && $value && !Helper::isValidPhoneNumber($value)) { | |
| 67 | + /* translators: %s: Field label */ | |
| 68 | + $errors[$fieldKey . '.valid_phone_number'] = sprintf(__('%s is not a valid phone number', 'fluent-booking'), $customField['label']); | |
| 69 | + continue; | |
| 70 | + } | |
| 71 | + | |
| 44 | 72 | $formattedValues[$fieldKey] = $value; |
| 45 | 73 | } |
| 46 | 74 | |
| 47 | 75 | if ($errors) { |
| @@ -50,10 +78,16 @@ | ||
| 50 | 78 | |
| 51 | 79 | return $formattedValues; |
| 52 | 80 | } |
| 53 | 81 | |
| 54 | - public static function getBookingFields(CalendarSlot $calendarSlot) | |
| 82 | + public static function getBookingFields(CalendarSlot $calendarSlot, $cached = false) | |
| 55 | 83 | { |
| 84 | + static $bookingFields = []; | |
| 85 | + | |
| 86 | + if ($cached && isset($bookingFields[$calendarSlot->id])) { | |
| 87 | + return $bookingFields[$calendarSlot->id]; | |
| 88 | + } | |
| 89 | + | |
| 56 | 90 | $requiredIndexes = ['name', 'email', 'message', 'cancellation_reason', 'rescheduling_reason']; |
| 57 | 91 | |
| 58 | 92 | $defaultFields = [ |
| 59 | 93 | 'name' => [ |
| @@ -132,8 +166,9 @@ | ||
| 132 | 166 | 'system_defined' => true, |
| 133 | 167 | 'disable_alter' => false |
| 134 | 168 | ]; |
| 135 | 169 | } |
| 170 | + | |
| 136 | 171 | if ($calendarSlot->isLocationFieldRequired()) { |
| 137 | 172 | $requiredIndexes[] = 'location'; |
| 138 | 173 | $defaultFields['location'] = [ |
| 139 | 174 | 'index' => 7, |
| @@ -222,16 +257,16 @@ | ||
| 222 | 257 | } |
| 223 | 258 | |
| 224 | 259 | $existingFields['email']['disabled'] = false; |
| 225 | 260 | |
| 226 | - return array_values($existingFields); | |
| 261 | + return $bookingFields[$calendarSlot->id] = apply_filters('fluent_booking/booking_fields', array_values($existingFields), $calendarSlot); | |
| 227 | 262 | } |
| 228 | 263 | |
| 229 | 264 | public static function getBookingFieldLabels(CalendarSlot $calendarSlot, $enabledOnly = false) |
| 230 | 265 | { |
| 231 | - $fields = self::getBookingFields($calendarSlot); | |
| 266 | + $fields = self::getBookingFields($calendarSlot, true); | |
| 267 | + | |
| 232 | 268 | $labels = []; |
| 233 | - | |
| 234 | 269 | foreach ($fields as $field) { |
| 235 | 270 | if ($enabledOnly && !Arr::isTrue($field, 'enabled')) { |
| 236 | 271 | continue; |
| 237 | 272 | } |
| @@ -243,9 +278,10 @@ | ||
| 243 | 278 | } |
| 244 | 279 | |
| 245 | 280 | public static function generateFieldName($calendarEvent, $fieldLabel) |
| 246 | 281 | { |
| 247 | - $fieldName = 'custom_' . sanitize_title($fieldLabel); | |
| 282 | + $fieldLabel = preg_replace('/[^A-Za-z0-9]/', '_', $fieldLabel); | |
| 283 | + $fieldName = 'custom_' . strtolower($fieldLabel); | |
| 248 | 284 | $bookingFields = self::getBookingFields($calendarEvent); |
| 249 | 285 | |
| 250 | 286 | $matched = 0; |
| 251 | 287 | foreach ($bookingFields as $field) { |
| @@ -259,10 +295,23 @@ | ||
| 259 | 295 | } |
| 260 | 296 | return $fieldName; |
| 261 | 297 | } |
| 262 | 298 | |
| 263 | - public static function getFormattedCustomBookingData(Booking $booking) | |
| 299 | + public static function maybeGenerateFieldName($calendarEvent, $fieldValue) | |
| 264 | 300 | { |
| 301 | + $bookingFields = self::getBookingFields($calendarEvent); | |
| 302 | + | |
| 303 | + foreach ($bookingFields as $field) { | |
| 304 | + if ($field['name'] == $fieldValue['name'] && $field['index'] != $fieldValue['index']) { | |
| 305 | + return self::generateFieldName($calendarEvent, $fieldValue['label']); | |
| 306 | + } | |
| 307 | + } | |
| 308 | + | |
| 309 | + return sanitize_text_field($fieldValue['name']); | |
| 310 | + } | |
| 311 | + | |
| 312 | + public static function getFormattedCustomBookingData(Booking $booking, $htmlSupport = true, $isPublic = false) | |
| 313 | + { | |
| 265 | 314 | $customFormData = $booking->getMeta('custom_fields_data', []); |
| 266 | 315 | if (!$customFormData) { |
| 267 | 316 | return []; |
| 268 | 317 | } |
| @@ -271,16 +320,34 @@ | ||
| 271 | 320 | |
| 272 | 321 | $formattedData = []; |
| 273 | 322 | |
| 274 | 323 | foreach ($customFormData as $dataKey => $value) { |
| 275 | - if (isset($labels[$dataKey])) { | |
| 276 | - $label = $labels[$dataKey]; | |
| 277 | - } else { | |
| 278 | - $label = $dataKey; | |
| 324 | + $label = $labels[$dataKey] ?? $dataKey; | |
| 325 | + | |
| 326 | + $formattedValue = is_array($value) ? implode(', ', $value) : $value; | |
| 327 | + | |
| 328 | + $field = self::getBookingFieldByName($booking->calendar_event, $dataKey); | |
| 329 | + | |
| 330 | + $fieldType = Arr::get($field, 'type'); | |
| 331 | + | |
| 332 | + if ($fieldType == 'file' && is_array($value)) { | |
| 333 | + $formattedValue = self::getUploadedFiles($value, $htmlSupport); | |
| 279 | 334 | } |
| 335 | + | |
| 336 | + if ($fieldType == 'hidden') { | |
| 337 | + if ($isPublic) continue; | |
| 338 | + $formattedValue = EditorShortcodeParser::parse($formattedValue, $booking); | |
| 339 | + | |
| 340 | + // Some shortcodes return HTML, and the admin renders hidden values as HTML. | |
| 341 | + if ($htmlSupport) { | |
| 342 | + $formattedValue = wp_kses_post($formattedValue); | |
| 343 | + } | |
| 344 | + } | |
| 345 | + | |
| 280 | 346 | $formattedData[$dataKey] = [ |
| 281 | 347 | 'label' => $label, |
| 282 | - 'value' => is_array($value) ? implode(', ', $value) : $value | |
| 348 | + 'type' => $fieldType, | |
| 349 | + 'value' => $formattedValue | |
| 283 | 350 | ]; |
| 284 | 351 | } |
| 285 | 352 | |
| 286 | 353 | return $formattedData; |
| @@ -311,9 +378,9 @@ | ||
| 311 | 378 | } |
| 312 | 379 | |
| 313 | 380 | public static function getBookingFieldByName($calendarEvent, $name) |
| 314 | 381 | { |
| 315 | - $fields = self::getBookingFields($calendarEvent); | |
| 382 | + $fields = self::getBookingFields($calendarEvent, true); | |
| 316 | 383 | |
| 317 | 384 | foreach ($fields as $field) { |
| 318 | 385 | if (Arr::get($field, 'name') == $name) { |
| 319 | 386 | return $field; |
| @@ -342,6 +409,116 @@ | ||
| 342 | 409 | } |
| 343 | 410 | } |
| 344 | 411 | } |
| 345 | 412 | return false; |
| 413 | + } | |
| 414 | + | |
| 415 | + public static function getUploadedFiles($fieldValue, $htmlSupport = true) | |
| 416 | + { | |
| 417 | + if (empty($fieldValue)) { | |
| 418 | + return ''; | |
| 419 | + } | |
| 420 | + | |
| 421 | + $files = array_map(function($file) use ($htmlSupport) { | |
| 422 | + if ($htmlSupport) { | |
| 423 | + return '<a href="' . esc_url($file) . '" target="_blank" download="' . esc_attr(basename($file)) . '">' . esc_html(basename($file)) . '</a>'; | |
| 424 | + } | |
| 425 | + return $file; | |
| 426 | + }, $fieldValue); | |
| 427 | + | |
| 428 | + $separator = $htmlSupport ? '<br>' : PHP_EOL; | |
| 429 | + | |
| 430 | + return implode($separator, $files); | |
| 431 | + } | |
| 432 | + | |
| 433 | + public static function validateDateFields($customFieldsData, $calendarEvent) | |
| 434 | + { | |
| 435 | + foreach ($customFieldsData as $fieldKey => $fieldValue) { | |
| 436 | + $field = self::getBookingFieldByName($calendarEvent, $fieldKey); | |
| 437 | + if ($fieldValue && Arr::get($field, 'type') == 'date') { | |
| 438 | + $minDate = Arr::get($field, 'min_date'); | |
| 439 | + $maxDate = Arr::get($field, 'max_date'); | |
| 440 | + | |
| 441 | + $fieldValue = DateTimeHelper::getFormattedDate($fieldValue, Arr::get($field, 'date_format')); | |
| 442 | + $minDate = gmdate('Y-m-d', strtotime($minDate ?: '1900-01-01')); | |
| 443 | + $maxDate = gmdate('Y-m-d', strtotime($maxDate ?: gmdate('Y-12-31'))); | |
| 444 | + | |
| 445 | + if ($minDate && $fieldValue < $minDate) { | |
| 446 | + /* translators: %1$s: Field label, %2$s: Minimum date */ | |
| 447 | + return new \WP_Error('invalid_date', sprintf(__('The date for %1$s cannot be earlier than %2$s.', 'fluent-booking'), $field['label'], $minDate)); | |
| 448 | + } | |
| 449 | + if ($maxDate && $fieldValue > $maxDate) { | |
| 450 | + /* translators: %1$s: Field label, %2$s: Maximum date */ | |
| 451 | + return new \WP_Error('invalid_date', sprintf(__('The date for %1$s cannot be later than %2$s.', 'fluent-booking'), $field['label'], $maxDate)); | |
| 452 | + } | |
| 453 | + } | |
| 454 | + } | |
| 455 | + return true; | |
| 456 | + } | |
| 457 | + | |
| 458 | + /** | |
| 459 | + * Format booking fields (text sanitized, terms-and-conditions kses'd). Shared | |
| 460 | + * by the admin save and calendar import so neither path can store raw HTML. | |
| 461 | + * $calendarEvent is null-safe (import skips name generation). | |
| 462 | + */ | |
| 463 | + public static function sanitizeBookingFields($bookingFields, $calendarEvent = null) | |
| 464 | + { | |
| 465 | + if (!is_array($bookingFields)) { | |
| 466 | + return []; | |
| 467 | + } | |
| 468 | + | |
| 469 | + $optionRequiredFields = ['dropdown', 'radio', 'checkbox-group', 'multi-select']; | |
| 470 | + $textFields = ['type', 'name', 'label', 'placeholder', 'limit', 'help_text', 'date_format', 'min_date', 'max_date']; | |
| 471 | + $booleanFields = ['enabled', 'required', 'system_defined', 'disable_alter', 'is_sms_number']; | |
| 472 | + | |
| 473 | + $formattedFields = []; | |
| 474 | + | |
| 475 | + foreach ($bookingFields as $value) { | |
| 476 | + if (!is_array($value)) { | |
| 477 | + continue; | |
| 478 | + } | |
| 479 | + | |
| 480 | + if ($calendarEvent) { | |
| 481 | + if (empty($value['name'])) { | |
| 482 | + $value['name'] = self::generateFieldName($calendarEvent, Arr::get($value, 'label', '')); | |
| 483 | + } else { | |
| 484 | + $value['name'] = self::maybeGenerateFieldName($calendarEvent, $value); | |
| 485 | + } | |
| 486 | + } else { | |
| 487 | + $value['name'] = sanitize_text_field(Arr::get($value, 'name', '')); | |
| 488 | + } | |
| 489 | + | |
| 490 | + $textValues = array_map('sanitize_text_field', Arr::only($value, $textFields)); | |
| 491 | + | |
| 492 | + $booleanValues = array_map(function ($valueItem) { | |
| 493 | + return $valueItem === true || $valueItem === 'true' || $valueItem == 1; | |
| 494 | + }, Arr::only($value, $booleanFields)); | |
| 495 | + | |
| 496 | + $formattedField = array_merge($textValues, $booleanValues); | |
| 497 | + | |
| 498 | + $fieldType = Arr::get($value, 'type'); | |
| 499 | + | |
| 500 | + $formattedField['index'] = (int) Arr::get($value, 'index'); | |
| 501 | + if (in_array($fieldType, $optionRequiredFields)) { | |
| 502 | + $formattedField['options'] = array_map('sanitize_text_field', (array) Arr::get($value, 'options', [])); | |
| 503 | + } | |
| 504 | + if ($fieldType == 'file') { | |
| 505 | + $formattedField['max_file_allow'] = intval(Arr::get($value, 'max_file_allow')); | |
| 506 | + $formattedField['allow_file_types'] = array_map('sanitize_text_field', (array) Arr::get($value, 'allow_file_types', [])); | |
| 507 | + $formattedField['file_size_value'] = intval(Arr::get($value, 'file_size_value')); | |
| 508 | + $formattedField['file_size_unit'] = SanitizeService::checkCollection(Arr::get($value, 'file_size_unit'), ['kb', 'mb']); | |
| 509 | + } | |
| 510 | + if ($fieldType == 'hidden') { | |
| 511 | + $formattedField['default_value'] = sanitize_text_field(Arr::get($value, 'default_value')); | |
| 512 | + } | |
| 513 | + if ($fieldType == 'terms-and-conditions') { | |
| 514 | + $formattedField['terms_and_conditions'] = wp_kses_post(Arr::get($value, 'terms_and_conditions')); | |
| 515 | + } | |
| 516 | + | |
| 517 | + $formattedField = apply_filters('fluent_booking/save_event_booking_field_' . $fieldType, $formattedField, $value, $calendarEvent); | |
| 518 | + | |
| 519 | + $formattedFields[] = $formattedField; | |
| 520 | + } | |
| 521 | + | |
| 522 | + return $formattedFields; | |
| 346 | 523 | } |
| 347 | 524 | } |