PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.0
1.7.0 1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 All 50 releases
fluent-cart / app / Services / Renderer / ReviewListRenderer.php

ReviewListRenderer.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.7.0, at app/Services/Renderer/ReviewListRenderer.php

521 lines 20.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Services\Renderer;
4
5 use FluentCart\Framework\Support\Arr;
6
7 /**
8 * Server-side renderer for the storefront review list rows.
9 *
10 * The rows used to be assembled in Reviews.js from the JSON payload; they
11 * are built here now, the way the shop app and the thread modal already
12 * render on the server, and the JS only swaps the returned markup in. The
13 * classes and data attributes are a contract: Pro decorates the rendered
14 * rows (vote buttons, media lightbox) by querying them.
15 */
16 class ReviewListRenderer
17 {
18 /**
19 * @var array
20 */
21 protected $options;
22
23 public function __construct(array $options = [])
24 {
25 $this->options = wp_parse_args($options, [
26 'show_reviewer' => true,
27 'show_date' => true,
28 'show_verified' => true,
29 'show_view_reply' => true,
30 'show_avatar' => true,
31 'show_title' => true,
32 'show_content' => true,
33 'show_photos' => true,
34 'show_footer' => true,
35 'show_variation' => true,
36 'can_thread' => false,
37 // A caller that builds the rows itself — the Review List block when
38 // an editor has filled it with blocks. Given the page's reviews it
39 // returns all of them, because what repeats is the Review Item
40 // block's business, not this renderer's. Unset, the renderer draws
41 // its own rows, which is what the shortcode and the product page
42 // get.
43 'rows_renderer' => null,
44 // How the pager draws itself. A block attribute, so it travels
45 // with the request the same way the display flags do — the script
46 // re-renders the pager on every page change and would otherwise
47 // hand back the default one on the first click.
48 'pagination_type' => 'numbers',
49 // Words to show before a Read more, 0 for the whole review. The
50 // Review Content block carries its own; this is the same limit
51 // for a row the renderer draws itself, which had no way to say
52 // it and so always printed reviews in full.
53 'max_words' => 0,
54 // The review attachments. media_visible is how many tiles a
55 // row shows before the + tile takes over, 0 for all of them;
56 // media_width and media_height are the tile size in pixels, 0 for
57 // the stylesheet's own. The Review Photos block carries the same
58 // three; these are for a row this renderer draws itself.
59 'media_visible' => 0,
60 'media_width' => 0,
61 'media_height' => 0,
62 // A tile as wide as the gallery instead of media_width, which puts
63 // one attachment on each line.
64 'media_full_width' => false,
65 // Pro, and refused without it by ReviewThreadMarkup: the
66 // attachment becomes the top of the card (flush) or fills it and
67 // takes the rest of the review over itself (backdrop).
68 'media_flush' => false,
69 'media_backdrop' => false,
70 // Where the + counting the attachments past media_visible goes:
71 // 'overlay' on the last visible one, 'tile' on one of its own, or
72 // 'none' at all.
73 'media_more' => 'overlay',
74 // How the row is arranged, mirroring LayoutPresets::row(). A
75 // preset that wants the photograph to sell the card puts it
76 // first; a testimonial leads with the quote and signs off with
77 // the badge underneath rather than beside the name.
78 'photos_first' => false,
79 'rating_first' => false,
80 'badge_last' => false,
81 // Who wrote it and how they rated it. Off, the card is whatever
82 // is left -- which for a photo strip is the photograph alone.
83 'show_meta' => true,
84 // A class the preset puts on the card. Whitelisted rather than
85 // taken as given: this value makes the round trip through the
86 // public reviews endpoint, so it is request input by the time a
87 // second page renders.
88 'item_class' => '',
89 ]);
90 }
91
92 /**
93 * The pagers this renders.
94 *
95 * One list, because three places have to agree on it: the block that
96 * saves the choice, the container attribute the script sends back, and
97 * the endpoint that re-renders the pager from it. A type any one of them
98 * did not know would come back as the numbered pager on the first page
99 * change and never return.
100 *
101 * @return array<int, string>
102 */
103 public static function paginationTypes(): array
104 {
105 return ['numbers', 'fraction', 'bullets'];
106 }
107
108 /**
109 * The rendered rows for one page of reviews.
110 *
111 * @param array $reviews Row arrays from the serialized, filtered response.
112 * @return string
113 */
114 public function renderReviewItems(array $reviews): string
115 {
116 if (!$reviews) {
117 return static::emptyStateHtml();
118 }
119
120 $rowsRenderer = $this->options['rows_renderer'];
121
122 if (is_callable($rowsRenderer)) {
123 return (string) call_user_func($rowsRenderer, $reviews);
124 }
125
126 $html = '';
127
128 foreach ($reviews as $review) {
129 $html .= $this->renderReviewItem((array) $review);
130 }
131
132 return $html;
133 }
134
135 /**
136 * What a list with nothing to show says.
137 *
138 * Shared, because the composed row draws it too: two spellings of the same
139 * empty state would be two things for the storefront script to find, and
140 * it looks for [data-reviews-empty].
141 *
142 * @return string
143 */
144 /**
145 * The card classes a preset may ask for.
146 *
147 * A closed list, because renderReviewItem() prints this into a class
148 * attribute and the value arrives from the public reviews endpoint on
149 * every sort, filter and page. Anything unrecognised becomes nothing.
150 *
151 * @return array<int, string>
152 */
153 public static function itemClasses(): array
154 {
155 return ['fct-review-photo-only', 'fct-review-photo-grid', 'fct-review-testimonial'];
156 }
157
158 /**
159 * @param mixed $value
160 * @return string
161 */
162 public static function itemClass($value): string
163 {
164 $value = is_string($value) ? trim($value) : '';
165
166 return in_array($value, static::itemClasses(), true) ? $value : '';
167 }
168
169 public static function emptyStateHtml(): string
170 {
171 return '<p class="fct-reviews-empty" data-reviews-empty>'
172 . esc_html__('No reviews yet. Be the first to write a review!', 'fluent-cart')
173 . '</p>';
174 }
175
176 /**
177 * The windowed pagination the list used to build client-side:
178 * 1 … current-1 current current+1 … last, with prev/next at the ends.
179 * Empty when there is a single page.
180 *
181 * @param array $paginator The serialized paginator (current_page, last_page).
182 * @return string
183 */
184 public function paginationHtml(array $paginator): string
185 {
186 $lastPage = (int) Arr::get($paginator, 'last_page', 1);
187 $currentPage = (int) Arr::get($paginator, 'current_page', 1);
188
189 if ($lastPage <= 1) {
190 return '';
191 }
192
193 $paginationType = (string) $this->options['pagination_type'];
194
195 if ($paginationType === 'fraction') {
196 return $this->fractionPagination($currentPage, $lastPage);
197 }
198
199 if ($paginationType === 'bullets') {
200 return $this->bulletPagination($currentPage, $lastPage);
201 }
202
203 return $this->numberedPagination($currentPage, $lastPage);
204 }
205
206 /**
207 * The default pager: first page, last page, and the ones either side of
208 * where the reader is, with an ellipsis across each gap.
209 */
210 protected function numberedPagination(int $current, int $last): string
211 {
212 $pages = [1 => true, $last => true];
213 for ($i = max(2, $current - 1); $i <= min($last - 1, $current + 1); $i++) {
214 $pages[$i] = true;
215 }
216 $pages = array_keys($pages);
217 sort($pages);
218
219 $html = '<div class="fct-reviews-pagination-inner">';
220
221 $html .= static::navButton('left', $current - 1, $current <= 1);
222
223 $previousPage = 0;
224 foreach ($pages as $page) {
225 if ($page - $previousPage > 1) {
226 $html .= '<span class="fct-reviews-page-ellipsis" aria-hidden="true">…</span>';
227 }
228
229 $html .= '<button class="fct-reviews-page-btn' . ($page === $current ? ' active' : '') . '"'
230 . ' data-page="' . esc_attr($page) . '"'
231 . ($page === $current ? ' aria-current="page"' : '')
232 . '>' . esc_html($page) . '</button>';
233
234 $previousPage = $page;
235 }
236
237 $html .= static::navButton('right', $current + 1, $current >= $last);
238
239 return $html . '</div>';
240 }
241
242 /**
243 * One review row — markup parity with what Reviews.js used to build.
244 *
245 * @param array $review
246 * @return string
247 */
248 protected function renderReviewItem(array $review): string
249 {
250 $reviewerName = (string) Arr::get($review, 'reviewer_name', '');
251 $rating = (int) Arr::get($review, 'rating', 0);
252
253 $nameLineParts = [];
254
255 if ($this->options['show_reviewer'] && $reviewerName !== '') {
256 $nameLineParts[] = '<span class="fct-review-item-author">' . esc_html($reviewerName) . '</span>';
257 }
258
259 // Stars above the words rather than beside the name: on a card the
260 // rating is the headline, and a wall of cards is read by its stars
261 // long before any of its sentences. Block-level, matching what the
262 // Review Rating block's own wrapper draws.
263 $ratingFirst = !empty($this->options['rating_first']);
264 $lead = '';
265 if ($rating > 0) {
266 $stars = ReviewThreadMarkup::starsHtml($rating);
267
268 if ($ratingFirst) {
269 $lead = '<div class="fct-review-item-stars">' . $stars . '</div>';
270 } else {
271 $nameLineParts[] = '<span class="fct-review-item-stars">' . $stars . '</span>';
272 }
273 }
274
275 // Signed off at the end rather than beside the name: on a card the eye
276 // reads downwards, so the badge is the last word and not part of the
277 // greeting. Wrapped for the same reason the template wraps it -- the
278 // signoff rule is what centres it under the byline.
279 $badgeLast = !empty($this->options['badge_last']);
280 $sign = '';
281 if ($this->options['show_verified'] && !empty($review['is_verified'])) {
282 $badge = '<span class="fct-review-verified">' . esc_html__('Verified Purchase', 'fluent-cart') . '</span>';
283
284 if ($badgeLast) {
285 $sign = '<div class="fct-review-item-signoff">' . $badge . '</div>';
286 } else {
287 $nameLineParts[] = $badge;
288 }
289 }
290
291 // Which item the review is about, when it names one — the label the
292 // buyer saw on their order, attached by attachItemLabels().
293 $itemLabel = trim((string) Arr::get($review, 'item_label', ''));
294 if ($this->options['show_variation'] && $itemLabel !== '') {
295 $nameLineParts[] = '<span class="fct-review-item-variant">' . esc_html($itemLabel) . '</span>';
296 }
297
298 $metaTextParts = [];
299
300 if ($nameLineParts) {
301 $metaTextParts[] = '<div class="fct-review-item-name-line">' . implode('', $nameLineParts) . '</div>';
302 }
303
304 if ($this->options['show_date'] && !empty($review['created_at'])) {
305 $metaTextParts[] = '<span class="fct-review-item-date">' . esc_html(ReviewThreadMarkup::relativeDate($review['created_at'])) . '</span>';
306 }
307
308 // Who wrote it. Dropped whole rather than field by field: a photo
309 // strip wants the photograph and nothing else, and an empty header
310 // still draws its own box.
311 $meta = '';
312 if (!empty($this->options['show_meta'])) {
313 $headerLeft = '<div class="fct-review-item-header-left">'
314 . ($this->options['show_avatar'] ? ReviewThreadMarkup::avatarHtml((string) Arr::get($review, 'photo', '')) : '')
315 . '<div class="fct-review-item-meta-text">' . implode('', $metaTextParts) . '</div>'
316 . '</div>';
317
318 $meta = '<div class="fct-review-item-header">' . $headerLeft . '</div>';
319 }
320
321 $title = '';
322 if ($this->options['show_title'] && !empty($review['title'])) {
323 $title = '<div class="fct-review-item-title">' . esc_html($review['title']) . '</div>';
324 }
325
326 $content = $this->options['show_content']
327 ? '<div class="fct-review-item-content"' . $this->maxWordsAttribute() . '>'
328 . esc_html((string) Arr::get($review, 'content', '')) . '</div>'
329 : '';
330
331 $media = $this->options['show_photos'] ? $this->renderMediaGallery($review) : '';
332
333 // The row's actions, drawn whether or not anything has landed in it
334 // yet. PRO appends the Helpful buttons to this element from the
335 // browser, and it looks the element up rather than creating one -- so a
336 // row that only drew the footer when it happened to have a reply sent
337 // the buttons to the card instead, where the footer's own styling does
338 // not reach them. That made a review with a reply and a review without
339 // one wear different buttons. Empty it collapses: reviews.scss hides
340 // .fct-review-footer:empty, margin and all.
341 $reply = $this->options['show_footer']
342 ? '<div class="fct-review-footer">' . $this->renderViewReplyButton($review) . '</div>'
343 : '';
344
345 $body = $title . $content;
346
347 // Two arrangements, the same two LayoutPresets::row() builds.
348 // Photographs first is a card the picture sells and the words explain;
349 // otherwise the words lead and the picture supports.
350 $inside = !empty($this->options['photos_first'])
351 ? $media . $lead . $body . $meta . $sign . $reply
352 : $meta . $lead . $body . $media . $sign . $reply;
353
354 $itemClass = static::itemClass(Arr::get($this->options, 'item_class', ''));
355
356 return '<div class="fct-review-item' . ($itemClass ? ' ' . $itemClass : '') . '"'
357 . ' data-review-id="' . esc_attr((string) Arr::get($review, 'id', '')) . '"'
358 . ' data-user-vote="' . (int) Arr::get($review, 'user_vote', 0) . '"'
359 . ' data-helpful-count="' . (int) Arr::get($review, 'helpful_count', 0) . '"'
360 . ' data-not-helpful-count="' . (int) Arr::get($review, 'not_helpful_count', 0) . '"'
361 . '>'
362 . $inside
363 . '</div>';
364 }
365
366 /**
367 * The word limit as an attribute, or nothing when there is none.
368 *
369 * review-clamp.js reads data-max-words off the content element and is
370 * indifferent to which layout drew it, so a storefront row only ever
371 * needed the attribute to behave like a composed one.
372 */
373 protected function maxWordsAttribute(): string
374 {
375 $maxWords = max(0, (int) $this->options['max_words']);
376
377 return $maxWords > 0 ? ' data-max-words="' . esc_attr((string) $maxWords) . '"' : '';
378 }
379
380 /**
381 * The media gallery (PRO attaches the items to the response).
382 *
383 * @param array $review
384 * @return string
385 */
386 protected function renderMediaGallery(array $review): string
387 {
388 $media = Arr::get($review, 'media', []);
389
390 if (empty($media) || !is_array($media)) {
391 return '';
392 }
393
394 return ReviewThreadMarkup::mediaGalleryHtml($media, 'list', [
395 'visible' => (int) $this->options['media_visible'],
396 'width' => (int) $this->options['media_width'],
397 'height' => (int) $this->options['media_height'],
398 'fullWidth' => (bool) $this->options['media_full_width'],
399 'flush' => (bool) $this->options['media_flush'],
400 'backdrop' => (bool) $this->options['media_backdrop'],
401 'more' => (string) $this->options['media_more'],
402 ]);
403 }
404
405 /**
406 * "Page 2 of 7" between the two arrows.
407 *
408 * For a list with many pages, where a row of numbers is mostly ellipses.
409 * The arrows carry the page to go to, so the script's [data-page] binding
410 * works unchanged — only the middle is different.
411 */
412 protected function fractionPagination(int $current, int $last): string
413 {
414 return '<div class="fct-reviews-pagination-inner is-fraction">'
415 . static::navButton('left', $current - 1, $current <= 1)
416 . '<span class="fct-reviews-page-fraction">'
417 . sprintf(
418 /* translators: 1: current page number, 2: total number of pages */
419 esc_html__('Page %1$s of %2$s', 'fluent-cart'),
420 '<strong>' . esc_html((string) $current) . '</strong>',
421 esc_html((string) $last)
422 )
423 . '</span>'
424 . static::navButton('right', $current + 1, $current >= $last)
425 . '</div>';
426 }
427
428 /**
429 * A dot per page.
430 *
431 * Only for a list short enough to count at a glance — past that the dots
432 * stop being a control anyone can aim at, and the numbered pager is what
433 * a reader gets instead. The label is what makes a dot usable at all: it
434 * has no text of its own.
435 */
436 protected function bulletPagination(int $current, int $last): string
437 {
438 if ($last > 10) {
439 return $this->numberedPagination($current, $last);
440 }
441
442 $html = '<div class="fct-reviews-pagination-inner is-bullets">'
443 . static::navButton('left', $current - 1, $current <= 1);
444
445 for ($page = 1; $page <= $last; $page++) {
446 $html .= '<button type="button" class="fct-reviews-page-bullet' . ($page === $current ? ' active' : '') . '"'
447 . ' data-page="' . esc_attr((string) $page) . '"'
448 . ($page === $current ? ' aria-current="page"' : '')
449 . ' aria-label="' . esc_attr(sprintf(
450 /* translators: %s: page number */
451 __('Page %s', 'fluent-cart'),
452 $page
453 )) . '"></button>';
454 }
455
456 return $html . static::navButton('right', $current + 1, $current >= $last) . '</div>';
457 }
458
459 /**
460 * The previous/next arrow both alternative pagers share.
461 */
462 protected static function navButton(string $direction, int $page, bool $disabled): string
463 {
464 $label = $direction === 'left'
465 ? __('Previous page', 'fluent-cart')
466 : __('Next page', 'fluent-cart');
467
468 return '<button type="button" class="fct-reviews-page-btn fct-reviews-page-nav" data-page="' . esc_attr((string) $page) . '"'
469 . ($disabled ? ' disabled' : '')
470 . ' aria-label="' . esc_attr($label) . '">' . static::chevronSvg($direction) . '</button>';
471 }
472
473 /**
474 * Chevron for the pagination prev/next buttons.
475 *
476 * @param string $direction 'left' or 'right'.
477 * @return string
478 */
479 protected static function chevronSvg($direction): string
480 {
481 $path = $direction === 'left' ? 'm15 18-6-6 6-6' : 'm9 18 6-6-6-6';
482
483 return '<svg class="fct-nav-arrow-svg" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="' . $path . '"/></svg>';
484 }
485
486 /**
487 * The View Reply / Reply trigger. With replies it opens the read-only
488 * thread; with none, it only renders as Reply for the review owner when
489 * threaded replies are enabled.
490 *
491 * @param array $review
492 * @return string
493 */
494 protected function renderViewReplyButton(array $review): string
495 {
496 if (!$this->options['show_view_reply']) {
497 return '';
498 }
499
500 $replyCount = (int) Arr::get($review, 'reply_count', 0);
501 $label = '';
502
503 if ($replyCount > 0) {
504 $label = __('View Reply', 'fluent-cart');
505 } elseif ($this->options['can_thread'] && !empty($review['is_owner'])) {
506 $label = __('Reply', 'fluent-cart');
507 }
508
509 if ($label === '') {
510 return '';
511 }
512
513 $icon = ReviewThreadMarkup::replyIconSvg();
514
515 return '<button type="button" class="fct-review-view-replies" data-view-replies aria-haspopup="dialog" data-review-id="' . esc_attr((string) Arr::get($review, 'id', '')) . '">'
516 . $icon
517 . '<span>' . esc_html($label) . '</span>'
518 . '</button>';
519 }
520 }
521