booking
/
includes
/
booking-resource-selector
/
catalog
/
class-wpbc-booking-resource-catalog-presenter.php
class-wpbc-booking-resource-catalog-presenter.php in Booking Calendar 11.7, at includes/booking-resource-selector/catalog/class-wpbc-booking-resource-catalog-presenter.php
| 1 | <?php |
| 2 | /** |
| 3 | * Dedicated frontend Booking Resource catalog presenter. |
| 4 | * |
| 5 | * @package Booking Calendar |
| 6 | * @since 11.6.0 |
| 7 | */ |
| 8 | |
| 9 | if ( ! defined( 'ABSPATH' ) ) { |
| 10 | exit; |
| 11 | } |
| 12 | |
| 13 | /** |
| 14 | * Render public Resource cards without depending on admin listing markup. |
| 15 | */ |
| 16 | final class WPBC_Booking_Resource_Catalog_Presenter { |
| 17 | |
| 18 | /** Horizontal gap mirrored by the frontend catalog stylesheet. */ |
| 19 | private const CATALOG_COLUMN_GAP_PX = 10; |
| 20 | |
| 21 | /** |
| 22 | * Render filters and a selectable public Resource collection. |
| 23 | * |
| 24 | * @param array<int,array<string,mixed>> $resources Public Resource DTO arrays. |
| 25 | * @param array<string,mixed> $args Presentation arguments. |
| 26 | * |
| 27 | * @return string Escaped public catalog markup. |
| 28 | */ |
| 29 | public function render_selectable_catalog( $resources, $args = array() ) { |
| 30 | $args = wp_parse_args( |
| 31 | is_array( $args ) ? $args : array(), |
| 32 | array( |
| 33 | 'layout' => 'grid', |
| 34 | 'show_filters' => false, |
| 35 | 'show_image' => true, |
| 36 | 'show_title' => true, |
| 37 | 'show_description' => true, |
| 38 | 'item_width' => '', |
| 39 | 'item_max_width' => 0, |
| 40 | 'grid_items_per_row' => 0, |
| 41 | 'list_items_per_row' => 0, |
| 42 | 'show_hierarchy' => true, |
| 43 | 'show_availability' => true, |
| 44 | 'show_price' => true, |
| 45 | 'selected_resource_id' => 0, |
| 46 | 'input_name' => 'wpbc_resource_selector_resource', |
| 47 | 'input_id_prefix' => 'wpbc_resource_selector', |
| 48 | 'catalog_label' => __( 'Booking Resources', 'booking' ), |
| 49 | ) |
| 50 | ); |
| 51 | $layout = 'list' === $args['layout'] ? 'list' : 'grid'; |
| 52 | $item_width = wpbc_booking_resource_selector_normalize_css_width( $args['item_width'] ); |
| 53 | $item_max_width = absint( $args['item_max_width'] ); |
| 54 | $requested_column_count = 'list' === $layout ? absint( $args['list_items_per_row'] ) : absint( $args['grid_items_per_row'] ); |
| 55 | $column_count = min( 12, $requested_column_count ); |
| 56 | $catalog_class = 'wpbc_booking_resource_catalog wpbc_booking_resource_catalog--' . $layout; |
| 57 | $catalog_styles = array(); |
| 58 | if ( $item_max_width > 0 ) { |
| 59 | $catalog_class .= ' wpbc_booking_resource_catalog--bounded'; |
| 60 | $catalog_styles[] = '--wpbc-resource-catalog-item-max-width:' . min( 1200, max( 280, $item_max_width ) ) . 'px'; |
| 61 | } |
| 62 | if ( empty( $args['show_image'] ) ) { |
| 63 | $catalog_class .= ' wpbc_booking_resource_catalog--without-images'; |
| 64 | } |
| 65 | if ( $column_count > 0 ) { |
| 66 | $catalog_class .= ' wpbc_booking_resource_catalog--explicit-columns wpbc_booking_resource_catalog--columns-' . $column_count; |
| 67 | $catalog_styles[] = '--wpbc-resource-catalog-items-per-row:' . $column_count; |
| 68 | } elseif ( '' !== $item_width ) { |
| 69 | $catalog_class .= ' wpbc_booking_resource_catalog--explicit-item-width'; |
| 70 | $catalog_styles[] = '--wpbc-resource-catalog-item-width:' . $item_width; |
| 71 | $catalog_styles[] = '--wpbc-resource-catalog-item-track-width:' . $this->get_item_track_width( $item_width ); |
| 72 | } |
| 73 | $catalog_style = empty( $catalog_styles ) ? '' : ' style="' . esc_attr( implode( ';', $catalog_styles ) . ';' ) . '"'; |
| 74 | $html = '<div class="' . esc_attr( $catalog_class ) . '" data-wpbc-resource-catalog="" data-layout="' . esc_attr( $layout ) . '"' . $catalog_style . '>'; |
| 75 | if ( ! empty( $args['show_filters'] ) && count( $resources ) > 1 ) { |
| 76 | $html .= $this->render_search_field(); |
| 77 | } |
| 78 | $html .= '<div class="wpbc_booking_resource_catalog__items wpbc_booking_resource_selector__choices" role="radiogroup" aria-label="' . esc_attr( $args['catalog_label'] ) . '" data-wpbc-resource-catalog-items="">'; |
| 79 | foreach ( $resources as $resource ) { |
| 80 | $html .= $this->render_selectable_card( $resource, $args ); |
| 81 | } |
| 82 | $html .= '</div>'; |
| 83 | $html .= '<p class="wpbc_booking_resource_catalog__empty" data-wpbc-resource-catalog-empty="" role="status" hidden>' . esc_html__( 'No Booking Resources match your search.', 'booking' ) . '</p>'; |
| 84 | $html .= '<p class="screen-reader-text" data-wpbc-resource-catalog-status="" aria-live="polite"></p>'; |
| 85 | $html .= '</div>'; |
| 86 | |
| 87 | return $html; |
| 88 | } |
| 89 | |
| 90 | /** |
| 91 | * Return a grid track width that preserves percentage widths beside gaps. |
| 92 | * |
| 93 | * Percentage tracks subtract their proportional share of the fixed catalog |
| 94 | * gap so familiar values such as 50% and 25% produce two and four columns |
| 95 | * without horizontal overflow. Other safe units are returned unchanged. |
| 96 | * |
| 97 | * @param string $item_width Normalized item width. |
| 98 | * |
| 99 | * @return string Safe CSS grid track width. |
| 100 | */ |
| 101 | private function get_item_track_width( $item_width ) { |
| 102 | if ( ! preg_match( '/^(\d+(?:\.\d+)?)%$/', $item_width, $matches ) ) { |
| 103 | return $item_width; |
| 104 | } |
| 105 | |
| 106 | $percentage_width = (float) $matches[1]; |
| 107 | $estimated_columns = max( 1, (int) floor( ( 100 + 0.0001 ) / $percentage_width ) ); |
| 108 | if ( $estimated_columns <= 1 ) { |
| 109 | return $item_width; |
| 110 | } |
| 111 | |
| 112 | $gap_adjustment = self::CATALOG_COLUMN_GAP_PX * ( $estimated_columns - 1 ) / $estimated_columns; |
| 113 | $gap_adjustment = rtrim( rtrim( number_format( $gap_adjustment, 4, '.', '' ), '0' ), '.' ); |
| 114 | |
| 115 | return 'calc(' . $item_width . ' - ' . $gap_adjustment . 'px)'; |
| 116 | } |
| 117 | |
| 118 | /** |
| 119 | * Render the public Resource search field. |
| 120 | * |
| 121 | * @return string Escaped filter markup. |
| 122 | */ |
| 123 | private function render_search_field() { |
| 124 | $filter_id = wp_unique_id( 'wpbc_resource_catalog_search_' ); |
| 125 | $html = '<div class="wpbc_booking_resource_catalog__filters" role="search" aria-label="' . esc_attr__( 'Filter Booking Resources', 'booking' ) . '">'; |
| 126 | $html .= '<label class="wpbc_booking_resource_catalog__search" for="' . esc_attr( $filter_id ) . '"><span class="screen-reader-text">' . esc_html__( 'Search Booking Resources', 'booking' ) . '</span><input id="' . esc_attr( $filter_id ) . '" type="search" placeholder="' . esc_attr__( 'Search Booking Resources', 'booking' ) . '" data-wpbc-resource-catalog-search="" autocomplete="off"></label>'; |
| 127 | $html .= '</div>'; |
| 128 | |
| 129 | return $html; |
| 130 | } |
| 131 | |
| 132 | /** |
| 133 | * Render one accessible selectable Resource card. |
| 134 | * |
| 135 | * @param array<string,mixed> $resource Public Resource DTO array. |
| 136 | * @param array<string,mixed> $args Presentation arguments. |
| 137 | * |
| 138 | * @return string Escaped card markup. |
| 139 | */ |
| 140 | private function render_selectable_card( $resource, $args ) { |
| 141 | $resource_id = absint( $resource['resource_id'] ); |
| 142 | $resource_type = ! empty( $args['show_hierarchy'] ) && ! empty( $resource['resource_type'] ) |
| 143 | ? sanitize_key( $resource['resource_type'] ) |
| 144 | : 'single'; |
| 145 | $input_id = sanitize_html_class( $args['input_id_prefix'] ) . '_' . $resource_id . '_' . wp_rand( 1000, 9999 ); |
| 146 | $is_selected = $resource_id === absint( $args['selected_resource_id'] ); |
| 147 | $search_text = wp_strip_all_tags( (string) $resource['title'] . ' ' . (string) $resource['description'] . ' ' . (string) $resource['parent_title'] ); |
| 148 | $classes = array( 'wpbc_booking_resource_selector__choice', 'wpbc_booking_resource_catalog__card', 'is-' . $resource_type ); |
| 149 | if ( $is_selected ) { |
| 150 | $classes[] = 'is-selected'; |
| 151 | } |
| 152 | |
| 153 | $html = '<label class="' . esc_attr( implode( ' ', array_map( 'sanitize_html_class', $classes ) ) ) . '" for="' . esc_attr( $input_id ) . '" data-resource-id="' . $resource_id . '" data-resource-type="' . esc_attr( $resource_type ) . '" data-resource-search="' . esc_attr( $search_text ) . '">'; |
| 154 | $html .= '<input id="' . esc_attr( $input_id ) . '" type="radio" name="' . esc_attr( $args['input_name'] ) . '" value="' . $resource_id . '"' . checked( $is_selected, true, false ) . ' required>'; |
| 155 | if ( ! empty( $args['show_image'] ) ) { |
| 156 | $html .= '<span class="wpbc_booking_resource_selector__resource_icon wpbc_booking_resource_catalog__image" aria-hidden="true">'; |
| 157 | if ( ! empty( $resource['image_url'] ) ) { |
| 158 | $html .= '<img src="' . esc_url( $resource['image_url'] ) . '" alt="" loading="lazy" decoding="async">'; |
| 159 | } else { |
| 160 | $html .= esc_html( wpbc_booking_resource_selector_get_resource_initials( $resource['title'] ) ); |
| 161 | } |
| 162 | $html .= '</span>'; |
| 163 | } |
| 164 | $html .= '<span class="wpbc_booking_resource_selector__choice_body wpbc_booking_resource_catalog__body"><span class="wpbc_booking_resource_catalog__title_line">'; |
| 165 | $html .= ! empty( $args['show_title'] ) |
| 166 | ? '<strong>' . esc_html( $resource['title'] ) . '</strong>' |
| 167 | : '<span class="screen-reader-text">' . esc_html( $resource['title'] ) . '</span>'; |
| 168 | $html .= $this->render_hierarchy_badge( $resource, $args ) . '</span>'; |
| 169 | if ( ! empty( $args['show_description'] ) && ! empty( $resource['description'] ) ) { |
| 170 | $html .= '<span class="wpbc_booking_resource_selector__choice_description wpbc_booking_resource_catalog__description">' . esc_html( $resource['description'] ) . '</span>'; |
| 171 | } |
| 172 | $html .= $this->render_summaries( $resource, $args ); |
| 173 | $html .= '</span><span class="wpbc_booking_resource_selector__choice_mark" aria-hidden="true"></span></label>'; |
| 174 | |
| 175 | return $html; |
| 176 | } |
| 177 | |
| 178 | /** |
| 179 | * Render a concise hierarchy badge for one Resource. |
| 180 | * |
| 181 | * @param array<string,mixed> $resource Public Resource DTO array. |
| 182 | * @param array<string,mixed> $args Presentation arguments. |
| 183 | * |
| 184 | * @return string Escaped hierarchy markup or an empty string. |
| 185 | */ |
| 186 | private function render_hierarchy_badge( $resource, $args ) { |
| 187 | if ( empty( $args['show_hierarchy'] ) ) { |
| 188 | return ''; |
| 189 | } |
| 190 | $resource_type = isset( $resource['resource_type'] ) ? sanitize_key( $resource['resource_type'] ) : 'single'; |
| 191 | if ( 'parent' === $resource_type ) { |
| 192 | $capacity = max( 1, absint( $resource['capacity'] ) ); |
| 193 | /* translators: %s: Number of bookable units. */ |
| 194 | $label = sprintf( _n( '%s unit', '%s units', $capacity, 'booking' ), number_format_i18n( $capacity ) ); |
| 195 | } elseif ( 'child' === $resource_type ) { |
| 196 | $label = ! empty( $resource['parent_title'] ) |
| 197 | ? sprintf( __( 'Unit of %s', 'booking' ), $resource['parent_title'] ) |
| 198 | : __( 'Booking unit', 'booking' ); |
| 199 | } else { |
| 200 | return ''; |
| 201 | } |
| 202 | |
| 203 | return '<span class="wpbc_booking_resource_catalog__hierarchy">' . esc_html( $label ) . '</span>'; |
| 204 | } |
| 205 | |
| 206 | /** |
| 207 | * Render availability and price summaries owned by the public DTO. |
| 208 | * |
| 209 | * @param array<string,mixed> $resource Public Resource DTO array. |
| 210 | * @param array<string,mixed> $args Presentation arguments. |
| 211 | * |
| 212 | * @return string Escaped summary markup. |
| 213 | */ |
| 214 | private function render_summaries( $resource, $args ) { |
| 215 | $summary_items = array(); |
| 216 | if ( ! empty( $args['show_availability'] ) && ! empty( $resource['availability_summary']['label'] ) ) { |
| 217 | $availability_description = isset( $resource['availability_summary']['description'] ) |
| 218 | ? (string) $resource['availability_summary']['description'] |
| 219 | : ''; |
| 220 | $summary_items[] = '<span class="wpbc_booking_resource_catalog__summary wpbc_booking_resource_catalog__summary--availability" title="' . esc_attr( $availability_description ) . '">' . esc_html( $resource['availability_summary']['label'] ) . ( '' !== $availability_description ? '<span class="screen-reader-text"> ' . esc_html( $availability_description ) . '</span>' : '' ) . '</span>'; |
| 221 | } |
| 222 | if ( ! empty( $args['show_price'] ) && ! empty( $resource['price_summary']['label'] ) ) { |
| 223 | $price_label = (string) $resource['price_summary']['label']; |
| 224 | if ( ! empty( $resource['price_summary']['period_label'] ) ) { |
| 225 | $price_label .= ' ' . (string) $resource['price_summary']['period_label']; |
| 226 | } |
| 227 | $summary_items[] = '<span class="wpbc_booking_resource_catalog__summary wpbc_booking_resource_catalog__summary--price">' . esc_html( $price_label ) . '</span>'; |
| 228 | } |
| 229 | |
| 230 | return empty( $summary_items ) ? '' : '<span class="wpbc_booking_resource_catalog__summaries">' . implode( '', $summary_items ) . '</span>'; |
| 231 | } |
| 232 | } |
| 233 | |
| 234 | /** |
| 235 | * Return the shared frontend Resource catalog presenter. |
| 236 | * |
| 237 | * @return WPBC_Booking_Resource_Catalog_Presenter Presenter singleton. |
| 238 | */ |
| 239 | function wpbc_booking_resource_catalog_presenter() { |
| 240 | static $presenter = null; |
| 241 | |
| 242 | if ( null === $presenter ) { |
| 243 | $presenter = new WPBC_Booking_Resource_Catalog_Presenter(); |
| 244 | } |
| 245 | |
| 246 | return $presenter; |
| 247 | } |
| 248 |