Files
wp-bnb/src/PostTypes/Booking.php
magdev 13ba264431
All checks were successful
Create Release Package / build-release (push) Successful in 1m1s
Release v0.6.1 - Bug fixes and enhancements
New Features:
- Auto-update system with configurable check frequency
- Updates tab in settings with manual check button
- Localhost development mode bypasses license validation
- Extended general settings (address, contact, social media)
- Pricing settings split into subtabs
- Guest ID/passport encryption using AES-256-CBC
- Guest auto-creation from booking form

Bug Fixes:
- Fixed Booking admin issues with auto-draft status
- Fixed guest dropdown loading in booking form
- Fixed booking history display on Guest edit page
- Fixed service pricing meta box (Gutenberg hiding meta boxes)

Changes:
- Admin submenu reordered for better organization
- Booking title shows guest name and dates (room removed)
- Service, Guest, Booking use classic editor (not Gutenberg)
- Settings tabs flush with content (no gap)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 15:18:27 +01:00

1649 lines
55 KiB
PHP

<?php
/**
* Booking post type.
*
* Custom post type for BnB bookings.
*
* @package Magdev\WpBnb\PostTypes
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\PostTypes;
use Magdev\WpBnb\Pricing\Calculator;
/**
* Booking post type class.
*/
final class Booking {
/**
* Services meta key.
*
* @var string
*/
public const SERVICES_META_KEY = '_bnb_booking_services';
/**
* Post type slug.
*
* @var string
*/
public const POST_TYPE = 'bnb_booking';
/**
* Meta key prefix.
*
* @var string
*/
private const META_PREFIX = '_bnb_booking_';
/**
* Initialize the post type.
*
* @return void
*/
public static function init(): void {
add_action( 'init', array( self::class, 'register' ) );
add_action( 'add_meta_boxes', array( self::class, 'add_meta_boxes' ) );
add_action( 'save_post_' . self::POST_TYPE, array( self::class, 'save_meta' ), 10, 2 );
add_filter( 'manage_' . self::POST_TYPE . '_posts_columns', array( self::class, 'add_columns' ) );
add_action( 'manage_' . self::POST_TYPE . '_posts_custom_column', array( self::class, 'render_column' ), 10, 2 );
add_filter( 'manage_edit-' . self::POST_TYPE . '_sortable_columns', array( self::class, 'sortable_columns' ) );
add_action( 'restrict_manage_posts', array( self::class, 'add_filters' ) );
add_action( 'pre_get_posts', array( self::class, 'filter_query' ) );
add_filter( 'enter_title_here', array( self::class, 'change_title_placeholder' ), 10, 2 );
add_action( 'admin_notices', array( self::class, 'show_conflict_notice' ) );
// Disable Gutenberg block editor for Bookings - use classic editor for form-based UI.
add_filter( 'use_block_editor_for_post_type', array( self::class, 'disable_block_editor' ), 10, 2 );
}
/**
* Disable block editor for Bookings post type.
*
* @param bool $use_block_editor Whether to use block editor.
* @param string $post_type Post type.
* @return bool
*/
public static function disable_block_editor( bool $use_block_editor, string $post_type ): bool {
if ( self::POST_TYPE === $post_type ) {
return false;
}
return $use_block_editor;
}
/**
* Register the post type.
*
* @return void
*/
public static function register(): void {
$labels = array(
'name' => _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_services',
__( 'Additional Services', 'wp-bnb' ),
array( self::class, 'render_services_meta_box' ),
self::POST_TYPE,
'normal',
'default'
);
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;
}
?>
<table class="form-table">
<tr>
<th scope="row">
<label for="bnb_booking_room_id"><?php esc_html_e( 'Room', 'wp-bnb' ); ?> <span class="required">*</span></label>
</th>
<td>
<select id="bnb_booking_room_id" name="bnb_booking_room_id" class="widefat" required>
<option value=""><?php esc_html_e( '— Select Room —', 'wp-bnb' ); ?></option>
<?php foreach ( $rooms_by_building as $building_data ) : ?>
<optgroup label="<?php echo esc_attr( $building_data['name'] ); ?>">
<?php foreach ( $building_data['rooms'] as $room ) : ?>
<?php
$room_number = get_post_meta( $room->ID, '_bnb_room_room_number', true );
$room_label = $room->post_title;
if ( $room_number ) {
$room_label .= ' (#' . $room_number . ')';
}
?>
<option value="<?php echo esc_attr( $room->ID ); ?>" <?php selected( $room_id, $room->ID ); ?>>
<?php echo esc_html( $room_label ); ?>
</option>
<?php endforeach; ?>
</optgroup>
<?php endforeach; ?>
</select>
<?php if ( empty( $rooms ) ) : ?>
<p class="description">
<?php
printf(
/* translators: %s: Link to add new room */
esc_html__( 'No rooms found. %s first.', 'wp-bnb' ),
'<a href="' . esc_url( admin_url( 'post-new.php?post_type=' . Room::POST_TYPE ) ) . '">' . esc_html__( 'Add a room', 'wp-bnb' ) . '</a>'
);
?>
</p>
<?php endif; ?>
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_booking_check_in"><?php esc_html_e( 'Check-in Date', 'wp-bnb' ); ?> <span class="required">*</span></label>
</th>
<td>
<input type="date" id="bnb_booking_check_in" name="bnb_booking_check_in"
value="<?php echo esc_attr( $check_in ); ?>" class="regular-text" required>
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_booking_check_out"><?php esc_html_e( 'Check-out Date', 'wp-bnb' ); ?> <span class="required">*</span></label>
</th>
<td>
<input type="date" id="bnb_booking_check_out" name="bnb_booking_check_out"
value="<?php echo esc_attr( $check_out ); ?>" class="regular-text" required>
</td>
</tr>
<tr>
<th scope="row">
<?php esc_html_e( 'Duration', 'wp-bnb' ); ?>
</th>
<td>
<div id="bnb-booking-nights-display" class="bnb-booking-info">
<?php
if ( $check_in && $check_out ) {
$nights = self::calculate_nights( $check_in, $check_out );
printf(
/* translators: %d: Number of nights */
esc_html( _n( '%d night', '%d nights', $nights, 'wp-bnb' ) ),
$nights
);
} else {
esc_html_e( 'Select dates to see duration', 'wp-bnb' );
}
?>
</div>
</td>
</tr>
<tr>
<th scope="row">
<?php esc_html_e( 'Availability', 'wp-bnb' ); ?>
</th>
<td>
<div id="bnb-booking-availability-display" class="bnb-booking-info">
<?php esc_html_e( 'Select room and dates to check availability', 'wp-bnb' ); ?>
</div>
</td>
</tr>
</table>
<?php
}
/**
* Render guest information meta box.
*
* @param \WP_Post $post Current post object.
* @return void
*/
public static function render_guest_meta_box( \WP_Post $post ): void {
$guest_id = (int) 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 );
$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 = 0;
}
}
?>
<input type="hidden" id="bnb_booking_guest_id" name="bnb_booking_guest_id" value="<?php echo esc_attr( $guest_id ); ?>">
<?php if ( $linked_guest ) : ?>
<div id="bnb-linked-guest-info" class="bnb-linked-guest">
<p>
<span class="dashicons dashicons-admin-users"></span>
<strong><?php echo esc_html( $linked_guest->post_title ); ?></strong>
<a href="<?php echo esc_url( get_edit_post_link( $guest_id ) ); ?>" target="_blank" class="button button-small">
<?php esc_html_e( 'View Guest Profile', 'wp-bnb' ); ?>
</a>
<button type="button" id="bnb-unlink-guest" class="button button-small button-link-delete">
<?php esc_html_e( 'Unlink', 'wp-bnb' ); ?>
</button>
</p>
<?php if ( $guest_email ) : ?>
<p><small><?php echo esc_html( $guest_email ); ?></small></p>
<?php endif; ?>
</div>
<?php endif; ?>
<div id="bnb-guest-search-container" class="bnb-guest-search" <?php echo $linked_guest ? 'style="display:none;"' : ''; ?>>
<table class="form-table">
<tr>
<th scope="row">
<label for="bnb_booking_guest_search"><?php esc_html_e( 'Search Guest', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="text" id="bnb_booking_guest_search" class="regular-text"
placeholder="<?php esc_attr_e( 'Search by email...', 'wp-bnb' ); ?>">
<p class="description"><?php esc_html_e( 'Search for existing guest or enter details below.', 'wp-bnb' ); ?></p>
<div id="bnb-guest-search-results" class="bnb-guest-search-results" style="display:none;"></div>
</td>
</tr>
</table>
</div>
<div id="bnb-guest-fields-container" <?php echo $linked_guest ? 'style="display:none;"' : ''; ?>>
<table class="form-table">
<tr>
<th scope="row">
<label for="bnb_booking_guest_name"><?php esc_html_e( 'Guest Name', 'wp-bnb' ); ?> <span class="required">*</span></label>
</th>
<td>
<input type="text" id="bnb_booking_guest_name" name="bnb_booking_guest_name"
value="<?php echo esc_attr( $guest_name ); ?>" class="regular-text" <?php echo $linked_guest ? '' : 'required'; ?>>
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_booking_guest_email"><?php esc_html_e( 'Email', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="email" id="bnb_booking_guest_email" name="bnb_booking_guest_email"
value="<?php echo esc_attr( $guest_email ); ?>" class="regular-text">
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_booking_guest_phone"><?php esc_html_e( 'Phone', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="tel" id="bnb_booking_guest_phone" name="bnb_booking_guest_phone"
value="<?php echo esc_attr( $guest_phone ); ?>" class="regular-text">
</td>
</tr>
</table>
</div>
<table class="form-table">
<tr>
<th scope="row">
<?php esc_html_e( 'Guests', 'wp-bnb' ); ?>
</th>
<td>
<label for="bnb_booking_adults"><?php esc_html_e( 'Adults', 'wp-bnb' ); ?></label>
<input type="number" id="bnb_booking_adults" name="bnb_booking_adults"
value="<?php echo esc_attr( $adults ?: '1' ); ?>" class="small-text" min="1" max="20">
&nbsp;&nbsp;
<label for="bnb_booking_children"><?php esc_html_e( 'Children', 'wp-bnb' ); ?></label>
<input type="number" id="bnb_booking_children" name="bnb_booking_children"
value="<?php echo esc_attr( $children ?: '0' ); ?>" class="small-text" min="0" max="20">
</td>
</tr>
<tr>
<th scope="row">
<label for="bnb_booking_guest_notes"><?php esc_html_e( 'Guest Notes', 'wp-bnb' ); ?></label>
</th>
<td>
<textarea id="bnb_booking_guest_notes" name="bnb_booking_guest_notes"
rows="3" class="large-text"><?php echo esc_textarea( $guest_notes ); ?></textarea>
<p class="description"><?php esc_html_e( 'Special requests or notes from the guest.', 'wp-bnb' ); ?></p>
</td>
</tr>
</table>
<?php
}
/**
* Render services meta box.
*
* @param \WP_Post $post Current post object.
* @return void
*/
public static function render_services_meta_box( \WP_Post $post ): void {
$selected_services = get_post_meta( $post->ID, self::SERVICES_META_KEY, true ) ?: array();
$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 );
$nights = ( $check_in && $check_out ) ? self::calculate_nights( $check_in, $check_out ) : 1;
// Get all active services.
$available_services = Service::get_services_for_booking();
if ( empty( $available_services ) ) {
?>
<p class="bnb-no-services-message">
<?php esc_html_e( 'No services available.', 'wp-bnb' ); ?>
<a href="<?php echo esc_url( admin_url( 'post-new.php?post_type=' . Service::POST_TYPE ) ); ?>">
<?php esc_html_e( 'Add a service', 'wp-bnb' ); ?>
</a>
</p>
<?php
return;
}
// Build a lookup map for selected services.
$selected_map = array();
if ( is_array( $selected_services ) ) {
foreach ( $selected_services as $service ) {
if ( isset( $service['service_id'] ) ) {
$selected_map[ $service['service_id'] ] = $service;
}
}
}
?>
<div class="bnb-services-selector" data-nights="<?php echo esc_attr( $nights ); ?>">
<p class="description">
<?php esc_html_e( 'Select additional services for this booking.', 'wp-bnb' ); ?>
</p>
<div class="bnb-services-list">
<?php foreach ( $available_services as $service ) : ?>
<?php
$is_selected = isset( $selected_map[ $service['id'] ] );
$quantity = $is_selected ? ( $selected_map[ $service['id'] ]['quantity'] ?? 1 ) : 1;
$service_total = $is_selected
? Service::calculate_service_price( $service['id'], $quantity, $nights )
: 0;
?>
<div class="bnb-service-item <?php echo $is_selected ? 'selected' : ''; ?>"
data-service-id="<?php echo esc_attr( $service['id'] ); ?>"
data-price="<?php echo esc_attr( $service['price'] ); ?>"
data-pricing-type="<?php echo esc_attr( $service['pricing_type'] ); ?>"
data-max-quantity="<?php echo esc_attr( $service['max_quantity'] ); ?>">
<label class="bnb-service-checkbox">
<input type="checkbox" name="bnb_booking_services[<?php echo esc_attr( $service['id'] ); ?>][selected]"
value="1" <?php checked( $is_selected ); ?>>
<span class="bnb-service-name"><?php echo esc_html( $service['name'] ); ?></span>
</label>
<div class="bnb-service-details">
<span class="bnb-service-price-label">
<?php
if ( 'included' === $service['pricing_type'] ) {
echo '<span class="bnb-service-included-badge">' . esc_html__( 'Included', 'wp-bnb' ) . '</span>';
} else {
echo esc_html( $service['formatted_price'] );
}
?>
</span>
<?php if ( $service['max_quantity'] > 1 && 'included' !== $service['pricing_type'] ) : ?>
<span class="bnb-service-quantity" <?php echo ! $is_selected ? 'style="display:none;"' : ''; ?>>
<label>
<?php esc_html_e( 'Qty:', 'wp-bnb' ); ?>
<input type="number" name="bnb_booking_services[<?php echo esc_attr( $service['id'] ); ?>][quantity]"
value="<?php echo esc_attr( $quantity ); ?>"
min="1" max="<?php echo esc_attr( $service['max_quantity'] ); ?>"
class="small-text bnb-service-qty-input">
</label>
</span>
<?php else : ?>
<input type="hidden" name="bnb_booking_services[<?php echo esc_attr( $service['id'] ); ?>][quantity]" value="1">
<?php endif; ?>
<span class="bnb-service-line-total" <?php echo ( ! $is_selected || $service_total <= 0 ) ? 'style="display:none;"' : ''; ?>>
= <strong class="bnb-service-total-value"><?php echo esc_html( Calculator::formatPrice( $service_total ) ); ?></strong>
</span>
</div>
</div>
<?php endforeach; ?>
</div>
<div class="bnb-services-total">
<strong><?php esc_html_e( 'Services Total:', 'wp-bnb' ); ?></strong>
<span id="bnb-services-total-amount">
<?php
$services_total = self::calculate_booking_services_total( $post->ID );
echo esc_html( Calculator::formatPrice( $services_total ) );
?>
</span>
</div>
</div>
<?php
}
/**
* Render pricing meta box.
*
* @param \WP_Post $post Current post object.
* @return void
*/
public static function render_pricing_meta_box( \WP_Post $post ): void {
$calculated_price = get_post_meta( $post->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' );
?>
<table class="form-table">
<tr>
<th scope="row">
<?php esc_html_e( 'Calculated Price', 'wp-bnb' ); ?>
</th>
<td>
<div id="bnb-booking-price-display" class="bnb-booking-price">
<?php
if ( $calculated_price ) {
echo '<strong>' . esc_html( Calculator::formatPrice( (float) $calculated_price ) ) . '</strong>';
} else {
esc_html_e( 'Price will be calculated when room and dates are selected.', 'wp-bnb' );
}
?>
</div>
<button type="button" id="bnb-recalculate-price" class="button button-secondary">
<?php esc_html_e( 'Recalculate Price', 'wp-bnb' ); ?>
</button>
<input type="hidden" id="bnb_booking_calculated_price" name="bnb_booking_calculated_price"
value="<?php echo esc_attr( $calculated_price ); ?>">
<input type="hidden" id="bnb_booking_price_breakdown" name="bnb_booking_price_breakdown"
value="<?php echo esc_attr( is_array( $price_breakdown ) ? wp_json_encode( $price_breakdown ) : '' ); ?>">
</td>
</tr>
<?php if ( $price_breakdown && is_array( $price_breakdown ) ) : ?>
<tr>
<th scope="row">
<?php esc_html_e( 'Price Breakdown', 'wp-bnb' ); ?>
</th>
<td>
<div id="bnb-booking-breakdown-display" class="bnb-booking-breakdown">
<?php echo self::format_price_breakdown( $price_breakdown ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
</div>
</td>
</tr>
<?php endif; ?>
<?php
$services_total = self::calculate_booking_services_total( $post->ID );
if ( $services_total > 0 ) :
?>
<tr>
<th scope="row">
<?php esc_html_e( 'Services', 'wp-bnb' ); ?>
</th>
<td>
<div class="bnb-booking-services-summary">
<?php echo esc_html( Calculator::formatPrice( $services_total ) ); ?>
</div>
</td>
</tr>
<tr>
<th scope="row">
<?php esc_html_e( 'Grand Total', 'wp-bnb' ); ?>
</th>
<td>
<div class="bnb-booking-grand-total">
<strong>
<?php
$room_price = (float) $calculated_price;
echo esc_html( Calculator::formatPrice( $room_price + $services_total ) );
?>
</strong>
</div>
</td>
</tr>
<?php endif; ?>
<tr>
<th scope="row">
<label for="bnb_booking_override_price"><?php esc_html_e( 'Override Price', 'wp-bnb' ); ?></label>
</th>
<td>
<input type="number" id="bnb_booking_override_price" name="bnb_booking_override_price"
value="<?php echo esc_attr( $override_price ); ?>" class="small-text" min="0" step="0.01">
<span><?php echo esc_html( $currency ); ?></span>
<p class="description"><?php esc_html_e( 'Leave empty to use calculated price. Enter a value to override.', 'wp-bnb' ); ?></p>
</td>
</tr>
</table>
<?php
}
/**
* Render status and notes meta box.
*
* @param \WP_Post $post Current post object.
* @return void
*/
public static function render_status_meta_box( \WP_Post $post ): void {
$status = get_post_meta( $post->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();
?>
<p>
<label for="bnb_booking_status"><strong><?php esc_html_e( 'Booking Status', 'wp-bnb' ); ?></strong></label>
</p>
<select id="bnb_booking_status" name="bnb_booking_status" class="widefat">
<?php foreach ( $statuses as $key => $label ) : ?>
<option value="<?php echo esc_attr( $key ); ?>" <?php selected( $status, $key ); ?>
data-color="<?php echo esc_attr( $colors[ $key ] ?? '#ccc' ); ?>">
<?php echo esc_html( $label ); ?>
</option>
<?php endforeach; ?>
</select>
<div id="bnb-status-preview" class="bnb-status-preview" style="margin-top: 10px;">
<span class="bnb-status-badge" style="background-color: <?php echo esc_attr( $colors[ $status ] ?? '#ccc' ); ?>">
<?php echo esc_html( $statuses[ $status ] ?? $status ); ?>
</span>
</div>
<?php if ( $confirmed_at ) : ?>
<p class="bnb-status-timestamp">
<?php
printf(
/* translators: %s: Date and time */
esc_html__( 'Confirmed: %s', 'wp-bnb' ),
esc_html( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), strtotime( $confirmed_at ) ) )
);
?>
</p>
<?php endif; ?>
<hr>
<p>
<label for="bnb_booking_notes"><strong><?php esc_html_e( 'Internal Notes', 'wp-bnb' ); ?></strong></label>
</p>
<textarea id="bnb_booking_notes" name="bnb_booking_notes" rows="4" class="widefat"><?php echo esc_textarea( $notes ); ?></textarea>
<p class="description"><?php esc_html_e( 'Notes for internal use only.', 'wp-bnb' ); ?></p>
<?php
}
/**
* Save post meta.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @return void
*/
public static function save_meta( int $post_id, \WP_Post $post ): void {
// Verify nonce.
if ( ! isset( $_POST['bnb_booking_meta_nonce'] ) ||
! wp_verify_nonce( sanitize_key( $_POST['bnb_booking_meta_nonce'] ), 'bnb_booking_meta' ) ) {
return;
}
// Check autosave.
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
// Check permissions.
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
// Get values for conflict check.
$room_id = isset( $_POST['bnb_booking_room_id'] ) ? absint( $_POST['bnb_booking_room_id'] ) : 0;
$check_in = isset( $_POST['bnb_booking_check_in'] ) ? sanitize_text_field( wp_unslash( $_POST['bnb_booking_check_in'] ) ) : '';
$check_out = isset( $_POST['bnb_booking_check_out'] ) ? sanitize_text_field( wp_unslash( $_POST['bnb_booking_check_out'] ) ) : '';
$status = isset( $_POST['bnb_booking_status'] ) ? sanitize_text_field( wp_unslash( $_POST['bnb_booking_status'] ) ) : 'pending';
// Validate dates.
if ( $check_in && $check_out && strtotime( $check_out ) <= strtotime( $check_in ) ) {
add_filter(
'redirect_post_location',
function ( $location ) {
return add_query_arg( 'booking_error', 'invalid_dates', $location );
}
);
return;
}
// Check for conflicts (only for non-cancelled bookings).
if ( $room_id && $check_in && $check_out && 'cancelled' !== $status ) {
if ( self::has_conflict( $room_id, $check_in, $check_out, $post_id ) ) {
add_filter(
'redirect_post_location',
function ( $location ) {
return add_query_arg( 'booking_conflict', '1', $location );
}
);
return;
}
}
// Get old status for comparison.
$old_status = get_post_meta( $post_id, self::META_PREFIX . 'status', true );
// Room ID.
update_post_meta( $post_id, self::META_PREFIX . 'room_id', $room_id );
// Dates (validate format Y-m-d).
if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $check_in ) ) {
update_post_meta( $post_id, self::META_PREFIX . 'check_in', $check_in );
}
if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $check_out ) ) {
update_post_meta( $post_id, self::META_PREFIX . 'check_out', $check_out );
}
// Status (validate against allowed statuses).
if ( array_key_exists( $status, self::get_booking_statuses() ) ) {
update_post_meta( $post_id, self::META_PREFIX . 'status', $status );
// Track confirmation timestamp.
if ( 'confirmed' === $status && 'confirmed' !== $old_status ) {
update_post_meta( $post_id, self::META_PREFIX . 'confirmed_at', current_time( 'mysql' ) );
}
}
// Guest ID (linked guest record).
$guest_id = isset( $_POST['bnb_booking_guest_id'] ) ? absint( $_POST['bnb_booking_guest_id'] ) : 0;
if ( $guest_id ) {
// Verify guest exists.
$guest = get_post( $guest_id );
if ( $guest && Guest::POST_TYPE === $guest->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 {
// No guest_id selected - get guest data from form fields.
$guest_name = isset( $_POST['bnb_booking_guest_name'] ) ? sanitize_text_field( wp_unslash( $_POST['bnb_booking_guest_name'] ) ) : '';
$guest_email = isset( $_POST['bnb_booking_guest_email'] ) ? sanitize_email( wp_unslash( $_POST['bnb_booking_guest_email'] ) ) : '';
$guest_phone = isset( $_POST['bnb_booking_guest_phone'] ) ? sanitize_text_field( wp_unslash( $_POST['bnb_booking_guest_phone'] ) ) : '';
// Try to find or create a Guest post.
$linked_guest_id = self::find_or_create_guest( $guest_name, $guest_email, $guest_phone );
if ( $linked_guest_id ) {
// Link to the guest and sync data.
update_post_meta( $post_id, self::META_PREFIX . 'guest_id', $linked_guest_id );
update_post_meta( $post_id, self::META_PREFIX . 'guest_name', Guest::get_full_name( $linked_guest_id ) );
update_post_meta( $post_id, self::META_PREFIX . 'guest_email', get_post_meta( $linked_guest_id, '_bnb_guest_email', true ) );
update_post_meta( $post_id, self::META_PREFIX . 'guest_phone', get_post_meta( $linked_guest_id, '_bnb_guest_phone', true ) );
} else {
// Fallback: save guest data directly to booking meta if guest creation failed.
delete_post_meta( $post_id, self::META_PREFIX . 'guest_id' );
update_post_meta( $post_id, self::META_PREFIX . 'guest_name', $guest_name );
update_post_meta( $post_id, self::META_PREFIX . 'guest_email', $guest_email );
update_post_meta( $post_id, self::META_PREFIX . 'guest_phone', $guest_phone );
}
}
// 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.
}
}
}
// Services.
$services_data = array();
if ( isset( $_POST['bnb_booking_services'] ) && is_array( $_POST['bnb_booking_services'] ) ) {
$nights = ( $check_in && $check_out ) ? self::calculate_nights( $check_in, $check_out ) : 1;
foreach ( $_POST['bnb_booking_services'] as $service_id => $service_input ) {
$service_id = absint( $service_id );
if ( ! $service_id ) {
continue;
}
// Only include if selected checkbox is checked.
if ( empty( $service_input['selected'] ) ) {
continue;
}
// Verify service exists and is active.
$service_data = Service::get_service_data( $service_id );
if ( ! $service_data || 'active' !== $service_data['status'] ) {
continue;
}
$quantity = isset( $service_input['quantity'] ) ? absint( $service_input['quantity'] ) : 1;
$quantity = max( 1, min( $quantity, $service_data['max_quantity'] ) );
$price = Service::calculate_service_price( $service_id, $quantity, $nights );
$services_data[] = array(
'service_id' => $service_id,
'quantity' => $quantity,
'price' => $price,
'pricing_type' => $service_data['pricing_type'],
);
}
}
update_post_meta( $post_id, self::SERVICES_META_KEY, $services_data );
// 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 );
}
// Generate comprehensive title with guest name, room, and dates.
self::generate_comprehensive_title( $post_id, $room_id, $check_in, $check_out );
}
/**
* Generate a comprehensive title for a booking.
*
* Format: "Guest Name (DD.MM - DD.MM.YYYY)"
*
* @param int $post_id Booking post ID.
* @param int $room_id Room post ID (unused, kept for signature compatibility).
* @param string $check_in Check-in date (Y-m-d).
* @param string $check_out Check-out date (Y-m-d).
* @return void
*/
private static function generate_comprehensive_title( int $post_id, int $room_id, string $check_in, string $check_out ): void {
// Get guest name.
$guest_name = get_post_meta( $post_id, self::META_PREFIX . 'guest_name', true );
if ( empty( $guest_name ) ) {
$guest_name = __( 'Unknown Guest', 'wp-bnb' );
}
// Format dates.
$date_part = '';
if ( $check_in && $check_out ) {
$check_in_date = \DateTime::createFromFormat( 'Y-m-d', $check_in );
$check_out_date = \DateTime::createFromFormat( 'Y-m-d', $check_out );
if ( $check_in_date && $check_out_date ) {
// Same year: "01.02 - 05.02.2026"
// Different year: "28.12.2025 - 02.01.2026"
if ( $check_in_date->format( 'Y' ) === $check_out_date->format( 'Y' ) ) {
$date_part = sprintf(
'%s - %s',
$check_in_date->format( 'd.m' ),
$check_out_date->format( 'd.m.Y' )
);
} else {
$date_part = sprintf(
'%s - %s',
$check_in_date->format( 'd.m.Y' ),
$check_out_date->format( 'd.m.Y' )
);
}
}
}
// Build title: "Guest Name (dates)".
$title = $guest_name;
if ( $date_part ) {
$title .= sprintf( ' (%s)', $date_part );
}
// Update the post title directly in the database to avoid infinite loop.
global $wpdb;
$wpdb->update(
$wpdb->posts,
array( 'post_title' => $title ),
array( 'ID' => $post_id ),
array( '%s' ),
array( '%d' )
);
// Clear post cache.
clean_post_cache( $post_id );
}
/**
* Find an existing guest by email or create a new one.
*
* @param string $name Guest full name.
* @param string $email Guest email.
* @param string $phone Guest phone (optional).
* @return int|null Guest post ID or null on failure.
*/
private static function find_or_create_guest( string $name, string $email, string $phone = '' ): ?int {
// Need at least a name to create a guest.
if ( empty( $name ) ) {
return null;
}
// Try to find existing guest by email.
if ( ! empty( $email ) ) {
$existing_guest = Guest::get_by_email( $email );
if ( $existing_guest ) {
return $existing_guest->ID;
}
}
// Parse name into first/last name.
$name_parts = explode( ' ', trim( $name ), 2 );
$first_name = $name_parts[0] ?? '';
$last_name = $name_parts[1] ?? '';
// Create new guest post.
$guest_id = wp_insert_post(
array(
'post_type' => Guest::POST_TYPE,
'post_status' => 'publish',
'post_title' => $name,
)
);
if ( is_wp_error( $guest_id ) || ! $guest_id ) {
return null;
}
// Save guest meta.
update_post_meta( $guest_id, '_bnb_guest_first_name', $first_name );
update_post_meta( $guest_id, '_bnb_guest_last_name', $last_name );
if ( ! empty( $email ) ) {
update_post_meta( $guest_id, '_bnb_guest_email', $email );
}
if ( ! empty( $phone ) ) {
update_post_meta( $guest_id, '_bnb_guest_phone', $phone );
}
// Set default status.
update_post_meta( $guest_id, '_bnb_guest_status', 'active' );
return $guest_id;
}
/**
* 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 = (int) 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(
'<a href="%s">%s</a>',
esc_url( get_edit_post_link( $room_id ) ),
esc_html( $room->post_title )
);
if ( $building ) {
echo '<br><small>' . esc_html( $building->post_title ) . '</small>';
}
} else {
echo '<em>' . esc_html__( 'Room deleted', 'wp-bnb' ) . '</em>';
}
} else {
echo '—';
}
break;
case 'guest':
$guest_id = (int) 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(
'<a href="%s">%s</a>',
esc_url( get_edit_post_link( $guest_id ) ),
esc_html( $guest_name )
);
echo ' <span class="dashicons dashicons-admin-users" style="font-size: 14px; vertical-align: middle;" title="' . esc_attr__( 'Linked Guest', 'wp-bnb' ) . '"></span>';
} else {
echo esc_html( $guest_name );
}
if ( $guest_email ) {
echo '<br><small><a href="mailto:' . esc_attr( $guest_email ) . '">' . esc_html( $guest_email ) . '</a></small>';
}
} 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 '<br><small>' . esc_html__( 'to', 'wp-bnb' ) . ' ' . esc_html( wp_date( $format, strtotime( $check_out ) ) ) . '</small>';
} 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':
$room_price = (float) get_post_meta( $post_id, self::META_PREFIX . 'calculated_price', true );
$services_total = self::calculate_booking_services_total( $post_id );
$total_price = $room_price + $services_total;
if ( $total_price > 0 ) {
echo esc_html( Calculator::formatPrice( $total_price ) );
$override = get_post_meta( $post_id, self::META_PREFIX . 'override_price', true );
if ( $override ) {
echo ' <span class="bnb-price-override" title="' . esc_attr__( 'Price manually overridden', 'wp-bnb' ) . '">*</span>';
}
if ( $services_total > 0 ) {
echo '<br><small style="color: #646970;">' . esc_html__( 'incl. services', 'wp-bnb' ) . '</small>';
}
} elseif ( $room_price > 0 ) {
echo esc_html( Calculator::formatPrice( $room_price ) );
} 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();
?>
<span class="bnb-status-badge" style="background-color: <?php echo esc_attr( $colors[ $status ] ?? '#ccc' ); ?>">
<?php echo esc_html( $statuses[ $status ] ?? $status ); ?>
</span>
<?php
break;
}
}
/**
* Add sortable columns.
*
* @param array $columns Existing sortable columns.
* @return array
*/
public static function sortable_columns( array $columns ): array {
$columns['dates'] = 'check_in';
$columns['guest'] = 'guest_name';
$columns['status'] = 'status';
return $columns;
}
/**
* Add filter dropdowns to admin list.
*
* @param string $post_type Current post type.
* @return void
*/
public static function add_filters( string $post_type ): void {
if ( self::POST_TYPE !== $post_type ) {
return;
}
// Room filter.
$rooms = get_posts(
array(
'post_type' => 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;
?>
<select name="room_id">
<option value=""><?php esc_html_e( 'All Rooms', 'wp-bnb' ); ?></option>
<?php foreach ( $rooms as $room ) : ?>
<option value="<?php echo esc_attr( $room->ID ); ?>" <?php selected( $selected_room, $room->ID ); ?>>
<?php echo esc_html( $room->post_title ); ?>
</option>
<?php endforeach; ?>
</select>
<?php
}
// Status filter.
$statuses = self::get_booking_statuses();
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter display only.
$selected_status = isset( $_GET['booking_status'] ) ? sanitize_text_field( wp_unslash( $_GET['booking_status'] ) ) : '';
?>
<select name="booking_status">
<option value=""><?php esc_html_e( 'All Statuses', 'wp-bnb' ); ?></option>
<?php foreach ( $statuses as $key => $label ) : ?>
<option value="<?php echo esc_attr( $key ); ?>" <?php selected( $selected_status, $key ); ?>>
<?php echo esc_html( $label ); ?>
</option>
<?php endforeach; ?>
</select>
<?php
}
/**
* Filter bookings by room and status in admin list.
*
* @param \WP_Query $query Current query.
* @return void
*/
public static function filter_query( \WP_Query $query ): void {
if ( ! is_admin() || ! $query->is_main_query() ) {
return;
}
if ( self::POST_TYPE !== $query->get( 'post_type' ) ) {
return;
}
// Exclude auto-drafts from the list - they're not real bookings.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Filter query only.
$post_status = isset( $_GET['post_status'] ) ? sanitize_key( $_GET['post_status'] ) : '';
if ( empty( $post_status ) || 'all' === $post_status ) {
$query->set( 'post_status', array( 'publish', 'pending', 'draft', 'private' ) );
}
$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 __( 'Title auto-generated from guest name and dates', 'wp-bnb' );
}
return $placeholder;
}
/**
* 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'] ) {
?>
<div class="notice notice-error is-dismissible">
<p>
<strong><?php esc_html_e( 'Booking Conflict!', 'wp-bnb' ); ?></strong>
<?php esc_html_e( 'The selected dates overlap with an existing booking for this room. Please choose different dates.', 'wp-bnb' ); ?>
</p>
</div>
<?php
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Notice display only.
if ( isset( $_GET['booking_error'] ) && 'invalid_dates' === $_GET['booking_error'] ) {
?>
<div class="notice notice-error is-dismissible">
<p>
<strong><?php esc_html_e( 'Invalid Dates!', 'wp-bnb' ); ?></strong>
<?php esc_html_e( 'Check-out date must be after check-in date.', 'wp-bnb' ); ?>
</p>
</div>
<?php
}
}
/**
* Get booking status options.
*
* @return array<string, string>
*/
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<string, string>
*/
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<string, array<string>>
*/
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 ) );
}
/**
* Calculate total services cost for a booking.
*
* @param int $booking_id Booking post ID.
* @return float Total services cost.
*/
public static function calculate_booking_services_total( int $booking_id ): float {
$services = get_post_meta( $booking_id, self::SERVICES_META_KEY, true );
if ( ! is_array( $services ) || empty( $services ) ) {
return 0.0;
}
$total = 0.0;
foreach ( $services as $service ) {
if ( isset( $service['price'] ) ) {
$total += (float) $service['price'];
}
}
return $total;
}
/**
* Get selected services for a booking.
*
* @param int $booking_id Booking post ID.
* @return array Array of service data with names.
*/
public static function get_booking_services( int $booking_id ): array {
$services = get_post_meta( $booking_id, self::SERVICES_META_KEY, true );
if ( ! is_array( $services ) || empty( $services ) ) {
return array();
}
$result = array();
foreach ( $services as $service ) {
$service_post = get_post( $service['service_id'] ?? 0 );
if ( $service_post ) {
$result[] = array_merge(
$service,
array( 'name' => $service_post->post_title )
);
}
}
return $result;
}
/**
* 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 = '<ul class="bnb-breakdown-list">';
if ( isset( $breakdown['tier'] ) ) {
$output .= '<li><strong>' . esc_html__( 'Pricing Tier:', 'wp-bnb' ) . '</strong> ';
$output .= esc_html( ucfirst( str_replace( '_', ' ', $breakdown['tier'] ) ) ) . '</li>';
}
if ( isset( $breakdown['nights'] ) && is_array( $breakdown['nights'] ) ) {
$output .= '<li><strong>' . esc_html__( 'Nights:', 'wp-bnb' ) . '</strong> ' . count( $breakdown['nights'] ) . '</li>';
$output .= '<li><strong>' . esc_html__( 'Nightly Rate:', 'wp-bnb' ) . '</strong> ';
$output .= esc_html( Calculator::formatPrice( (float) ( $breakdown['nightly_rate'] ?? 0 ) ) ) . '</li>';
} elseif ( isset( $breakdown['weeks'] ) ) {
$output .= '<li><strong>' . esc_html__( 'Weeks:', 'wp-bnb' ) . '</strong> ' . esc_html( $breakdown['weeks'] ) . '</li>';
$output .= '<li><strong>' . esc_html__( 'Weekly Rate:', 'wp-bnb' ) . '</strong> ';
$output .= esc_html( Calculator::formatPrice( (float) ( $breakdown['weekly_rate'] ?? 0 ) ) ) . '</li>';
} elseif ( isset( $breakdown['months'] ) ) {
$output .= '<li><strong>' . esc_html__( 'Months:', 'wp-bnb' ) . '</strong> ' . esc_html( $breakdown['months'] ) . '</li>';
$output .= '<li><strong>' . esc_html__( 'Monthly Rate:', 'wp-bnb' ) . '</strong> ';
$output .= esc_html( Calculator::formatPrice( (float) ( $breakdown['monthly_rate'] ?? 0 ) ) ) . '</li>';
}
if ( isset( $breakdown['total'] ) ) {
$output .= '<li><strong>' . esc_html__( 'Total:', 'wp-bnb' ) . '</strong> ';
$output .= esc_html( Calculator::formatPrice( (float) $breakdown['total'] ) ) . '</li>';
}
$output .= '</ul>';
return $output;
}
}