| 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 |
|