Files
wp-bnb/src/Frontend/Search.php

678 lines
21 KiB
PHP
Raw Normal View History

<?php
/**
* Frontend room search.
*
* Handles room search with availability checking and filtering.
*
* @package Magdev\WpBnb\Frontend
*/
declare( strict_types=1 );
namespace Magdev\WpBnb\Frontend;
use Magdev\WpBnb\Booking\Availability;
use Magdev\WpBnb\PostTypes\Booking;
use Magdev\WpBnb\PostTypes\Building;
use Magdev\WpBnb\PostTypes\Room;
use Magdev\WpBnb\Pricing\Calculator;
use Magdev\WpBnb\Pricing\PricingTier;
use Magdev\WpBnb\Taxonomies\Amenity;
use Magdev\WpBnb\Taxonomies\RoomType;
/**
* Search class for frontend room searches.
*/
final class Search {
/**
* Initialize the search system.
*
* @return void
*/
public static function init(): void {
// Public AJAX handlers (no login required).
add_action( 'wp_ajax_wp_bnb_search_rooms', array( self::class, 'ajax_search_rooms' ) );
add_action( 'wp_ajax_nopriv_wp_bnb_search_rooms', array( self::class, 'ajax_search_rooms' ) );
add_action( 'wp_ajax_wp_bnb_get_availability', array( self::class, 'ajax_get_availability' ) );
add_action( 'wp_ajax_nopriv_wp_bnb_get_availability', array( self::class, 'ajax_get_availability' ) );
add_action( 'wp_ajax_wp_bnb_get_calendar', array( self::class, 'ajax_get_calendar' ) );
add_action( 'wp_ajax_nopriv_wp_bnb_get_calendar', array( self::class, 'ajax_get_calendar' ) );
add_action( 'wp_ajax_wp_bnb_calculate_price', array( self::class, 'ajax_calculate_price' ) );
add_action( 'wp_ajax_nopriv_wp_bnb_calculate_price', array( self::class, 'ajax_calculate_price' ) );
}
/**
* Search for rooms with filters.
*
* @param array $args Search arguments.
* @return array Array of room data.
*/
public static function search( array $args = array() ): array {
$defaults = array(
'check_in' => '',
'check_out' => '',
'guests' => 0,
'room_type' => '',
'amenities' => array(),
'price_min' => 0,
'price_max' => 0,
'building_id' => 0,
'orderby' => 'title',
'order' => 'ASC',
'limit' => -1,
'offset' => 0,
);
$args = wp_parse_args( $args, $defaults );
// Build base query.
$query_args = array(
'post_type' => Room::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => (int) $args['limit'],
'offset' => (int) $args['offset'],
'meta_query' => array(
'relation' => 'AND',
),
'tax_query' => array(
'relation' => 'AND',
),
);
// Filter by building.
if ( ! empty( $args['building_id'] ) ) {
$query_args['meta_query'][] = array(
'key' => '_bnb_room_building_id',
'value' => (int) $args['building_id'],
);
}
// Filter by capacity.
if ( ! empty( $args['guests'] ) && (int) $args['guests'] > 0 ) {
$query_args['meta_query'][] = array(
'key' => '_bnb_room_capacity',
'value' => (int) $args['guests'],
'compare' => '>=',
'type' => 'NUMERIC',
);
}
// Filter by room status (only available rooms).
$query_args['meta_query'][] = array(
'relation' => 'OR',
array(
'key' => '_bnb_room_status',
'value' => 'available',
),
array(
'key' => '_bnb_room_status',
'compare' => 'NOT EXISTS',
),
);
// Filter by room type.
if ( ! empty( $args['room_type'] ) ) {
$query_args['tax_query'][] = array(
'taxonomy' => RoomType::TAXONOMY,
'field' => is_numeric( $args['room_type'] ) ? 'term_id' : 'slug',
'terms' => $args['room_type'],
);
}
// Filter by amenities (all must match).
if ( ! empty( $args['amenities'] ) ) {
$amenities = is_array( $args['amenities'] ) ? $args['amenities'] : explode( ',', $args['amenities'] );
$amenities = array_map( 'trim', $amenities );
$amenities = array_filter( $amenities );
if ( ! empty( $amenities ) ) {
$query_args['tax_query'][] = array(
'taxonomy' => Amenity::TAXONOMY,
'field' => is_numeric( $amenities[0] ) ? 'term_id' : 'slug',
'terms' => $amenities,
'operator' => 'AND',
);
}
}
// Handle ordering.
switch ( $args['orderby'] ) {
case 'price':
$query_args['meta_key'] = '_bnb_room_price_' . PricingTier::SHORT_TERM->value;
$query_args['orderby'] = 'meta_value_num';
break;
case 'capacity':
$query_args['meta_key'] = '_bnb_room_capacity';
$query_args['orderby'] = 'meta_value_num';
break;
case 'date':
$query_args['orderby'] = 'date';
break;
case 'random':
$query_args['orderby'] = 'rand';
break;
default:
$query_args['orderby'] = 'title';
break;
}
$query_args['order'] = strtoupper( $args['order'] ) === 'DESC' ? 'DESC' : 'ASC';
// Execute query.
$rooms = get_posts( $query_args );
// Filter by availability if dates provided.
if ( ! empty( $args['check_in'] ) && ! empty( $args['check_out'] ) ) {
$rooms = self::filter_by_availability( $rooms, $args['check_in'], $args['check_out'] );
}
// Filter by price range.
if ( ( ! empty( $args['price_min'] ) || ! empty( $args['price_max'] ) ) && ! empty( $args['check_in'] ) && ! empty( $args['check_out'] ) ) {
$rooms = self::filter_by_price_range(
$rooms,
(float) $args['price_min'],
(float) $args['price_max'],
$args['check_in'],
$args['check_out']
);
}
// Build result array with room data.
$results = array();
foreach ( $rooms as $room ) {
$results[] = self::get_room_data( $room, $args['check_in'], $args['check_out'] );
}
return $results;
}
/**
* Filter rooms by availability.
*
* @param array $rooms Array of WP_Post objects.
* @param string $check_in Check-in date (Y-m-d).
* @param string $check_out Check-out date (Y-m-d).
* @return array Filtered rooms.
*/
public static function filter_by_availability( array $rooms, string $check_in, string $check_out ): array {
return array_filter(
$rooms,
function ( $room ) use ( $check_in, $check_out ) {
return Availability::is_available( $room->ID, $check_in, $check_out );
}
);
}
/**
* Filter rooms by price range.
*
* @param array $rooms Array of WP_Post objects.
* @param float $min Minimum price.
* @param float $max Maximum price.
* @param string $check_in Check-in date.
* @param string $check_out Check-out date.
* @return array Filtered rooms.
*/
public static function filter_by_price_range( array $rooms, float $min, float $max, string $check_in, string $check_out ): array {
return array_filter(
$rooms,
function ( $room ) use ( $min, $max, $check_in, $check_out ) {
try {
$calculator = new Calculator( $room->ID, $check_in, $check_out );
$price = $calculator->calculate();
if ( $min > 0 && $price < $min ) {
return false;
}
if ( $max > 0 && $price > $max ) {
return false;
}
return true;
} catch ( \Exception $e ) {
return false;
}
}
);
}
/**
* Get complete room data for display.
*
* @param \WP_Post $room Room post object.
* @param string $check_in Optional check-in date.
* @param string $check_out Optional check-out date.
* @return array Room data array.
*/
public static function get_room_data( \WP_Post $room, string $check_in = '', string $check_out = '' ): array {
$building_id = get_post_meta( $room->ID, '_bnb_room_building_id', true );
$building = $building_id ? get_post( $building_id ) : null;
// Get room types.
$room_types = wp_get_post_terms( $room->ID, RoomType::TAXONOMY, array( 'fields' => 'names' ) );
// Get amenities with icons.
$amenities = wp_get_post_terms( $room->ID, Amenity::TAXONOMY );
$amenity_list = array();
foreach ( $amenities as $amenity ) {
$amenity_list[] = array(
'id' => $amenity->term_id,
'name' => $amenity->name,
'slug' => $amenity->slug,
'icon' => get_term_meta( $amenity->term_id, 'amenity_icon', true ),
);
}
// Get gallery images.
$gallery_ids = get_post_meta( $room->ID, '_bnb_room_gallery', true );
$gallery = array();
if ( $gallery_ids ) {
$ids = explode( ',', $gallery_ids );
foreach ( $ids as $id ) {
$image = wp_get_attachment_image_src( (int) $id, 'large' );
if ( $image ) {
$gallery[] = array(
'id' => (int) $id,
'url' => $image[0],
'width' => $image[1],
'height' => $image[2],
'thumb' => wp_get_attachment_image_src( (int) $id, 'thumbnail' )[0] ?? $image[0],
);
}
}
}
// Get pricing.
$pricing = Calculator::getRoomPricing( $room->ID );
$nightly_price = $pricing[ PricingTier::SHORT_TERM->value ]['price'] ?? null;
// Calculate stay price if dates provided.
$stay_price = null;
$nights = 0;
if ( ! empty( $check_in ) && ! empty( $check_out ) ) {
try {
$calculator = new Calculator( $room->ID, $check_in, $check_out );
$stay_price = $calculator->calculate();
$nights = $calculator->getNights();
} catch ( \Exception $e ) {
$stay_price = null;
}
}
return array(
'id' => $room->ID,
'title' => $room->post_title,
'slug' => $room->post_name,
'excerpt' => get_the_excerpt( $room ),
'content' => apply_filters( 'the_content', $room->post_content ),
'permalink' => get_permalink( $room->ID ),
'featured_image' => get_the_post_thumbnail_url( $room->ID, 'large' ),
'thumbnail' => get_the_post_thumbnail_url( $room->ID, 'medium' ),
'gallery' => $gallery,
'building' => $building ? array(
'id' => $building->ID,
'title' => $building->post_title,
'permalink' => get_permalink( $building->ID ),
'city' => get_post_meta( $building->ID, '_bnb_building_city', true ),
) : null,
'room_number' => get_post_meta( $room->ID, '_bnb_room_room_number', true ),
'floor' => (int) get_post_meta( $room->ID, '_bnb_room_floor', true ),
'capacity' => (int) get_post_meta( $room->ID, '_bnb_room_capacity', true ),
'max_adults' => (int) get_post_meta( $room->ID, '_bnb_room_max_adults', true ),
'max_children' => (int) get_post_meta( $room->ID, '_bnb_room_max_children', true ),
'size' => (float) get_post_meta( $room->ID, '_bnb_room_size', true ),
'beds' => get_post_meta( $room->ID, '_bnb_room_beds', true ),
'bathrooms' => (float) get_post_meta( $room->ID, '_bnb_room_bathrooms', true ),
'room_types' => $room_types,
'amenities' => $amenity_list,
'nightly_price' => $nightly_price,
'price_formatted' => $nightly_price ? Calculator::formatPrice( $nightly_price ) : null,
'stay_price' => $stay_price,
'stay_price_formatted' => $stay_price ? Calculator::formatPrice( $stay_price ) : null,
'nights' => $nights,
);
}
/**
* Get data for search form (room types, amenities, buildings).
*
* @return array Form data.
*/
public static function get_search_form_data(): array {
// Get all room types.
$room_types = get_terms(
array(
'taxonomy' => RoomType::TAXONOMY,
'hide_empty' => true,
'orderby' => 'meta_value_num',
'meta_key' => 'room_type_sort_order',
'order' => 'ASC',
)
);
// Get all amenities.
$amenities = get_terms(
array(
'taxonomy' => Amenity::TAXONOMY,
'hide_empty' => true,
'orderby' => 'name',
'order' => 'ASC',
)
);
// Get all buildings with rooms.
$buildings = get_posts(
array(
'post_type' => Building::POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'orderby' => 'title',
'order' => 'ASC',
)
);
// Filter buildings to only those with rooms.
$buildings_with_rooms = array();
foreach ( $buildings as $building ) {
$rooms = Room::get_rooms_for_building( $building->ID );
if ( ! empty( $rooms ) ) {
$buildings_with_rooms[] = array(
'id' => $building->ID,
'title' => $building->post_title,
'city' => get_post_meta( $building->ID, '_bnb_building_city', true ),
);
}
}
// Get price range from all rooms.
$price_range = self::get_price_range();
// Get capacity range.
$capacity_range = self::get_capacity_range();
return array(
'room_types' => array_map(
function ( $term ) {
return array(
'id' => $term->term_id,
'name' => $term->name,
'slug' => $term->slug,
'parent' => $term->parent,
'count' => $term->count,
'capacity' => (int) get_term_meta( $term->term_id, 'room_type_base_capacity', true ),
);
},
$room_types
),
'amenities' => array_map(
function ( $term ) {
return array(
'id' => $term->term_id,
'name' => $term->name,
'slug' => $term->slug,
'icon' => get_term_meta( $term->term_id, 'amenity_icon', true ),
'count' => $term->count,
);
},
$amenities
),
'buildings' => $buildings_with_rooms,
'price_range' => $price_range,
'capacity_range' => $capacity_range,
'currency' => get_option( 'wp_bnb_currency', 'CHF' ),
);
}
/**
* Get price range from all rooms.
*
* @return array Min and max prices.
*/
public static function get_price_range(): array {
global $wpdb;
$meta_key = '_bnb_room_price_' . PricingTier::SHORT_TERM->value;
$result = $wpdb->get_row(
$wpdb->prepare(
"SELECT MIN(CAST(meta_value AS DECIMAL(10,2))) as min_price,
MAX(CAST(meta_value AS DECIMAL(10,2))) as max_price
FROM {$wpdb->postmeta} pm
JOIN {$wpdb->posts} p ON pm.post_id = p.ID
WHERE pm.meta_key = %s
AND pm.meta_value != ''
AND pm.meta_value > 0
AND p.post_type = %s
AND p.post_status = 'publish'",
$meta_key,
Room::POST_TYPE
)
);
return array(
'min' => $result ? (float) $result->min_price : 0,
'max' => $result ? (float) $result->max_price : 500,
);
}
/**
* Get capacity range from all rooms.
*
* @return array Min and max capacity.
*/
public static function get_capacity_range(): array {
global $wpdb;
$result = $wpdb->get_row(
$wpdb->prepare(
"SELECT MIN(CAST(meta_value AS UNSIGNED)) as min_capacity,
MAX(CAST(meta_value AS UNSIGNED)) as max_capacity
FROM {$wpdb->postmeta} pm
JOIN {$wpdb->posts} p ON pm.post_id = p.ID
WHERE pm.meta_key = '_bnb_room_capacity'
AND pm.meta_value != ''
AND p.post_type = %s
AND p.post_status = 'publish'",
Room::POST_TYPE
)
);
return array(
'min' => $result && $result->min_capacity ? (int) $result->min_capacity : 1,
'max' => $result && $result->max_capacity ? (int) $result->max_capacity : 10,
);
}
/**
* AJAX handler for room search.
*
* @return void
*/
public static function ajax_search_rooms(): void {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- Public API.
$args = array(
'check_in' => isset( $_POST['check_in'] ) ? sanitize_text_field( wp_unslash( $_POST['check_in'] ) ) : '',
'check_out' => isset( $_POST['check_out'] ) ? sanitize_text_field( wp_unslash( $_POST['check_out'] ) ) : '',
'guests' => isset( $_POST['guests'] ) ? absint( $_POST['guests'] ) : 0,
'room_type' => isset( $_POST['room_type'] ) ? sanitize_text_field( wp_unslash( $_POST['room_type'] ) ) : '',
'amenities' => isset( $_POST['amenities'] ) ? array_map( 'sanitize_text_field', (array) $_POST['amenities'] ) : array(),
'price_min' => isset( $_POST['price_min'] ) ? (float) $_POST['price_min'] : 0,
'price_max' => isset( $_POST['price_max'] ) ? (float) $_POST['price_max'] : 0,
'building_id' => isset( $_POST['building_id'] ) ? absint( $_POST['building_id'] ) : 0,
'orderby' => isset( $_POST['orderby'] ) ? sanitize_text_field( wp_unslash( $_POST['orderby'] ) ) : 'title',
'order' => isset( $_POST['order'] ) ? sanitize_text_field( wp_unslash( $_POST['order'] ) ) : 'ASC',
'limit' => isset( $_POST['limit'] ) ? absint( $_POST['limit'] ) : 12,
'offset' => isset( $_POST['offset'] ) ? absint( $_POST['offset'] ) : 0,
);
// phpcs:enable WordPress.Security.NonceVerification.Missing
// Validate dates if provided.
if ( ! empty( $args['check_in'] ) && ! empty( $args['check_out'] ) ) {
$check_in = strtotime( $args['check_in'] );
$check_out = strtotime( $args['check_out'] );
if ( ! $check_in || ! $check_out || $check_out <= $check_in ) {
wp_send_json_error(
array( 'message' => __( 'Invalid date range.', 'wp-bnb' ) )
);
}
if ( $check_in < strtotime( 'today' ) ) {
wp_send_json_error(
array( 'message' => __( 'Check-in date cannot be in the past.', 'wp-bnb' ) )
);
}
}
$results = self::search( $args );
wp_send_json_success(
array(
'rooms' => $results,
'count' => count( $results ),
'args' => $args,
)
);
}
/**
* AJAX handler for availability check.
*
* @return void
*/
public static function ajax_get_availability(): void {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- Public API.
$room_id = isset( $_POST['room_id'] ) ? absint( $_POST['room_id'] ) : 0;
$check_in = isset( $_POST['check_in'] ) ? sanitize_text_field( wp_unslash( $_POST['check_in'] ) ) : '';
$check_out = isset( $_POST['check_out'] ) ? sanitize_text_field( wp_unslash( $_POST['check_out'] ) ) : '';
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( ! $room_id || ! $check_in || ! $check_out ) {
wp_send_json_error(
array( 'message' => __( 'Missing required parameters.', 'wp-bnb' ) )
);
}
$available = Availability::is_available( $room_id, $check_in, $check_out );
$result = array(
'available' => $available,
'room_id' => $room_id,
'check_in' => $check_in,
'check_out' => $check_out,
);
if ( $available ) {
try {
$calculator = new Calculator( $room_id, $check_in, $check_out );
$price = $calculator->calculate();
$result['price'] = $price;
$result['price_formatted'] = Calculator::formatPrice( $price );
$result['nights'] = $calculator->getNights();
$result['breakdown'] = $calculator->getBreakdown();
} catch ( \Exception $e ) {
$result['price_error'] = $e->getMessage();
}
}
wp_send_json_success( $result );
}
/**
* AJAX handler for calendar data.
*
* @return void
*/
public static function ajax_get_calendar(): void {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- Public API.
$room_id = isset( $_POST['room_id'] ) ? absint( $_POST['room_id'] ) : 0;
$year = isset( $_POST['year'] ) ? absint( $_POST['year'] ) : (int) gmdate( 'Y' );
$month = isset( $_POST['month'] ) ? absint( $_POST['month'] ) : (int) gmdate( 'n' );
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( ! $room_id ) {
wp_send_json_error(
array( 'message' => __( 'Room ID is required.', 'wp-bnb' ) )
);
}
// Validate month.
$month = max( 1, min( 12, $month ) );
// Get calendar data.
$calendar = Availability::get_calendar_data( $room_id, $year, $month );
// Simplify for frontend (remove booking details, just show availability).
$days = array();
foreach ( $calendar['days'] as $day_num => $day_data ) {
$days[ $day_num ] = array(
'date' => $day_data['date'],
'day' => $day_data['day'],
'available' => ! $day_data['is_booked'],
'is_past' => $day_data['is_past'],
'is_today' => $day_data['is_today'],
);
}
wp_send_json_success(
array(
'room_id' => $room_id,
'year' => $year,
'month' => $month,
'month_name' => $calendar['month_name'],
'days_in_month' => $calendar['days_in_month'],
'first_day_of_week' => $calendar['first_day_of_week'],
'days' => $days,
'prev_month' => $calendar['prev_month'],
'next_month' => $calendar['next_month'],
)
);
}
/**
* AJAX handler for price calculation.
*
* @return void
*/
public static function ajax_calculate_price(): void {
// phpcs:disable WordPress.Security.NonceVerification.Missing -- Public API.
$room_id = isset( $_POST['room_id'] ) ? absint( $_POST['room_id'] ) : 0;
$check_in = isset( $_POST['check_in'] ) ? sanitize_text_field( wp_unslash( $_POST['check_in'] ) ) : '';
$check_out = isset( $_POST['check_out'] ) ? sanitize_text_field( wp_unslash( $_POST['check_out'] ) ) : '';
// phpcs:enable WordPress.Security.NonceVerification.Missing
if ( ! $room_id || ! $check_in || ! $check_out ) {
wp_send_json_error(
array( 'message' => __( 'Missing required parameters.', 'wp-bnb' ) )
);
}
try {
$calculator = new Calculator( $room_id, $check_in, $check_out );
$price = $calculator->calculate();
$breakdown = $calculator->getBreakdown();
wp_send_json_success(
array(
'room_id' => $room_id,
'check_in' => $check_in,
'check_out' => $check_out,
'nights' => $calculator->getNights(),
'price' => $price,
'price_formatted' => Calculator::formatPrice( $price ),
'tier' => $breakdown['tier'] ?? null,
'breakdown' => $breakdown,
)
);
} catch ( \Exception $e ) {
wp_send_json_error(
array( 'message' => $e->getMessage() )
);
}
}
}