Files
wp-bnb/src/PostTypes/Booking.php

1138 lines
37 KiB
PHP
Raw Normal View History

<?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 {
/**
* 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_filter( 'wp_insert_post_data', array( self::class, 'auto_generate_title' ), 10, 2 );
add_action( 'admin_notices', array( self::class, 'show_conflict_notice' ) );
}
/**
* 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_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_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 );
?>
<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" 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>
<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 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; ?>
<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 text fields.
$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 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(
'<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_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 ) {
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':
$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 ' <span class="bnb-price-override" title="' . esc_attr__( 'Price manually overridden', 'wp-bnb' ) . '">*</span>';
}
} 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;
}
$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'] ) {
?>
<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 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 = '<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;
}
}