^value~…" (e.g. * "text^firstname1^Anna~email^email1^anna@example.com"); wpbc_get_parsed_booking_data_arr() decodes it to * field name => value. The field names are the site owner's: the Standard form asks for firstname, * secondname, email, phone, adults, children, details and accept_terms; wpbc_get__in_all_forms__field_names_arr() * lists the fields of every published form. * - A booking is created by wpbc_booking_save() (the booking form, the admin's Add booking, edits, imports): * wpbc_booking_after_save ( $booking_id, $params, $where ) fires after every save; $params says whether it was * an edit (is_edit_booking) or a Google Calendar import (sync_gid). A new booking is pending unless "auto * approve" is on (option booking_auto_approve_new_bookings_is_active, or added in the admin panel with * booking_auto_approve_bookings_if_added_in_admin_panel). * - Approve / set pending: the Booking Listing fires wpbc_booking_action__approved ( "3,7", "1"|"0" ) after the * update; the older admin screen and the after-payment approval fire wpbc_booking_approved ( "3,7", 1|0 ), the * latter before the update. wpbc_booking_is_approved_during_creation fires only when e-mails are sent: unused. * - Cancel: moving to the trash (wpbc_booking_action__trash ( "3,7", "1" ) after the update, wpbc_booking_trash * ( "3|7", 1 ) before it); "0" restores from the trash. Deleting (wpbc_booking_action__delete ( "3,7" ), * wpbc_booking_delete ( "3,7" )) fires before the rows are removed. Both mean "the booking is off" for the * customer: one event, "canceled", sent once per booking (deleting a trashed booking sends nothing). * wpbc_auto_cancel_booking() (paid versions, unpaid bookings) trashes without a hook: no SMS, its reminder is * skipped. * - Payment status (wpbc_booking_action__set_payment_status) exists only with paid add-ons: not used. * * Dates are shown in the site's time zone and date/time format, as for Amelia and FluentBooking. A full-day * booking has no hour: its reminder counts from noon of its first day (FULL_DAY_HOUR), so "24 hours before" * arrives at noon the day before, not at midnight. * * @package Smsportal */ namespace Smsportal\Integrations; use Smsportal\Events; use Smsportal\Phone; use Smsportal\Queue; use Smsportal\Retention; use Smsportal\Rules; use Smsportal\Variables; defined( 'ABSPATH' ) || exit; /** * WP Booking Calendar bookings as SMS events. The customer's number is the booking form's field chosen in the * rule, by default "phone". Reminders work as for Amelia and FluentBooking: one job per reminder rule planned * when the booking is saved, checked again before sending, plus an hourly sweep. */ class BookingCalendar { const GROUP = 'WP Booking Calendar'; const EVENT_CREATED = 'wpbc.booking_created'; const EVENT_APPROVED = 'wpbc.booking_approved'; const EVENT_PENDING = 'wpbc.booking_pending'; const EVENT_CANCELLED = 'wpbc.booking_cancelled'; const EVENT_REMINDER = 'wpbc.booking_reminder'; const OBJECT_TYPE = 'wpbc_booking'; const REMINDER_HOOK = 'smsportal_wpbc_reminder'; /** * Form field with the customer's number when the rule chose none (the Standard form's phone field). */ const DEFAULT_PHONE_FIELD = 'phone'; /** * Hour (site time) a full-day booking's reminder counts from. */ const FULL_DAY_HOUR = 12; /** * Statuses that get a reminder. */ const REMINDER_STATUSES = array( 'approved', 'pending' ); /** * Context keys of our own (prefixed so they cannot clash with form field names). */ const KEY_ID = '_smsportal_booking_id'; const KEY_STATUS = '_smsportal_status'; const KEY_START = '_smsportal_start'; /** * Whether WP Booking Calendar is active. * * @return bool */ public static function available(): bool { return defined( 'WP_BK_VERSION_NUM' ) && function_exists( 'wpbc_get_parsed_booking_data_arr' ); } /** * Register hooks. * * @return void */ public static function register(): void { add_filter( 'smsportal_register_events', array( self::class, 'events' ) ); add_action( self::REMINDER_HOOK, array( self::class, 'send_reminder' ), 10, 3 ); add_action( Retention::HOURLY_HOOK, array( self::class, 'sweep_reminders' ) ); add_filter( 'smsportal_privacy_phones', array( self::class, 'privacy_phones' ), 10, 2 ); add_action( 'wpbc_booking_after_save', array( self::class, 'on_saved' ), 20, 2 ); // Booking Listing, and the older admin screen / after-payment approval. foreach ( array( 'wpbc_booking_action__approved', 'wpbc_booking_approved' ) as $hook ) { add_action( $hook, array( self::class, 'on_approval' ), 20, 2 ); } foreach ( array( 'wpbc_booking_action__trash', 'wpbc_booking_trash' ) as $hook ) { add_action( $hook, array( self::class, 'on_trash' ), 20, 2 ); } foreach ( array( 'wpbc_booking_action__delete', 'wpbc_booking_delete' ) as $hook ) { add_action( $hook, array( self::class, 'on_delete' ), 20 ); } } /** * Add the events. * * @param mixed $events Events so far. * @return array> */ public static function events( $events ): array { $events = is_array( $events ) ? $events : array(); $variables = self::variables(); $events[] = self::event( self::EVENT_CREATED, __( 'WP Booking Calendar: new booking', '85pl-android-sms-api' ), '', $variables ); $events[] = self::event( self::EVENT_APPROVED, __( 'WP Booking Calendar: booking approved', '85pl-android-sms-api' ), 'approved', $variables ); $events[] = self::event( self::EVENT_PENDING, __( 'WP Booking Calendar: booking set back to pending', '85pl-android-sms-api' ), 'pending', $variables ); $events[] = self::event( self::EVENT_CANCELLED, __( 'WP Booking Calendar: booking canceled (moved to the trash or deleted)', '85pl-android-sms-api' ), 'cancelled', $variables ); $events[] = self::event( self::EVENT_REMINDER, __( 'WP Booking Calendar: reminder before the booked date', '85pl-android-sms-api' ), '', $variables ); return $events; } /** * Hook wpbc_booking_after_save: a new booking (not an edit, not a calendar import); every save plans the * reminders of the booking's (maybe new) dates. * * @param mixed $booking_id Booking ID. * @param mixed $params WP Booking Calendar's save parameters. * @return void */ public static function on_saved( $booking_id, $params = array() ): void { $id = is_scalar( $booking_id ) ? absint( $booking_id ) : 0; $params = is_array( $params ) ? $params : array(); if ( $id < 1 || ! empty( $params['sync_gid'] ) ) { return; } $edit = ! empty( $params['is_edit_booking'] ) && empty( $params['is_duplicate_booking'] ); if ( ! $edit ) { try { $context = self::context( $id ); if ( null !== $context ) { Events::fire( self::EVENT_CREATED, $context ); } } catch ( \Throwable $e ) { Events::report( $e, self::EVENT_CREATED ); } } self::plan_reminders( $id ); } /** * Hooks wpbc_booking_action__approved / wpbc_booking_approved: approved or set back to pending. The hook's * own flag is the new status (the after-payment approval fires before saving it). Approving also takes a * booking out of the trash, so its reminders are planned again. * * @param mixed $booking_ids Booking IDs ("3,7"). * @param mixed $approved 1 approved, 0 pending. * @return void */ public static function on_approval( $booking_ids, $approved = 1 ): void { $status = is_scalar( $approved ) && 1 === (int) $approved ? 'approved' : 'pending'; $event = 'approved' === $status ? self::EVENT_APPROVED : self::EVENT_PENDING; foreach ( self::ids( $booking_ids ) as $id ) { self::fire_status( $event, $id, $status ); self::plan_reminders( $id, true ); } } /** * Hooks wpbc_booking_action__trash / wpbc_booking_trash: moved to the trash (canceled) or restored (its * reminders are planned again). * * @param mixed $booking_ids Booking IDs ("3,7" or "3|7"). * @param mixed $trash 1 trash, 0 restore. * @return void */ public static function on_trash( $booking_ids, $trash = 1 ): void { foreach ( self::ids( $booking_ids ) as $id ) { if ( is_scalar( $trash ) && 1 === (int) $trash ) { self::fire_status( self::EVENT_CANCELLED, $id, 'cancelled' ); } else { self::plan_reminders( $id, true ); } } } /** * Hooks wpbc_booking_action__delete / wpbc_booking_delete (before the rows go): canceled. * * @param mixed $booking_ids Booking IDs ("3,7"). * @return void */ public static function on_delete( $booking_ids ): void { foreach ( self::ids( $booking_ids ) as $id ) { self::fire_status( self::EVENT_CANCELLED, $id, 'cancelled' ); } } /** * Fire a status event for one booking. * * @param string $event_id Event ID. * @param int $id Booking ID. * @param string $status Its new status. * @return void */ private static function fire_status( string $event_id, int $id, string $status ): void { try { $context = self::context( $id, $status ); if ( null !== $context ) { Events::fire( $event_id, $context ); } } catch ( \Throwable $e ) { Events::report( $e, $event_id ); } } /** * Job: reminder of one rule for one booking. Skipped when the booking is gone, canceled or moved (a move * plans a new job). * * @param int|string $booking_id Booking ID. * @param int|string $rule_id Rule ID. * @param int|string $start Start the job was planned for (Unix time). * @return void */ public static function send_reminder( $booking_id, $rule_id, $start ): void { try { $context = self::context( absint( $booking_id ) ); if ( null === $context || (int) $context[ self::KEY_START ] !== (int) $start || ! in_array( $context[ self::KEY_STATUS ], self::REMINDER_STATUSES, true ) ) { return; } // A full calendar reminds together: no per-rule hourly cap (country and per-number limits apply). Events::fire_rule( self::EVENT_REMINDER, absint( $rule_id ), $context, (string) $context[ self::KEY_START ], false ); } catch ( \Throwable $e ) { Events::report( $e, self::EVENT_REMINDER ); } } /** * Hourly: for each enabled reminder rule, bookings that start within its hours_before plus an hour. A * reminder due within the hour gets its job (unless one waits); a reminder whose time has passed is sent * now, unless the booking was made after that time. The SMS's de-duplication (booking, rule, start) keeps a * reminder that already went from going again. * * @return void */ public static function sweep_reminders(): void { try { $rules = Rules::enabled_for_event( self::EVENT_REMINDER ); if ( array() === $rules ) { return; } $now = time(); $hours = max( array_map( 'intval', wp_list_pluck( $rules, 'hours_before' ) ) ); foreach ( self::upcoming( $now, $now + ( $hours + 1 ) * HOUR_IN_SECONDS ) as $booking ) { foreach ( $rules as $rule ) { self::sweep_one( $booking, $rule, $now ); } } } catch ( \Throwable $e ) { Events::report( $e, self::EVENT_REMINDER ); } } /** * The sweep for one booking and one rule. * * @param array{id:int,start:int,created:int|null} $booking Booking. * @param array $rule Reminder rule. * @param int $now Unix time. * @return void */ private static function sweep_one( array $booking, array $rule, int $now ): void { $before = (int) $rule['hours_before'] * HOUR_IN_SECONDS; if ( $booking['start'] > $now + $before + HOUR_IN_SECONDS ) { return; } $when = $booking['start'] - $before; $args = array( $booking['id'], (int) $rule['id'], $booking['start'] ); if ( $when > $now ) { if ( ! Queue::is_hook_scheduled( self::REMINDER_HOOK, $args ) ) { Queue::schedule_hook( self::REMINDER_HOOK, $args, $when ); } return; } if ( null === $booking['created'] || $booking['created'] < $when ) { self::send_reminder( ...$args ); } } /** * Plan a reminder job per enabled reminder rule (hours_before before the start; none when that time has * passed or the same job already waits). * * @param int $id Booking ID. * @param bool $restored The booking is leaving the trash (the older admin screen reports it before saving). * @return void */ private static function plan_reminders( int $id, bool $restored = false ): void { try { $rules = Rules::enabled_for_event( self::EVENT_REMINDER ); $booking = array() === $rules ? null : self::read( $id ); if ( null === $booking || ( $booking['trash'] && ! $restored ) ) { return; } foreach ( $rules as $rule ) { $when = $booking['start'] - (int) $rule['hours_before'] * HOUR_IN_SECONDS; $args = array( $id, (int) $rule['id'], $booking['start'] ); if ( $when > time() && ! Queue::is_hook_scheduled( self::REMINDER_HOOK, $args ) ) { Queue::schedule_hook( self::REMINDER_HOOK, $args, $when ); } } } catch ( \Throwable $e ) { Events::report( $e, self::EVENT_REMINDER ); } } /** * Bookings out of the trash starting in a time range. * * @param int $from Unix time. * @param int $to Unix time. * @return array */ private static function upcoming( int $from, int $to ): array { global $wpdb; $suppress = $wpdb->suppress_errors( true ); // A full day's reminder counts from its noon: its stored midnight may lie up to a day before $from. // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WP Booking Calendar's tables, prefix from $wpdb; the sweep needs the current state. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT b.booking_id, b.creation_date, MIN( d.booking_date ) AS first_date, MAX( d.booking_date ) AS last_date FROM {$wpdb->prefix}booking b INNER JOIN {$wpdb->prefix}bookingdates d ON d.booking_id = b.booking_id WHERE b.trash = 0 GROUP BY b.booking_id, b.creation_date HAVING first_date > %s AND first_date <= %s", wp_date( 'Y-m-d H:i:s', $from - DAY_IN_SECONDS ), wp_date( 'Y-m-d H:i:s', $to ) ), ARRAY_A ); // phpcs:enable $wpdb->suppress_errors( $suppress ); $result = array(); foreach ( is_array( $rows ) ? $rows : array() as $row ) { $times = self::times( (string) $row['first_date'], (string) $row['last_date'] ); if ( null === $times || $times['start'] <= $from || $times['start'] > $to ) { continue; } $created = empty( $row['creation_date'] ) ? false : strtotime( $row['creation_date'] . ' UTC' ); $result[] = array( 'id' => (int) $row['booking_id'], 'start' => $times['start'], 'created' => false === $created ? null : $created, ); } return $result; } /** * Numbers of a person for the privacy tools: the phone fields (the default one and those chosen in the * rules) of the bookings whose form holds their e-mail address. * * @param mixed $phones Numbers so far. * @param string $email E-mail address. * @return array */ public static function privacy_phones( $phones, $email ): array { global $wpdb; $phones = is_array( $phones ) ? $phones : array(); $email = trim( (string) $email ); if ( '' === $email ) { return $phones; } $suppress = $wpdb->suppress_errors( true ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- WP Booking Calendar's table, a rare privacy request. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT booking_type, form FROM {$wpdb->prefix}booking WHERE form LIKE %s", '%' . $wpdb->esc_like( $email ) . '%' ), ARRAY_A ); $wpdb->suppress_errors( $suppress ); $fields = self::phone_fields(); foreach ( is_array( $rows ) ? $rows : array() as $row ) { $values = self::fields( (string) $row['form'], (int) $row['booking_type'] ); if ( ! in_array( strtolower( $email ), array_map( 'strtolower', $values ), true ) ) { continue; } foreach ( $fields as $field ) { $phone = '' === ( $values[ $field ] ?? '' ) ? null : Phone::normalize( $values[ $field ], Phone::default_country_code() ); if ( null !== $phone ) { $phones[] = $phone; } } } return array_values( array_unique( array_filter( $phones, 'is_string' ) ) ); } /** * Form fields that may hold a customer's number: the default one and those chosen in this integration's rules. * * @return array */ private static function phone_fields(): array { $fields = array( self::DEFAULT_PHONE_FIELD ); foreach ( Rules::all() as $rule ) { if ( 0 === strpos( (string) $rule['event_id'], 'wpbc.' ) && '' !== $rule['phone_field'] ) { $fields[] = (string) $rule['phone_field']; } } return array_values( array_unique( $fields ) ); } /** * Template variables of a booking: its form fields, its dates and its data. * * @param int $booking_id Booking ID. * @param string $status Its status when the hook knows better than the database (approved, pending, * cancelled); '' = as stored. * @return array|null Null when there is no such booking. */ public static function context( int $booking_id, string $status = '' ): ?array { $booking = self::read( $booking_id ); if ( null === $booking ) { return null; } $status = '' === $status ? $booking['status'] : $status; $statuses = self::statuses(); $title = function_exists( 'wpbc_get_resource_title' ) ? wpbc_get_resource_title( $booking['resource'] ) : ''; return array_merge( self::fields( $booking['form'], $booking['resource'] ), array( 'data' => wp_date( (string) get_option( 'date_format' ), $booking['first_day'] ), 'data_do' => wp_date( (string) get_option( 'date_format' ), $booking['last_day'] ), 'godzina' => $booking['full_day'] ? '' : wp_date( (string) get_option( 'time_format' ), $booking['start'] ), 'zasob' => is_string( $title ) ? wp_specialchars_decode( $title, ENT_QUOTES ) : '', 'status' => $statuses[ $status ] ?? $status, 'numer_rezerwacji' => (string) $booking['id'], 'nazwa_firmy' => wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), self::KEY_ID => $booking['id'], self::KEY_STATUS => $status, self::KEY_START => $booking['start'], ) ); } /** * A booking from WP Booking Calendar's tables; null when there is none (or no dates) or the tables fail. * * @param int $booking_id Booking ID. * @return array{id:int,resource:int,form:string,trash:bool,status:string,start:int,first_day:int,last_day:int,full_day:bool}|null */ private static function read( int $booking_id ): ?array { global $wpdb; if ( $booking_id < 1 ) { return null; } // WP Booking Calendar's schema is not ours: a missing table or column must not print errors on the page. $suppress = $wpdb->suppress_errors( true ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- WP Booking Calendar's tables; the current state is needed. $row = $wpdb->get_row( $wpdb->prepare( "SELECT booking_id, booking_type, form, trash FROM {$wpdb->prefix}booking WHERE booking_id = %d", $booking_id ), ARRAY_A ); $dates = $wpdb->get_results( $wpdb->prepare( "SELECT booking_date, approved FROM {$wpdb->prefix}bookingdates WHERE booking_id = %d ORDER BY booking_date", $booking_id ), ARRAY_A ); // phpcs:enable $wpdb->suppress_errors( $suppress ); if ( ! is_array( $row ) || ! is_array( $dates ) || array() === $dates ) { return null; } $times = self::times( (string) $dates[0]['booking_date'], (string) $dates[ count( $dates ) - 1 ]['booking_date'] ); if ( null === $times ) { return null; } $trash = 1 === (int) $row['trash']; return array_merge( $times, array( 'id' => (int) $row['booking_id'], 'resource' => (int) $row['booking_type'], 'form' => (string) $row['form'], 'trash' => $trash, 'status' => $trash ? 'cancelled' : ( 1 === (int) $dates[0]['approved'] ? 'approved' : 'pending' ), ) ); } /** * When a booking starts, from its first and last stored date (site time). A time slot starts at its time * (WP Booking Calendar's check-in second dropped); full days (both dates at "00:00:00") count from * FULL_DAY_HOUR. * * @param string $first First booked date, "Y-m-d H:i:s". * @param string $last Last booked date. * @return array{start:int,first_day:int,last_day:int,full_day:bool}|null */ private static function times( string $first, string $last ): ?array { $zone = wp_timezone(); $full_day = '00:00:00' === substr( $first, 11, 8 ) && '00:00:00' === substr( $last, 11, 8 ); $start = date_create_immutable( substr( $first, 0, 10 ) . ' ' . ( $full_day ? sprintf( '%02d:00:00', self::FULL_DAY_HOUR ) : substr( $first, 11, 5 ) . ':00' ), $zone ); $first_day = date_create_immutable( substr( $first, 0, 10 ) . ' 00:00:00', $zone ); $last_day = date_create_immutable( substr( $last, 0, 10 ) . ' 00:00:00', $zone ); if ( false === $start || false === $first_day || false === $last_day ) { return null; } return array( 'start' => $start->getTimestamp(), 'first_day' => $first_day->getTimestamp(), 'last_day' => $last_day->getTimestamp(), 'full_day' => $full_day, ); } /** * The booking form's answers as field name => text (lists joined with commas by WP Booking Calendar, HTML * entities decoded). * * @param string $form Encoded form data. * @param int $resource_id Booking resource (part of the encoded field names). * @return array */ private static function fields( string $form, int $resource_id ): array { $parsed = wpbc_get_parsed_booking_data_arr( $form, $resource_id, array( 'get' => 'value' ) ); $fields = array(); foreach ( is_array( $parsed ) ? $parsed : array() as $name => $value ) { if ( is_scalar( $value ) && 0 !== strpos( (string) $name, '_smsportal_' ) ) { $fields[ (string) $name ] = trim( html_entity_decode( (string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ); } } return $fields; } /** * IDs from a hook's argument ("3,7", "3|7", 3 or an array). * * @param mixed $ids IDs. * @return array */ private static function ids( $ids ): array { $ids = is_array( $ids ) ? implode( ',', array_filter( $ids, 'is_scalar' ) ) : ( is_scalar( $ids ) ? (string) $ids : '' ); return array_values( array_unique( array_filter( array_map( 'absint', (array) preg_split( '/[^0-9]+/', $ids ) ) ) ) ); } /** * One event definition. * * @param string $id Event ID. * @param string $label Label. * @param string $status Status the event is about ('' = any). * @param array $variables Variables. * @return array */ private static function event( string $id, string $label, string $status, array $variables ): array { return array( 'id' => $id, 'label' => $label, 'group' => self::GROUP, 'icon' => SMSPORTAL_URL_BASE . 'assets/integrations/wp-booking-calendar.png', 'variables' => $variables, 'customer_phone' => static function ( array $context, array $rule = array() ) { $field = trim( (string) ( $rule['phone_field'] ?? '' ) ); $phone = $context[ '' === $field ? self::DEFAULT_PHONE_FIELD : $field ] ?? null; return is_string( $phone ) && '' !== trim( $phone ) ? $phone : null; }, 'phone_field_default' => self::DEFAULT_PHONE_FIELD, 'object' => static function ( array $context ): array { $booking = (string) ( $context[ self::KEY_ID ] ?? '' ); return array( 'type' => self::OBJECT_TYPE, 'id' => $booking, 'url' => admin_url( 'admin.php?page=wpbc&tab=vm_booking_listing&wh_booking_id=' . rawurlencode( $booking ) ), ); }, 'sample' => static function () use ( $status ) { return self::sample( $status ); }, 'rule_fields' => self::EVENT_REMINDER === $id ? array( 'phone_field', 'hours_before' ) : array( 'phone_field' ), ); } /** * Variables of every event: the fields of WP Booking Calendar's published forms (named as in the form), then * the booking's own data (named in the admin's language). * * @return array Name => label. */ private static function variables(): array { $variables = array(); foreach ( self::form_fields() as $name ) { $variables[ $name ] = $name; } $own = Variables::for_editor( array( 'data' => __( 'Date', '85pl-android-sms-api' ), 'data_do' => __( 'Last day', '85pl-android-sms-api' ), 'godzina' => __( 'Time', '85pl-android-sms-api' ), 'zasob' => __( 'Booking resource (calendar)', '85pl-android-sms-api' ), 'status' => __( 'Booking status', '85pl-android-sms-api' ), 'numer_rezerwacji' => __( 'Booking number', '85pl-android-sms-api' ), 'nazwa_firmy' => __( 'Site name', '85pl-android-sms-api' ), ) ); return array_merge( $variables, $own ); } /** * Field names of WP Booking Calendar's published booking forms, the Standard form first. * * @return array */ private static function form_fields(): array { global $wpdb; if ( ! function_exists( 'wpbc_get__in_all_forms__field_names_arr' ) ) { return array(); } $suppress = $wpdb->suppress_errors( true ); try { $forms = wpbc_get__in_all_forms__field_names_arr(); } catch ( \Throwable $e ) { $forms = array(); } finally { $wpdb->suppress_errors( $suppress ); } $names = array(); foreach ( is_array( $forms ) ? $forms : array() as $form ) { foreach ( (array) ( $form['listing']['fields'] ?? array() ) as $name ) { $name = is_string( $name ) ? trim( $name ) : ''; if ( 1 === preg_match( '/^[A-Za-z0-9_\-]+$/', $name ) && ! in_array( $name, $names, true ) ) { $names[] = $name; } } } return $names; } /** * Context of the latest booking (with the given status), for the rule preview. * * @param string $status Status, '' = any. * @return array|null */ private static function sample( string $status ): ?array { global $wpdb; $suppress = $wpdb->suppress_errors( true ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WP Booking Calendar's tables, prefix from $wpdb. if ( '' === $status ) { $id = $wpdb->get_var( "SELECT MAX(booking_id) FROM {$wpdb->prefix}booking" ); } elseif ( 'cancelled' === $status ) { $id = $wpdb->get_var( "SELECT MAX(booking_id) FROM {$wpdb->prefix}booking WHERE trash = 1" ); } else { $id = $wpdb->get_var( $wpdb->prepare( "SELECT MAX(b.booking_id) FROM {$wpdb->prefix}booking b INNER JOIN {$wpdb->prefix}bookingdates d ON d.booking_id = b.booking_id WHERE b.trash = 0 AND d.approved = %d", 'approved' === $status ? 1 : 0 ) ); } // phpcs:enable $wpdb->suppress_errors( $suppress ); return null === $id ? null : self::context( (int) $id ); } /** * Status names. * * @return array */ private static function statuses(): array { return array( 'approved' => __( 'Approved', '85pl-android-sms-api' ), 'pending' => __( 'Pending', '85pl-android-sms-api' ), 'cancelled' => __( 'Canceled', '85pl-android-sms-api' ), ); } }