PluginProbe ʕ •ᴥ•ʔ
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More / trunk
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More vtrunk
2.8.0 2.7.0 2.6.7 2.6.8 2.6.5 2.6.4 2.6.3 2.6.2 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 trunk 1.0 1.0.1 1.0.2 1.0.3 1.1 1.1.1 1.1.2 1.2.0 2.0 2.1.0 2.1.1 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 2.4.6 2.5.0 2.5.1
reviews-feed / class / Common / FeedDisplay.php
reviews-feed / class / Common Last commit date
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 }