ID === $listing_page_id; } /** * Get the configured reservation success page ID. */ public static function get_reservation_success_page_id(): int { return absint(get_option('bizdex_uplisting_reservation_success_page', 0)); } /** * Whether the given (or current) page is the configured reservation success page. * * @param int|null $page_id Optional page ID; defaults to the global $post. */ public static function is_reservation_success_page(?int $page_id = null): bool { $success_page_id = self::get_reservation_success_page_id(); if (!$success_page_id) { return false; } if (null !== $page_id) { return $page_id === $success_page_id; } if (function_exists('is_page') && is_page($success_page_id)) { return true; } global $post; return is_a($post, 'WP_Post') && (int) $post->ID === $success_page_id; } /** * Whether the given (or current) page is the configured reservation page. * * @param int|null $page_id Optional page ID; defaults to the global $post. */ public static function is_reservation_page(?int $page_id = null): bool { $reservation_page_id = self::get_reservation_page_id(); if (!$reservation_page_id) { return false; } if (null !== $page_id) { return $page_id === $reservation_page_id; } if (function_exists('is_page') && is_page($reservation_page_id)) { return true; } global $post; return is_a($post, 'WP_Post') && (int) $post->ID === $reservation_page_id; } /** * Whether listing/archive frontend assets should load. */ public static function should_load_listing_assets(): bool { return self::is_property_archive_template(); } /** * Resolve the listing page permalink. */ public static function get_listing_page_url(): string { $page_id = self::get_listing_page_id(); if ($page_id) { $permalink = get_permalink($page_id); if ($permalink) { return $permalink; } } $archive_link = get_post_type_archive_link('bizdexul_property'); return $archive_link ? $archive_link : home_url('/properties/'); } /** * Listing page content is rendered via the archive template — do not inject shortcodes. * * @param string $content Post content from `the_content` filter. * @return string Unmodified content. */ public function auto_inject_listing($content) { return $content; } /** * Whether the current request should use the native property archive template. */ public static function is_property_archive_template(): bool { return self::is_listing_page() || is_post_type_archive('bizdexul_property') || is_tax('bizdexul_amenity'); } /** * Render the reservation checkout on the configured checkout page. * * @param string $content Post content from `the_content` filter. * @return string */ public function auto_inject_reservation($content) { if (is_admin() || !self::is_reservation_page()) { return $content; } // Strip shortcode tags; checkout is injected from Page Settings. $content = preg_replace('/\[\/?bizdex_uplisting_reservation(?:\s[^\]]*)?\]/', '', (string) $content) ?? (string) $content; return $content . wp_kses($this->render_reservation_shortcode([]), self::frontend_allowed_html()); } /** * Render the reservation success page on the configured confirmation page. * * @param string $content Post content from `the_content` filter. * @return string */ public function auto_inject_reservation_success($content) { if (is_admin() || !self::is_reservation_success_page()) { return $content; } // Strip shortcode tags; confirmation is injected from Page Settings. $content = preg_replace('/\[\/?bizdex_uplisting_reservation_success(?:\s[^\]]*)?\]/', '', (string) $content) ?? (string) $content; return $content . wp_kses($this->render_reservation_success_shortcode([]), self::frontend_allowed_html()); } /** * Resolve the reservation/checkout page permalink. * * @return string */ public static function get_reservation_page_url(): string { $page_id = self::get_reservation_page_id(); if (!empty($page_id)) { $permalink = get_permalink($page_id); if ($permalink) { return $permalink; } } return home_url('/reservation/'); } /** * Resolve the reservation success page permalink with optional booking query args. * * @param int $booking_id Booking post ID. * @param string $confirmation_key One-time confirmation key. */ public static function get_reservation_success_page_url(int $booking_id = 0, string $confirmation_key = ''): string { $page_id = self::get_reservation_success_page_id(); if (!$page_id) { return ''; } $permalink = get_permalink($page_id); if (!$permalink) { return ''; } if ($booking_id > 0 && $confirmation_key !== '') { $permalink = add_query_arg( [ 'booking_id' => $booking_id, 'key' => $confirmation_key, ], $permalink ); } return $permalink; } /** * Shortcode `[bizdex_uplisting_reservation_success]` — render booking confirmation. * * @param array|string $atts Unused shortcode attributes. * @return string */ public function render_reservation_success_shortcode($atts) { $booking = self::get_confirmed_booking_from_request(); if (!$booking) { return wp_kses( '

' . esc_html__('Booking confirmation not found. Please check your confirmation link.', 'advanced-connector-for-uplisting') . '

', self::frontend_allowed_html() ); } $property = bizdex_uplisting_get_property($booking->get_property_id()); if (!$property) { return wp_kses( '

' . esc_html__('Property not found.', 'advanced-connector-for-uplisting') . '

', self::frontend_allowed_html() ); } self::hydrate_booking_from_meta($booking); $args = self::get_booking_review_data($booking, false); if ($args === false) { return wp_kses( '

' . esc_html__('Unable to load booking details.', 'advanced-connector-for-uplisting') . '

', self::frontend_allowed_html() ); } $payment_status = (string) bizdex_uplisting_get_post_meta($booking->get_id(), '_booking_payment_status', true); $default_message = $payment_status === 'enquiry' ? __('Your enquiry has been received!', 'advanced-connector-for-uplisting') : __('Your reservation has been confirmed!', 'advanced-connector-for-uplisting'); $args['confirmation_message'] = sanitize_text_field((string) apply_filters( 'bizdex_uplisting_booking_success_page_message', $default_message, $booking )); $args['booking_reference'] = $booking->get_id(); $args['guest_name'] = trim($booking->get_first_name() . ' ' . $booking->get_last_name()); $args['guest_email'] = $booking->get_email(); $args['guest_phone'] = $booking->get_phone(); $args['payment_status'] = $payment_status; $GLOBALS['bizdex_uplisting_reservation_success_args'] = $args; self::localize_success_page_script($args); ob_start(); bizdex_uplisting_get_template_part('reservation-success-page', '', $args); return wp_kses((string) ob_get_clean(), self::frontend_allowed_html()); } /** * Load a confirmed booking from `booking_id` and `key` query parameters. */ public static function get_confirmed_booking_from_request(): ?BookingDetails { $booking_id = Request::absint('get', 'booking_id'); $key = Request::text('get', 'key'); if ($booking_id <= 0 || $key === '') { return null; } if (!BookingDetails::validate_confirmation_key($booking_id, $key)) { return null; } if (get_post_type($booking_id) !== 'bizdexul_booking') { return null; } return new BookingDetails($booking_id); } /** * Copy persisted booking meta into in-memory props for review display. */ public static function hydrate_booking_from_meta(BookingDetails $booking): void { if (!$booking->get_id()) { return; } $booking->set_props([ 'property_id' => $booking->get_property_id(), 'dates' => $booking->get_stay_date_range_display(), 'adults' => $booking->get_adults(), 'kids' => $booking->get_kids(), 'check_in' => $booking->get_check_in(), 'check_out' => $booking->get_check_out(), ]); } /** * Shortcode `[bizdex_uplisting_reservation]` — render checkout page. * * @param array|string $atts Unused shortcode attributes. * @return string */ public function render_reservation_shortcode($atts) { $session = SessionHandler::instance()->get_all(); if (empty($session['property_id'])) { return wp_kses( '

' . esc_html__('No active booking session found. Please select dates on a property page first.', 'advanced-connector-for-uplisting') . '

', self::frontend_allowed_html() ); } $booking = new BookingDetails(); $booking->set_props([ 'property_id' => $session['property_id'], 'dates' => $session['dates'] ?? '', 'adults' => $session['adults'] ?? 0, 'kids' => $session['kids'] ?? 0, 'start_date' => $session['start_date'] ?? '', 'end_date' => $session['end_date'] ?? '', 'check_in' => $session['check_in'] ?? '', 'check_out' => $session['check_out'] ?? '', ]); $property = bizdex_uplisting_get_property($session['property_id']); if (!$property) { return wp_kses( '

' . esc_html__('Property not found.', 'advanced-connector-for-uplisting') . '

', self::frontend_allowed_html() ); } $args = array_merge([ 'booking' => $booking, 'property' => $property, 'selected_dates' => $session['dates'] ?? '', 'selected_adults' => $session['adults'] ?? 0, 'selected_kids' => $session['kids'] ?? 0, 'base_price' => $session['base_price'] ?? 0, 'show_payment_section' => ReservationService::instance()->checkout_shows_payment_section($booking), ], self::get_booking_review_data($booking) ?: []); $GLOBALS['bizdex_uplisting_reservation_review_args'] = $args; self::localize_reservation_script($args); ob_start(); bizdex_uplisting_get_template_part('reservation-page', '', $args); return wp_kses((string) ob_get_clean(), self::frontend_allowed_html()); } /** * Template variables for `templates/single-property/meta.php`. * * @param array $args Incoming template args. * @return array|false */ public static function get_single_property_meta_template_args(array $args = []) { $property = bizdex_uplisting_resolve_template_property($args); if (!$property) { $property = bizdex_uplisting_get_property(); } if (!$property) { return false; } // Free Connector: Stripe gateway is not available in this version; checkout uses Manual Payment. return [ 'property' => $property, 'stripe_enabled' => false, 'stripe_publishable' => '', 'stripe_mode' => '', ]; } /** * Build template variables for the booking review sidebar/card. * * @param BookingDetails|null $booking Booking object with session props set. * @param bool $use_live_pricing When true, fetch live calendar rates (checkout). * When false, use stored booking totals (thank-you). * @return array|false */ public static function get_booking_review_data($booking = null, $use_live_pricing = true) { if (!$booking instanceof BookingDetails) { return false; } $property_id = (int) $booking->get_prop('property_id'); if ($property_id <= 0) { $property_id = (int) $booking->get_property_id(); } if ($property_id <= 0) { return false; } $property_post = new PropertyDetails($property_id); if (!$property_post->get_id()) { return false; } $source_id = $property_post->get_property_source_id(); $selected_dates = (string) $booking->get_prop('dates', ''); if ($selected_dates === '') { $selected_dates = $booking->get_stay_date_range_display(); } $selected_adults = (int) $booking->get_prop('adults', 0); if ($selected_adults <= 0) { $selected_adults = (int) $booking->get_adults(); } $selected_kids = (int) $booking->get_prop('kids', 0); if ($selected_kids <= 0) { $selected_kids = (int) $booking->get_kids(); } $check_in = (string) $booking->get_prop('check_in', ''); $last_night = (string) $booking->get_prop('end_date', ''); if (empty($last_night)) { $last_night = (string) $booking->get_prop('check_out', ''); } if (empty($check_in) || empty($last_night)) { $parsed = BookingDetails::parse_date_range($selected_dates); $check_in = $parsed['start_date']; $last_night = $parsed['end_date']; } $calculated_nights = 0; $nights_total = 0; $pricing_breakdown = []; $average_day_rate = 0.0; if (!$use_live_pricing) { $pricing_breakdown = $booking->get_display_pricing_breakdown(); $calculated_nights = (int) ($pricing_breakdown['nights'] ?? 0); $nights_total = (float) ($pricing_breakdown['nights_total'] ?? 0); $average_day_rate = (float) ($pricing_breakdown['average_day_rate'] ?? 0); } else { $pricing = null; if ($property_post->uses_uplisting_api_for_pricing()) { if (!empty($source_id)) { $pricing = $booking->calculate_pricing($source_id, $check_in, $last_night, $property_post->get_id()); } else { $pricing = $booking->calculate_local_pricing($property_post, $check_in, $last_night); } } elseif ($property_post->uses_local_booking_fields()) { $pricing = $booking->calculate_local_pricing($property_post, $check_in, $last_night); } if (!is_wp_error($pricing) && is_array($pricing)) { $nights_total = $pricing['nights_total']; $calculated_nights = $pricing['nights_count']; $average_day_rate = $pricing['average_day_rate']; } $pricing_breakdown = $booking->get_pricing_breakdown($property_post, [ 'nights' => $calculated_nights, 'nights_total' => $nights_total, 'average_day_rate' => $average_day_rate, 'selected_dates' => $selected_dates, 'for_checkout' => true, ]); if (!empty($pricing_breakdown['valid'])) { $pricing_breakdown = $booking->apply_extra_charges_policy($pricing_breakdown, $property_post, [ 'for_checkout' => true, ]); } } $discount_amount = $pricing_breakdown['discounts'] ?? 0; $nights_total = $pricing_breakdown['nights_total'] ?? $nights_total; $nights_total_after_discount = $nights_total - $discount_amount; $booking_total = $pricing_breakdown['grand_total'] ?? $nights_total; $display_average_rate = $pricing_breakdown['average_day_rate'] ?? $average_day_rate; $pricing_response = PricingBreakdown::build_reservation_pricing_response($pricing_breakdown, [ 'nights' => $calculated_nights, 'property' => $property_post, ]); $guest_text = __('Select guests', 'advanced-connector-for-uplisting'); if ($selected_adults > 0) { $guest_text = $selected_adults . ' ' . _n('Adult', 'Adults', $selected_adults, 'advanced-connector-for-uplisting'); if ($selected_kids > 0) { $guest_text .= ', ' . $selected_kids . ' ' . _n('Kid', 'Kids', $selected_kids, 'advanced-connector-for-uplisting'); } } return [ 'booking' => $booking, 'property' => $property_post, 'selected_dates' => $selected_dates, 'selected_adults' => $selected_adults, 'selected_kids' => $selected_kids, 'price' => bizdex_uplisting_format_price($display_average_rate), 'total_price' => bizdex_uplisting_format_price($nights_total), 'nights_total' => $nights_total, 'nights_count' => $calculated_nights, 'nights_total_after_discount' => $nights_total_after_discount, 'discount_amount' => $discount_amount, 'booking_total' => $booking_total, 'applied_discount' => $pricing_breakdown['applied_discount_rule'] ?? null, 'pricing_breakdown' => $pricing_response['pricing_breakdown'], 'pricing_response' => $pricing_response, 'guest_text' => $guest_text, 'total_text' => $property_post->get_meta('total_text', __('Estimated total *', 'advanced-connector-for-uplisting')), 'total_disclaimer_text' => $property_post->get_meta('total_disclaimer_text', __('Pricing and dates subject to availability', 'advanced-connector-for-uplisting')), ]; } /** * Pass reservation pricing/session data to checkout JavaScript. * * @param array $args Template args from {@see get_booking_review_data()}. */ public static function localize_reservation_script(array $args): void { $property = $args['property'] ?? null; $booking = $args['booking'] ?? null; if (!$property instanceof PropertyDetails || !$booking instanceof BookingDetails) { return; } self::enqueue_reservation_script(); $pricing_response = is_array($args['pricing_response'] ?? null) ? $args['pricing_response'] : []; $checkout_gateway = ReservationService::instance()->get_checkout_gateway($booking, $property); $active_gateway = $checkout_gateway ?: ReservationService::instance()->get_active_gateway(); wp_localize_script('bizdex-uplisting-reservation', 'bizdexUplistingReservationData', [ 'reservation_token' => '', 'property_id' => $property->get_id(), 'booking_option' => $property->get_booking_option() ?: 'booking_form', 'local_booking_only'=> $property->creates_local_site_booking_only(), 'use_uplisting_api' => $property->uses_uplisting_api_for_pricing(), 'dates_input' => $booking->get_prop('dates', ''), 'adults_input' => (int) $booking->get_prop('adults', 0), 'kids_input' => (int) $booking->get_prop('kids', 0), 'rest' => RestApi::get_localized_script_data(), 'currency_symbol' => bizdex_uplisting_get_currency_symbol(), 'default_phone_country' => bizdex_uplisting_get_default_phone_country($property), 'property_price' => !empty($args['base_price']) ? $args['base_price'] : $property->get_display_price(), 'cleaning_fee' => $property->get_cleaning_fee(), 'tax_percentage' => $property->get_tax_percentage(), 'nights_count' => (int) ($args['nights_count'] ?? 0), 'booking_total' => (float) ($args['booking_total'] ?? 0), 'pricing_response' => $pricing_response, 'apply_extra_charges_at_checkout' => $property->applies_extra_charges_at_checkout(), 'active_gateway' => $active_gateway ? $active_gateway->get_id() : ReservationService::instance()->get_active_gateway_id(), 'show_payment_section' => ReservationService::instance()->checkout_shows_payment_section($booking), 'i18n' => self::get_script_i18n(), ]); PricingBreakdown::localize_script($property, 'checkout', 'bizdex-uplisting-reservation'); } /** * Pass confirmed booking pricing data to the success page JavaScript. * * Uses stored booking totals only — no live Uplisting request. * * @param array $args Template args from {@see get_booking_review_data()}. */ public static function localize_success_page_script(array $args): void { $property = $args['property'] ?? null; if (!$property instanceof PropertyDetails) { return; } self::enqueue_reservation_script(); $pricing_response = is_array($args['pricing_response'] ?? null) ? $args['pricing_response'] : []; wp_localize_script('bizdex-uplisting-reservation', 'bizdexUplistingReservationSuccessData', [ 'nights_count' => (int) ($args['nights_count'] ?? 0), 'pricing_response' => $pricing_response, ]); PricingBreakdown::localize_script($property, 'checkout', 'bizdex-uplisting-reservation'); } /** * Calculate and return live pricing for a property date range (REST). * * Expects `property_source_id`, optional `start_date`, `end_date`, * `adults`, and `kids`. Uses {@see BookingDetails::calculate_pricing()}. * Validates guest capacity against property maximum. * * @param array $input Sanitized REST input (no request globals). * @return array{success: bool, data: array, status?: int} */ public function get_property_price(array $input): array { $property = $this->resolve_property_from_input($input); if ($property && $property->uses_local_booking_fields()) { return $this->send_local_property_price_response($property, $input); } $property_source_id = $this->resolve_property_source_id_from_input($input, $property); if ($property_source_id === '') { if ($property) { return $this->send_local_property_price_response($property, $input, true); } return RestEndpointHelper::build_error_result([ 'status' => 'error', 'message' => __('Property source ID is required.', 'advanced-connector-for-uplisting'), ]); } if (empty(get_option('bizdex_uplisting_api_key', ''))) { return RestEndpointHelper::build_error_result([ 'status' => 'error', 'message' => __('Uplisting API key is not configured.', 'advanced-connector-for-uplisting'), ]); } $property_source_id = sanitize_text_field($property_source_id); $property_post_id = $property instanceof PropertyDetails ? $property->get_id() : absint($input['property_id'] ?? 0); $check_in = (string) ($input['check_in'] ?? gmdate('Y-m-d')); $check_out = (string) ($input['check_out'] ?? $check_in); $adults = absint($input['adults'] ?? 1); $kids = absint($input['kids'] ?? 0); $booking_obj = new BookingDetails(); $pricing = $booking_obj->calculate_pricing( $property_source_id, $check_in, $check_out, $property_post_id ); if (is_wp_error($pricing)) { return RestEndpointHelper::build_error_result([ 'status' => 'error', 'price' => 'N/A', 'available' => false, 'message' => $pricing->get_error_message(), ]); } $day_rate = $pricing['day_rate']; $available = $pricing['available']; $minimum_length_of_stay = $pricing['minimum_length_of_stay']; $average_day_rate = $pricing['average_day_rate']; $pricing_breakdown = $pricing['pricing_breakdown']; $message = ''; $nights_count = (int) ($pricing['nights_count'] ?? 0); $maximum_length_of_stay = (int) ($pricing['maximum_length_of_stay'] ?? 0); if ($maximum_length_of_stay <= 0 && $property instanceof PropertyDetails) { $maximum_length_of_stay = (int) $property->get_effective_max_stay(); } if ($available && $nights_count > 0 && $nights_count < $minimum_length_of_stay) { $available = false; $message = sprintf( /* translators: 1: minimum nights required, 2: nights selected */ __('Minimum stay is %1$d nights. You selected %2$d night(s).', 'advanced-connector-for-uplisting'), (int) $minimum_length_of_stay, $nights_count ); } if ($available && $maximum_length_of_stay > 0 && $nights_count > $maximum_length_of_stay) { $available = false; $message = sprintf( /* translators: %d: maximum nights allowed */ __('Maximum stay is %d nights.', 'advanced-connector-for-uplisting'), $maximum_length_of_stay ); } if ($available && $property instanceof PropertyDetails) { $capacity_message = $this->guest_capacity_violation_message($property, $adults, $kids); if ($capacity_message !== '') { $available = false; $message = $capacity_message; } } if (empty($pricing_breakdown['valid'])) { return RestEndpointHelper::build_error_result([ 'status' => 'error', 'price' => 'N/A', 'available' => false, 'message' => !empty($pricing_breakdown['error']) ? $pricing_breakdown['error'] : __('Unable to calculate pricing.', 'advanced-connector-for-uplisting'), ]); } if ($property instanceof PropertyDetails) { $pricing_breakdown = $booking_obj->apply_extra_charges_policy($pricing_breakdown, $property); } $display_average_rate = $pricing_breakdown['average_day_rate'] ?? $average_day_rate; if ($day_rate === null) { return RestEndpointHelper::build_error_result([ 'status' => 'error', 'price' => 'N/A', 'available' => false, 'message' => __('Unable to calculate price for selected dates.', 'advanced-connector-for-uplisting'), ]); } $pricing_breakdown_display = PricingBreakdown::prepare_property_display_payload( $pricing_breakdown, null, [ 'nights' => $pricing['nights_count'], ] ); $pricing_breakdown_validation = PricingBreakdown::validate_property_display_payload($pricing_breakdown_display); $pricing_breakdown_setup = PricingBreakdown::verify_setup(); return RestEndpointHelper::build_success_result([ 'status' => 'success', 'price' => bizdex_uplisting_format_price($display_average_rate), 'average_day_rate' => $display_average_rate, 'pricing_breakdown' => $pricing_breakdown_display, 'pricing_breakdown_valid' => $pricing_breakdown_validation['valid'], 'pricing_breakdown_errors' => $pricing_breakdown_validation['errors'], 'pricing_breakdown_setup' => $pricing_breakdown_setup['valid'], 'available' => $available, 'minimum_length_of_stay' => $minimum_length_of_stay, 'maximum_length_of_stay' => $maximum_length_of_stay, 'nights_count' => $nights_count, 'message' => $message, 'pricing' => $pricing, ]); } /** * Return busy dates for the next year from the Uplisting calendar API (REST). * * Expects `property_source_id`. Marks dates busy when * both `available` is false and `available_count` is zero. * * Optional `include_price=1` adds today's nightly rate from the same calendar * response (used on single-property page load to avoid a second API call). * * @param array $input Sanitized REST input (no request globals). * @return array{success: bool, data: array, status?: int} */ public function get_availability_check(array $input): array { $property = $this->resolve_property_from_input($input); if ($property && $property->uses_local_booking_fields()) { return $this->send_local_availability_check($property, $input); } $property_source_id = $this->resolve_property_source_id_from_input($input, $property); if ($property_source_id === '') { if ($property) { return $this->send_local_availability_check($property, $input, true); } return RestEndpointHelper::build_error_result(['message' => __('Property source ID is required.', 'advanced-connector-for-uplisting')]); } if (empty(get_option('bizdex_uplisting_api_key', ''))) { return RestEndpointHelper::build_error_result(['message' => __('Uplisting API key is not configured.', 'advanced-connector-for-uplisting')]); } $property_source_id = sanitize_text_field($property_source_id); $api_client = new UplistingClient(); $startDate = gmdate('Y-m-d'); // Match Uplisting calendar coverage; Flatpickr is capped to the last returned day. $endDate = gmdate('Y-m-d', strtotime('+2 years')); $response = $api_client->get_calendar($property_source_id, $startDate, $endDate); if (is_wp_error($response)) { return RestEndpointHelper::build_error_result(['message' => __('API connection failed: ', 'advanced-connector-for-uplisting') . $response->get_error_message()]); } $calendar_days = BookingDetails::extract_calendar_days($response); if (empty($calendar_days)) { return RestEndpointHelper::build_error_result(['message' => __('Invalid API response.', 'advanced-connector-for-uplisting')]); } $busy_dates = []; $last_calendar_date = ''; foreach ($calendar_days as $date_info) { if (!is_array($date_info) || empty($date_info['date'])) { continue; } $date = (string) $date_info['date']; if ($date > $last_calendar_date) { $last_calendar_date = $date; } $available = isset($date_info['available']) ? $date_info['available'] : false; $available_count = isset($date_info['available_count']) ? $date_info['available_count'] : 0; if (!$available && !$available_count) { $busy_dates[] = $date; } } $response_data = [ 'source' => 'api', 'busy_dates' => $busy_dates, 'dates' => $calendar_days, 'from' => $startDate, 'to' => $endDate, // Never allow selecting past the last day Uplisting actually returned. 'max_date' => $last_calendar_date !== '' ? $last_calendar_date : $endDate, ]; if (!empty($input['include_price'])) { $today = gmdate('Y-m-d'); $day_rate = null; $minimum_length_of_stay = 1; foreach ($calendar_days as $date_info) { if (($date_info['date'] ?? '') !== $today) { continue; } if (isset($date_info['day_rate'])) { $day_rate = (float) $date_info['day_rate']; } if (isset($date_info['minimum_length_of_stay'])) { $minimum_length_of_stay = (int) $date_info['minimum_length_of_stay']; } break; } if ($day_rate === null) { foreach ($calendar_days as $date_info) { if (!empty($date_info['day_rate'])) { $day_rate = (float) $date_info['day_rate']; break; } } } if ($day_rate === null && $property instanceof PropertyDetails) { $day_rate = (float) $property->get_display_price(); } $response_data['status'] = 'success'; $response_data['price'] = $day_rate !== null ? bizdex_uplisting_format_price($day_rate) : 'N/A'; $response_data['average_day_rate'] = $day_rate; $response_data['minimum_length_of_stay'] = $minimum_length_of_stay; $response_data['available'] = $day_rate !== null; } return RestEndpointHelper::build_success_result($response_data); } /** * Calendar day rates and availability for a date window (REST). * * Prefetches 6 months from Uplisting on cache miss, then serves month * changes from a session-scoped transient (auto-expires). * * @since 1.0.0 * @param array $input Sanitized REST input (no request globals). * @return array{success: bool, data: array, status?: int} */ public function get_calendar_rates(array $input): array { $property = $this->resolve_property_from_input($input); // Shared by booking-form calendar and calendar-rates modal (same busy dates + day rates). if (!$property instanceof PropertyDetails || !$property->uses_uplisting_api_for_calendar()) { return RestEndpointHelper::build_error_result(['message' => __('Calendar dates are not available for this property.', 'advanced-connector-for-uplisting')], 403); } if (empty(get_option('bizdex_uplisting_api_key', ''))) { return RestEndpointHelper::build_error_result(['message' => __('Uplisting API key is not configured.', 'advanced-connector-for-uplisting')]); } $property_source_id = $this->resolve_property_source_id_from_input($input, $property); if ($property_source_id === '') { return RestEndpointHelper::build_error_result(['message' => __('Property source ID is required.', 'advanced-connector-for-uplisting')]); } $from = (string) ($input['from'] ?? gmdate('Y-m-01')); $to = (string) ($input['to'] ?? gmdate('Y-m-t', strtotime($from) ?: time())); $month_key = gmdate('Y-m', strtotime($from) ?: time()); $cache = $this->get_calendar_rates_cache($property_source_id); $from_cache = is_array($cache) && $this->calendar_rates_cache_has_month($cache, $month_key); if (!$from_cache) { $fetched = $this->fetch_calendar_rates_window($property_source_id, $month_key); if (is_wp_error($fetched)) { return RestEndpointHelper::build_error_result(['message' => __('API connection failed: ', 'advanced-connector-for-uplisting') . $fetched->get_error_message()]); } // Merge with any existing session window so navigating back stays cached. $cache = $this->merge_calendar_rates_cache(is_array($cache) ? $cache : null, $fetched); $this->store_calendar_rates_cache($property_source_id, $cache); } $days = array_values($cache['days'] ?? []); $max_date = (string) ($cache['to'] ?? $to); if ($days !== []) { $day_dates = []; foreach ($days as $day) { if (is_array($day) && !empty($day['date'])) { $day_dates[] = (string) $day['date']; } } if ($day_dates !== []) { sort($day_dates); $max_date = (string) end($day_dates); } } return RestEndpointHelper::build_success_result([ 'from' => $from, 'to' => $to, 'from_cache' => $from_cache, 'months' => array_values($cache['months'] ?? []), 'busy_dates' => array_values($cache['busy_dates'] ?? []), 'days' => $days, 'max_date' => $max_date, ]); } /** * Shared transient key for calendar rates (not visitor-specific). * * @since 1.0.0 */ private function get_calendar_rates_cache_key(string $property_source_id): string { return 'bizdex_uplisting_cr_' . md5(sanitize_text_field($property_source_id)); } /** * Read the session calendar-rates cache for a property. * * @since 1.0.0 * * @return array|null */ private function get_calendar_rates_cache(string $property_source_id): ?array { $cached = get_transient($this->get_calendar_rates_cache_key($property_source_id)); return is_array($cached) && !empty($cached['days']) && !empty($cached['months']) ? $cached : null; } /** * Persist calendar rates for all visitors (12-hour transient). * * @since 1.0.0 * * @param array $cache */ private function store_calendar_rates_cache(string $property_source_id, array $cache): void { set_transient( $this->get_calendar_rates_cache_key($property_source_id), $cache, 12 * HOUR_IN_SECONDS ); } /** * Whether the cached window includes the given Y-m month. * * @since 1.0.0 * * @param array $cache */ private function calendar_rates_cache_has_month(array $cache, string $month_key): bool { $months = $cache['months'] ?? []; return is_array($months) && in_array($month_key, $months, true); } /** * Fetch a 6-month Uplisting window starting at $month_key. * * @since 1.0.0 * * @return array|\WP_Error */ private function fetch_calendar_rates_window(string $property_source_id, string $month_key) { $window_start = strtotime($month_key . '-01'); if ($window_start === false) { $window_start = strtotime(gmdate('Y-m-01')); } $today_start = strtotime(gmdate('Y-m-01')); if ($today_start !== false && $window_start < $today_start) { $window_start = $today_start; } $window_from = gmdate('Y-m-01', $window_start); $window_to = gmdate('Y-m-t', strtotime('+5 months', $window_start) ?: $window_start); $api_client = new UplistingClient(); $response = $api_client->get_calendar($property_source_id, $window_from, $window_to); if (is_wp_error($response)) { return $response; } return $this->normalize_calendar_rates_response($response, $window_from, $window_to); } /** * Merge two calendar-rate cache windows (days keyed by date). * * @since 1.0.0 * * @param array|null $existing * @param array $incoming * @return array */ private function merge_calendar_rates_cache(?array $existing, array $incoming): array { if ($existing === null) { return $incoming; } $days_by_date = []; foreach (array_merge($existing['days'] ?? [], $incoming['days'] ?? []) as $day) { if (!is_array($day) || empty($day['date'])) { continue; } $days_by_date[(string) $day['date']] = $day; } ksort($days_by_date); $busy_dates = array_values(array_unique(array_merge( $existing['busy_dates'] ?? [], $incoming['busy_dates'] ?? [] ))); sort($busy_dates); $months = array_values(array_unique(array_merge( $existing['months'] ?? [], $incoming['months'] ?? [] ))); sort($months); $dates = array_keys($days_by_date); return [ 'from' => $dates !== [] ? $dates[0] : (string) ($incoming['from'] ?? ''), 'to' => $dates !== [] ? $dates[count($dates) - 1] : (string) ($incoming['to'] ?? ''), 'months' => $months, 'busy_dates' => $busy_dates, 'days' => array_values($days_by_date), ]; } /** * Normalize an Uplisting calendar payload into cache/response shape. * * @since 1.0.0 * * @param array $response API response. * @return array{ * from: string, * to: string, * months: array, * busy_dates: array, * days: array> * } */ private function normalize_calendar_rates_response($response, string $window_from, string $window_to): array { $calendar_days = BookingDetails::extract_calendar_days($response); $days = []; $busy_dates = []; $months = []; $cursor = strtotime($window_from); $end = strtotime($window_to); if ($cursor !== false && $end !== false) { while ($cursor <= $end) { $months[] = gmdate('Y-m', $cursor); $cursor = strtotime('+1 month', $cursor); if ($cursor === false) { break; } } } $months = array_values(array_unique($months)); foreach ($calendar_days as $date_info) { if (!is_array($date_info) || empty($date_info['date'])) { continue; } $date = (string) $date_info['date']; $available = !empty($date_info['available']); $available_count = isset($date_info['available_count']) ? (int) $date_info['available_count'] : 0; $day_rate = isset($date_info['day_rate']) ? (float) $date_info['day_rate'] : 0.0; $is_busy = !$available && $available_count <= 0; if ($is_busy) { $busy_dates[] = $date; } $days[] = [ 'date' => $date, 'available' => !$is_busy, 'day_rate' => $day_rate, 'price' => $day_rate > 0 ? bizdex_uplisting_format_price($day_rate) : '', 'minimum_length_of_stay' => isset($date_info['minimum_length_of_stay']) ? (int) $date_info['minimum_length_of_stay'] : 1, ]; } return [ 'from' => $window_from, 'to' => $window_to, 'months' => $months, 'busy_dates' => array_values(array_unique($busy_dates)), 'days' => $days, ]; } /** * Resolve Uplisting property source ID from sanitized input or linked post meta. * * @param array $input Sanitized endpoint input. * @param PropertyDetails|null $property Optional already-resolved property. * @return string */ private function resolve_property_source_id_from_input(array $input, ?PropertyDetails $property = null): string { $property_source_id = sanitize_text_field((string) ($input['property_source_id'] ?? '')); if ($property_source_id !== '') { return $property_source_id; } if (!$property) { $property = $this->resolve_property_from_input($input); } if ($property) { $source_id = (string) $property->get_property_source_id(); if ($source_id !== '') { return $source_id; } } return ''; } /** * Resolve a property model from sanitized input (`post_id` or `property_id`). * * @param array $input Sanitized endpoint input. * @return PropertyDetails|null */ private function resolve_property_from_input(array $input): ?PropertyDetails { $property_post_id = absint($input['post_id'] ?? 0); if ($property_post_id <= 0) { $property_post_id = absint($input['property_id'] ?? 0); } if ($property_post_id <= 0) { return null; } $property = new PropertyDetails($property_post_id); return $property->get_id() > 0 ? $property : null; } /** * Local busy dates + base rate from property meta (inquiry form mode). * * @param PropertyDetails $property Property model. * @param array $input Sanitized endpoint input. * @param bool $allow_meta_fallback Allow local path when API source is missing. * @return array{success: bool, data: array, status?: int} */ private function send_local_availability_check(PropertyDetails $property, array $input, bool $allow_meta_fallback = false): array { if (!$allow_meta_fallback && !$property->uses_local_booking_fields()) { return RestEndpointHelper::build_error_result(['message' => __('Local availability is only used for inquiry booking properties.', 'advanced-connector-for-uplisting')]); } $busy_dates = $property->get_busy_dates(); $max_date = ''; foreach ($busy_dates as $busy_date) { $busy_date = (string) $busy_date; if ($busy_date > $max_date) { $max_date = $busy_date; } } $response_data = [ 'source' => 'local', 'busy_dates' => $busy_dates, 'dates' => [], 'max_date' => $max_date, ]; if (!empty($input['include_price'])) { $day_rate = $property->get_display_price(); $response_data['status'] = 'success'; $response_data['price'] = $day_rate > 0 ? bizdex_uplisting_format_price($day_rate) : 'N/A'; $response_data['average_day_rate'] = $day_rate; $response_data['minimum_length_of_stay'] = $property->get_effective_min_stay(); $response_data['maximum_length_of_stay'] = $property->get_effective_max_stay(); $response_data['available'] = $day_rate > 0; } return RestEndpointHelper::build_success_result($response_data); } /** * Local pricing for inquiry-form properties. * * @param PropertyDetails $property Property model. * @param array $input Sanitized endpoint input. * @param bool $allow_meta_fallback Allow local path when API source is missing. * @return array{success: bool, data: array, status?: int} */ private function send_local_property_price_response(PropertyDetails $property, array $input, bool $allow_meta_fallback = false): array { if (!$allow_meta_fallback && !$property->uses_local_booking_fields()) { return RestEndpointHelper::build_error_result([ 'status' => 'error', 'price' => 'N/A', 'available' => false, 'message' => __('Local pricing is only used for inquiry booking properties.', 'advanced-connector-for-uplisting'), ]); } $check_in = (string) ($input['check_in'] ?? gmdate('Y-m-d')); $check_out = (string) ($input['check_out'] ?? $check_in); $adults = absint($input['adults'] ?? 1); $kids = absint($input['kids'] ?? 0); $booking_obj = new BookingDetails(); $pricing = $booking_obj->calculate_local_pricing($property, $check_in, $check_out); $available = (bool) $pricing['available']; $minimum_length_of_stay = (int) $pricing['minimum_length_of_stay']; $average_day_rate = (float) $pricing['average_day_rate']; $pricing_breakdown = $pricing['pricing_breakdown']; $message = ''; $nights_count = (int) ($pricing['nights_count'] ?? 0); $day_rate = $pricing['day_rate']; $maximum_length_of_stay = (int) ($pricing['maximum_length_of_stay'] ?? $property->get_effective_max_stay()); if ($available && $nights_count > 0 && $nights_count < $minimum_length_of_stay) { $available = false; $message = sprintf( /* translators: 1: minimum nights required, 2: nights selected */ __('Minimum stay is %1$d nights. You selected %2$d night(s).', 'advanced-connector-for-uplisting'), $minimum_length_of_stay, $nights_count ); } if ($available && $maximum_length_of_stay > 0 && $nights_count > $maximum_length_of_stay) { $available = false; $message = sprintf( /* translators: %d: maximum nights allowed */ __('Maximum stay is %d nights.', 'advanced-connector-for-uplisting'), $maximum_length_of_stay ); } if ($available) { $capacity_message = $this->guest_capacity_violation_message($property, $adults, $kids); if ($capacity_message !== '') { $available = false; $message = $capacity_message; } } if (empty($pricing_breakdown['valid'])) { return RestEndpointHelper::build_error_result([ 'status' => 'error', 'price' => 'N/A', 'available' => false, 'message' => !empty($pricing_breakdown['error']) ? $pricing_breakdown['error'] : __('Unable to calculate pricing.', 'advanced-connector-for-uplisting'), ]); } $pricing_breakdown = $booking_obj->apply_extra_charges_policy($pricing_breakdown, $property); $display_average_rate = $pricing_breakdown['average_day_rate'] ?? $average_day_rate; if ($day_rate === null) { return RestEndpointHelper::build_error_result([ 'status' => 'error', 'price' => 'N/A', 'available' => false, 'message' => __('Unable to calculate price for selected dates.', 'advanced-connector-for-uplisting'), ]); } $pricing_breakdown_display = PricingBreakdown::prepare_property_display_payload( $pricing_breakdown, null, [ 'nights' => $nights_count, ] ); $pricing_breakdown_validation = PricingBreakdown::validate_property_display_payload($pricing_breakdown_display); $pricing_breakdown_setup = PricingBreakdown::verify_setup(); return RestEndpointHelper::build_success_result([ 'status' => 'success', 'source' => 'local', 'price' => bizdex_uplisting_format_price($display_average_rate), 'average_day_rate' => $display_average_rate, 'pricing_breakdown' => $pricing_breakdown_display, 'pricing_breakdown_valid' => $pricing_breakdown_validation['valid'], 'pricing_breakdown_errors' => $pricing_breakdown_validation['errors'], 'pricing_breakdown_setup' => $pricing_breakdown_setup['valid'], 'available' => $available, 'minimum_length_of_stay' => $minimum_length_of_stay, 'maximum_length_of_stay' => $maximum_length_of_stay, 'nights_count' => $nights_count, 'message' => $message, 'pricing' => $pricing, ]); } /** * Return a capacity error message when requested guests exceed property maximum. * * Capacity comes from {@see PropertyDetails::get_guests()}, with * {@see PropertyDetails::get_person_capacity()} as fallback. * * @param PropertyDetails $property Property model. * @param int $adults Requested adults. * @param int $kids Requested children. * @return string Empty string when capacity is fine or unknown. */ private function guest_capacity_violation_message(PropertyDetails $property, int $adults, int $kids): string { $max_capacity = (int) $property->get_guests(); if ($max_capacity <= 0) { $max_capacity = $property->get_person_capacity(); } if ($max_capacity <= 0) { return ''; } if (($adults + $kids) <= $max_capacity) { return ''; } return sprintf( /* translators: %d: maximum number of guests allowed */ __('Maximum capacity is %d guests.', 'advanced-connector-for-uplisting'), $max_capacity ); } /** * Shortcode `[bizdex_uplisting_properties]` — render the property archive/listing. * * Supports grid and list views. Merges URL query filters * (location, dates, guests, bedrooms) with shortcode attributes. * Loads `templates/archive-property.php` (theme override supported). * * @param array|string $atts { * @type string $show_filter Show filter UI (yes/no). * @type string $show_pagination Show pagination (yes/no). * @type string $orderby WP_Query orderby field. * @type string $order ASC or DESC. * @type int $limit Posts per page (default 12). * @type int $columns Grid column count. * @type string $view Default view mode: grid|list. * @type string $location Default location filter. * @type string $bedrooms Default bedroom filter. * @type string $slider_autoplay Slider autoplay (yes/no). * @type string $slider_speed Autoplay interval in ms. * @type string $slider_items Visible slider items count. * } * @return string Buffered HTML from the archive template. */ public function render_properties_shortcode($atts) { $this->enqueue_listing_assets(); ListingQuery::setup_property_archive(is_array($atts) ? $atts : []); ob_start(); bizdex_uplisting_get_template_part('shortcode', 'properties'); return wp_kses((string) ob_get_clean(), self::frontend_allowed_html()); } /** * Build archive query, filters, and layout context for property listings. * * @param array $overrides Shortcode attributes or archive overrides. * @return array */ public function setup_property_archive(array $overrides = []): array { $defaults = ShortcodeRegistry::get_listing_settings(); $raw = is_array($overrides) ? $overrides : []; // Keep block-only layout/search keys that sanitize_properties_attributes strips. $preserved = []; foreach (['enabled_views', 'search_visible', 'search_listing_url'] as $key) { if (array_key_exists($key, $raw)) { $preserved[$key] = $raw[$key]; } } $overrides = ShortcodeRegistry::sanitize_properties_attributes($raw); $atts = array_merge($defaults, $overrides, $preserved); $default_limit = absint($atts['limit'] ?? 12); if ($default_limit < 1) { $default_limit = 12; } $atts['limit'] = (string) $default_limit; $allowed_views = ['grid', 'list']; $default_view = (string) ($atts['view'] ?? 'list'); if (!in_array($default_view, $allowed_views, true)) { $default_view = 'list'; } $current_view = ListingQuery::get_request_view($default_view); if (!in_array($current_view, $allowed_views, true)) { $current_view = $default_view; } $filters = ListingQuery::get_filters_from_request([]); $selected_location = $filters['selected_location']; $selected_dates = $filters['selected_dates']; $selected_adults = $filters['selected_adults']; $selected_kids = $filters['selected_kids']; $selected_bedrooms = $filters['selected_bedrooms']; $selected_bathrooms = $filters['selected_bathrooms']; $selected_beds = $filters['selected_beds']; $total_guests = $filters['total_guests']; $locations = $filters['locations']; $paged = ListingQuery::get_paged(); $show_pagination = ($current_view === 'slider') ? 'no' : ($atts['show_pagination'] ?: 'yes'); $atts['show_pagination'] = $show_pagination; $posts_per_page = ($current_view === 'slider') ? -1 : absint($atts['limit']); $args = [ 'post_type' => 'bizdexul_property', 'posts_per_page' => $posts_per_page, 'paged' => $paged, 'post_status' => 'publish', 'orderby' => $atts['orderby'], 'order' => $atts['order'], ]; if (is_tax('bizdexul_amenity')) { $term = get_queried_object(); if ($term instanceof \WP_Term) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Amenity taxonomy archive scope. $args['tax_query'] = [ [ 'taxonomy' => 'bizdexul_amenity', 'field' => 'term_id', 'terms' => $term->term_id, ], ]; } } $meta_query = ListingQuery::build_meta_query($filters); if (!empty($meta_query)) { // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Listing filters require meta lookup. $args['meta_query'] = $meta_query; } $properties_query = new \WP_Query($args); $atts['filter'] = [ 'selected_location' => $selected_location, 'selected_dates' => $selected_dates, 'start_date' => (string) ($filters['start_date'] ?? ''), 'end_date' => (string) ($filters['end_date'] ?? ''), 'selected_adults' => $selected_adults, 'selected_kids' => $selected_kids, 'selected_bedrooms' => $selected_bedrooms, 'selected_bathrooms' => $selected_bathrooms, 'selected_beds' => $selected_beds, 'total_guests' => $total_guests, 'locations' => $locations, ]; $shortcode_id = uniqid('uplisting_'); $columns = absint($atts['columns'] ?? 2); $slider_items = absint($atts['slider_items'] ?: 3); $slider_autoplay = ($atts['slider_autoplay'] ?? '') === 'yes' ? 'yes' : 'no'; $slider_speed = absint($atts['slider_speed'] ?: 3000); $enabled_views = self::resolve_enabled_views($atts); $wrapper_classes = ['uplisting-shortcode-wrapper']; if (self::is_property_archive_template()) { $wrapper_classes[] = 'uplisting-archive-wrapper'; } $main_content_classes = [sanitize_html_class($current_view) . '-view']; $grid_classes = ['uplisting-properties-grid', 'columns-' . $columns]; if ($current_view === 'slider') { $grid_classes[] = 'items-' . $slider_items; } $atts['enabled_views'] = implode(',', $enabled_views); return [ 'query' => $properties_query, 'atts' => $atts, 'current_view' => $current_view, 'shortcode_id' => $shortcode_id, 'columns' => $columns, 'slider_items' => $slider_items, 'slider_autoplay' => $slider_autoplay, 'slider_speed' => $slider_speed, 'enabled_views' => $enabled_views, 'wrapper_classes' => $wrapper_classes, 'main_content_classes' => $main_content_classes, 'grid_classes' => $grid_classes, ]; } /** * Views enabled for the listing toggle UI. * * @param array $atts * @return list */ public static function resolve_enabled_views(array $atts): array { $raw_views = $atts['enabled_views'] ?? null; if (is_string($raw_views) && $raw_views !== '') { $parsed = array_filter(array_map('sanitize_key', explode(',', $raw_views))); } elseif (is_array($raw_views)) { $parsed = array_filter(array_map('sanitize_key', $raw_views)); } else { $parsed = []; } if (!empty($parsed)) { $views = array_values(array_intersect(['grid', 'list'], $parsed)); if (!empty($views)) { return $views; } } // Connector always exposes grid + list toggles. return ['grid', 'list']; } /** * Override the theme template for single `property` posts. * * Skips override during Elementor edit/preview modes. Search order: * `{theme}/wp-uplisting-connector/single-property.php` → plugin `templates/single-property.php`. * * @param string $template Path to the template WordPress would load. * @return string Resolved template path. */ public function template_loader($template) { // Allow Elementor Pro to edit and override templates if (class_exists('\Elementor\Plugin')) { if (\Elementor\Plugin::$instance->editor->is_edit_mode() || \Elementor\Plugin::$instance->preview->is_preview_mode()) { return $template; } } if (Request::has('get', 'elementor-preview')) { return $template; } if (is_singular('bizdexul_property')) { $theme_template = locate_template([ 'wp-uplisting-connector/single-property.php', 'single-property.php' ]); if ($theme_template) { return $theme_template; } $plugin_template = BIZDEX_UPLISTING_PLUGIN_DIR . 'templates/single-property.php'; if (file_exists($plugin_template)) { return $plugin_template; } } if (is_post_type_archive('bizdexul_property') || is_tax('bizdexul_amenity') || self::is_listing_page()) { $theme_template = locate_template([ 'wp-uplisting-connector/archive-property.php', 'archive-property.php', ]); if ($theme_template) { return $theme_template; } $plugin_template = BIZDEX_UPLISTING_PLUGIN_DIR . 'templates/archive-property.php'; if (file_exists($plugin_template)) { return $plugin_template; } } return $template; } /** * Whether the current post content includes the properties listing shortcode or block. */ public static function post_has_properties_listing(?\WP_Post $post = null): bool { if (!$post instanceof \WP_Post) { global $post; } if (!$post instanceof \WP_Post) { return false; } if (has_shortcode($post->post_content, 'bizdex_uplisting_properties')) { return true; } return false; } /** * Register frontend styles (WooCommerce-style). * * `bizdex-uplisting-layout` is the shared layout sheet. * Template sheets register only for screens whose layout differs. */ private function register_frontend_styles(): void { $url = BIZDEX_UPLISTING_PLUGIN_URL; $dir = BIZDEX_UPLISTING_PLUGIN_DIR; $version = static function (string $relative) use ($dir): string { return BIZDEX_UPLISTING_VERSION . '.' . (string) filemtime($dir . $relative); }; if (!wp_style_is('bizdex-uplisting-icons', 'registered')) { wp_register_style( 'bizdex-uplisting-icons', $url . 'assets/css/icons.css', [], $version('assets/css/icons.css'), 'all' ); } if (!wp_style_is('bizdex-uplisting-layout', 'registered')) { wp_register_style( 'bizdex-uplisting-layout', $url . 'assets/css/bizdex-uplisting-layout.css', [], $version('assets/css/bizdex-uplisting-layout.css'), 'all' ); } if (!wp_style_is('bizdex-uplisting-single-property-css', 'registered')) { wp_register_style( 'bizdex-uplisting-single-property-css', $url . 'assets/css/single-property.css', ['bizdex-uplisting-layout', 'bizdex-uplisting-icons'], $version('assets/css/single-property.css'), 'all' ); } if (!wp_style_is('bizdex-uplisting-reservation', 'registered')) { wp_register_style( 'bizdex-uplisting-reservation', $url . 'assets/css/reservation.css', ['bizdex-uplisting-layout'], $version('assets/css/reservation.css'), 'all' ); } } /** * Enqueue listing scripts and the shared layout stylesheet. */ public function enqueue_listing_assets(): void { $this->ensure_flatpickr_assets(); self::ensure_slider_assets(); $this->register_frontend_styles(); wp_enqueue_style('bizdex-uplisting-layout'); wp_enqueue_style('bizdex-uplisting-icons'); if (!wp_script_is('bizdex-uplisting-archive-js', 'enqueued')) { $archive_js = BIZDEX_UPLISTING_PLUGIN_DIR . 'assets/js/archive.js'; wp_enqueue_script( 'bizdex-uplisting-archive-js', BIZDEX_UPLISTING_PLUGIN_URL . 'assets/js/archive.js', ['bizdex-uplisting-dom', 'bizdex-uplisting-slider', 'bizdex-uplisting-flatpickr-js'], BIZDEX_UPLISTING_VERSION . '.' . (string) filemtime($archive_js), true ); wp_localize_script('bizdex-uplisting-archive-js', 'bizdexUplistingArchiveData', [ 'i18n' => self::get_script_i18n(), ]); } } /** * Register + enqueue shared vanilla DOM helpers (no jQuery). */ public static function ensure_dom_assets(): void { if (!wp_script_is('bizdex-uplisting-dom', 'registered')) { $dom_js = BIZDEX_UPLISTING_PLUGIN_DIR . 'assets/js/dom.js'; wp_register_script( 'bizdex-uplisting-dom', BIZDEX_UPLISTING_PLUGIN_URL . 'assets/js/dom.js', [], BIZDEX_UPLISTING_VERSION . '.' . (string) filemtime($dom_js), true ); } wp_enqueue_script('bizdex-uplisting-dom'); } /** * Register + enqueue shared card carousel / listing strip slider helpers. */ public static function ensure_slider_assets(): void { self::ensure_dom_assets(); if (!wp_script_is('bizdex-uplisting-slider', 'registered')) { $slider_js = BIZDEX_UPLISTING_PLUGIN_DIR . 'assets/js/slider.js'; wp_register_script( 'bizdex-uplisting-slider', BIZDEX_UPLISTING_PLUGIN_URL . 'assets/js/slider.js', ['bizdex-uplisting-dom'], BIZDEX_UPLISTING_VERSION . '.' . (string) filemtime($slider_js), true ); } wp_enqueue_script('bizdex-uplisting-slider'); } /** * Register + enqueue shared pricing breakdown display helpers. */ public static function ensure_pricing_display_assets(): void { self::ensure_dom_assets(); if (!wp_script_is('bizdex-uplisting-pricing-display', 'registered')) { $pricing_js = BIZDEX_UPLISTING_PLUGIN_DIR . 'assets/js/pricing-display.js'; wp_register_script( 'bizdex-uplisting-pricing-display', BIZDEX_UPLISTING_PLUGIN_URL . 'assets/js/pricing-display.js', ['bizdex-uplisting-dom'], BIZDEX_UPLISTING_VERSION . '.' . (string) filemtime($pricing_js), true ); } wp_enqueue_script('bizdex-uplisting-pricing-display'); } /** * Register + enqueue checkout/success reservation.js so localize data can attach. * * Safe to call from `the_content` (footer scripts) if `wp_enqueue_scripts` missed the page. */ public static function enqueue_reservation_script(): void { self::instance()->register_frontend_styles(); if (wp_style_is('bizdex-uplisting-reservation', 'registered')) { wp_enqueue_style('bizdex-uplisting-reservation'); } if (wp_script_is('bizdex-uplisting-reservation', 'enqueued')) { return; } self::ensure_dom_assets(); self::ensure_pricing_display_assets(); self::enqueue_rest_client_script(); $path = BIZDEX_UPLISTING_PLUGIN_DIR . 'assets/js/reservation.js'; wp_enqueue_script( 'bizdex-uplisting-reservation', BIZDEX_UPLISTING_PLUGIN_URL . 'assets/js/reservation.js', ['bizdex-uplisting-dom', 'bizdex-uplisting-pricing-display', 'bizdex-uplisting-rest-client'], BIZDEX_UPLISTING_VERSION . '.' . (string) filemtime($path), true ); } /** * Register + enqueue Flatpickr vendor assets and the plugin datepicker stylesheet once. */ public function ensure_flatpickr_assets(): void { static $ensured = false; if (!wp_style_is('bizdex-uplisting-flatpickr', 'registered')) { wp_register_style( 'bizdex-uplisting-flatpickr', BIZDEX_UPLISTING_PLUGIN_URL . 'assets/vendor/flatpickr/flatpickr.min.css', [], '4.6.13' ); } if (!wp_style_is('bizdex-uplisting-datepicker', 'registered')) { wp_register_style( 'bizdex-uplisting-datepicker', BIZDEX_UPLISTING_PLUGIN_URL . 'assets/css/datepicker.css', ['bizdex-uplisting-flatpickr'], BIZDEX_UPLISTING_VERSION . '.' . (string) filemtime(BIZDEX_UPLISTING_PLUGIN_DIR . 'assets/css/datepicker.css') ); } if (!wp_script_is('bizdex-uplisting-flatpickr-js', 'registered')) { wp_register_script( 'bizdex-uplisting-flatpickr-js', BIZDEX_UPLISTING_PLUGIN_URL . 'assets/vendor/flatpickr/flatpickr.min.js', [], '4.6.13', true ); } if ($ensured) { return; } $ensured = true; wp_enqueue_style('bizdex-uplisting-flatpickr'); wp_enqueue_style('bizdex-uplisting-datepicker'); wp_enqueue_script('bizdex-uplisting-flatpickr-js'); } /** * Build localized property data for single-property scripts. * * @param PropertyDetails $property Property model. * @return array */ public static function get_single_property_script_data(PropertyDetails $property): array { $booking_option = $property->get_booking_option() ?: 'booking_form'; $is_inquiry_form = $booking_option === 'uplisting_booking_form'; $property_source_id = $is_inquiry_form ? '' : (string) $property->get_property_source_id(); return [ 'propertyId' => (string) $property->get_id(), 'propertySourceId' => (string) $property_source_id, 'bookingOption' => $booking_option, 'useUplistingApi' => $property->uses_uplisting_api_for_calendar(), 'usesLocalMeta' => $property->uses_local_booking_fields(), 'localBookingOnly' => $property->creates_local_site_booking_only(), 'rest' => RestApi::get_localized_script_data(), 'showCalendarRates' => $property->should_show_calendar_rates(), 'datesRestPath' => 'property/get-dates', 'maxGuests' => (int) $property->get_meta('guests'), 'minStay' => (int) $property->get_effective_min_stay(), 'maxStay' => (int) $property->get_effective_max_stay(), 'basePrice' => (float) $property->get_display_price(), 'cleaningFee' => (float) $property->get_cleaning_fee(), 'currencySymbol' => function_exists('bizdex_uplisting_get_currency_symbol') ? (string) bizdex_uplisting_get_currency_symbol() : '$', 'i18n' => self::get_script_i18n(), ]; } /** * Translatable strings for frontend JS (wp_localize_script). * * @return array */ public static function get_script_i18n(): array { return [ 'showLess' => __('Show less', 'advanced-connector-for-uplisting'), 'close' => __('Close', 'advanced-connector-for-uplisting'), 'closeCalendar' => __('Close Calendar', 'advanced-connector-for-uplisting'), 'clear' => __('Clear', 'advanced-connector-for-uplisting'), 'loading' => __('Loading...', 'advanced-connector-for-uplisting'), 'bookNow' => __('Book Now', 'advanced-connector-for-uplisting'), 'addGuests' => __('Add guests', 'advanced-connector-for-uplisting'), /* translators: %d: number of guests */ 'guestSingular' => __('%d guest', 'advanced-connector-for-uplisting'), /* translators: %d: number of guests */ 'guestPlural' => __('%d guests', 'advanced-connector-for-uplisting'), 'addLocation' => __('Add location', 'advanced-connector-for-uplisting'), 'addBedrooms' => __('Add bedrooms', 'advanced-connector-for-uplisting'), /* translators: %d: number of bedrooms */ 'bedroomSingular' => __('%d bedroom', 'advanced-connector-for-uplisting'), /* translators: %d: number of bedrooms */ 'bedroomPlural' => __('%d bedrooms', 'advanced-connector-for-uplisting'), 'selectDateRange' => __('Please select a date range of at least 1 night', 'advanced-connector-for-uplisting'), 'notAvailable' => __('Not available', 'advanced-connector-for-uplisting'), 'checkingAvailability' => __('Checking availability...', 'advanced-connector-for-uplisting'), 'availabilityCheckError' => __('Error checking availability. Please try again.', 'advanced-connector-for-uplisting'), 'unavailableDatesSelected' => __('Selected dates include unavailable dates. Please choose different dates.', 'advanced-connector-for-uplisting'), 'createBookingFailed' => __('Failed to create booking', 'advanced-connector-for-uplisting'), /* translators: %s: error message */ 'networkError' => __('Network error: %s', 'advanced-connector-for-uplisting'), /* translators: %d: number of nights */ 'nightSingular' => __('%d night', 'advanced-connector-for-uplisting'), /* translators: %d: number of nights */ 'nightPlural' => __('%d nights', 'advanced-connector-for-uplisting'), /* translators: 1: check-in date, 2: check-out date, 3: night count label */ 'yourStay' => __('Your stay: %1$s to %2$s · %3$s', 'advanced-connector-for-uplisting'), 'perNight' => __('/night', 'advanced-connector-for-uplisting'), 'viewDetails' => __('View Details', 'advanced-connector-for-uplisting'), 'processing' => __('Processing...', 'advanced-connector-for-uplisting'), 'processingPayment' => __('Processing Payment...', 'advanced-connector-for-uplisting'), 'savingBooking' => __('Saving Booking...', 'advanced-connector-for-uplisting'), 'bookingSuccessRedirect' => __('Booking completed successfully! Redirecting...', 'advanced-connector-for-uplisting'), 'bookingFailed' => __('Booking failed', 'advanced-connector-for-uplisting'), 'verifyingAvailability' => __('Verifying availability...', 'advanced-connector-for-uplisting'), 'datesUnavailable' => __('Dates Unavailable', 'advanced-connector-for-uplisting'), 'datesNoLongerAvailable' => __('These dates are no longer available.', 'advanced-connector-for-uplisting'), 'fillRequiredFields' => __('Please fill in all required fields.', 'advanced-connector-for-uplisting'), 'validEmailRequired' => __('Please enter a valid email address.', 'advanced-connector-for-uplisting'), 'validPhoneRequired' => __('Please enter a valid phone number.', 'advanced-connector-for-uplisting'), 'confirmBooking' => __('Confirm Booking', 'advanced-connector-for-uplisting'), 'approxDailyPrice' => __('Approx. daily price', 'advanced-connector-for-uplisting'), 'nights' => __('Nights', 'advanced-connector-for-uplisting'), 'totalDayRate' => __('Total Day Rate', 'advanced-connector-for-uplisting'), 'addons' => __('Add-ons', 'advanced-connector-for-uplisting'), 'discount' => __('Discount', 'advanced-connector-for-uplisting'), 'total' => __('Total', 'advanced-connector-for-uplisting'), ]; } /** * Admin JS translatable strings. * * @return array */ public static function get_admin_script_i18n(): array { return [ 'selectGalleryImages' => __('Select Gallery Images', 'advanced-connector-for-uplisting'), 'enterApiKeyFirst' => __('Please enter an API key first.', 'advanced-connector-for-uplisting'), 'testing' => __('Testing...', 'advanced-connector-for-uplisting'), 'connectionFailed' => __('Connection failed.', 'advanced-connector-for-uplisting'), 'networkError' => __('Network error.', 'advanced-connector-for-uplisting'), 'update' => __('Update', 'advanced-connector-for-uplisting'), 'edit' => __('Edit', 'advanced-connector-for-uplisting'), 'selectPropertiesImport' => __('Please select at least one property to import/update.', 'advanced-connector-for-uplisting'), /* translators: %d: number of properties */ 'bulkImportStarting' => __('Starting bulk import/update of %d properties...', 'advanced-connector-for-uplisting'), /* translators: 1: processed count, 2: total count, 3: percent complete */ 'processedProgress' => __('%1$d/%2$d processed (%3$d%%)', 'advanced-connector-for-uplisting'), 'showDebugInfo' => __('Show Debug Info', 'advanced-connector-for-uplisting'), 'hideDebugInfo' => __('Hide Debug Info', 'advanced-connector-for-uplisting'), 'importing' => __('Importing...', 'advanced-connector-for-uplisting'), 'updating' => __('Updating...', 'advanced-connector-for-uplisting'), 'importedSuccessfully' => __('Imported Successfully!', 'advanced-connector-for-uplisting'), 'updatedSuccessfully' => __('Updated Successfully!', 'advanced-connector-for-uplisting'), 'failed' => __('Failed!', 'advanced-connector-for-uplisting'), ]; } /** * Booking card CSS lives in single-property.css. */ public function enqueue_booking_form_assets(): void { if (!is_singular('bizdexul_property')) { return; } $property = bizdex_uplisting_get_property(); if (!$property instanceof PropertyDetails) { return; } $asset_context = self::resolve_property_booking_asset_context($property); if (!$asset_context['uses_calendar']) { return; } $this->ensure_flatpickr_assets(); } /** * Enqueue Flatpickr, frontend CSS/JS on property, listing, and reservation pages. * * @return void */ public function enqueue_frontend_assets() { global $post; $is_property = is_singular('bizdexul_property'); $is_listing_page = self::should_load_listing_assets(); $is_reservation_page = self::is_reservation_page(); $is_success_page = self::is_reservation_success_page(); $has_listing_shortcode = self::post_has_properties_listing(is_a($post, 'WP_Post') ? $post : null); $has_reservation_shortcode = false; $has_success_shortcode = false; if (!$is_property && !$is_listing_page && !$is_reservation_page && !$is_success_page && !$has_listing_shortcode && !$has_reservation_shortcode && !$has_success_shortcode) { return; } $this->ensure_flatpickr_assets(); $this->register_frontend_styles(); wp_enqueue_style('bizdex-uplisting-layout'); if ($is_listing_page || $has_listing_shortcode) { $this->enqueue_listing_assets(); } elseif ($is_property || $is_reservation_page || $is_success_page || $has_reservation_shortcode || $has_success_shortcode) { if ($is_property) { wp_enqueue_style('bizdex-uplisting-icons'); wp_enqueue_style('bizdex-uplisting-single-property-css'); } if ($is_reservation_page || $is_success_page || $has_reservation_shortcode || $has_success_shortcode) { wp_enqueue_style('bizdex-uplisting-reservation'); } } if ($is_property || $is_reservation_page || $is_success_page || $has_reservation_shortcode || $has_success_shortcode) { $this->enqueue_booking_form_assets(); } $single_property_ajax_deps = ['bizdex-uplisting-dom']; $property_for_assets = null; if (is_singular('bizdexul_property')) { $resolved_property = bizdex_uplisting_get_property(); if ($resolved_property instanceof PropertyDetails) { $property_for_assets = $resolved_property; } } $asset_context = self::resolve_property_booking_asset_context($property_for_assets); $booking_option_for_assets = $asset_context['booking_option']; $needs_property_calendar = $property_for_assets && $asset_context['uses_calendar']; $needs_property_pricing_breakdown = $property_for_assets && $asset_context['uses_pricing_breakdown']; $needs_reservation_assets = $has_reservation_shortcode || $is_reservation_page; $needs_success_assets = $has_success_shortcode || $is_success_page; if ($needs_property_calendar) { $this->enqueue_single_property_page_assets( $property_for_assets, true, $needs_property_pricing_breakdown, $single_property_ajax_deps ); } elseif (is_singular('bizdexul_property') && $property_for_assets) { $this->enqueue_single_property_page_assets( $property_for_assets, false, false, $single_property_ajax_deps, $booking_option_for_assets ); } if ($needs_reservation_assets || $needs_success_assets) { self::enqueue_reservation_script(); $pricing_context = !$needs_property_pricing_breakdown ? 'generic' : 'property'; PricingBreakdown::localize_script(null, $pricing_context, 'bizdex-uplisting-pricing-display'); } } /** * Add body classes for property and reservation templates. * * @param array $classes Existing body classes. * @return array */ public function add_body_classes(array $classes): array { $is_plugin_view = is_singular('bizdexul_property') || self::is_property_archive_template() || self::is_reservation_page() || self::is_reservation_success_page() || self::post_has_properties_listing(); if ($is_plugin_view) { $classes[] = sanitize_html_class('bizdex-uplisting'); } if (is_singular('bizdexul_property')) { $classes[] = sanitize_html_class('single-property'); $classes[] = sanitize_html_class('uplisting-properties'); } if (self::is_property_archive_template()) { $classes[] = sanitize_html_class('archive-property'); $classes[] = sanitize_html_class('uplisting-properties'); } if (self::is_reservation_page()) { $classes[] = sanitize_html_class('bizdex-uplisting-reservation-page'); } if (self::is_reservation_success_page()) { $classes[] = sanitize_html_class('bizdex-uplisting-reservation-success-page'); } return $classes; } /** * Resolve booking-form asset flags for a property page. * * @return array{booking_option: string, calendar_options: array, uses_calendar: bool, uses_pricing_breakdown: bool} */ private static function resolve_property_booking_asset_context(?PropertyDetails $property): array { $default_calendar_options = ['booking_form', 'uplisting_booking_form']; if (!$property instanceof PropertyDetails) { return [ 'booking_option' => '', 'calendar_options' => $default_calendar_options, 'uses_calendar' => false, 'uses_pricing_breakdown' => false, ]; } $booking_option = $property->get_booking_option() ?: 'booking_form'; $calendar_options = apply_filters( 'bizdex_uplisting_booking_card_calendar_options', $default_calendar_options, $property ); return [ 'booking_option' => $booking_option, 'calendar_options' => $calendar_options, 'uses_calendar' => in_array($booking_option, $calendar_options, true), 'uses_pricing_breakdown' => $property->booking_card_uses_calendar_form(), ]; } /** * Enqueue shared single-property scripts (calendar and non-calendar layouts). * * @param array $single_property_ajax_deps */ private function enqueue_single_property_page_assets( PropertyDetails $property, bool $with_calendar, bool $needs_property_pricing_breakdown, array $single_property_ajax_deps, string $booking_option = '' ): void { self::ensure_dom_assets(); self::ensure_pricing_display_assets(); self::enqueue_rest_client_script(); $deps = array_values(array_unique(array_merge( ['bizdex-uplisting-dom', 'bizdex-uplisting-pricing-display', 'bizdex-uplisting-rest-client'], $single_property_ajax_deps ))); if ($with_calendar) { $deps[] = 'bizdex-uplisting-flatpickr-js'; } $path = BIZDEX_UPLISTING_PLUGIN_DIR . 'assets/js/single-property.js'; wp_enqueue_script( 'bizdex-uplisting-single-property', BIZDEX_UPLISTING_PLUGIN_URL . 'assets/js/single-property.js', $deps, BIZDEX_UPLISTING_VERSION . '.' . (string) filemtime($path), true ); if ($with_calendar && $needs_property_pricing_breakdown) { PricingBreakdown::localize_script(null, 'property', 'bizdex-uplisting-pricing-display'); } wp_localize_script( 'bizdex-uplisting-single-property', 'bizdexUplistingPropertyData', self::get_single_property_script_data($property) ); } /** * Register + enqueue the shared REST fetch helper and localize nonce/root. */ public static function enqueue_rest_client_script(): void { $path = BIZDEX_UPLISTING_PLUGIN_DIR . 'assets/js/rest-client.js'; $ver = BIZDEX_UPLISTING_VERSION . '.' . (string) filemtime($path); wp_enqueue_script( 'bizdex-uplisting-rest-client', BIZDEX_UPLISTING_PLUGIN_URL . 'assets/js/rest-client.js', [], $ver, true ); wp_localize_script( 'bizdex-uplisting-rest-client', 'bizdexUplistingRest', RestApi::get_localized_script_data() ); } /** * Allowed HTML for listing/checkout markup returned from shortcodes and the_content. * * @return array> */ private static function frontend_allowed_html(): array { $allowed = wp_kses_allowed_html('post'); $control_atts = [ 'class' => true, 'id' => true, 'name' => true, 'type' => true, 'value' => true, 'placeholder' => true, 'required' => true, 'disabled' => true, 'readonly' => true, 'checked' => true, 'selected' => true, 'min' => true, 'max' => true, 'step' => true, 'minlength' => true, 'maxlength' => true, 'pattern' => true, 'autocomplete' => true, 'inputmode' => true, 'for' => true, 'method' => true, 'action' => true, 'novalidate' => true, 'tabindex' => true, 'role' => true, 'hidden' => true, 'title' => true, 'href' => true, 'src' => true, 'alt' => true, 'width' => true, 'height' => true, 'rel' => true, 'target' => true, 'aria-*' => true, 'data-*' => true, ]; foreach (['form', 'input', 'select', 'option', 'optgroup', 'textarea', 'button', 'label', 'fieldset', 'legend', 'output'] as $tag) { $allowed[$tag] = isset($allowed[$tag]) && is_array($allowed[$tag]) ? array_merge($allowed[$tag], $control_atts) : $control_atts; } foreach (['div', 'span', 'a', 'ul', 'ol', 'li', 'nav', 'section', 'article', 'header', 'footer', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'img', 'dl', 'dt', 'dd', 'strong', 'em'] as $tag) { if (!isset($allowed[$tag]) || !is_array($allowed[$tag])) { $allowed[$tag] = []; } $allowed[$tag]['data-*'] = true; $allowed[$tag]['aria-*'] = true; $allowed[$tag]['role'] = true; $allowed[$tag]['hidden'] = true; $allowed[$tag]['id'] = true; $allowed[$tag]['class'] = true; $allowed[$tag]['tabindex'] = true; } return $allowed; } }