PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.8.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.8.0
1.8.0 1.7.1 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 All 52 releases
fluent-cart / app / Services / Renderer / ReviewListRenderer.php

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

522 lines 20.6 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-review-post-id="' . (int) Arr::get($review, 'post_id', 0) . '"'
359 . ' data-user-vote="' . (int) Arr::get($review, 'user_vote', 0) . '"'
360 . ' data-helpful-count="' . (int) Arr::get($review, 'helpful_count', 0) . '"'
361 . ' data-not-helpful-count="' . (int) Arr::get($review, 'not_helpful_count', 0) . '"'
362 . '>'
363 . $inside
364 . '</div>';
365 }
366
367 /**
368 * The word limit as an attribute, or nothing when there is none.
369 *
370 * review-clamp.js reads data-max-words off the content element and is
371 * indifferent to which layout drew it, so a storefront row only ever
372 * needed the attribute to behave like a composed one.
373 */
374 protected function maxWordsAttribute(): string
375 {
376 $maxWords = max(0, (int) $this->options['max_words']);
377
378 return $maxWords > 0 ? ' data-max-words="' . esc_attr((string) $maxWords) . '"' : '';
379 }
380
381 /**
382 * The media gallery (PRO attaches the items to the response).
383 *
384 * @param array $review
385 * @return string
386 */
387 protected function renderMediaGallery(array $review): string
388 {
389 $media = Arr::get($review, 'media', []);
390
391 if (empty($media) || !is_array($media)) {
392 return '';
393 }
394
395 return ReviewThreadMarkup::mediaGalleryHtml($media, 'list', [
396 'visible' => (int) $this->options['media_visible'],
397 'width' => (int) $this->options['media_width'],
398 'height' => (int) $this->options['media_height'],
399 'fullWidth' => (bool) $this->options['media_full_width'],
400 'flush' => (bool) $this->options['media_flush'],
401 'backdrop' => (bool) $this->options['media_backdrop'],
402 'more' => (string) $this->options['media_more'],
403 ]);
404 }
405
406 /**
407 * "Page 2 of 7" between the two arrows.
408 *
409 * For a list with many pages, where a row of numbers is mostly ellipses.
410 * The arrows carry the page to go to, so the script's [data-page] binding
411 * works unchanged — only the middle is different.
412 */
413 protected function fractionPagination(int $current, int $last): string
414 {
415 return '<div class="fct-reviews-pagination-inner is-fraction">'
416 . static::navButton('left', $current - 1, $current <= 1)
417 . '<span class="fct-reviews-page-fraction">'
418 . sprintf(
419 /* translators: 1: current page number, 2: total number of pages */
420 esc_html__('Page %1$s of %2$s', 'fluent-cart'),
421 '<strong>' . esc_html((string) $current) . '</strong>',
422 esc_html((string) $last)
423 )
424 . '</span>'
425 . static::navButton('right', $current + 1, $current >= $last)
426 . '</div>';
427 }
428
429 /**
430 * A dot per page.
431 *
432 * Only for a list short enough to count at a glance — past that the dots
433 * stop being a control anyone can aim at, and the numbered pager is what
434 * a reader gets instead. The label is what makes a dot usable at all: it
435 * has no text of its own.
436 */
437 protected function bulletPagination(int $current, int $last): string
438 {
439 if ($last > 10) {
440 return $this->numberedPagination($current, $last);
441 }
442
443 $html = '<div class="fct-reviews-pagination-inner is-bullets">'
444 . static::navButton('left', $current - 1, $current <= 1);
445
446 for ($page = 1; $page <= $last; $page++) {
447 $html .= '<button type="button" class="fct-reviews-page-bullet' . ($page === $current ? ' active' : '') . '"'
448 . ' data-page="' . esc_attr((string) $page) . '"'
449 . ($page === $current ? ' aria-current="page"' : '')
450 . ' aria-label="' . esc_attr(sprintf(
451 /* translators: %s: page number */
452 __('Page %s', 'fluent-cart'),
453 $page
454 )) . '"></button>';
455 }
456
457 return $html . static::navButton('right', $current + 1, $current >= $last) . '</div>';
458 }
459
460 /**
461 * The previous/next arrow both alternative pagers share.
462 */
463 protected static function navButton(string $direction, int $page, bool $disabled): string
464 {
465 $label = $direction === 'left'
466 ? __('Previous page', 'fluent-cart')
467 : __('Next page', 'fluent-cart');
468
469 return '<button type="button" class="fct-reviews-page-btn fct-reviews-page-nav" data-page="' . esc_attr((string) $page) . '"'
470 . ($disabled ? ' disabled' : '')
471 . ' aria-label="' . esc_attr($label) . '">' . static::chevronSvg($direction) . '</button>';
472 }
473
474 /**
475 * Chevron for the pagination prev/next buttons.
476 *
477 * @param string $direction 'left' or 'right'.
478 * @return string
479 */
480 protected static function chevronSvg($direction): string
481 {
482 $path = $direction === 'left' ? 'm15 18-6-6 6-6' : 'm9 18 6-6-6-6';
483
484 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>';
485 }
486
487 /**
488 * The View Reply / Reply trigger. With replies it opens the read-only
489 * thread; with none, it only renders as Reply for the review owner when
490 * threaded replies are enabled.
491 *
492 * @param array $review
493 * @return string
494 */
495 protected function renderViewReplyButton(array $review): string
496 {
497 if (!$this->options['show_view_reply']) {
498 return '';
499 }
500
501 $replyCount = (int) Arr::get($review, 'reply_count', 0);
502 $label = '';
503
504 if ($replyCount > 0) {
505 $label = __('View Reply', 'fluent-cart');
506 } elseif ($this->options['can_thread'] && !empty($review['is_owner'])) {
507 $label = __('Reply', 'fluent-cart');
508 }
509
510 if ($label === '') {
511 return '';
512 }
513
514 $icon = ReviewThreadMarkup::replyIconSvg();
515
516 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', '')) . '">'
517 . $icon
518 . '<span>' . esc_html($label) . '</span>'
519 . '</button>';
520 }
521 }
522