';
}
$placeholder = ''
. ' '
. ' '
. ' ';
return '
'
. $photoTag
. $placeholder
. '
';
}
/**
* Relative date for a row — "5 mins ago" while recent, the site's date
* format once it is a month old.
*
* @param string $createdAt
* @return string
*/
public static function relativeDate($createdAt): string
{
if (!$createdAt) {
return '';
}
$createdTimestamp = strtotime($createdAt);
if (!$createdTimestamp) {
return '';
}
$secondsAgo = time() - $createdTimestamp;
if ($secondsAgo < MINUTE_IN_SECONDS) {
return __('just now', 'fluent-cart');
}
if ($secondsAgo < MONTH_IN_SECONDS) {
/* translators: 1: human readable time difference, e.g. "5 mins" */
$relative = sprintf(__('%1$s ago', 'fluent-cart'), human_time_diff($createdTimestamp));
return $relative;
}
return DateTime::gmtToTimezone($createdAt, wp_timezone_string())->format(get_option('date_format'));
}
/**
* Star row with its screen-reader label, matching the storefront list.
*
* @param int $rating
* @return string
*/
public static function starsHtml($rating): string
{
$rating = (int) $rating;
/* translators: 1: the star rating, e.g. "Rated 4 out of 5" */
$srLabel = sprintf(__('Rated %1$d out of 5', 'fluent-cart'), $rating);
$srLabel = esc_html($srLabel);
$html = '' . $srLabel . ' ';
for ($i = 1; $i <= 5; $i++) {
$html .= '' . static::starSvg() . ' ';
}
return $html;
}
/**
* The one star glyph every review surface draws — an SVG filled with
* currentColor, so the existing colour classes keep working.
*
* @return string
*/
/**
* One review media item's markup, shared by the list and the modal.
*
* The default preserves the contract every consumer of these payloads
* has always had: type "video" renders a thumbnail with
* data-media-type="video", anything else renders the image thumbnail —
* so existing extension payloads keep working with no paired deploy.
* The filter lets the extension that owns a type replace its markup,
* the same way allowed file types are extended.
*
* @param array $item media item from the filtered review payload
* @param string $context 'list' or 'modal'
* @return string
*/
public static function mediaItemHtml(array $item, string $context, bool $hidden = false): string
{
$url = esc_url((string) Arr::get($item, 'url', ''));
$type = (string) Arr::get($item, 'type', 'image');
// Past the visible limit the tile stays in the markup but out of the
// layout, and stays that way: the lightbox builds its album from the
// gallery, and an album without the hidden photos is one the + tile
// has nothing to open into.
$hiddenAttr = $hidden ? ' hidden' : '';
$html = '';
if ($url && $type === 'video') {
// A plain box around the video, not a button: once it plays the
// video shows its own controls, and a control nested in a button
// is markup a keyboard and a screen reader cannot make sense of —
// and a click the button would swallow. The box carries the thumb
// class, where the gallery's sizing has always applied, and the
// play overlay is the one thing in here that is pressable.
$html = ''
. ' '
. ' '
. ' ';
} elseif ($url) {
// A button to the keyboard: Reviews.js opens it in the lightbox
// on click, Enter or Space.
$html = ' ';
}
/**
* The rendered markup for one review media item. Consumers owning a
* media type return their own markup for it and are responsible for
* escaping their output. A consumer that honours 'hidden' keeps its
* tile out of an overflowing gallery; one that ignores it renders the
* tile visible, as it always did.
*
* @param string $html the default markup
* @param array $context ['item' => array, 'context' => 'list'|'modal', 'hidden' => bool]
*/
return (string) apply_filters('fluent_cart/review/media_item_html', $html, [
'item' => $item,
'context' => $context,
'hidden' => $hidden,
]);
}
/**
* The whole gallery for one review, the markup the list rows and the
* composed Review Photos block both draw.
*
* Shared so the two paths cannot disagree about how many tiles a gallery
* shows or how big they are — the same reason mediaItemHtml is shared.
*
* @param array $media media items from the filtered review payload
* @param string $context 'list' or 'modal'
* @param array $options ['visible' => int, 'width' => int, 'height' => int]
* @return string
*/
public static function mediaGalleryHtml(array $media, string $context, array $options = []): string
{
$items = array_values(array_filter($media, 'is_array'));
if (!$items) {
return '';
}
$tiles = static::mediaTilesHtml($items, $context, $options);
if ($tiles === '') {
return '';
}
$style = static::mediaSizeStyle($options);
// Same modifier the Review Photos block writes, so a rendered row and
// a composed one flush a photograph alike.
$photoStyling = ProductReviewService::isPhotoStylingAllowed();
$flush = ($photoStyling && Arr::get($options, 'flush')) ? ' fct-review-media-gallery--flush' : '';
$backdrop = ($photoStyling && Arr::get($options, 'backdrop')) ? ' fct-review-media-gallery--backdrop' : '';
return '' . $tiles . '
';
}
/**
* The tiles alone, for a caller that owns the gallery element.
*
* The Review Photos block is that caller: its wrapper carries the block's
* spacing support, and a second element around the gallery would stack
* that margin on top of the gallery's own.
*
* @param array $media
* @param string $context
* @param array $options
* @return string
*/
public static function mediaTilesHtml(array $media, string $context, array $options = []): string
{
$items = array_values(array_filter($media, 'is_array'));
if (!$items) {
return '';
}
$visible = max(0, (int) Arr::get($options, 'visible', 0));
$more = static::moreTilePlacement(Arr::get($options, 'more', 'overlay'));
// An item can render as nothing — a payload with no URL, or a consumer
// of the filter declining the type — so the limit counts tiles that
// were drawn, not items that were offered. Counting the offer would
// let a payload that renders nothing eat a place in the row: ask for
// three and get two, with the third behind a + that stands for it.
$shown = 0;
$rendered = [];
foreach ($items as $item) {
$hidden = $visible > 0 && $shown >= $visible;
$html = static::mediaItemHtml($item, $context, $hidden);
if ($html === '') {
continue;
}
if (!$hidden) {
$shown++;
}
$rendered[] = ['html' => $html, 'hidden' => $hidden];
}
if (!$rendered) {
return '';
}
$hiddenCount = 0;
$lastVisible = -1;
foreach ($rendered as $index => $tile) {
if ($tile['hidden']) {
$hiddenCount++;
} else {
$lastVisible = $index;
}
}
// 'none' leaves the overflow simply hidden, with nothing announcing
// it. The photos are still in the album, so the lightbox pages into
// them from any visible photo — the + is the shortcut, not the only
// way there.
$showMore = $hiddenCount > 0 && $more !== 'none';
// Overlaid on the last visible attachment, which needs a box around
// the two of them: a thumbnail is an , and a replaced element
// holds nothing. With nothing visible to sit on it falls back to a
// tile of its own.
$overlay = $showMore && $more === 'overlay' && $lastVisible > -1;
$tiles = '';
foreach ($rendered as $index => $tile) {
if ($overlay && $index === $lastVisible) {
$tiles .= ''
. $tile['html']
. static::mediaMoreTileHtml($hiddenCount, true)
. ' ';
continue;
}
$tiles .= $tile['html'];
}
if ($showMore && !$overlay) {
$tiles .= static::mediaMoreTileHtml($hiddenCount, false);
}
return $tiles;
}
/**
* Where the + goes: over the last visible attachment, on a tile of its
* own, or nowhere.
*
* Whitelisted rather than passed through, for the reason the pager type
* is: it selects a render path, and an unknown value arriving from a
* shortcode would otherwise draw nothing at all.
*
* @param mixed $value
* @return string
*/
public static function moreTilePlacement($value): string
{
$placement = is_string($value) ? strtolower(trim($value)) : '';
return in_array($placement, ['overlay', 'tile', 'none'], true) ? $placement : 'overlay';
}
/**
* The "+2" tile that stands in for the attachments past the limit.
*
* A real button, because it is one: Reviews.js opens the lightbox on the
* first photo it stands for, leaving the row as it is — the tiles behind
* it stay hidden and the + stays put, so closing the lightbox returns the
* visitor to the gallery they left. The count is on the attribute as well
* as in the label, so a script reading the gallery does not have to parse
* the "+2" out of the text — named -count because the container carries a
* data-media-more of its own, which is the placement.
*
* @param int $remaining
* @return string
*/
protected static function mediaMoreTileHtml(int $remaining, bool $overlay = false): string
{
// Overlaid, it is a badge in the corner of a photo and takes none of
// the gallery's tile sizing; on its own it is a tile like the others.
$class = $overlay
? 'fct-review-media-more is-overlay'
: 'fct-review-media-thumb fct-review-media-more';
return ''
. static::mediaMoreIconSvg()
. '+' . esc_html(number_format_i18n($remaining)) . ' '
. ' ';
}
/**
* The stacked-photos glyph on the + badge.
*
* Here rather than inline at its call site for the reason the star and the
* reply arrow are: the editor's canvas draws the same badge, and a copy
* that drifts is a badge that looks like a different one.
*
* @return string
*/
public static function mediaMoreIconSvg(): string
{
return ''
. ' '
. ' '
. ' '
. ' ';
}
/**
* The tile size as custom properties, as a style declaration.
*
* Inline rather than in the stylesheet because PRO's review stylesheet
* enqueues after this plugin's and carries a plain `width: 72px` for the
* same class — a rule of equal specificity that source order would hand
* the win to. An inline custom property is out of that argument entirely.
*
* Public because the Review Photos block hands it to
* get_block_wrapper_attributes(), which merges it with whatever the
* block's own style supports have written.
*
* @param array $options
* @return string
*/
public static function mediaSizeStyle(array $options): string
{
$width = max(0, (int) Arr::get($options, 'width', 0));
$height = max(0, (int) Arr::get($options, 'height', 0));
$style = '';
if (Arr::get($options, 'fullWidth')) {
// A tile as wide as the gallery, which puts one on each line: the
// tiles are flex items that do not shrink, so a 100% basis wraps
// every one of them. The pixel width is the setting it replaces,
// so it is not also printed.
$style .= '--fct-review-thumb-w:100%;';
// With no height of its own a full-width tile takes the photo's,
// which is the only height that does not crop it: the thumbnail
// covers its box, and a photo a thousand pixels wide squeezed into
// a 72px strip is a sliver of one. A height that was asked for is
// still honoured — that is how a banner-shaped row is made.
if ($height < 1) {
$style .= '--fct-review-thumb-h:auto;';
}
} elseif ($width > 0) {
$style .= '--fct-review-thumb-w:' . $width . 'px;';
}
if ($height > 0) {
$style .= '--fct-review-thumb-h:' . $height . 'px;';
}
return $style;
}
/**
* The curved arrow on the View Reply button.
*
* Here rather than inline at its two call sites — the storefront row and
* the Review Reply block — because those two draw the same button and a
* copy that drifts is a button that looks like a different one.
*
* @return string
*/
public static function replyIconSvg(): string
{
return ' ';
}
public static function starSvg(): string
{
return ' ';
}
}