Admin
1 week ago
Builder
12 hours ago
Customizer
1 week ago
Exceptions
5 months ago
Helpers
1 month ago
Integrations
1 week ago
Migrations
3 years ago
ReviewAlerts
1 week ago
Services
3 weeks ago
Settings
2 months ago
Support
2 months ago
Traits
5 months ago
Utils
3 weeks ago
AuthorizationStatusCheck.php
1 week ago
BusinessDataCache.php
5 months ago
Clear_Cache.php
2 months ago
Container.php
5 months ago
DisplayElements.php
1 month ago
Email_Notification.php
5 months ago
Error_Reporter.php
2 months ago
Feed.php
2 weeks ago
FeedCache.php
4 months ago
FeedCacheUpdater.php
2 months ago
FeedDisplay.php
1 week ago
Feed_Locator.php
5 months ago
Parser.php
3 weeks ago
PostAggregator.php
3 weeks ago
RemoteRequest.php
1 week ago
SBR_Education.php
2 months ago
SBR_Settings.php
5 months ago
ServiceContainer.php
2 weeks ago
SinglePostCache.php
2 weeks ago
TemplateRenderer.php
5 months ago
Tooltip_Wizard.php
2 months ago
Util.php
12 hours ago
FeedDisplay.php
549 lines
| 1 | <?php |
| 2 | |
| 3 | /** |
| 4 | * Class FeedDisplay |
| 5 | * |
| 6 | * @since 1.0 |
| 7 | */ |
| 8 | |
| 9 | namespace SmashBalloon\Reviews\Common; |
| 10 | |
| 11 | use SmashBalloon\Reviews\Common\Integrations\SBR_GDPR; |
| 12 | |
| 13 | class FeedDisplay { |
| 14 | protected $feed; |
| 15 | protected $parser; |
| 16 | protected $translations; |
| 17 | |
| 18 | public function __construct(Feed $feed, Parser $parser) |
| 19 | { |
| 20 | $this->feed = $feed; |
| 21 | $this->parser = $parser; |
| 22 | $settings = sbr_recursive_parse_args(get_option('sbr_settings', []), sbr_plugin_settings_defaults()); |
| 23 | $this->translations = $settings['translations']; |
| 24 | } |
| 25 | |
| 26 | public function with_wrap() |
| 27 | { |
| 28 | $header_data = ! $this->feed->is_single_manual_review() ? $this->feed->get_header_data() : []; |
| 29 | $header_data = is_array($header_data) ? $header_data : []; |
| 30 | $posts = $this->feed->get_post_set_page(); |
| 31 | $feed_id = $this->feed->get_feed_id(); |
| 32 | // SMASH-1583: enrich the header with real per-source counts pulled from |
| 33 | // the FULL cached review set (get_posts(), not the paginated $posts |
| 34 | // above) so providers that report no count — Facebook recommendations |
| 35 | // being the canonical case — still contribute to the combined total and |
| 36 | // the weighted average. No-op for sources that already report a count. |
| 37 | // Gate on non-empty header_data so get_posts() isn't evaluated for |
| 38 | // single-manual-review feeds where backfill can never have any effect. |
| 39 | if (! empty($header_data)) { |
| 40 | $header_data = $this->parser->backfill_review_counts($header_data, $this->feed->get_posts()); |
| 41 | } |
| 42 | |
| 43 | $shortcode_atts = '{}'; |
| 44 | $settings = $this->feed->get_settings(); |
| 45 | $parser = $this->parser; |
| 46 | |
| 47 | wp_enqueue_script('sbr_scripts'); |
| 48 | |
| 49 | ob_start(); |
| 50 | // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSS output is sanitized |
| 51 | echo $this->custom_styles(); |
| 52 | |
| 53 | include sbr_get_feed_template_part('feed', $settings); |
| 54 | $html = ob_get_contents(); |
| 55 | ob_get_clean(); |
| 56 | return $html; |
| 57 | } |
| 58 | |
| 59 | public function items_only($page = 1) |
| 60 | { |
| 61 | $posts = $this->feed->get_post_set_page($page); |
| 62 | $feed_id = $this->feed->get_feed_id(); |
| 63 | $shortcode_atts = '{}'; |
| 64 | $settings = $this->feed->get_settings(); |
| 65 | $parser = $this->parser; |
| 66 | |
| 67 | ob_start(); |
| 68 | $this->posts_loop($posts, $settings); |
| 69 | $html = ob_get_contents(); |
| 70 | ob_get_clean(); |
| 71 | return $html; |
| 72 | } |
| 73 | |
| 74 | public function custom_styles() |
| 75 | { |
| 76 | $settings = $this->feed->get_settings(); |
| 77 | $feed_id = $this->feed->get_feed_id(); |
| 78 | |
| 79 | ob_start(); |
| 80 | ?> |
| 81 | <style> |
| 82 | <?php echo '#' . esc_attr(sbr_container_id($feed_id)); ?>{ |
| 83 | --column-gutter : <?php echo isset($settings['horizontalSpacing']) ? absint($settings['horizontalSpacing']) : 0 ?>px; |
| 84 | } |
| 85 | <?php |
| 86 | // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSS output is sanitized |
| 87 | echo $this->feed->get_feed_style(); |
| 88 | ?> |
| 89 | </style> |
| 90 | <?php |
| 91 | $html = ob_get_contents(); |
| 92 | ob_get_clean(); |
| 93 | return $html; |
| 94 | } |
| 95 | public function render_post_elements($post) |
| 96 | { |
| 97 | $settings = $this->feed->get_settings(); |
| 98 | $allowed_files = array( 'author', 'text', 'rating' ); |
| 99 | |
| 100 | foreach ($settings['postElements'] as $file_name) { |
| 101 | if (in_array($file_name, $allowed_files, true)) { |
| 102 | include sbr_get_feed_template_part('post-elements/' . $file_name, $settings); |
| 103 | } |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | public function should_show($element, $item = '') |
| 108 | { |
| 109 | $settings = $this->feed->get_settings(); |
| 110 | if ($element === 'header') { |
| 111 | if (empty($item)) { |
| 112 | return ! empty($settings['showHeader']); |
| 113 | } |
| 114 | if (in_array($item, (array) $settings['headerContent'], true)) { |
| 115 | return true; |
| 116 | } |
| 117 | } elseif ($element === 'author') { |
| 118 | if (in_array($item, (array) $settings['authorContent'], true)) { |
| 119 | return true; |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | return false; |
| 124 | } |
| 125 | |
| 126 | public function get_header_heading_content() |
| 127 | { |
| 128 | if ($this->feed->is_init_wpml()) { |
| 129 | return __('Reviews', 'reviews-feed'); |
| 130 | } |
| 131 | |
| 132 | $settings = $this->feed->get_settings(); |
| 133 | if (! empty($settings['headerHeadingContent'])) { |
| 134 | return $settings['headerHeadingContent']; |
| 135 | } |
| 136 | |
| 137 | return __('Reviews', 'reviews-feed'); |
| 138 | } |
| 139 | |
| 140 | public function get_header_button_text() |
| 141 | { |
| 142 | return isset($this->translations['writeReview']) && !$this->feed->is_init_wpml() ? $this->translations['writeReview'] : __('Write a Review', 'reviews-feed'); |
| 143 | } |
| 144 | |
| 145 | |
| 146 | public function get_review_link($header_data) |
| 147 | { |
| 148 | $settings = $this->feed->get_settings(); |
| 149 | if (! empty($settings['headerButtonLinkTo']) && $settings['headerButtonLinkTo'] === 'external' && ! empty($settings['headerButtonExternalLink'])) { |
| 150 | $link = str_replace(array( 'https://', 'http://' ), '', $settings['headerButtonExternalLink']); |
| 151 | return 'https://' . $link; |
| 152 | } |
| 153 | $header_data = isset($header_data[0]) ? $header_data[0] : $header_data; |
| 154 | $source_data = isset($settings['sources'][0]) ? $settings['sources'][0] : []; |
| 155 | |
| 156 | return $this->parser->get_review_url( |
| 157 | $header_data, |
| 158 | $source_data |
| 159 | ); |
| 160 | } |
| 161 | |
| 162 | public function is_truncated_yelp_review($post) |
| 163 | { |
| 164 | $text = $this->parser->get_text($post); |
| 165 | |
| 166 | if (! empty($post['provider']['name']) && $post['provider']['name'] === 'yelp') { |
| 167 | if (substr($text, -3) === '...') { |
| 168 | return true; |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | return false; |
| 173 | } |
| 174 | |
| 175 | public function more_link($post) |
| 176 | { |
| 177 | if (! $this->is_truncated_yelp_review($post)) { |
| 178 | return ''; |
| 179 | } |
| 180 | |
| 181 | if (! empty($post['source']['url'])) { |
| 182 | return $post['source']['url']; |
| 183 | } |
| 184 | return ''; |
| 185 | } |
| 186 | |
| 187 | public function get_review_text($post) |
| 188 | { |
| 189 | if (! $this->is_truncated_yelp_review($post)) { |
| 190 | return $this->parser->get_text($post); |
| 191 | } |
| 192 | $text = $this->parser->get_text($post); |
| 193 | |
| 194 | // remove ellipsis |
| 195 | return substr($text, 0, -3); |
| 196 | } |
| 197 | |
| 198 | public function posts_loop($posts, $settings) |
| 199 | { |
| 200 | if (isset($settings['sortRandomEnabled']) && $settings['sortRandomEnabled'] === true) { |
| 201 | shuffle($posts); |
| 202 | } |
| 203 | foreach ($posts as $post) { |
| 204 | include sbr_get_feed_template_part('item', $settings); |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | public function star_rating_display($post, $settings) |
| 209 | { |
| 210 | $star_rating = intval($this->parser->get_rating($post)); |
| 211 | if ($star_rating <= 0) { |
| 212 | $aria_label = __('Not rated', 'reviews-feed'); |
| 213 | } else { |
| 214 | $aria_label = sprintf( |
| 215 | /* translators: %d: numeric rating out of 5 */ |
| 216 | __('%d out of 5 stars', 'reviews-feed'), |
| 217 | $star_rating |
| 218 | ); |
| 219 | } |
| 220 | $return = '<span role="img" aria-label="' . esc_attr($aria_label) . '">'; |
| 221 | for ($i = 0; $i < 5; $i++) { |
| 222 | $iconClass = $star_rating - $i < 1 ? ' sb-item-rating-icon-dimmed' : ''; |
| 223 | if ($star_rating - $i === 0.5) { |
| 224 | $return .= '<span class="sb-item-rating-icon sb-feed-item-icon-half" aria-hidden="true"> |
| 225 | <span class="sb-item-rating-icon sb-item-rating-icon-dimmed">' . DisplayElements::get_star_icon() . '</span> |
| 226 | <span class="sb-item-rating-icon-halfdimmed">' . DisplayElements::get_star_icon() . '</span> |
| 227 | </span>'; |
| 228 | } else { |
| 229 | $return .= '<span class="sb-item-rating-icon ' . $iconClass . '" aria-hidden="true">' . DisplayElements::get_star_icon() . '</span>'; |
| 230 | } |
| 231 | } |
| 232 | $return .= '</span>'; |
| 233 | |
| 234 | return $return; |
| 235 | } |
| 236 | |
| 237 | public function overall_star_rating_display($business, $settings) |
| 238 | { |
| 239 | $star_rating = floatval($this->parser->get_average_rating($business)); |
| 240 | $display_rating = ( floor($star_rating * 2) / 2 ); |
| 241 | if ($display_rating <= 0) { |
| 242 | $aria_label = __('Not rated', 'reviews-feed'); |
| 243 | } else { |
| 244 | $aria_label = sprintf( |
| 245 | /* translators: %s: numeric rating out of 5 (may include half-stars) */ |
| 246 | __('%s out of 5 stars', 'reviews-feed'), |
| 247 | $display_rating |
| 248 | ); |
| 249 | } |
| 250 | $return = '<span role="img" aria-label="' . esc_attr($aria_label) . '">'; |
| 251 | for ($i = 0; $i < 5; $i++) { |
| 252 | $iconClass = $star_rating - $i < 1 ? ' sb-item-rating-icon-dimmed' : ''; |
| 253 | if ($star_rating - $i < 1 && $star_rating - $i >= 0.5) { |
| 254 | $return .= '<span class="sb-feed-item-icon sb-feed-item-icon-half" aria-hidden="true"> |
| 255 | <span class="sb-feed-header-icon sb-item-rating-icon-dimmed">' . DisplayElements::get_star_icon() . '</span> |
| 256 | <span class="sb-item-rating-icon-halfdimmed">' . DisplayElements::get_star_icon() . '</span> |
| 257 | </span>'; |
| 258 | } else { |
| 259 | $return .= '<span class="sb-feed-header-icon ' . $iconClass . '" aria-hidden="true">' . DisplayElements::get_star_icon() . '</span>'; |
| 260 | } |
| 261 | } |
| 262 | $return .= '</span>'; |
| 263 | |
| 264 | return $return; |
| 265 | } |
| 266 | |
| 267 | public function provider_icon_url($post, $settings) |
| 268 | { |
| 269 | $provider = $this->parser->get_provider_name($post) ; |
| 270 | if (! empty($provider)) { |
| 271 | return trailingslashit(SBR_PLUGIN_URL) . 'assets/icons/' . $provider . '-provider.svg'; |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | // phpcs:disable Generic.Metrics.CyclomaticComplexity.MaxExceeded -- Date formatting requires many conditional checks |
| 276 | public function date($post, $translations) |
| 277 | { |
| 278 | |
| 279 | if (! isset($post['time']) || $post['time'] === null || !$post['time']) { |
| 280 | return ''; |
| 281 | } |
| 282 | |
| 283 | $settings = $this->feed->get_settings(); |
| 284 | $timestamp = $this->parser->get_time($post); |
| 285 | |
| 286 | $now = time(); |
| 287 | $date_formats = self::get_date_formats(); |
| 288 | |
| 289 | if (intval($settings['dateFormat']) === 1) { |
| 290 | $second = $this->feed->is_init_wpml() ? __('second', 'reviews-feed') : $translations['second']; |
| 291 | $seconds = $this->feed->is_init_wpml() ? __('seconds', 'reviews-feed') : $translations['seconds']; |
| 292 | $minute = $this->feed->is_init_wpml() ? __('minute', 'reviews-feed') : $translations['minute']; |
| 293 | $minutes = $this->feed->is_init_wpml() ? __('minutes', 'reviews-feed') : $translations['minutes']; |
| 294 | $hour = $this->feed->is_init_wpml() ? __('hour', 'reviews-feed') : $translations['hour']; |
| 295 | $hours = $this->feed->is_init_wpml() ? __('hours', 'reviews-feed') : $translations['hours']; |
| 296 | $day = $this->feed->is_init_wpml() ? __('day', 'reviews-feed') : $translations['day']; |
| 297 | $days = $this->feed->is_init_wpml() ? __('days', 'reviews-feed') : $translations['days']; |
| 298 | $week = $this->feed->is_init_wpml() ? __('week', 'reviews-feed') : $translations['week']; |
| 299 | $weeks = $this->feed->is_init_wpml() ? __('weeks', 'reviews-feed') : $translations['weeks']; |
| 300 | $month = $this->feed->is_init_wpml() ? __('month', 'reviews-feed') : $translations['month']; |
| 301 | $months = $this->feed->is_init_wpml() ? __('months', 'reviews-feed') : $translations['months']; |
| 302 | $year = $this->feed->is_init_wpml() ? __('years', 'reviews-feed') : $translations['years']; |
| 303 | $years = $this->feed->is_init_wpml() ? __('years', 'reviews-feed') : $translations['years']; |
| 304 | $ago = $this->feed->is_init_wpml() ? __('ago', 'reviews-feed') : $translations['ago']; |
| 305 | |
| 306 | $lengths = array("60", "60", "24", "7", "4.35", "12", "10"); |
| 307 | $periods = array($second, $minute, $hour, $day, $week, $month, $year, "decade"); |
| 308 | $periods_plural = array($seconds, $minutes, $hours, $days, $weeks, $months, $years, "decade"); |
| 309 | |
| 310 | // is it future date or past date |
| 311 | if ($now > $timestamp) { |
| 312 | $difference = $now - $timestamp; |
| 313 | $tense = $ago; |
| 314 | } else { |
| 315 | $difference = $timestamp - $now; |
| 316 | $tense = $ago; |
| 317 | } |
| 318 | for ($j = 0; $difference >= $lengths[$j] && $j < count($lengths) - 1; $j++) { |
| 319 | $difference /= $lengths[$j]; |
| 320 | } |
| 321 | |
| 322 | $difference = round($difference); |
| 323 | |
| 324 | if ($difference != 1) { |
| 325 | $periods[$j] = $periods_plural[$j]; |
| 326 | } |
| 327 | $date_str = "$difference $periods[$j] {$tense}"; |
| 328 | } else { |
| 329 | if ($settings['dateFormat'] !== 'custom') { |
| 330 | $date_str = date_i18n($date_formats[ intval($settings['dateFormat']) ], $timestamp); |
| 331 | } |
| 332 | } |
| 333 | $before_padding = ''; |
| 334 | if (! empty($settings['dateBeforeText'])) { |
| 335 | $before_padding = ' '; |
| 336 | } |
| 337 | $after_padding = ''; |
| 338 | if (! empty($settings['dateAfterText'])) { |
| 339 | $after_padding = ' '; |
| 340 | } |
| 341 | |
| 342 | if ($settings['dateFormat'] === 'custom' && !empty($settings['dateCustomFormat'])) { |
| 343 | $custom_date = $settings['dateCustomFormat']; |
| 344 | $custom_date = str_replace("{hide-start}", "<k>", $custom_date); |
| 345 | $custom_date = str_replace("{hide-end}", "</k>", $custom_date); |
| 346 | $date_str = date_i18n($custom_date, $timestamp); |
| 347 | } |
| 348 | |
| 349 | return $settings['dateBeforeText'] . $before_padding . $date_str . $after_padding . $settings['dateAfterText']; |
| 350 | } |
| 351 | // phpcs:enable Generic.Metrics.CyclomaticComplexity.MaxExceeded |
| 352 | |
| 353 | public static function get_date_formats() |
| 354 | { |
| 355 | return [ |
| 356 | 2 => 'F jS, g:i a', |
| 357 | 3 => 'F jS', |
| 358 | 4 => 'D F jS', |
| 359 | 5 => 'l F jS', |
| 360 | 6 => 'D M jS, Y', |
| 361 | 7 => 'l F jS, Y', |
| 362 | 8 => 'l F jS, Y - g:i a', |
| 363 | 9 => "l M jS, 'y", |
| 364 | 10 => 'm.d.y', |
| 365 | 18 => 'm.d.y - G:i', |
| 366 | 11 => 'm/d/y', |
| 367 | 12 => 'd.m.y', |
| 368 | 19 => 'd.m.y - G:i', |
| 369 | 13 => 'd/m/y', |
| 370 | 14 => 'd-m-Y, G:i', |
| 371 | 15 => 'jS F Y, G:i', |
| 372 | 16 => 'd M Y, G:i', |
| 373 | 17 => 'l jS F Y, G:i', |
| 374 | 18 => 'Y-m-d', |
| 375 | ]; |
| 376 | } |
| 377 | |
| 378 | public function feed_classes() |
| 379 | { |
| 380 | $settings = $this->feed->get_settings(); |
| 381 | $classes = array(); |
| 382 | if ($settings['layout'] === 'masonry') { |
| 383 | $classes[] = 'sb-cols-' . absint($settings[ $settings['layout'] . "DesktopColumns"]); |
| 384 | $classes[] = 'sb-colstablet-' . absint($settings[ $settings['layout'] . "TabletColumns"]); |
| 385 | $classes[] = 'sb-colsmobile-' . absint($settings[ $settings['layout'] . "MobileColumns"]); |
| 386 | } |
| 387 | |
| 388 | if ($settings['layout'] === 'grid') { |
| 389 | $classes[] = 'sb-grid-wrapper'; |
| 390 | } |
| 391 | |
| 392 | return implode(' ', $classes); |
| 393 | } |
| 394 | |
| 395 | public function item_classes($post) |
| 396 | { |
| 397 | $classes = array(); |
| 398 | |
| 399 | $id = $this->parser->get_id($post); |
| 400 | $classes[] = 'sbr-item-' . $id; |
| 401 | |
| 402 | if (! empty($post['provider']['name'])) { |
| 403 | $classes[] = 'sbr-provider-' . $post['provider']['name']; |
| 404 | } |
| 405 | |
| 406 | return implode(' ', $classes); |
| 407 | } |
| 408 | |
| 409 | public function should_show_header() |
| 410 | { |
| 411 | $settings = $this->feed->get_settings(); |
| 412 | } |
| 413 | |
| 414 | public function misc_atts() |
| 415 | { |
| 416 | $atts = ''; |
| 417 | $settings = $this->feed->get_settings(); |
| 418 | $misc_atts = array(); |
| 419 | |
| 420 | $misc_atts['num'] = array( |
| 421 | 'desktop' => absint($settings['numPostDesktop']), |
| 422 | 'tablet' => absint($settings['numPostTablet']), |
| 423 | 'mobile' => absint($settings['numPostMobile']), |
| 424 | ); |
| 425 | |
| 426 | $misc_atts['flagLastPage'] = $this->feed->is_last_page(1); |
| 427 | $misc_atts['contentLength'] = $settings['contentLength']; |
| 428 | |
| 429 | $atts = ' data-misc="' . esc_attr(wp_json_encode($misc_atts)) . '"'; |
| 430 | |
| 431 | if ($settings['layout'] === 'grid') { |
| 432 | $atts .= ' data-grid-columns="' . esc_attr($settings['gridDesktopColumns']) . '" data-grid-tablet-columns="' . esc_attr($settings['gridTabletColumns']) . '" data-grid-mobile-columns="' . esc_attr($settings['gridMobileColumns']) . '" '; |
| 433 | } |
| 434 | |
| 435 | return $atts; |
| 436 | } |
| 437 | |
| 438 | public function misc_flags() |
| 439 | { |
| 440 | $doing_gdpr = SBR_GDPR::doing_gdpr(); |
| 441 | return $doing_gdpr ? 'gdpr' : ''; |
| 442 | } |
| 443 | |
| 444 | public function error_html() |
| 445 | { |
| 446 | if (! sbr_current_user_can('manage_reviews_feed_options')) { |
| 447 | return ''; |
| 448 | } |
| 449 | $errors = $this->feed->get_errors(); |
| 450 | if (empty($errors)) { |
| 451 | return ''; |
| 452 | } |
| 453 | ob_start(); |
| 454 | ?> |
| 455 | <div class="sbr-feed-error"> |
| 456 | <span><?php esc_html_e('This error message is only visible to WordPress admins', 'reviews-feed'); ?></span><br /> |
| 457 | <?php foreach ($errors as $error) : ?> |
| 458 | <p><strong><?php echo wp_kses_post($error['message']); ?></strong> |
| 459 | <p><?php echo wp_kses_post($error['directions']); ?></p> |
| 460 | <?php endforeach; ?> |
| 461 | </div> |
| 462 | <?php |
| 463 | $html = ob_get_contents(); |
| 464 | ob_get_clean(); |
| 465 | return $html; |
| 466 | } |
| 467 | |
| 468 | public function get_header_reviews_number($number) |
| 469 | { |
| 470 | $the_text = isset($this->translations['reviewsHeader']) && !$this->feed->is_init_wpml() ? $this->translations['reviewsHeader'] : __('Over %s Reviews', 'sb-customizer'); |
| 471 | return !empty($number) ? str_replace('%s', $number, $the_text) : ''; |
| 472 | } |
| 473 | |
| 474 | /** |
| 475 | * Booking-only feeds show the hotel's REAL Booking general rating |
| 476 | * (review_score 0-10 + review_score_word, e.g. "9.5 Exceptional") in the |
| 477 | * header instead of the converted 5-star average — so the header speaks the |
| 478 | * same scale as the per-card score badge (rating-extras/booking.php). |
| 479 | * |
| 480 | * Only fires when EVERY source carries a native review_score: a single |
| 481 | * non-Booking source (no native score) falls back to stars, as does older |
| 482 | * Booking data cached before the review_score field existed. Strictly |
| 483 | * additive — existing star feeds are untouched. |
| 484 | * |
| 485 | * @param array $businesses Header data (one entry per source). |
| 486 | * @return array{is_booking_only:bool,score:float,word:string} |
| 487 | */ |
| 488 | public static function get_booking_header_rating($businesses) |
| 489 | { |
| 490 | $fallback = array('is_booking_only' => false, 'score' => 0.0, 'word' => ''); |
| 491 | if (! is_array($businesses) || empty($businesses)) { |
| 492 | return $fallback; |
| 493 | } |
| 494 | |
| 495 | $scores = array(); |
| 496 | $words = array(); |
| 497 | $weighted_sum = 0.0; |
| 498 | $total_count = 0; |
| 499 | foreach ($businesses as $business) { |
| 500 | $info = $business['info'] ?? array(); |
| 501 | if (is_string($info)) { |
| 502 | $decoded = json_decode($info, true); |
| 503 | $info = is_array($decoded) ? $decoded : array(); |
| 504 | } |
| 505 | $score = $info['review_score'] ?? null; |
| 506 | // One source without a native score disqualifies the whole feed. |
| 507 | if (! is_numeric($score) || floatval($score) <= 0) { |
| 508 | return $fallback; |
| 509 | } |
| 510 | $score_f = floatval($score); |
| 511 | $scores[] = $score_f; |
| 512 | // Count-weight the native 0-10 score by each hotel's review volume so a |
| 513 | // 12k-review hotel dominates a 300-review one — the same count-weighted |
| 514 | // logic normal feeds use, but kept on Booking's own 0-10 scale (never |
| 515 | // normalized to 5). Booking stores the volume in total_rating; some |
| 516 | // payloads use review_count. (SMASH-782 follow-up.) |
| 517 | $count = (int) ($info['review_count'] ?? $info['total_rating'] ?? 0); |
| 518 | if ($count > 0) { |
| 519 | $weighted_sum += $score_f * $count; |
| 520 | $total_count += $count; |
| 521 | } |
| 522 | $word = isset($info['review_score_word']) ? trim((string) $info['review_score_word']) : ''; |
| 523 | if ($word !== '') { |
| 524 | $words[] = $word; |
| 525 | } |
| 526 | } |
| 527 | |
| 528 | // $scores is guaranteed non-empty here: $businesses is non-empty and any |
| 529 | // source missing a native score returns early above, so every iteration |
| 530 | // pushed a score. Prefer the count-weighted mean on the 0-10 scale; fall |
| 531 | // back to the unweighted mean only when no source reported a usable count |
| 532 | // (a single source, or count-less data, still yields its own score). 1 dp. |
| 533 | $score = $total_count > 0 |
| 534 | ? round($weighted_sum / $total_count, 1) |
| 535 | : round(array_sum($scores) / count($scores), 1); |
| 536 | |
| 537 | // Word shows only when every source carries the same word (a single |
| 538 | // source trivially qualifies) — averaging distinct words would fabricate |
| 539 | // a rating Booking never gave. reset() avoids an unchecked [0] offset. |
| 540 | $first_word = reset($words); |
| 541 | $word = ($first_word !== false |
| 542 | && count($words) === count($scores) |
| 543 | && count(array_unique($words)) === 1) |
| 544 | ? $first_word |
| 545 | : ''; |
| 546 | |
| 547 | return array('is_booking_only' => true, 'score' => $score, 'word' => $word); |
| 548 | } |
| 549 | } |