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 / ReviewThreadMarkup.php

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

441 lines 17.9 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\App\Services\DateTime\DateTime;
6 use FluentCart\Framework\Support\Arr;
7 use FluentCart\App\Services\ProductReviewService;
8
9 /**
10 * Markup pieces shared by every server-rendered review surface — the thread
11 * modal and the review list. One implementation of the avatar, the palette
12 * colour behind it, and the relative date, so the surfaces cannot drift.
13 */
14 class ReviewThreadMarkup
15 {
16 /**
17 * The avatar for one row: the photo when there is one, the person icon
18 * underneath as the placeholder. A photo that fails to load — Gravatar
19 * default=404 does exactly that for an address with no avatar — removes
20 * itself and the icon shows, so the markup needs no script of its own.
21 *
22 * @param string $photoUrl
23 * @return string
24 */
25 public static function avatarHtml($photoUrl): string
26 {
27 $photoTag = '';
28
29 if ($photoUrl) {
30 $photoTag = '<img class="fct-review-avatar-photo" src="' . esc_url($photoUrl) . '" alt="" loading="lazy" onerror="this.remove()"/>';
31 }
32
33 $placeholder = '<svg class="fct-review-avatar-placeholder" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" aria-hidden="true">'
34 . '<path d="M16.6667 17.5V15.8333C16.6667 14.9493 16.3155 14.1014 15.6904 13.4763C15.0653 12.8512 14.2174 12.5 13.3334 12.5H6.66671C5.78265 12.5 4.93481 12.8512 4.30968 13.4763C3.68456 14.1014 3.33337 14.9493 3.33337 15.8333V17.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'
35 . '<path d="M10 9.16667C11.8410 9.16667 13.3334 7.67428 13.3334 5.83333C13.3334 3.99238 11.8410 2.5 10 2.5C8.15909 2.5 6.66671 3.99238 6.66671 5.83333C6.66671 7.67428 8.15909 9.16667 10 9.16667Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'
36 . '</svg>';
37
38 return '<div class="fct-review-avatar-circle" data-review-avatar>'
39 . $photoTag
40 . $placeholder
41 . '</div>';
42 }
43
44 /**
45 * Relative date for a row — "5 mins ago" while recent, the site's date
46 * format once it is a month old.
47 *
48 * @param string $createdAt
49 * @return string
50 */
51 public static function relativeDate($createdAt): string
52 {
53 if (!$createdAt) {
54 return '';
55 }
56
57 $createdTimestamp = strtotime($createdAt);
58 if (!$createdTimestamp) {
59 return '';
60 }
61
62 $secondsAgo = time() - $createdTimestamp;
63
64 if ($secondsAgo < MINUTE_IN_SECONDS) {
65 return __('just now', 'fluent-cart');
66 }
67
68 if ($secondsAgo < MONTH_IN_SECONDS) {
69 /* translators: 1: human readable time difference, e.g. "5 mins" */
70 $relative = sprintf(__('%1$s ago', 'fluent-cart'), human_time_diff($createdTimestamp));
71
72 return $relative;
73 }
74
75 return DateTime::gmtToTimezone($createdAt, wp_timezone_string())->format(get_option('date_format'));
76 }
77
78 /**
79 * Star row with its screen-reader label, matching the storefront list.
80 *
81 * @param int $rating
82 * @return string
83 */
84 public static function starsHtml($rating): string
85 {
86 $rating = (int) $rating;
87
88 /* translators: 1: the star rating, e.g. "Rated 4 out of 5" */
89 $srLabel = sprintf(__('Rated %1$d out of 5', 'fluent-cart'), $rating);
90 $srLabel = esc_html($srLabel);
91
92 $html = '<span class="fct-sr-only">' . $srLabel . '</span>';
93
94 for ($i = 1; $i <= 5; $i++) {
95 $html .= '<span class="fct-star ' . ($i <= $rating ? 'fct-star-filled' : 'fct-star-empty') . '" aria-hidden="true">' . static::starSvg() . '</span>';
96 }
97
98 return $html;
99 }
100
101 /**
102 * The one star glyph every review surface draws — an SVG filled with
103 * currentColor, so the existing colour classes keep working.
104 *
105 * @return string
106 */
107 /**
108 * One review media item's markup, shared by the list and the modal.
109 *
110 * The default preserves the contract every consumer of these payloads
111 * has always had: type "video" renders a <video> thumbnail with
112 * data-media-type="video", anything else renders the image thumbnail —
113 * so existing extension payloads keep working with no paired deploy.
114 * The filter lets the extension that owns a type replace its markup,
115 * the same way allowed file types are extended.
116 *
117 * @param array $item media item from the filtered review payload
118 * @param string $context 'list' or 'modal'
119 * @return string
120 */
121 public static function mediaItemHtml(array $item, string $context, bool $hidden = false): string
122 {
123 $url = esc_url((string) Arr::get($item, 'url', ''));
124 $type = (string) Arr::get($item, 'type', 'image');
125
126 // Past the visible limit the tile stays in the markup but out of the
127 // layout, and stays that way: the lightbox builds its album from the
128 // gallery, and an album without the hidden photos is one the + tile
129 // has nothing to open into.
130 $hiddenAttr = $hidden ? ' hidden' : '';
131
132 $html = '';
133 if ($url && $type === 'video') {
134 // A plain box around the video, not a button: once it plays the
135 // video shows its own controls, and a control nested in a button
136 // is markup a keyboard and a screen reader cannot make sense of —
137 // and a click the button would swallow. The box carries the thumb
138 // class, where the gallery's sizing has always applied, and the
139 // play overlay is the one thing in here that is pressable.
140 $html = '<span class="fct-review-media-thumb fct-review-media-video"'
141 . ' data-media-url="' . $url . '" data-media-type="video"' . $hiddenAttr . '>'
142 . '<video src="' . $url . '" preload="metadata" playsinline></video>'
143 . '<button type="button" class="fct-review-media-play"'
144 . ' aria-label="' . esc_attr__('Play video', 'fluent-cart') . '"></button>'
145 . '</span>';
146 } elseif ($url) {
147 // A button to the keyboard: Reviews.js opens it in the lightbox
148 // on click, Enter or Space.
149 $html = '<img class="fct-review-media-thumb" src="' . $url . '" alt=""'
150 . ' role="button" tabindex="0" aria-label="' . esc_attr__('View photo', 'fluent-cart') . '"'
151 . ' data-media-url="' . $url . '" data-media-type="image" loading="lazy"' . $hiddenAttr . '/>';
152 }
153
154 /**
155 * The rendered markup for one review media item. Consumers owning a
156 * media type return their own markup for it and are responsible for
157 * escaping their output. A consumer that honours 'hidden' keeps its
158 * tile out of an overflowing gallery; one that ignores it renders the
159 * tile visible, as it always did.
160 *
161 * @param string $html the default markup
162 * @param array $context ['item' => array, 'context' => 'list'|'modal', 'hidden' => bool]
163 */
164 return (string) apply_filters('fluent_cart/review/media_item_html', $html, [
165 'item' => $item,
166 'context' => $context,
167 'hidden' => $hidden,
168 ]);
169 }
170
171 /**
172 * The whole gallery for one review, the markup the list rows and the
173 * composed Review Photos block both draw.
174 *
175 * Shared so the two paths cannot disagree about how many tiles a gallery
176 * shows or how big they are — the same reason mediaItemHtml is shared.
177 *
178 * @param array $media media items from the filtered review payload
179 * @param string $context 'list' or 'modal'
180 * @param array $options ['visible' => int, 'width' => int, 'height' => int]
181 * @return string
182 */
183 public static function mediaGalleryHtml(array $media, string $context, array $options = []): string
184 {
185 $items = array_values(array_filter($media, 'is_array'));
186
187 if (!$items) {
188 return '';
189 }
190
191 $tiles = static::mediaTilesHtml($items, $context, $options);
192
193 if ($tiles === '') {
194 return '';
195 }
196
197 $style = static::mediaSizeStyle($options);
198
199 // Same modifier the Review Photos block writes, so a rendered row and
200 // a composed one flush a photograph alike.
201 $photoStyling = ProductReviewService::isPhotoStylingAllowed();
202 $flush = ($photoStyling && Arr::get($options, 'flush')) ? ' fct-review-media-gallery--flush' : '';
203 $backdrop = ($photoStyling && Arr::get($options, 'backdrop')) ? ' fct-review-media-gallery--backdrop' : '';
204
205 return '<div class="fct-review-media-gallery' . $flush . $backdrop . '"'
206 . ($style ? ' style="' . esc_attr($style) . '"' : '')
207 . '>' . $tiles . '</div>';
208 }
209
210 /**
211 * The tiles alone, for a caller that owns the gallery element.
212 *
213 * The Review Photos block is that caller: its wrapper carries the block's
214 * spacing support, and a second element around the gallery would stack
215 * that margin on top of the gallery's own.
216 *
217 * @param array $media
218 * @param string $context
219 * @param array $options
220 * @return string
221 */
222 public static function mediaTilesHtml(array $media, string $context, array $options = []): string
223 {
224 $items = array_values(array_filter($media, 'is_array'));
225
226 if (!$items) {
227 return '';
228 }
229
230 $visible = max(0, (int) Arr::get($options, 'visible', 0));
231 $more = static::moreTilePlacement(Arr::get($options, 'more', 'overlay'));
232
233 // An item can render as nothing — a payload with no URL, or a consumer
234 // of the filter declining the type — so the limit counts tiles that
235 // were drawn, not items that were offered. Counting the offer would
236 // let a payload that renders nothing eat a place in the row: ask for
237 // three and get two, with the third behind a + that stands for it.
238 $shown = 0;
239 $rendered = [];
240 foreach ($items as $item) {
241 $hidden = $visible > 0 && $shown >= $visible;
242 $html = static::mediaItemHtml($item, $context, $hidden);
243
244 if ($html === '') {
245 continue;
246 }
247
248 if (!$hidden) {
249 $shown++;
250 }
251
252 $rendered[] = ['html' => $html, 'hidden' => $hidden];
253 }
254
255 if (!$rendered) {
256 return '';
257 }
258
259 $hiddenCount = 0;
260 $lastVisible = -1;
261 foreach ($rendered as $index => $tile) {
262 if ($tile['hidden']) {
263 $hiddenCount++;
264 } else {
265 $lastVisible = $index;
266 }
267 }
268
269 // 'none' leaves the overflow simply hidden, with nothing announcing
270 // it. The photos are still in the album, so the lightbox pages into
271 // them from any visible photo — the + is the shortcut, not the only
272 // way there.
273 $showMore = $hiddenCount > 0 && $more !== 'none';
274
275 // Overlaid on the last visible attachment, which needs a box around
276 // the two of them: a thumbnail is an <img>, and a replaced element
277 // holds nothing. With nothing visible to sit on it falls back to a
278 // tile of its own.
279 $overlay = $showMore && $more === 'overlay' && $lastVisible > -1;
280
281 $tiles = '';
282 foreach ($rendered as $index => $tile) {
283 if ($overlay && $index === $lastVisible) {
284 $tiles .= '<span class="fct-review-media-tile">'
285 . $tile['html']
286 . static::mediaMoreTileHtml($hiddenCount, true)
287 . '</span>';
288 continue;
289 }
290
291 $tiles .= $tile['html'];
292 }
293
294 if ($showMore && !$overlay) {
295 $tiles .= static::mediaMoreTileHtml($hiddenCount, false);
296 }
297
298 return $tiles;
299 }
300
301 /**
302 * Where the + goes: over the last visible attachment, on a tile of its
303 * own, or nowhere.
304 *
305 * Whitelisted rather than passed through, for the reason the pager type
306 * is: it selects a render path, and an unknown value arriving from a
307 * shortcode would otherwise draw nothing at all.
308 *
309 * @param mixed $value
310 * @return string
311 */
312 public static function moreTilePlacement($value): string
313 {
314 $placement = is_string($value) ? strtolower(trim($value)) : '';
315
316 return in_array($placement, ['overlay', 'tile', 'none'], true) ? $placement : 'overlay';
317 }
318
319 /**
320 * The "+2" tile that stands in for the attachments past the limit.
321 *
322 * A real button, because it is one: Reviews.js opens the lightbox on the
323 * first photo it stands for, leaving the row as it is — the tiles behind
324 * it stay hidden and the + stays put, so closing the lightbox returns the
325 * visitor to the gallery they left. The count is on the attribute as well
326 * as in the label, so a script reading the gallery does not have to parse
327 * the "+2" out of the text — named -count because the container carries a
328 * data-media-more of its own, which is the placement.
329 *
330 * @param int $remaining
331 * @return string
332 */
333 protected static function mediaMoreTileHtml(int $remaining, bool $overlay = false): string
334 {
335 // Overlaid, it is a badge in the corner of a photo and takes none of
336 // the gallery's tile sizing; on its own it is a tile like the others.
337 $class = $overlay
338 ? 'fct-review-media-more is-overlay'
339 : 'fct-review-media-thumb fct-review-media-more';
340
341 return '<button type="button" class="' . $class . '"'
342 . ' data-media-more-count="' . esc_attr((string) $remaining) . '"'
343 . ' aria-label="' . esc_attr(
344 sprintf(
345 /* translators: %s: number of attachments not shown */
346 _n('View %s more attachment', 'View %s more attachments', $remaining, 'fluent-cart'),
347 number_format_i18n($remaining)
348 )
349 ) . '">'
350 . static::mediaMoreIconSvg()
351 . '<span aria-hidden="true">+' . esc_html(number_format_i18n($remaining)) . '</span>'
352 . '</button>';
353 }
354
355 /**
356 * The stacked-photos glyph on the + badge.
357 *
358 * Here rather than inline at its call site for the reason the star and the
359 * reply arrow are: the editor's canvas draws the same badge, and a copy
360 * that drifts is a badge that looks like a different one.
361 *
362 * @return string
363 */
364 public static function mediaMoreIconSvg(): string
365 {
366 return '<svg class="fct-review-media-more-icon" width="14" height="14" viewBox="0 0 24 24"'
367 . ' fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"'
368 . ' stroke-linejoin="round" aria-hidden="true" focusable="false">'
369 . '<rect x="3" y="3" width="18" height="18" rx="3"/>'
370 . '<circle cx="8.5" cy="8.5" r="1.5"/>'
371 . '<path d="M21 15l-5-5L5 21"/>'
372 . '</svg>';
373 }
374
375 /**
376 * The tile size as custom properties, as a style declaration.
377 *
378 * Inline rather than in the stylesheet because PRO's review stylesheet
379 * enqueues after this plugin's and carries a plain `width: 72px` for the
380 * same class — a rule of equal specificity that source order would hand
381 * the win to. An inline custom property is out of that argument entirely.
382 *
383 * Public because the Review Photos block hands it to
384 * get_block_wrapper_attributes(), which merges it with whatever the
385 * block's own style supports have written.
386 *
387 * @param array $options
388 * @return string
389 */
390 public static function mediaSizeStyle(array $options): string
391 {
392 $width = max(0, (int) Arr::get($options, 'width', 0));
393 $height = max(0, (int) Arr::get($options, 'height', 0));
394
395 $style = '';
396 if (Arr::get($options, 'fullWidth')) {
397 // A tile as wide as the gallery, which puts one on each line: the
398 // tiles are flex items that do not shrink, so a 100% basis wraps
399 // every one of them. The pixel width is the setting it replaces,
400 // so it is not also printed.
401 $style .= '--fct-review-thumb-w:100%;';
402
403 // With no height of its own a full-width tile takes the photo's,
404 // which is the only height that does not crop it: the thumbnail
405 // covers its box, and a photo a thousand pixels wide squeezed into
406 // a 72px strip is a sliver of one. A height that was asked for is
407 // still honoured — that is how a banner-shaped row is made.
408 if ($height < 1) {
409 $style .= '--fct-review-thumb-h:auto;';
410 }
411 } elseif ($width > 0) {
412 $style .= '--fct-review-thumb-w:' . $width . 'px;';
413 }
414
415 if ($height > 0) {
416 $style .= '--fct-review-thumb-h:' . $height . 'px;';
417 }
418
419 return $style;
420 }
421
422 /**
423 * The curved arrow on the View Reply button.
424 *
425 * Here rather than inline at its two call sites — the storefront row and
426 * the Review Reply block — because those two draw the same button and a
427 * copy that drifts is a button that looks like a different one.
428 *
429 * @return string
430 */
431 public static function replyIconSvg(): string
432 {
433 return '<svg class="fct-review-reply-icon" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>';
434 }
435
436 public static function starSvg(): string
437 {
438 return '<svg class="fct-star-svg" width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="M12 2.25l2.955 6.377 6.98.638-5.268 4.62 1.558 6.865L12 17.155 5.775 20.75l1.558-6.865-5.268-4.62 6.98-.638L12 2.25z"/></svg>';
439 }
440 }
441