_x( 'Bookings', 'post type general name', 'wp-bnb' ), 'singular_name' => _x( 'Booking', 'post type singular name', 'wp-bnb' ), 'menu_name' => _x( 'Bookings', 'admin menu', 'wp-bnb' ), 'name_admin_bar' => _x( 'Booking', 'add new on admin bar', 'wp-bnb' ), 'add_new' => _x( 'Add New', 'booking', 'wp-bnb' ), 'add_new_item' => __( 'Add New Booking', 'wp-bnb' ), 'new_item' => __( 'New Booking', 'wp-bnb' ), 'edit_item' => __( 'Edit Booking', 'wp-bnb' ), 'view_item' => __( 'View Booking', 'wp-bnb' ), 'all_items' => __( 'Bookings', 'wp-bnb' ), 'search_items' => __( 'Search Bookings', 'wp-bnb' ), 'parent_item_colon' => __( 'Parent Bookings:', 'wp-bnb' ), 'not_found' => __( 'No bookings found.', 'wp-bnb' ), 'not_found_in_trash' => __( 'No bookings found in Trash.', 'wp-bnb' ), 'archives' => __( 'Booking archives', 'wp-bnb' ), 'insert_into_item' => __( 'Insert into booking', 'wp-bnb' ), 'uploaded_to_this_item' => __( 'Uploaded to this booking', 'wp-bnb' ), 'filter_items_list' => __( 'Filter bookings list', 'wp-bnb' ), 'items_list_navigation' => __( 'Bookings list navigation', 'wp-bnb' ), 'items_list' => __( 'Bookings list', 'wp-bnb' ), ); $args = array( 'labels' => $labels, 'public' => false, 'publicly_queryable' => false, 'show_ui' => true, 'show_in_menu' => 'wp-bnb', 'query_var' => false, 'capability_type' => 'post', 'has_archive' => false, 'hierarchical' => false, 'menu_position' => null, 'menu_icon' => 'dashicons-calendar-alt', 'supports' => array( 'revisions' ), 'show_in_rest' => true, 'rest_base' => 'bookings', 'rest_controller_class' => 'WP_REST_Posts_Controller', ); register_post_type( self::POST_TYPE, $args ); } /** * Add meta boxes. * * @return void */ public static function add_meta_boxes(): void { add_meta_box( 'bnb_booking_room_dates', __( 'Room & Dates', 'wp-bnb' ), array( self::class, 'render_room_dates_meta_box' ), self::POST_TYPE, 'normal', 'high' ); add_meta_box( 'bnb_booking_guest', __( 'Guest Information', 'wp-bnb' ), array( self::class, 'render_guest_meta_box' ), self::POST_TYPE, 'normal', 'high' ); add_meta_box( 'bnb_booking_pricing', __( 'Pricing', 'wp-bnb' ), array( self::class, 'render_pricing_meta_box' ), self::POST_TYPE, 'normal', 'default' ); add_meta_box( 'bnb_booking_status', __( 'Status & Notes', 'wp-bnb' ), array( self::class, 'render_status_meta_box' ), self::POST_TYPE, 'side', 'high' ); } /** * Render room and dates meta box. * * @param \WP_Post $post Current post object. * @return void */ public static function render_room_dates_meta_box( \WP_Post $post ): void { wp_nonce_field( 'bnb_booking_meta', 'bnb_booking_meta_nonce' ); $room_id = get_post_meta( $post->ID, self::META_PREFIX . 'room_id', true ); $check_in = get_post_meta( $post->ID, self::META_PREFIX . 'check_in', true ); $check_out = get_post_meta( $post->ID, self::META_PREFIX . 'check_out', true ); $rooms = get_posts( array( 'post_type' => Room::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => -1, 'orderby' => 'title', 'order' => 'ASC', ) ); // Group rooms by building. $rooms_by_building = array(); foreach ( $rooms as $room ) { $building = Room::get_building( $room->ID ); $building_name = $building ? $building->post_title : __( 'No Building', 'wp-bnb' ); $building_id = $building ? $building->ID : 0; if ( ! isset( $rooms_by_building[ $building_id ] ) ) { $rooms_by_building[ $building_id ] = array( 'name' => $building_name, 'rooms' => array(), ); } $rooms_by_building[ $building_id ]['rooms'][] = $room; } ?>

' . esc_html__( 'Add a room', 'wp-bnb' ) . '' ); ?>

ID, self::META_PREFIX . 'guest_id', true ); $guest_name = get_post_meta( $post->ID, self::META_PREFIX . 'guest_name', true ); $guest_email = get_post_meta( $post->ID, self::META_PREFIX . 'guest_email', true ); $guest_phone = get_post_meta( $post->ID, self::META_PREFIX . 'guest_phone', true ); $adults = get_post_meta( $post->ID, self::META_PREFIX . 'adults', true ); $children = get_post_meta( $post->ID, self::META_PREFIX . 'children', true ); $guest_notes = get_post_meta( $post->ID, self::META_PREFIX . 'guest_notes', true ); // If guest_id exists, get guest data from Guest CPT. $linked_guest = null; if ( $guest_id ) { $linked_guest = get_post( $guest_id ); if ( $linked_guest && Guest::POST_TYPE === $linked_guest->post_type ) { $guest_name = Guest::get_full_name( $guest_id ); $guest_email = get_post_meta( $guest_id, '_bnb_guest_email', true ); $guest_phone = get_post_meta( $guest_id, '_bnb_guest_phone', true ); } else { $linked_guest = null; $guest_id = ''; } } ?>

post_title ); ?>

>
>
  

ID, self::META_PREFIX . 'calculated_price', true ); $price_breakdown = get_post_meta( $post->ID, self::META_PREFIX . 'price_breakdown', true ); $override_price = get_post_meta( $post->ID, self::META_PREFIX . 'override_price', true ); $currency = get_option( 'wp_bnb_currency', 'CHF' ); ?>
' . esc_html( Calculator::formatPrice( (float) $calculated_price ) ) . ''; } else { esc_html_e( 'Price will be calculated when room and dates are selected.', 'wp-bnb' ); } ?>

ID, self::META_PREFIX . 'status', true ) ?: 'pending'; $notes = get_post_meta( $post->ID, self::META_PREFIX . 'notes', true ); $confirmed_at = get_post_meta( $post->ID, self::META_PREFIX . 'confirmed_at', true ); $statuses = self::get_booking_statuses(); $colors = self::get_status_colors(); ?>


post_type ) { update_post_meta( $post_id, self::META_PREFIX . 'guest_id', $guest_id ); // Sync guest data from Guest CPT for searching/display purposes. update_post_meta( $post_id, self::META_PREFIX . 'guest_name', Guest::get_full_name( $guest_id ) ); update_post_meta( $post_id, self::META_PREFIX . 'guest_email', get_post_meta( $guest_id, '_bnb_guest_email', true ) ); update_post_meta( $post_id, self::META_PREFIX . 'guest_phone', get_post_meta( $guest_id, '_bnb_guest_phone', true ) ); } else { delete_post_meta( $post_id, self::META_PREFIX . 'guest_id' ); } } else { delete_post_meta( $post_id, self::META_PREFIX . 'guest_id' ); // Guest text fields (only save if no guest_id). $guest_fields = array( 'guest_name', 'guest_email', 'guest_phone', 'guest_notes' ); foreach ( $guest_fields as $field ) { $key = 'bnb_booking_' . $field; if ( isset( $_POST[ $key ] ) ) { $value = wp_unslash( $_POST[ $key ] ); if ( 'guest_email' === $field ) { $value = sanitize_email( $value ); } elseif ( 'guest_notes' === $field ) { $value = sanitize_textarea_field( $value ); } else { $value = sanitize_text_field( $value ); } update_post_meta( $post_id, self::META_PREFIX . $field, $value ); } } } // Guest notes are always saved (per-booking notes). if ( isset( $_POST['bnb_booking_guest_notes'] ) ) { update_post_meta( $post_id, self::META_PREFIX . 'guest_notes', sanitize_textarea_field( wp_unslash( $_POST['bnb_booking_guest_notes'] ) ) ); } // Guest counts. if ( isset( $_POST['bnb_booking_adults'] ) ) { update_post_meta( $post_id, self::META_PREFIX . 'adults', absint( $_POST['bnb_booking_adults'] ) ); } if ( isset( $_POST['bnb_booking_children'] ) ) { update_post_meta( $post_id, self::META_PREFIX . 'children', absint( $_POST['bnb_booking_children'] ) ); } // Notes. if ( isset( $_POST['bnb_booking_notes'] ) ) { update_post_meta( $post_id, self::META_PREFIX . 'notes', sanitize_textarea_field( wp_unslash( $_POST['bnb_booking_notes'] ) ) ); } // Pricing. $override_price = isset( $_POST['bnb_booking_override_price'] ) && '' !== $_POST['bnb_booking_override_price'] ? floatval( $_POST['bnb_booking_override_price'] ) : null; if ( null !== $override_price ) { update_post_meta( $post_id, self::META_PREFIX . 'override_price', $override_price ); update_post_meta( $post_id, self::META_PREFIX . 'calculated_price', $override_price ); } else { delete_post_meta( $post_id, self::META_PREFIX . 'override_price' ); // Calculate price if room and dates are set. if ( $room_id && $check_in && $check_out ) { try { $calculator = new Calculator( $room_id, $check_in, $check_out ); $price = $calculator->calculate(); $breakdown = $calculator->getBreakdown(); update_post_meta( $post_id, self::META_PREFIX . 'calculated_price', $price ); update_post_meta( $post_id, self::META_PREFIX . 'price_breakdown', $breakdown ); } catch ( \Exception $e ) { // Keep existing price if calculation fails. } } } // Trigger status change action. if ( $old_status && $status !== $old_status ) { /** * Fires when a booking status changes. * * @param int $post_id Booking post ID. * @param string $old_status Previous status. * @param string $status New status. */ do_action( 'wp_bnb_booking_status_changed', $post_id, $old_status, $status ); } } /** * Add custom columns to the post list. * * @param array $columns Existing columns. * @return array */ public static function add_columns( array $columns ): array { $new_columns = array(); foreach ( $columns as $key => $value ) { $new_columns[ $key ] = $value; if ( 'title' === $key ) { $new_columns['room'] = __( 'Room', 'wp-bnb' ); $new_columns['guest'] = __( 'Guest', 'wp-bnb' ); $new_columns['dates'] = __( 'Dates', 'wp-bnb' ); $new_columns['nights'] = __( 'Nights', 'wp-bnb' ); $new_columns['price'] = __( 'Price', 'wp-bnb' ); $new_columns['status'] = __( 'Status', 'wp-bnb' ); } } // Remove date column, we have our own dates. unset( $new_columns['date'] ); return $new_columns; } /** * Render custom column content. * * @param string $column Column name. * @param int $post_id Post ID. * @return void */ public static function render_column( string $column, int $post_id ): void { switch ( $column ) { case 'room': $room_id = get_post_meta( $post_id, self::META_PREFIX . 'room_id', true ); if ( $room_id ) { $room = get_post( $room_id ); if ( $room ) { $building = Room::get_building( $room_id ); printf( '%s', esc_url( get_edit_post_link( $room_id ) ), esc_html( $room->post_title ) ); if ( $building ) { echo '
' . esc_html( $building->post_title ) . ''; } } else { echo '' . esc_html__( 'Room deleted', 'wp-bnb' ) . ''; } } else { echo '—'; } break; case 'guest': $guest_id = get_post_meta( $post_id, self::META_PREFIX . 'guest_id', true ); $guest_name = get_post_meta( $post_id, self::META_PREFIX . 'guest_name', true ); $guest_email = get_post_meta( $post_id, self::META_PREFIX . 'guest_email', true ); if ( $guest_name ) { if ( $guest_id ) { // Linked guest - show link to guest profile. printf( '%s', esc_url( get_edit_post_link( $guest_id ) ), esc_html( $guest_name ) ); echo ' '; } else { echo esc_html( $guest_name ); } if ( $guest_email ) { echo '
' . esc_html( $guest_email ) . ''; } } else { echo '—'; } break; case 'dates': $check_in = get_post_meta( $post_id, self::META_PREFIX . 'check_in', true ); $check_out = get_post_meta( $post_id, self::META_PREFIX . 'check_out', true ); if ( $check_in && $check_out ) { $format = get_option( 'date_format' ); echo esc_html( wp_date( $format, strtotime( $check_in ) ) ); echo '
' . esc_html__( 'to', 'wp-bnb' ) . ' ' . esc_html( wp_date( $format, strtotime( $check_out ) ) ) . ''; } else { echo '—'; } break; case 'nights': $check_in = get_post_meta( $post_id, self::META_PREFIX . 'check_in', true ); $check_out = get_post_meta( $post_id, self::META_PREFIX . 'check_out', true ); if ( $check_in && $check_out ) { $nights = self::calculate_nights( $check_in, $check_out ); echo esc_html( $nights ); } else { echo '—'; } break; case 'price': $price = get_post_meta( $post_id, self::META_PREFIX . 'calculated_price', true ); if ( $price ) { echo esc_html( Calculator::formatPrice( (float) $price ) ); $override = get_post_meta( $post_id, self::META_PREFIX . 'override_price', true ); if ( $override ) { echo ' *'; } } else { echo '—'; } break; case 'status': $status = get_post_meta( $post_id, self::META_PREFIX . 'status', true ) ?: 'pending'; $statuses = self::get_booking_statuses(); $colors = self::get_status_colors(); ?> Room::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => -1, 'orderby' => 'title', 'order' => 'ASC', ) ); if ( ! empty( $rooms ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter display only. $selected_room = isset( $_GET['room_id'] ) ? absint( $_GET['room_id'] ) : 0; ?> is_main_query() ) { return; } if ( self::POST_TYPE !== $query->get( 'post_type' ) ) { return; } $meta_query = array(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter query only. if ( ! empty( $_GET['room_id'] ) ) { $meta_query[] = array( 'key' => self::META_PREFIX . 'room_id', 'value' => absint( $_GET['room_id'] ), ); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter query only. if ( ! empty( $_GET['booking_status'] ) ) { $meta_query[] = array( 'key' => self::META_PREFIX . 'status', 'value' => sanitize_text_field( wp_unslash( $_GET['booking_status'] ) ), ); } if ( ! empty( $meta_query ) ) { $meta_query['relation'] = 'AND'; $query->set( 'meta_query', $meta_query ); } // Handle sorting. $orderby = $query->get( 'orderby' ); if ( 'check_in' === $orderby ) { $query->set( 'meta_key', self::META_PREFIX . 'check_in' ); $query->set( 'orderby', 'meta_value' ); } elseif ( 'guest_name' === $orderby ) { $query->set( 'meta_key', self::META_PREFIX . 'guest_name' ); $query->set( 'orderby', 'meta_value' ); } elseif ( 'status' === $orderby ) { $query->set( 'meta_key', self::META_PREFIX . 'status' ); $query->set( 'orderby', 'meta_value' ); } } /** * Change title placeholder. * * @param string $placeholder Default placeholder. * @param \WP_Post $post Current post. * @return string */ public static function change_title_placeholder( string $placeholder, \WP_Post $post ): string { if ( self::POST_TYPE === $post->post_type ) { return __( 'Booking reference (auto-generated)', 'wp-bnb' ); } return $placeholder; } /** * Auto-generate booking reference as title. * * @param array $data Post data. * @param array $postarr Post array. * @return array */ public static function auto_generate_title( array $data, array $postarr ): array { if ( self::POST_TYPE !== $data['post_type'] ) { return $data; } // Only generate if title is empty or matches auto-generated pattern. if ( empty( $data['post_title'] ) || preg_match( '/^BNB-\d{4}-\d{5}$/', $data['post_title'] ) ) { $data['post_title'] = self::generate_reference(); } return $data; } /** * Show conflict notice in admin. * * @return void */ public static function show_conflict_notice(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Notice display only. if ( isset( $_GET['booking_conflict'] ) && '1' === $_GET['booking_conflict'] ) { ?>

*/ public static function get_booking_statuses(): array { return array( 'pending' => __( 'Pending', 'wp-bnb' ), 'confirmed' => __( 'Confirmed', 'wp-bnb' ), 'checked_in' => __( 'Checked In', 'wp-bnb' ), 'checked_out' => __( 'Checked Out', 'wp-bnb' ), 'cancelled' => __( 'Cancelled', 'wp-bnb' ), ); } /** * Get status color codes. * * @return array */ public static function get_status_colors(): array { return array( 'pending' => '#dba617', 'confirmed' => '#00a32a', 'checked_in' => '#72aee6', 'checked_out' => '#646970', 'cancelled' => '#d63638', ); } /** * Get valid status transitions. * * @return array> */ public static function get_status_transitions(): array { return array( 'pending' => array( 'confirmed', 'cancelled' ), 'confirmed' => array( 'checked_in', 'cancelled' ), 'checked_in' => array( 'checked_out' ), 'checked_out' => array(), 'cancelled' => array( 'pending' ), ); } /** * Check if a status transition is valid. * * @param string $from Current status. * @param string $to New status. * @return bool */ public static function can_transition_to( string $from, string $to ): bool { if ( $from === $to ) { return true; } $transitions = self::get_status_transitions(); return isset( $transitions[ $from ] ) && in_array( $to, $transitions[ $from ], true ); } /** * Generate a booking reference number. * * @return string */ public static function generate_reference(): string { $year = gmdate( 'Y' ); $count = wp_count_posts( self::POST_TYPE ); $total = ( $count->publish ?? 0 ) + ( $count->draft ?? 0 ) + ( $count->pending ?? 0 ) + ( $count->trash ?? 0 ) + 1; return sprintf( 'BNB-%s-%05d', $year, $total ); } /** * Calculate number of nights between two dates. * * @param string $check_in Check-in date (Y-m-d). * @param string $check_out Check-out date (Y-m-d). * @return int */ public static function calculate_nights( string $check_in, string $check_out ): int { $start = new \DateTimeImmutable( $check_in ); $end = new \DateTimeImmutable( $check_out ); return max( 1, (int) $start->diff( $end )->days ); } /** * Check if dates conflict with existing bookings. * * @param int $room_id Room post ID. * @param string $check_in Check-in date (Y-m-d). * @param string $check_out Check-out date (Y-m-d). * @param int|null $exclude_id Booking ID to exclude (for editing). * @return bool */ public static function has_conflict( int $room_id, string $check_in, string $check_out, ?int $exclude_id = null ): bool { $args = array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => 1, 'fields' => 'ids', 'meta_query' => array( 'relation' => 'AND', array( 'key' => self::META_PREFIX . 'room_id', 'value' => $room_id, ), array( 'key' => self::META_PREFIX . 'status', 'value' => 'cancelled', 'compare' => '!=', ), // Overlap detection: existing.check_in < new.check_out AND existing.check_out > new.check_in. array( 'key' => self::META_PREFIX . 'check_in', 'value' => $check_out, 'compare' => '<', 'type' => 'DATE', ), array( 'key' => self::META_PREFIX . 'check_out', 'value' => $check_in, 'compare' => '>', 'type' => 'DATE', ), ), ); if ( $exclude_id ) { $args['post__not_in'] = array( $exclude_id ); } $conflicts = get_posts( $args ); return ! empty( $conflicts ); } /** * Get room for a booking. * * @param int $booking_id Booking post ID. * @return \WP_Post|null */ public static function get_room( int $booking_id ): ?\WP_Post { $room_id = get_post_meta( $booking_id, self::META_PREFIX . 'room_id', true ); if ( ! $room_id ) { return null; } return get_post( $room_id ); } /** * Get building for a booking (through room). * * @param int $booking_id Booking post ID. * @return \WP_Post|null */ public static function get_building( int $booking_id ): ?\WP_Post { $room = self::get_room( $booking_id ); if ( ! $room ) { return null; } return Room::get_building( $room->ID ); } /** * Get guest for a booking. * * Returns the linked Guest post if guest_id exists, or a stdClass object * with guest data from booking meta for backward compatibility. * * @param int $booking_id Booking post ID. * @return \WP_Post|\stdClass|null Guest post, virtual guest object, or null. */ public static function get_guest( int $booking_id ) { $guest_id = get_post_meta( $booking_id, self::META_PREFIX . 'guest_id', true ); // If linked to Guest CPT, return the guest post. if ( $guest_id ) { $guest = get_post( $guest_id ); if ( $guest && Guest::POST_TYPE === $guest->post_type ) { return $guest; } } // Otherwise, create a virtual guest object from booking meta. $guest_name = get_post_meta( $booking_id, self::META_PREFIX . 'guest_name', true ); $guest_email = get_post_meta( $booking_id, self::META_PREFIX . 'guest_email', true ); $guest_phone = get_post_meta( $booking_id, self::META_PREFIX . 'guest_phone', true ); if ( ! $guest_name && ! $guest_email ) { return null; } // Return virtual guest object for backward compatibility. $virtual_guest = new \stdClass(); $virtual_guest->ID = 0; $virtual_guest->post_type = 'virtual_guest'; $virtual_guest->post_title = $guest_name ?: ''; $virtual_guest->name = $guest_name ?: ''; $virtual_guest->email = $guest_email ?: ''; $virtual_guest->phone = $guest_phone ?: ''; return $virtual_guest; } /** * Get all bookings for a room. * * @param int $room_id Room post ID. * @param array $args Additional query args. * @return array<\WP_Post> */ public static function get_bookings_for_room( int $room_id, array $args = array() ): array { $defaults = array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => -1, 'meta_query' => array( array( 'key' => self::META_PREFIX . 'room_id', 'value' => $room_id, ), ), 'orderby' => 'meta_value', 'meta_key' => self::META_PREFIX . 'check_in', 'order' => 'ASC', ); return get_posts( array_merge( $defaults, $args ) ); } /** * Format price breakdown for display. * * @param array $breakdown Price breakdown array. * @return string HTML output. */ private static function format_price_breakdown( array $breakdown ): string { $output = ''; return $output; } }