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

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

1,680 lines 83.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\CPT\FluentProducts;
6 use FluentCart\App\Modules\Templating\TemplateActions;
7 use FluentCart\App\Helpers\Status;
8 use FluentCart\App\Helpers\Helper;
9 use FluentCart\App\Models\ProductReview;
10 use FluentCart\App\Services\ProductReviewService;
11 use FluentCart\App\Hooks\Handlers\BlockEditors\ProductReviewList\InnerBlocks\InnerBlocks;
12 use FluentCart\App\Vite;
13 use FluentCart\Framework\Support\Arr;
14
15 class ProductReviewRenderer
16 {
17 /**
18 * The largest a media tile may be asked to render.
19 *
20 * It comes from a block attribute or a shortcode attribute, so it is
21 * editor input: a tile of 40000px is a page nobody can scroll past. How
22 * MANY tiles a row may show is not a constant — it is the store's own
23 * max photos per review, read by mediaVisibleCount().
24 */
25 const MAX_MEDIA_TILE_PX = 400;
26
27 /**
28 * What the store allows per review when it has not said, matching PRO's
29 * own default in ReviewMediaService::getMaxPhotosPerReview().
30 */
31 const DEFAULT_MAX_PHOTOS_PER_REVIEW = 5;
32
33 /**
34 * The star colour a section gets when its block asks for none.
35 *
36 * reviews.scss falls back to this same value (--fct-review-star-color), so
37 * a section left on it prints no override and the stylesheet paints it.
38 * Anything else the block asks for is printed as --fct-star-color. Keep
39 * the two in step: tests/js/reviews-palette-colours.test.js checks them.
40 */
41 const DEFAULT_STAR_COLOR = '#f59e0b';
42
43 /**
44 * The empty-star colour; reviews.scss and product-card.scss fall back to it
45 * (--fct-review-star-empty-color).
46 */
47 const DEFAULT_EMPTY_STAR_COLOR = '#d1d5db';
48
49 /**
50 * The store-wide star colours.
51 *
52 * Stars are not palette colours, so Appearance has no picker for them; a
53 * developer sets them through `fluent_cart/reviews/star_colors`. Each value
54 * must be a hex colour, otherwise its default stands.
55 *
56 * @return array ['filled' => hex, 'empty' => hex]
57 */
58 public static function starColors(): array
59 {
60 $defaults = [
61 'filled' => self::DEFAULT_STAR_COLOR,
62 'empty' => self::DEFAULT_EMPTY_STAR_COLOR,
63 ];
64
65 /**
66 * Filter the store-wide review star colours.
67 *
68 * @param array $colors ['filled' => hex, 'empty' => hex]
69 */
70 $filtered = apply_filters('fluent_cart/reviews/star_colors', $defaults);
71
72 if (!is_array($filtered)) {
73 return $defaults;
74 }
75
76 $colors = [];
77
78 foreach ($defaults as $key => $default) {
79 $hex = sanitize_hex_color((string) Arr::get($filtered, $key, ''));
80 $colors[$key] = $hex ? strtolower($hex) : $default;
81 }
82
83 return $colors;
84 }
85
86 /**
87 * The star colours the filter changed, as the variables every star reads.
88 *
89 * `:root:root` rather than `:root`: reviews.scss and product-card.scss
90 * declare the defaults on `:root`, and either can load after the head, so
91 * source order alone would not decide it.
92 *
93 * @return string CSS, or '' while the defaults stand.
94 */
95 public static function starColorCss(): string
96 {
97 $colors = self::starColors();
98 $vars = [
99 'filled' => ['--fct-review-star-color', self::DEFAULT_STAR_COLOR],
100 'empty' => ['--fct-review-star-empty-color', self::DEFAULT_EMPTY_STAR_COLOR],
101 ];
102
103 $declarations = '';
104
105 foreach ($vars as $key => $var) {
106 if ($colors[$key] !== $var[1]) {
107 $declarations .= $var[0] . ':' . $colors[$key] . ';';
108 }
109 }
110
111 return $declarations === '' ? '' : ':root:root{' . $declarations . '}';
112 }
113
114 protected $postId;
115 protected $settings;
116 protected $renderOptions;
117
118 /**
119 * Identifies this renderer's trigger and its form to each other.
120 *
121 * Two Write a Review blocks can point at the same product with different
122 * settings — one a modal, one a drawer. Without a name tying each button
123 * to its own form, every button for a product opens whichever form
124 * happened to initialise first, so the modal button opens the drawer.
125 *
126 * Both call sites that emit a trigger render the matching form from the
127 * same instance, so an instance-scoped id pairs them.
128 *
129 * @var string
130 */
131 protected $instanceId;
132
133 /** @var bool renderForm() has printed this instance's form */
134 protected $formRendered = false;
135
136 protected static $instanceCount = 0;
137
138 public function __construct($postId, $options = [])
139 {
140 $this->postId = $postId;
141 $this->instanceId = 'fct-review-form-' . $postId . '-' . (++static::$instanceCount);
142 $this->settings = ProductReviewService::getReviewSettings();
143 $showVerifiedBadge = !isset($this->settings['show_verified_badge']) || $this->settings['show_verified_badge'] === 'yes';
144 $this->renderOptions = apply_filters('fluent_cart/review/renderer_options', wp_parse_args($options, [
145 'showSummary' => true,
146 'summaryMode' => 'side',
147 'showCount' => true,
148 'showSortControls' => true,
149 // The star chips, separately from the sort control beside them.
150 // One flag covered both because the blocks only ever wanted both;
151 // the shortcode can ask for a list that filters but does not sort,
152 // or the reverse. Without an explicit chips option, preserve the
153 // old flag's behavior for existing renderer and block callers.
154 'showFilterChips' => Arr::get($options, 'showSortControls', true),
155 // Only reviews carrying photos. A query filter, not a display one:
156 // the rest never reach the page, so the count and the pager agree
157 // with what is on screen.
158 'hasMedia' => false,
159 'showVerifiedBadge' => $showVerifiedBadge,
160 'showReviewDate' => true,
161 'showReviewerName' => true,
162 'showAvatar' => true,
163 'showTitle' => true,
164 'showContent' => true,
165 'showPhotos' => true,
166 'showFooter' => true,
167 'showVariation' => true,
168 'starColor' => '#f59e0b',
169 'defaultSortBy' => 'created_at',
170 'defaultSortOrder' => 'DESC',
171 'perPage' => 0,
172 'showViewReply' => true,
173 // The form has two independent axes. 'layout' paces the fields:
174 // 'steps' walks a wizard, 'inline' shows everything at once.
175 // 'container' is the chrome: a side 'drawer', a centred 'modal',
176 // or 'none' — printed straight onto the page with no trigger.
177 'layout' => 'inline',
178 'container' => 'drawer',
179 // An order uuid that proves the visitor bought this product. Set
180 // by the public order-review page, where a guest reaches the form
181 // through an emailed link and has no account to be logged into.
182 'orderHash' => '',
183 // The variation this placement reviews, 0 for the product. Set
184 // only by the order-review page, one row per line item; the
185 // form prints it as data-item-id and ReviewForm.js sends it back
186 // in the request body.
187 'itemId' => 0,
188 // A caller that builds the rows itself — the Review List block when
189 // an editor has filled it with blocks. Unset, the renderer draws
190 // its own rows, which is what the shortcode and the product page
191 // get.
192 'rows_renderer' => null,
193 // The lowest rating the list shows, lifted off the Review Item
194 // block. 0 is every review.
195 'minRating' => 0,
196 // 'numbers' | 'fraction' | 'bullets'. Printed on the container so
197 // the script can send it back: it re-renders the pager on every
198 // page change, and without this the first click would replace the
199 // chosen pager with the default one.
200 'paginationType' => 'numbers',
201 // Words a review shows before a Read more, 0 for all of them.
202 // Printed on the container beside paginationType and for the same
203 // reason: the script re-renders the rows on every page change.
204 'maxWords' => 0,
205 // The review attachments on a row this renderer draws
206 // itself. mediaVisible is how many tiles show before the + tile
207 // takes over (0 for all of them), mediaWidth and mediaHeight the
208 // tile size in pixels (0 for the stylesheet's own). Printed on the
209 // container alongside maxWords and for the same reason: the script
210 // re-renders the rows on every page change.
211 'mediaVisible' => 0,
212 'mediaWidth' => 0,
213 'mediaHeight' => 0,
214 'mediaFullWidth' => false,
215 // Pro. A flush tile becomes the top of the card and a backdrop
216 // fills it; ReviewThreadMarkup refuses both without Pro, so these
217 // travel as intent and the gate stays in one place.
218 'mediaFlush' => false,
219 'mediaBackdrop' => false,
220 'mediaMore' => 'overlay',
221 // How a row this renderer draws is arranged, mirroring
222 // LayoutPresets::row(). Printed on the container beside the media
223 // settings and for the same reason: the script re-renders the
224 // rows on every page change.
225 'photosFirst' => false,
226 'ratingFirst' => false,
227 'badgeLast' => false,
228 'showMeta' => true,
229 'itemClass' => '',
230 // 'list' stacks the rows full width; 'grid' lays them out in
231 // gridColumns columns; 'slider' puts that same row of columns in a
232 // Swiper. The class goes on the list element itself, which the
233 // storefront script empties and refills rather than replaces — so
234 // the layout survives a page change without having to travel with
235 // the request the way paginationType does.
236 'viewMode' => 'list',
237 'gridColumns' => 2,
238 'sliderSettings' => [
239 'pagination' => 'no',
240 'paginationType'=> 'bullets',
241 ],
242 // Where the reviews endpoint can find the same composed row
243 // again. Set only by the Review List block, and only when it has
244 // children to compose.
245 ]), $this->postId);
246 }
247
248 /**
249 * The item this placement is bound to, 0 for the product as a whole.
250 */
251 /**
252 * @var array<int, ProductReview|null> existingReviewFor() results by user
253 */
254 protected $existingReviewMemo = [];
255
256 protected function itemId(): int
257 {
258 return max(0, (int) Arr::get($this->renderOptions, 'itemId', 0));
259 }
260
261 /**
262 * The signed-in visitor's own review in this placement's (product, item)
263 * slot — what decides between "Write a review" and "Edit your review".
264 * Scoped to the slot the same way the duplicate guard is, so the two
265 * never disagree: a review of Red does not put Blue's form in edit mode,
266 * and neither puts the product-level form there.
267 *
268 * @param int $userId
269 * @return ProductReview|null
270 */
271 protected function existingReviewFor($userId)
272 {
273 // A page that has already settled the answer for every row it
274 // renders — the order-review page, whose rows are pending precisely
275 // because no review exists in the slot — hands it in and no query
276 // runs at all.
277 if (array_key_exists('existingReview', $this->renderOptions)) {
278 return $this->renderOptions['existingReview'];
279 }
280
281 $userId = $this->editableReviewUserId($userId);
282 if (!$userId) {
283 return null;
284 }
285
286 // The CTA and the form of one placement ask the same question; one
287 // query per renderer answers both.
288 $memoKey = (int) $userId;
289 if (array_key_exists($memoKey, $this->existingReviewMemo)) {
290 return $this->existingReviewMemo[$memoKey];
291 }
292
293 // topLevel() is load-bearing, not tidiness. A reply is a row on the
294 // same product carrying the same user_id, so without it the first
295 // match can be the visitor's reply to someone else's review — a store
296 // owner who has answered a review being the ordinary case. The form
297 // then opens bound to that reply: prefilled with its text, no rating,
298 // and saving edits the reply instead of the review.
299 //
300 // whereNull('parent_id') is the topLevel() scope spelled out: model
301 // scopes resolve through Builder::__call(), which static analysis
302 // cannot see. Keep in step with that scope's predicate.
303 return $this->existingReviewMemo[$memoKey] = ProductReviewService::scopeToItem(ProductReview::query(), $this->itemId())
304 ->whereNull('parent_id')
305 ->where('post_id', $this->postId)
306 ->where('user_id', $userId)
307 ->whereIn('status', [Status::REVIEW_APPROVED, Status::REVIEW_PENDING])
308 ->first();
309 }
310
311 /**
312 * Whether this product's reviews should be rendered.
313 *
314 * Reviews have to be enabled — the store switch and the product's own
315 * toggle. On a single product page the page's display switch applies as
316 * well, so turning it off clears the section however the page renders
317 * it. Elsewhere the switch does not apply: a review block on another page
318 * is a deliberate placement, which is how the relevant-products shortcode
319 * behaves too.
320 *
321 * @return bool
322 */
323 protected function shouldRenderReviews(): bool
324 {
325 return static::isVisibleFor($this->postId);
326 }
327
328 /**
329 * Whether the current page shows a product's reviews at all. The one
330 * policy for everything that speaks about reviews on a page — the list
331 * and the form here, the JSON-LD in ProductSchema — so none of them can
332 * name a review the page does not render.
333 *
334 * @param int $postId
335 */
336 public static function isVisibleFor($postId): bool
337 {
338 // isReviewEnabledForProduct checks Enable Product Reviews — the module
339 // switch in store settings — first, then the product's own toggle.
340 if (!ProductReviewService::isReviewEnabledForProduct($postId)) {
341 return false;
342 }
343
344 if (is_singular(FluentProducts::CPT_NAME)) {
345 return TemplateActions::shouldShowReviewsOnProductPage($postId);
346 }
347
348 return true;
349 }
350
351 public function render()
352 {
353 if (!$this->shouldRenderReviews()) {
354 return;
355 }
356
357 $summary = ProductReviewService::getProductRatingSummary($this->postId);
358 $restInfo = Helper::getRestInfo();
359
360 $perPageOption = (int) Arr::get($this->renderOptions, 'perPage', 0);
361 $perPage = $perPageOption > 0 ? $perPageOption : $this->settings['reviews_per_page'];
362 $starColor = sanitize_hex_color(Arr::get($this->renderOptions, 'starColor')) ?: self::DEFAULT_STAR_COLOR;
363 $defaultSort = Arr::get($this->renderOptions, 'defaultSortBy', 'created_at') . '-' . Arr::get($this->renderOptions, 'defaultSortOrder', 'DESC');
364
365 $showSummary = (bool) Arr::get($this->renderOptions, 'showSummary', true);
366 // showReviewCount was used briefly by the first adapter draft. Keep it
367 // as a fallback for callers that already passed it, while the shared
368 // builder contract uses the shorter showCount name.
369 $showCount = (bool) Arr::get(
370 $this->renderOptions,
371 'showCount',
372 Arr::get($this->renderOptions, 'showReviewCount', true)
373 );
374 $summaryMode = Arr::get($this->renderOptions, 'summaryMode', 'side');
375 $summaryMode = in_array($summaryMode, ['side', 'top', 'cta'], true) ? $summaryMode : 'side';
376 $showSortControls = (bool) Arr::get($this->renderOptions, 'showSortControls', true);
377 $showFilterChips = (bool) Arr::get($this->renderOptions, 'showFilterChips', true);
378 $hasMedia = (bool) Arr::get($this->renderOptions, 'hasMedia', false);
379 $showVerifiedBadge = (bool) Arr::get($this->renderOptions, 'showVerifiedBadge', true);
380 $showReviewDate = (bool) Arr::get($this->renderOptions, 'showReviewDate', true);
381 $showReviewerName = (bool) Arr::get($this->renderOptions, 'showReviewerName', true);
382 $showViewReply = (bool) Arr::get($this->renderOptions, 'showViewReply', true);
383 $minRating = (int) Arr::get($this->renderOptions, 'minRating', 0);
384 $minRating = ($minRating >= 1 && $minRating <= 5) ? $minRating : 0;
385 $viewMode = Arr::get($this->renderOptions, 'viewMode');
386 $viewMode = ProductReviewService::resolveViewMode($viewMode);
387 // The slider lays its slides out in columns too, so both modes carry
388 // the column count and both drop the row's own bottom margin.
389 $isGrid = $viewMode === 'grid';
390 $isSlider = $viewMode === 'slider';
391 // Masonry is columns too — CSS columns rather than grid ones, but it
392 // reads the same --fct-review-columns and wants the same modifier.
393 $hasColumns = $isGrid || $isSlider || $viewMode === 'masonry';
394 // Two to six. One column is the list view. Four is as many as a card
395 // carrying words can be cut into before a review is too narrow to read,
396 // and the column control offers no more than that — but a row of
397 // photographs is not read, and Photo Strip asks for six. A preset can
398 // therefore ask past what the control offers; a merchant cannot.
399 $gridColumns = min(6, max(2, (int) Arr::get($this->renderOptions, 'gridColumns', 2)));
400
401 // In grid view the page has to hold whole rows. Three per row with a
402 // page of two leaves a row that is permanently one short and a pager
403 // that looks broken — the reader sees two of three reviews with an
404 // empty column beside them. Rounded down, so a page never shows more
405 // than was asked for, but never below a single full row.
406 if ($isGrid) {
407 $perPage = max($gridColumns, $perPage - ($perPage % $gridColumns));
408 }
409
410 // A slider with nothing to slide is the default case to avoid: two per
411 // slide against the store's page of two puts every review on screen at
412 // once and the arrows do nothing. So when no page length was chosen,
413 // the slider takes three screens' worth instead of the store's number.
414 //
415 // Only when none was chosen. A page length set on the Review
416 // Pagination block, in the shortcode or on the widget is the editor
417 // saying how many reviews a page holds, and raising it past that gives
418 // them a page they did not ask for while leaving the pager with fewer
419 // pages than it should have — the slider swallowing its own
420 // pagination. A slider that ends up with nothing to slide says so
421 // instead: the arrows stay and mark themselves disabled.
422 //
423 // Three screens' worth, and no further. Taking the endpoint's maximum
424 // would have every product page fetch, process and render up to a
425 // hundred reviews to guarantee a second slide.
426 if ($isSlider && $perPageOption <= 0) {
427 $perPage = max($perPage, $gridColumns * 3);
428 }
429
430 $sliderSettings = static::normalizeSliderSettings(
431 (array) Arr::get($this->renderOptions, 'sliderSettings', [])
432 );
433
434 // Swiper is 150kb of JS and CSS, so it loads only where a slider
435 // actually is — not on every product page that shows reviews.
436 if ($isSlider) {
437 static::enqueueSliderAssets();
438 }
439
440 $maxWords = max(0, (int) Arr::get($this->renderOptions, 'maxWords', 0));
441 $mediaVisible = static::mediaVisibleCount(Arr::get($this->renderOptions, 'mediaVisible', 0));
442 $mediaWidth = static::mediaTileSize(Arr::get($this->renderOptions, 'mediaWidth', 0));
443 $mediaHeight = static::mediaTileSize(Arr::get($this->renderOptions, 'mediaHeight', 0));
444 $mediaFullWidth = (bool) Arr::get($this->renderOptions, 'mediaFullWidth', false);
445 $mediaFlush = (bool) Arr::get($this->renderOptions, 'mediaFlush', false);
446 $mediaBackdrop = (bool) Arr::get($this->renderOptions, 'mediaBackdrop', false);
447 $photosFirst = (bool) Arr::get($this->renderOptions, 'photosFirst', false);
448 $ratingFirst = (bool) Arr::get($this->renderOptions, 'ratingFirst', false);
449 $badgeLast = (bool) Arr::get($this->renderOptions, 'badgeLast', false);
450 $showMeta = (bool) Arr::get($this->renderOptions, 'showMeta', true);
451 $itemClass = ReviewListRenderer::itemClass(Arr::get($this->renderOptions, 'itemClass', ''));
452 $mediaMore = ReviewThreadMarkup::moreTilePlacement(Arr::get($this->renderOptions, 'mediaMore', 'overlay'));
453 $paginationType = (string) Arr::get($this->renderOptions, 'paginationType', 'numbers');
454 $paginationType = in_array($paginationType, ReviewListRenderer::paginationTypes(), true)
455 ? $paginationType
456 : 'numbers';
457
458 // The first page renders on the server, so the list is real content
459 // with scripting disabled and the script only takes over for sort,
460 // filters and pagination. Same payload pipeline as the REST endpoint.
461 $sortParts = explode('-', (string) $defaultSort);
462 $initialPayload = ProductReviewService::getPublicReviewsPayload([
463 'post_id' => $this->postId,
464 'status' => 'approved',
465 'sort_by' => $sortParts[0] ?: 'created_at',
466 'sort_order' => isset($sortParts[1]) && $sortParts[1] ? $sortParts[1] : 'DESC',
467 'per_page' => $perPage,
468 'min_rating' => $minRating,
469 'has_media' => $hasMedia,
470 // Explicit first page — without it the paginator resolves `page`
471 // from the ambient request, so a stray ?page=2 on the product URL
472 // would render a later review page while the script starts at 1.
473 'page' => 1,
474 ], $this->postId);
475
476 // What the count says, matching what the list is showing. The summary's
477 // own total is the product's and stays that way for the breakdown
478 // aside; a floor changes which reviews this list holds, so "N Reviews"
479 // has to follow it. Reviews.js sets the same number from the list
480 // response on every sort, filter and page, so the two agree from the
481 // first render onward.
482 $displayTotal = $minRating || $hasMedia
483 ? (int) Arr::get($initialPayload, 'reviews.total', 0)
484 : (int) Arr::get((array) $summary, 'total', 0);
485
486 $initialListRenderer = new ReviewListRenderer([
487 'show_reviewer' => $showReviewerName,
488 'show_date' => $showReviewDate,
489 'show_verified' => $showVerifiedBadge,
490 'show_view_reply' => $showViewReply,
491 'show_avatar' => (bool) Arr::get($this->renderOptions, 'showAvatar', true),
492 'show_title' => (bool) Arr::get($this->renderOptions, 'showTitle', true),
493 'show_content' => (bool) Arr::get($this->renderOptions, 'showContent', true),
494 'show_photos' => (bool) Arr::get($this->renderOptions, 'showPhotos', true),
495 'show_footer' => (bool) Arr::get($this->renderOptions, 'showFooter', true),
496 'show_variation' => (bool) Arr::get($this->renderOptions, 'showVariation', true),
497 'can_thread' => ProductReviewService::isMultipleRepliesAllowed() && is_user_logged_in(),
498 'pagination_type' => $paginationType,
499 'max_words' => $maxWords,
500 'media_visible' => $mediaVisible,
501 'media_width' => $mediaWidth,
502 'media_height' => $mediaHeight,
503 'media_full_width' => $mediaFullWidth,
504 'media_flush' => $mediaFlush,
505 'media_backdrop' => $mediaBackdrop,
506 'media_more' => $mediaMore,
507 'photos_first' => $photosFirst,
508 'rating_first' => $ratingFirst,
509 'badge_last' => $badgeLast,
510 'show_meta' => $showMeta,
511 'item_class' => $itemClass,
512 ]);
513 // Computed before the blocks render, because the pager block draws this
514 // and the blocks are what render next.
515 $initialPagination = $initialListRenderer->paginationHtml(Arr::get($initialPayload, 'reviews', []));
516
517 // The blocks an editor placed, rendered once each in the order they sit
518 // in. What they drew is read back afterwards so the renderer can fill
519 // in the parts they left out.
520 $composedBody = '';
521 $composedDrew = ['header' => false, 'list' => false, 'pagination' => false];
522 $rowsRenderer = Arr::get($this->renderOptions, 'rows_renderer');
523
524 /**
525 * Whether an editor built this list out of blocks.
526 *
527 * The difference between a part that is missing and a part that was
528 * taken away. A list nobody has composed — the shortcode, the
529 * single-product template, a Review List block left empty — has no
530 * blocks to say what it wants, so the renderer draws the whole thing.
531 * A composed list has already said: its blocks are the answer, and a
532 * header absent from them is a header an editor removed.
533 */
534 $isComposed = is_callable($rowsRenderer);
535
536 if ($isComposed) {
537 $composedBody = (string) call_user_func(
538 $rowsRenderer,
539 Arr::get($initialPayload, 'reviews.data', []),
540 [
541 'header' => [
542 'post_id' => $this->postId,
543 'total' => $displayTotal,
544 'min_rating' => $minRating,
545 'default_sort' => (string) $defaultSort,
546 ],
547 'pagination' => [
548 'inner_html' => $initialPagination,
549 ],
550 ]
551 );
552
553 foreach (array_keys($composedDrew) as $part) {
554 $composedDrew[$part] = InnerBlocks::wasDrawn($part);
555 }
556 }
557
558 $initialRows = $composedDrew['list']
559 ? ''
560 : $initialListRenderer->renderReviewItems(Arr::get($initialPayload, 'reviews.data', []));
561
562 $initialLastPage = (int) Arr::get($initialPayload, 'reviews.last_page', 1);
563
564 ?>
565 <?php
566 $extraDataAttrs = apply_filters('fluent_cart/review/container_data_attrs', [], $this->postId, $this->renderOptions);
567 $extraAttrHtml = '';
568 foreach ($extraDataAttrs as $attrKey => $attrVal) {
569 $extraAttrHtml .= ' data-' . esc_attr($attrKey) . '="' . esc_attr($attrVal) . '"';
570 }
571 ?>
572 <div class="fct-product-reviews-section"
573 data-fluent-cart-reviews
574 data-post-id="<?php echo esc_attr($this->postId); ?>"
575 data-product-name="<?php echo esc_attr(get_post_field('post_title', $this->postId, 'raw')); ?>"
576 data-rest-url="<?php echo esc_url($restInfo['url']); ?>"
577 data-rest-nonce="<?php echo esc_attr($restInfo['nonce']); ?>"
578 data-per-page="<?php echo esc_attr($perPage); ?>"
579 data-min-rating="<?php echo esc_attr($minRating); ?>"
580 data-default-sort="<?php echo esc_attr($defaultSort); ?>"
581 data-show-verified="<?php echo $showVerifiedBadge ? '1' : '0'; ?>"
582 data-show-date="<?php echo $showReviewDate ? '1' : '0'; ?>"
583 data-show-reviewer="<?php echo $showReviewerName ? '1' : '0'; ?>"
584 data-show-view-reply="<?php echo $showViewReply ? '1' : '0'; ?>"
585 data-show-avatar="<?php echo Arr::get($this->renderOptions, 'showAvatar', true) ? '1' : '0'; ?>"
586 data-show-title="<?php echo Arr::get($this->renderOptions, 'showTitle', true) ? '1' : '0'; ?>"
587 data-show-content="<?php echo Arr::get($this->renderOptions, 'showContent', true) ? '1' : '0'; ?>"
588 data-show-photos="<?php echo Arr::get($this->renderOptions, 'showPhotos', true) ? '1' : '0'; ?>"
589 data-show-footer="<?php echo Arr::get($this->renderOptions, 'showFooter', true) ? '1' : '0'; ?>"
590 data-show-variation="<?php echo Arr::get($this->renderOptions, 'showVariation', true) ? '1' : '0'; ?>"
591 data-star-required="<?php echo ProductReviewService::isStarRatingRequired() ? '1' : '0'; ?>"
592 data-last-page="<?php echo esc_attr($initialLastPage); ?>"
593 data-pagination-type="<?php echo esc_attr($paginationType); ?>"
594 data-max-words="<?php echo esc_attr((string) $maxWords); ?>"
595 data-media-visible="<?php echo esc_attr((string) $mediaVisible); ?>"
596 data-media-width="<?php echo esc_attr((string) $mediaWidth); ?>"
597 data-media-height="<?php echo esc_attr((string) $mediaHeight); ?>"
598 data-media-full-width="<?php echo $mediaFullWidth ? '1' : '0'; ?>"
599 data-media-flush="<?php echo $mediaFlush ? '1' : '0'; ?>"
600 data-media-backdrop="<?php echo $mediaBackdrop ? '1' : '0'; ?>"
601 data-media-more="<?php echo esc_attr($mediaMore); ?>"
602 data-photos-first="<?php echo $photosFirst ? '1' : '0'; ?>"
603 data-rating-first="<?php echo $ratingFirst ? '1' : '0'; ?>"
604 data-badge-last="<?php echo $badgeLast ? '1' : '0'; ?>"
605 data-show-meta="<?php echo $showMeta ? '1' : '0'; ?>"
606 data-item-class="<?php echo esc_attr($itemClass); ?>"
607 <?php /* A list narrowed to reviews with photos stays narrowed:
608 the script re-fetches every sort, filter and page from the
609 endpoint, and without this the second page would quietly
610 widen to every review. */ ?>
611 data-only-with-photos="<?php echo $hasMedia ? '1' : '0'; ?>"
612 <?php if (!$this->isDefaultStarColor($starColor)) : ?>
613 style="--fct-star-color: <?php echo esc_attr($starColor); ?>"
614 <?php endif; ?>
615 <?php echo $extraAttrHtml; ?>
616 >
617 <div class="fct-reviews-layout<?php echo $showSummary ? ' fct-reviews-layout--summary-' . esc_attr($summaryMode) : ' fct-reviews-layout--single'; ?>">
618 <?php if ($showSummary) : ?>
619 <aside class="fct-reviews-layout-left">
620 <?php if ($summaryMode === 'cta') : ?>
621 <?php // The same card the composed section builds:
622 // its template puts the invitation inside a
623 // summary group, which is what gives it the
624 // border and the padding. Wrapped here rather
625 // than inside renderWriteReviewCta(), which
626 // also draws the standalone Write a Review
627 // block and widget -- neither of which wants
628 // a card around it. ?>
629 <div class="fct-review-summary-group">
630 <?php $this->renderWriteReviewCta(); ?>
631 </div>
632 <?php else : ?>
633 <?php $this->renderRatingSummary($summary); ?>
634 <?php endif; ?>
635 </aside>
636 <?php endif; ?>
637
638 <div class="fct-reviews-layout-right">
639 <?php // A composed list draws what its blocks say and
640 // nothing else: a header absent from them is one an
641 // editor removed, and each block — count, chips,
642 // sort — renders only itself, so placing one does
643 // not bring the other two along.
644 //
645 // This header is for the lists nobody composed: the
646 // shortcode, the single-product template, a Review
647 // List block left empty. Those have no blocks to
648 // state a preference, so they get the lot. ?>
649 <?php if (!$isComposed) : ?>
650 <?php if ($showCount || $showSortControls || $showFilterChips) : ?>
651 <div class="fct-reviews-list-header">
652 <?php if ($showCount) : ?>
653 <h3 class="fct-reviews-section-title">
654 <?php
655 /* translators: %s - total review count */
656 printf(
657 esc_html__('%s Reviews', 'fluent-cart'),
658 '<span data-reviews-total-count>' . esc_html($displayTotal) . '</span>'
659 );
660 ?>
661 </h3>
662 <?php endif; ?>
663
664 <?php if ($showSortControls || $showFilterChips) : ?>
665 <?php $this->renderControls($defaultSort, $showFilterChips, $showSortControls); ?>
666 <?php endif; ?>
667 </div>
668 <?php endif; ?>
669 <?php endif; ?>
670
671 <?php // Shown by Reviews.js while a sort, filter or page
672 // change is in flight. It takes no room of its own -
673 // the spinner floats over the rows, which stay where
674 // they are, dimmed, until the new ones land - so
675 // nothing above or below it moves. ?>
676 <div class="fct-reviews-loading" data-reviews-loading role="status">
677 <span class="fct-loader-spinner" aria-hidden="true"></span>
678 <span class="fct-sr-only"><?php esc_html_e('Loading reviews...', 'fluent-cart'); ?></span>
679 </div>
680
681 <p class="fct-reviews-error" data-reviews-error hidden>
682 <?php esc_html_e('Unable to load reviews. Please try again later.', 'fluent-cart'); ?>
683 </p>
684
685 <?php if ($composedBody !== '') : ?>
686 <?php echo $composedBody; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
687 <?php endif; ?>
688
689 <?php if (!$composedDrew['list']) : ?>
690 <div class="fct-reviews-list<?php echo $hasColumns ? ' fct-reviews-list--' . $viewMode . ' fct-reviews-list--cols-' . (int) $gridColumns : ''; ?>"
691 <?php if ($hasColumns) : ?>style="--fct-review-columns: <?php echo (int) $gridColumns; ?>"<?php endif; ?>
692 <?php if ($isSlider) : ?>
693 data-reviews-slider="<?php echo (int) $gridColumns; ?>"
694 data-slider-settings="<?php echo esc_attr(wp_json_encode($sliderSettings)); ?>"
695 <?php endif; ?>
696 data-reviews-list>
697 <?php echo $initialRows; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
698 </div>
699 <?php endif; ?>
700
701 <?php // Same rule, same reason: a pager an editor did not
702 // place is a pager they did not want. Reviews.js
703 // looks this element up before wiring it and again
704 // before refilling it, and steps over a list that
705 // has none — so the rows simply page no further
706 // than the first. ?>
707 <?php if (!$isComposed && Arr::get($this->renderOptions, 'showPagination', true) && (!$isSlider || $initialLastPage > 1)) : ?>
708 <div class="fct-reviews-pagination" data-reviews-pagination><?php echo $initialPagination; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></div>
709 <?php endif; ?>
710 </div>
711 </div>
712
713 <?php if ($showSummary) : ?>
714 <?php
715 // The summary rendered its Write a Review CTA, so this block
716 // must also supply the drawer that CTA opens — a standalone
717 // reviews block otherwise emits a dead button. The drawer
718 // dedupes per product, so the single-product template's
719 // later renderForm() call cannot add a second one.
720 $this->renderForm();
721 ?>
722 <?php endif; ?>
723
724 <?php $this->renderThreadModalShell(); ?>
725
726 <?php do_action('fluent_cart/review/after_section', $this->postId); ?>
727 </div>
728 <?php
729 }
730
731 /**
732 * The slider's behaviour settings, every value checked.
733 *
734 * They are printed into a data attribute and handed straight to Swiper, so
735 * an unrecognised value has to land on a default rather than reach the
736 * library — a saved attribute is hand-editable.
737 *
738 * Public because the composed row prints the same attribute from the same
739 * saved values, and two sets of rules for one attribute is one set that
740 * will drift.
741 *
742 * @param array $settings
743 * @return array
744 */
745 public static function normalizeSliderSettings(array $settings): array
746 {
747 $autoplay = Arr::get($settings, 'autoplay', 'no');
748 $arrowsSize = Arr::get($settings, 'arrowsSize', 'md');
749
750 return [
751 'autoplay' => in_array($autoplay, ['no', 'yes', 'hover'], true) ? $autoplay : 'no',
752 // Floored at 300ms: below that the slides move faster than they
753 // can be read, which is a carousel nobody can use.
754 'autoplayDelay' => min(10000, max(300, (int) Arr::get($settings, 'autoplayDelay', 3000))),
755 'arrows' => Arr::get($settings, 'arrows') === 'no' ? 'no' : 'yes',
756 'arrowsSize' => in_array($arrowsSize, ['sm', 'md', 'lg'], true) ? $arrowsSize : 'md',
757 // Where the arrows sit: over the first and last card, clear of
758 // the track on either side, or in a row beneath it.
759 'arrowsPosition' => in_array(Arr::get($settings, 'arrowsPosition'), ['overlap', 'outside', 'bottom'], true)
760 ? Arr::get($settings, 'arrowsPosition')
761 : 'overlap',
762 'pagination' => Arr::get($settings, 'pagination') === 'yes' ? 'yes' : 'no',
763 'paginationType' => in_array(Arr::get($settings, 'paginationType'), ['bullets', 'fraction', 'progressbar', 'segmented'], true)
764 ? Arr::get($settings, 'paginationType')
765 : 'bullets',
766 'infinite' => Arr::get($settings, 'infinite') === 'yes' ? 'yes' : 'no',
767 ];
768 }
769
770 /**
771 * Swiper, for the slider view only.
772 *
773 * The same bundle the product carousel uses, enqueued the same way — one
774 * copy of the library, whichever block asks for it first.
775 */
776 protected static function enqueueSliderAssets(): void
777 {
778 static $enqueued = false;
779
780 if ($enqueued) {
781 return;
782 }
783
784 $enqueued = true;
785 $slug = fluentCart()->config->get('app.slug');
786
787 Vite::enqueueStaticScript(
788 $slug . '-fluentcart-swiper-js',
789 'public/lib/swiper/swiper-bundle.min.js',
790 [$slug . '-app']
791 );
792
793 Vite::enqueueStaticStyle(
794 $slug . '-fluentcart-swiper-css',
795 'public/lib/swiper/swiper-bundle.min.css'
796 );
797 }
798
799 /**
800 * Empty shell for the review thread modal.
801 *
802 * Printed hidden and shown by the storefront script, the same split the
803 * cart drawer uses for its loader: the markup lives in PHP, the script
804 * only toggles it and swaps in the panel fetched from the modal
805 * endpoint. It carries no review data, so nothing here can drift from
806 * ReviewModalRenderer.
807 */
808 protected function renderThreadModalShell()
809 {
810 ?>
811 <div class="fct-review-modal-overlay" data-review-modal-shell role="dialog" aria-modal="true"
812 aria-label="<?php esc_attr_e('Review thread', 'fluent-cart'); ?>" hidden>
813 <div class="fct-review-modal" data-review-modal tabindex="-1">
814 <div class="fct-review-modal-body" data-review-modal-body>
815 <div class="fct-review-modal-loading" data-review-modal-loading role="status"
816 aria-label="<?php esc_attr_e('Loading replies...', 'fluent-cart'); ?>">
817 <div class="fct-loader-wrap show">
818 <div class="fct-loader-spinner"></div>
819 </div>
820 </div>
821 </div>
822
823 <?php // Error body the script swaps in when the thread fails
824 // to load — cloned from here so no markup lives in JS. ?>
825 <template data-modal-error-template>
826 <div class="fct-review-modal-empty"><?php esc_html_e('Could not load this review. Please try again.', 'fluent-cart'); ?></div>
827 <button type="button" class="fct-review-modal-close" data-modal-close aria-label="<?php esc_attr_e('Close', 'fluent-cart'); ?>">
828 <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true" focusable="false"><path d="M12.8337 1.16663L1.16699 12.8333M1.16699 1.16663L12.8337 12.8333" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
829 </button>
830 </template>
831 </div>
832 </div>
833 <?php
834 }
835
836 /**
837 * The submission form, in whichever shape the placement asked for.
838 *
839 * Two independent axes:
840 * - container: 'drawer' | 'modal' | 'none' — the chrome around it
841 * - layout: 'steps' | 'inline' — how the fields are paced
842 *
843 * Every combination is valid: a stepped modal, a flat drawer, a stepped
844 * card printed on the page, a flat card. The field markup and the whole
845 * data-* contract are identical in all four, so ReviewForm.js and the
846 * Pro upload zone never have to know which one they are driving.
847 */
848 public function renderForm()
849 {
850 if (!$this->shouldRenderReviews()) {
851 return;
852 }
853
854 // One form per renderer. The single-product template asks twice —
855 // once through render(), whose summary CTA needs the drawer it
856 // opens, and once on its own — and both carry this instance's id,
857 // so a second copy would leave the trigger bound to whichever
858 // registered last while the other sat unused in the page.
859 if ($this->formRendered) {
860 return;
861 }
862 $this->formRendered = true;
863
864 $restInfo = Helper::getRestInfo();
865 $starColor = sanitize_hex_color(Arr::get($this->renderOptions, 'starColor')) ?: self::DEFAULT_STAR_COLOR;
866 $permissionMode = $this->settings['review_permission_mode'] ?? 'verified_buyers';
867 $userId = get_current_user_id();
868 // A valid order grant is proof of purchase, so it answers the same
869 // question logging in would — see ProductReviewService::resolveOrderGrant().
870 $needsLogin = (!$this->orderGrant() && $permissionMode !== 'anyone' && !$userId);
871 $layout = $this->formLayout();
872 $container = $this->formContainer();
873 $isStepped = ($layout === 'steps');
874
875 // Only compute form state when the user can actually use it
876 $extraFieldsHtml = '';
877 $hasPhotosStep = false;
878 $starEnabled = ProductReviewService::isStarRatingEnabled();
879 $existingReview = null;
880 $isEditMode = false;
881
882 // Photos are attached after the review submission is authorized,
883 // including submissions authorized by an order grant.
884 if (!$needsLogin) {
885 // Check if Pro photo step has content.
886 //
887 // The review itself rides along as a third argument: a consumer
888 // that has to re-derive "the visitor's review" from (post, user)
889 // will get it wrong in the two ways this renderer already handles
890 // — a reply matches those columns, and so does the same person's
891 // review of a different variation. Handing over the row this form
892 // is actually bound to removes the question. Memoized, so asking
893 // here costs nothing; null when the form is writing a new review.
894 ob_start();
895 do_action(
896 'fluent_cart/review/form_extra_fields',
897 $this->postId,
898 $this->editableReviewUserId($userId),
899 $this->existingReviewFor($userId)
900 );
901 $extraFieldsHtml = trim(ob_get_clean());
902 $hasPhotosStep = !empty($extraFieldsHtml);
903 }
904
905 // Calculate total steps: Rating (if enabled) + Details + Photos (if Pro)
906 $totalSteps = 1; // Details is always present
907 if ($starEnabled) {
908 $totalSteps++;
909 }
910 if ($hasPhotosStep) {
911 $totalSteps++;
912 }
913
914 // Inline has no wizard: every field is on screen at once, so the whole
915 // form is a single step and ReviewForm.js validates it as one.
916 if (!$isStepped) {
917 $totalSteps = 1;
918 }
919
920 // Check if logged-in user already has a review for this product
921 if (!$needsLogin) {
922 $existingReview = $this->existingReviewFor($userId);
923 }
924 $isEditMode = !empty($existingReview);
925 ?>
926 <div class="fct-review-form-section fct-review-form-section-<?php echo esc_attr($container); ?>"
927 data-fluent-cart-review-form
928 data-form-id="<?php echo esc_attr($this->instanceId); ?>"
929 data-layout="<?php echo esc_attr($layout); ?>"
930 data-container="<?php echo esc_attr($container); ?>"
931 data-post-id="<?php echo esc_attr($this->postId); ?>"
932 data-item-id="<?php echo esc_attr($this->itemId()); ?>"
933 data-rest-url="<?php echo esc_url($restInfo['url']); ?>"
934 data-rest-nonce="<?php echo esc_attr($restInfo['nonce']); ?>"
935 data-star-enabled="<?php echo $starEnabled ? '1' : '0'; ?>"
936 data-star-required="<?php echo ProductReviewService::isStarRatingRequired() ? '1' : '0'; ?>"
937 data-total-steps="<?php echo esc_attr($totalSteps); ?>"
938 <?php if ($isEditMode) : ?>
939 data-edit-mode="1"
940 data-review-id="<?php echo esc_attr($existingReview->id); ?>"
941 data-existing-rating="<?php echo esc_attr($existingReview->rating); ?>"
942 data-existing-title="<?php echo esc_attr($existingReview->title); ?>"
943 data-existing-content="<?php echo esc_attr($existingReview->content); ?>"
944 <?php endif; ?>
945 <?php if (!$this->isDefaultStarColor($starColor)) : ?>
946 style="--fct-star-color: <?php echo esc_attr($starColor); ?>"
947 <?php endif; ?>
948 >
949 <?php if ($needsLogin && $container === 'none') : ?>
950 <?php
951 // A drawer or a modal has a trigger, and that trigger becomes
952 // a log-in link for a visitor who needs one. Printed on the
953 // page there is no trigger, so without this the visitor gets
954 // an empty box: no form, no reason, nothing to click.
955 $this->renderLoginRequired();
956 ?>
957 <?php endif; ?>
958
959 <?php if (!$needsLogin) : ?>
960 <?php if ($container === 'none') : ?>
961 <?php $this->renderStandalone($userId, $extraFieldsHtml, $hasPhotosStep, $totalSteps, $isStepped, $isEditMode); ?>
962 <?php else : ?>
963 <?php
964 // Multiple sections for one product each carry an overlay;
965 // ReviewForm.js elects one primary controller per product,
966 // so only the first ever opens — the rest stay inert.
967 $this->renderOverlay($container, $userId, $extraFieldsHtml, $hasPhotosStep, $totalSteps, $isStepped, $isEditMode);
968 ?>
969 <?php endif; ?>
970 <?php endif; ?>
971
972 <?php do_action('fluent_cart/review/after_form_section', $this->postId); ?>
973 </div>
974 <?php
975 }
976
977 /**
978 * The form behind a trigger: a side drawer or a centred modal. Both carry
979 * the same data-review-drawer contract — the difference is the class the
980 * stylesheet hangs the animation and position off, so the open/close,
981 * focus trap and Escape handling in ReviewForm.js serve both.
982 */
983 protected function renderOverlay($container, $userId, $extraFieldsHtml, $hasPhotos, $totalSteps, $isStepped, $isEditMode)
984 {
985 $isModal = ($container === 'modal');
986 // Per instance, not per product: two of these blocks can sit on one
987 // page for one product, and ids are document-wide. Duplicates break
988 // the label-to-field pairing and leave both dialogs pointing
989 // aria-labelledby at the same heading.
990 $titleId = 'fct-review-drawer-title-' . $this->instanceId;
991 ?>
992 <?php
993 // fct-review-form-modal, not fct-review-modal: the latter is the review
994 // thread modal, which has its own rules and never takes the open class
995 // this overlay animates on. Sharing the name made the thread modal
996 // inherit this one's hidden state and render transparent.
997 ?>
998 <div class="<?php echo $isModal ? 'fct-review-form-modal-overlay' : 'fct-review-drawer-overlay'; ?>" data-review-drawer style="display:none;">
999 <div class="<?php echo $isModal ? 'fct-review-form-modal' : 'fct-review-drawer'; ?>" data-review-drawer-panel role="dialog" aria-modal="true" aria-labelledby="<?php echo esc_attr($titleId); ?>" tabindex="-1">
1000 <div class="fct-review-drawer-header">
1001 <h4 class="fct-review-drawer-title" data-review-drawer-title id="<?php echo esc_attr($titleId); ?>"><?php esc_html_e('Write a review', 'fluent-cart'); ?></h4>
1002 <button type="button" class="fct-review-drawer-close" data-close-review-drawer aria-label="<?php esc_attr_e('Close', 'fluent-cart'); ?>"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true" focusable="false"><path d="M12.8337 1.16663L1.16699 12.8333M1.16699 1.16663L12.8337 12.8333" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
1003 </div>
1004
1005 <?php $this->renderStepper($hasPhotos, $totalSteps, $isStepped); ?>
1006
1007 <div class="fct-review-drawer-body">
1008 <div class="fct-review-form-message" data-review-form-message role="alert" style="display: none;"></div>
1009
1010 <?php $this->renderFields($userId, $extraFieldsHtml, $hasPhotos, $isStepped, $this->instanceId); ?>
1011 </div>
1012
1013 <div class="fct-review-drawer-footer">
1014 <?php $this->renderFooterButtons($totalSteps, $isStepped, $isEditMode); ?>
1015 </div>
1016 </div>
1017 </div>
1018 <?php
1019 }
1020
1021 /**
1022 * The form printed straight onto the page — no trigger, no overlay. Still
1023 * honours the layout axis: a stepped card walks through the same steps in
1024 * place, an inline card shows everything at once.
1025 */
1026 protected function renderStandalone($userId, $extraFieldsHtml, $hasPhotos, $totalSteps, $isStepped, $isEditMode)
1027 {
1028 // Ids have to be unique across the whole document, not just against
1029 // the overlay form the product template may already have printed: two
1030 // of these blocks can sit on one page for one product. Duplicate ids
1031 // break the label-to-field pairing, so clicking the second form's
1032 // "Your review" would focus the first form's box.
1033 //
1034 // $instanceId is already unique per renderer — it is what pairs a
1035 // trigger with its own form — so it serves here too.
1036 $idSuffix = $this->instanceId;
1037 ?>
1038 <div class="fct-review-standalone">
1039 <div class="fct-review-form-message" data-review-form-message role="alert" style="display: none;"></div>
1040
1041 <?php $this->renderStepper($hasPhotos, $totalSteps, $isStepped); ?>
1042
1043 <?php $this->renderFields($userId, $extraFieldsHtml, $hasPhotos, $isStepped, $idSuffix); ?>
1044
1045 <div class="fct-review-standalone-footer">
1046 <?php $this->renderFooterButtons($totalSteps, $isStepped, $isEditMode); ?>
1047 </div>
1048 </div>
1049 <?php
1050 }
1051
1052 /**
1053 * Shown in place of the form when the store requires a login the visitor
1054 * does not have. role="status" so the reason is announced rather than
1055 * only seen, and the link comes back here rather than to the product,
1056 * which on a review page is not where they were.
1057 */
1058 protected function renderLoginRequired()
1059 {
1060 $loginUrl = wp_login_url($this->loginRedirectUrl());
1061 ?>
1062 <div class="fct-review-login-required" role="status">
1063 <p class="fct-review-login-required-text">
1064 <?php esc_html_e('Please log in to write a review for this product.', 'fluent-cart'); ?>
1065 </p>
1066 <a href="<?php echo esc_url($loginUrl); ?>" class="fct-review-cta-btn">
1067 <?php
1068 $loginText = trim((string) Arr::get($this->renderOptions, 'ctaLoginText', ''));
1069 echo $loginText !== '' ? esc_html($loginText) : esc_html__('Log in to Review', 'fluent-cart');
1070 ?>
1071 </a>
1072 </div>
1073 <?php
1074 }
1075
1076 /**
1077 * The numbered progress row. Steps only, and only when there is more than
1078 * one of them — a single-step wizard is just a form.
1079 */
1080 protected function renderStepper($hasPhotos, $totalSteps, $isStepped)
1081 {
1082 if (!$isStepped || $totalSteps <= 1) {
1083 return;
1084 }
1085
1086 $starEnabled = ProductReviewService::isStarRatingEnabled();
1087 ?>
1088 <div class="fct-review-stepper" data-review-stepper>
1089 <?php if ($starEnabled) : ?>
1090 <div class="fct-review-step-indicator active" data-step-indicator="1">
1091 <span class="fct-step-circle">1</span>
1092 <span class="fct-step-label"><?php esc_html_e('Rating', 'fluent-cart'); ?></span>
1093 </div>
1094 <div class="fct-step-line"></div>
1095 <div class="fct-review-step-indicator" data-step-indicator="2">
1096 <span class="fct-step-circle">2</span>
1097 <span class="fct-step-label"><?php esc_html_e('Details', 'fluent-cart'); ?></span>
1098 </div>
1099 <?php if ($hasPhotos) : ?>
1100 <div class="fct-step-line"></div>
1101 <div class="fct-review-step-indicator" data-step-indicator="3">
1102 <span class="fct-step-circle">3</span>
1103 <span class="fct-step-label"><?php esc_html_e('Photos', 'fluent-cart'); ?></span>
1104 </div>
1105 <?php endif; ?>
1106 <?php else : ?>
1107 <div class="fct-review-step-indicator active" data-step-indicator="1">
1108 <span class="fct-step-circle">1</span>
1109 <span class="fct-step-label"><?php esc_html_e('Details', 'fluent-cart'); ?></span>
1110 </div>
1111 <?php if ($hasPhotos) : ?>
1112 <div class="fct-step-line"></div>
1113 <div class="fct-review-step-indicator" data-step-indicator="2">
1114 <span class="fct-step-circle">2</span>
1115 <span class="fct-step-label"><?php esc_html_e('Photos', 'fluent-cart'); ?></span>
1116 </div>
1117 <?php endif; ?>
1118 <?php endif; ?>
1119 </div>
1120 <?php
1121 }
1122
1123 /**
1124 * The <form> itself. Stepped wraps each group in a data-review-step div
1125 * the wizard shows and hides; inline drops the same groups in flat.
1126 */
1127 protected function renderFields($userId, $extraFieldsHtml, $hasPhotos, $isStepped, $idSuffix)
1128 {
1129 $currentUser = $userId ? get_userdata($userId) : null;
1130 // Through the service, not the settings array: isStarRatingRequired()
1131 // already folds in "ratings are off entirely", and reading the two
1132 // keys here is how the storefront and the server paths drifted apart
1133 // before.
1134 $starEnabled = ProductReviewService::isStarRatingEnabled();
1135 $starRequired = ProductReviewService::isStarRatingRequired();
1136 $groupClass = $isStepped ? 'fct-review-step' : 'fct-review-inline-group';
1137 ?>
1138 <form class="fct-review-form" data-review-form>
1139 <?php
1140 // ReviewForm.js builds its payload with new FormData(form), so a
1141 // hidden field here is all it takes to send the hash back — no
1142 // client change, and the value never leaves the form it belongs to.
1143 $grantHash = $this->orderGrant() ? (string) Arr::get($this->renderOptions, 'orderHash', '') : '';
1144 ?>
1145 <?php if ($grantHash !== '') : ?>
1146 <input type="hidden" name="order_hash" value="<?php echo esc_attr($grantHash); ?>"/>
1147 <?php endif; ?>
1148
1149 <?php if ($starEnabled) : ?>
1150 <div class="<?php echo esc_attr($groupClass); ?>" <?php echo $isStepped ? 'data-review-step="1"' : ''; ?>>
1151 <?php $this->renderRatingField($starRequired); ?>
1152 </div>
1153 <?php endif; ?>
1154
1155 <div class="<?php echo esc_attr($groupClass); ?>"
1156 <?php if ($isStepped) : ?>
1157 data-review-step="<?php echo $starEnabled ? '2' : '1'; ?>" <?php echo $starEnabled ? 'style="display:none;"' : ''; ?>
1158 <?php endif; ?>
1159 >
1160 <?php $this->renderDetailsFields($userId, $currentUser, $idSuffix); ?>
1161 </div>
1162
1163 <?php if ($hasPhotos) : ?>
1164 <div class="<?php echo esc_attr($groupClass); ?>"
1165 <?php if ($isStepped) : ?>
1166 data-review-step="<?php echo $starEnabled ? '3' : '2'; ?>" style="display:none;"
1167 <?php endif; ?>
1168 >
1169 <?php if ($isStepped) : ?>
1170 <p class="fct-review-step-description">
1171 <?php esc_html_e('Add photos to help other shoppers. Up to 5 images — JPG, PNG, or WEBP.', 'fluent-cart'); ?>
1172 </p>
1173 <?php endif; ?>
1174 <?php echo $extraFieldsHtml; // Already escaped by Pro renderer ?>
1175 <?php if ($isStepped) : ?>
1176 <p class="fct-review-step-hint">
1177 <?php esc_html_e('No photos? That\'s fine — you can skip this step.', 'fluent-cart'); ?>
1178 </p>
1179 <?php endif; ?>
1180 </div>
1181 <?php endif; ?>
1182 </form>
1183 <?php
1184 }
1185
1186 /**
1187 * Back / Next / Submit. Inline has nowhere to go, so it gets the submit
1188 * button alone, named for the write it performs — an existing review is
1189 * updated, not posted a second time.
1190 */
1191 protected function renderFooterButtons($totalSteps, $isStepped, $isEditMode)
1192 {
1193 ?>
1194 <?php if ($isStepped) : ?>
1195 <button type="button" class="fct-review-back-btn" data-review-back style="display:none;">
1196 <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"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg> <?php esc_html_e('Back', 'fluent-cart'); ?>
1197 </button>
1198 <button type="button" class="fct-review-next-btn" data-review-next>
1199 <?php esc_html_e('Next', 'fluent-cart'); ?> <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"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
1200 </button>
1201 <?php endif; ?>
1202 <button type="button" class="fct-review-submit-btn" data-review-submit <?php echo $isStepped ? 'style="display:none;"' : ''; ?>>
1203 <?php
1204 if ($isEditMode) {
1205 esc_html_e('Update review', 'fluent-cart');
1206 } else {
1207 esc_html_e('Submit review', 'fluent-cart');
1208 }
1209 ?><?php echo $isStepped ? ' &#10003;' : ''; ?>
1210 </button>
1211 <?php if ($isStepped) : ?>
1212 <div class="fct-review-step-info" data-review-step-info>
1213 <?php
1214 /* translators: 1: current step, 2: total steps */
1215 printf(esc_html__('Step %1$s of %2$s', 'fluent-cart'), '1', esc_html($totalSteps));
1216 ?>
1217 </div>
1218 <?php endif; ?>
1219 <?php
1220 }
1221
1222 /**
1223 * Star selector plus the hidden input ReviewForm.js writes the value to.
1224 * Shared by every layout — stepped wraps it in a step, inline does not.
1225 */
1226 protected function renderRatingField($starRequired)
1227 {
1228 ?>
1229 <label class="fct-review-step-question">
1230 <?php esc_html_e('How would you rate this product?', 'fluent-cart'); ?>
1231 <?php if ($starRequired) : ?> <span class="required">*</span><?php endif; ?>
1232 </label>
1233 <?php
1234 // Each star carries its own tooltip, so the bubble is positioned
1235 // against the star it belongs to and nothing has to measure anything.
1236 // Rendered here rather than written by script: the words are known at
1237 // render time, and a rating that has been chosen keeps its tooltip up
1238 // with scripting disabled.
1239 $ratingWords = [
1240 1 => __('Poor', 'fluent-cart'),
1241 2 => __('Average', 'fluent-cart'),
1242 3 => __('Good', 'fluent-cart'),
1243 4 => __('Very Good', 'fluent-cart'),
1244 5 => __('Excellent', 'fluent-cart'),
1245 ];
1246 // Poor reads as a failure, Average a caution, Good neither, the top
1247 // two a success.
1248 $ratingTypes = [1 => 'danger', 2 => 'warning', 3 => 'info', 4 => 'success', 5 => 'success'];
1249 ?>
1250 <div class="fct-star-selector-wrap">
1251 <div class="fct-star-selector" data-star-selector role="radiogroup" aria-label="<?php esc_attr_e('Rating', 'fluent-cart'); ?>">
1252 <?php for ($i = 1; $i <= 5; $i++) : ?>
1253 <button type="button" class="fct-star-select" data-star-value="<?php echo esc_attr($i); ?>"
1254 role="radio" aria-checked="false"
1255 aria-label="<?php printf(esc_attr__('%d star', 'fluent-cart'), $i); ?>"
1256 ><?php echo ReviewThreadMarkup::starSvg(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?><?php
1257 // aria-hidden: the button's own aria-label already says
1258 // "3 star", and the tooltip would read as a second name
1259 // for the same control.
1260 ?><span class="fct-star-tooltip fct-<?php echo esc_attr($ratingTypes[$i]); ?>" aria-hidden="true"><?php echo esc_html($ratingWords[$i]); ?></span></button>
1261 <?php endfor; ?>
1262 </div>
1263 </div>
1264 <input type="hidden" name="rating" value="" data-review-rating/>
1265 <?php
1266 }
1267
1268 /**
1269 * Reviewer identity (guests type it, members post it hidden), title and
1270 * content. $idSuffix keeps label/input ids unique when two forms for the
1271 * same product share a page.
1272 */
1273 protected function renderDetailsFields($userId, $currentUser, $idSuffix)
1274 {
1275 ?>
1276 <?php
1277 // A visitor holding an order grant already has an identity: the order's
1278 // customer. The submit path takes those values from the order itself
1279 // and ignores whatever arrives, so no identity fields are rendered at
1280 // all — not even hidden ones. Printing the buyer's email into a hidden
1281 // input would publish it to anyone the review link reaches, for a value
1282 // the server discards anyway.
1283 $grant = $this->orderGrant();
1284 $grantOwns = ProductReviewService::grantOwnsSubmission($grant);
1285 $grantIdentity = $grantOwns ? ProductReviewService::orderGrantIdentity($grant) : null;
1286 $grantName = $grantIdentity ? Arr::get($grantIdentity, 'name', '') : '';
1287
1288 // An order whose customer row is gone resolves to no email, and the
1289 // submit path then falls back to the typed fields — so the form has to
1290 // show them rather than a byline the visitor cannot supply. Signed in
1291 // or not: the submit path files that review under nobody's account,
1292 // so the visitor's own name and address are not offered either.
1293 if ($grantIdentity && trim((string) Arr::get($grantIdentity, 'email', '')) === '') {
1294 $grantIdentity = null;
1295 }
1296 $typedIdentity = !$userId || ($grantOwns && !$grantIdentity);
1297 ?>
1298 <?php if ($grantIdentity) : ?>
1299 <p class="fct-review-posting-as">
1300 <?php
1301 /* translators: %1$s: the name the review will be published under */
1302 printf(esc_html__('Posting as %1$s', 'fluent-cart'), '<strong>' . esc_html($grantName) . '</strong>');
1303 ?>
1304 </p>
1305 <?php elseif ($typedIdentity) : ?>
1306 <div class="fct-review-form-row">
1307 <div class="fct-review-form-field">
1308 <label for="fct-review-name-<?php echo esc_attr($idSuffix); ?>"><?php esc_html_e('Your name', 'fluent-cart'); ?> <span class="required">*</span></label>
1309 <input type="text" id="fct-review-name-<?php echo esc_attr($idSuffix); ?>" name="reviewer_name" required placeholder="<?php esc_attr_e('Your name', 'fluent-cart'); ?>"/>
1310 </div>
1311 <div class="fct-review-form-field">
1312 <label for="fct-review-email-<?php echo esc_attr($idSuffix); ?>"><?php esc_html_e('Email', 'fluent-cart'); ?> <span class="required">*</span> <span class="fct-label-hint">(<?php esc_html_e('private', 'fluent-cart'); ?>)</span></label>
1313 <input type="email" id="fct-review-email-<?php echo esc_attr($idSuffix); ?>" name="reviewer_email" maxlength="192" required placeholder="<?php esc_attr_e('[email protected]', 'fluent-cart'); ?>"/>
1314 </div>
1315 </div>
1316 <?php else : ?>
1317 <input type="hidden" name="reviewer_name" value="<?php echo esc_attr($currentUser->display_name); ?>"/>
1318 <input type="hidden" name="reviewer_email" value="<?php echo esc_attr($currentUser->user_email); ?>"/>
1319 <?php endif; ?>
1320
1321 <div class="fct-review-form-field">
1322 <label for="fct-review-title-<?php echo esc_attr($idSuffix); ?>"><?php esc_html_e('Review title', 'fluent-cart'); ?></label>
1323 <input type="text" id="fct-review-title-<?php echo esc_attr($idSuffix); ?>" name="title" maxlength="80" placeholder="<?php esc_attr_e('Summarize your experience', 'fluent-cart'); ?>"/>
1324 <span class="fct-char-count" data-char-count="fct-review-title-<?php echo esc_attr($idSuffix); ?>">0 / 80</span>
1325 </div>
1326
1327 <div class="fct-review-form-field">
1328 <label for="fct-review-content-<?php echo esc_attr($idSuffix); ?>"><?php esc_html_e('Your review', 'fluent-cart'); ?> <span class="required">*</span></label>
1329 <textarea id="fct-review-content-<?php echo esc_attr($idSuffix); ?>" name="content" rows="5" maxlength="1500" placeholder="<?php esc_attr_e('What did you like or dislike? How did you use this product?', 'fluent-cart'); ?>"></textarea>
1330 <span class="fct-char-count" data-char-count="fct-review-content-<?php echo esc_attr($idSuffix); ?>">0 / 1500</span>
1331 </div>
1332 <?php
1333 }
1334
1335 /**
1336 * How the fields are paced: 'steps' walks a wizard, 'inline' shows all.
1337 *
1338 * @return string
1339 */
1340 protected function formLayout(): string
1341 {
1342 $layout = Arr::get($this->renderOptions, 'layout');
1343
1344 return $layout === 'inline' ? 'inline' : 'steps';
1345 }
1346
1347 /**
1348 * The chrome around the fields: a side drawer, a centred modal, or none
1349 * at all (printed straight onto the page).
1350 *
1351 * @return string
1352 */
1353 /**
1354 * The default in any letter case is still the default: a colour picker
1355 * may hand back #F59E0B for the value the block stored as #f59e0b.
1356 *
1357 * @param string $starColor
1358 * @return bool
1359 */
1360 protected function isDefaultStarColor($starColor): bool
1361 {
1362 return strtolower((string) $starColor) === self::DEFAULT_STAR_COLOR;
1363 }
1364
1365 protected function formContainer(): string
1366 {
1367 $container = Arr::get($this->renderOptions, 'container');
1368
1369 return in_array($container, ['drawer', 'modal', 'none'], true) ? $container : 'drawer';
1370 }
1371
1372 /**
1373 * The order this placement's hash names, when that order contains this
1374 * product. Non-null means the visitor may review it whatever the store's
1375 * permission mode says and whether or not they are logged in.
1376 *
1377 * @return \FluentCart\App\Models\Order|null
1378 */
1379 /**
1380 * Where a login link brings the visitor back to. The page they are on
1381 * when one exists — the order-review page in particular is reached from
1382 * an email and is not the product page, so a link there must not drop
1383 * them on the product afterwards — otherwise the product.
1384 *
1385 * @return string
1386 */
1387 protected function loginRedirectUrl(): string
1388 {
1389 if (Arr::get($this->renderOptions, 'orderHash', '') !== '') {
1390 $current = ProductReviewService::currentRequestUrl();
1391 if ($current !== '') {
1392 return $current;
1393 }
1394 }
1395
1396 $queriedId = is_singular() ? get_queried_object_id() : 0;
1397 $redirect = $queriedId ? get_permalink($queriedId) : get_permalink($this->postId);
1398
1399 return $redirect ?: home_url('/');
1400 }
1401
1402 /**
1403 * The account whose existing review the form would open for editing.
1404 *
1405 * When an order grant owns the submission the review posts as the buyer,
1406 * whoever is signed in — so the visitor's own review of this product is
1407 * not the one this form edits. Seeding it would put the form in edit
1408 * mode and have it update the visitor's review under a byline naming the
1409 * buyer. The buyer signed in to their own account keeps their edit flow.
1410 *
1411 * @param int $userId
1412 * @return int 0 when no account's review is editable here
1413 */
1414 protected function editableReviewUserId($userId)
1415 {
1416 if ($userId && ProductReviewService::grantOwnsSubmission($this->orderGrant())) {
1417 return 0;
1418 }
1419
1420 return (int) $userId;
1421 }
1422
1423 protected function orderGrant()
1424 {
1425 return ProductReviewService::resolveOrderGrant(
1426 $this->postId,
1427 Arr::get($this->renderOptions, 'orderHash', ''),
1428 $this->itemId()
1429 );
1430 }
1431 /**
1432 * The rating summary card on its own — the standalone Rating Summary
1433 * block. Its summary-only marker refreshes these numbers without
1434 * initializing a review list.
1435 */
1436 public function renderSummarySection()
1437 {
1438 if (!$this->shouldRenderReviews()) {
1439 return;
1440 }
1441
1442 $summary = ProductReviewService::getProductRatingSummary($this->postId);
1443 $restInfo = Helper::getRestInfo();
1444 $starColor = sanitize_hex_color(Arr::get($this->renderOptions, 'starColor')) ?: self::DEFAULT_STAR_COLOR;
1445 ?>
1446 <div class="fct-product-reviews-section fct-reviews-summary-only"
1447 data-fluent-cart-review-summary
1448 data-post-id="<?php echo esc_attr($this->postId); ?>"
1449 data-rest-url="<?php echo esc_url($restInfo['url']); ?>"
1450 data-rest-nonce="<?php echo esc_attr($restInfo['nonce']); ?>"
1451 <?php if (!$this->isDefaultStarColor($starColor)) : ?>
1452 style="--fct-star-color: <?php echo esc_attr($starColor); ?>"
1453 <?php endif; ?>
1454 >
1455 <?php // No built-in CTA — the Write a Review block is its own CTA ?>
1456 <?php $this->renderRatingSummary($summary, false); ?>
1457 </div>
1458 <?php
1459 }
1460
1461 protected function renderRatingSummary($summary, $withCta = true)
1462 {
1463 ?>
1464 <div class="fct-reviews-summary" data-reviews-summary>
1465 <div class="fct-reviews-summary-left">
1466 <div class="fct-reviews-average">
1467 <span class="fct-reviews-average-number" data-reviews-average><?php echo esc_html($summary['average']); ?></span>
1468 <span class="fct-reviews-average-max">/5</span>
1469 </div>
1470 <div class="fct-reviews-summary-head-meta">
1471 <div class="fct-reviews-stars-display" data-reviews-stars role="img" aria-label="<?php printf(esc_attr__('Rated %s out of 5', 'fluent-cart'), esc_attr($summary['average'])); ?>">
1472 <?php $this->renderStars($summary['average']); ?>
1473 </div>
1474 <div class="fct-reviews-total" data-reviews-total>
1475 <?php
1476 /* translators: %s - total review count formatted with commas */
1477 printf(esc_html__('Based on %s reviews', 'fluent-cart'), esc_html(number_format_i18n($summary['total'])));
1478 ?>
1479 </div>
1480 </div>
1481 </div>
1482
1483
1484 <div class="fct-reviews-summary-right">
1485 <?php foreach ([5, 4, 3, 2, 1] as $star) :
1486 $count = $summary['breakdown'][$star] ?? 0;
1487 $percentage = $summary['total'] > 0 ? round(($count / $summary['total']) * 100) : 0;
1488 ?>
1489 <div class="fct-reviews-bar-row" data-reviews-bar-row>
1490 <div class="fct-reviews-bar-track">
1491 <div class="fct-reviews-bar-fill" data-reviews-bar-fill style="width: <?php echo esc_attr($percentage); ?>%"></div>
1492 </div>
1493 <span class="fct-reviews-bar-label"><?php echo esc_html(number_format_i18n($star, 1)); ?> <span class="fct-bar-star-icon"><?php echo ReviewThreadMarkup::starSvg(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></span></span>
1494 <span class="fct-reviews-bar-count" data-reviews-bar-count><?php echo esc_html(number_format_i18n($count)); ?></span>
1495 </div>
1496 <?php endforeach; ?>
1497 </div>
1498 <?php if ($withCta) : ?>
1499 <?php $this->renderWriteReviewCta(); ?>
1500 <?php endif; ?>
1501 </div>
1502 <?php
1503 }
1504
1505 /**
1506 * The "Write a Review" / "Edit your review" / "Log in to Review" trigger
1507 * that opens the shared review drawer. Renders wherever a caller asks —
1508 * the rating summary (behind its toggle) and every Write a Review block.
1509 * ReviewForm.js binds triggers by delegation, so any of them opens the
1510 * product's single drawer.
1511 */
1512 public function renderWriteReviewCta()
1513 {
1514 if (!$this->shouldRenderReviews()) {
1515 return;
1516 }
1517
1518 $permissionMode = $this->settings['review_permission_mode'] ?? 'verified_buyers';
1519 $userId = get_current_user_id();
1520 $needsLogin = (!$this->orderGrant() && $permissionMode !== 'anyone' && !$userId);
1521
1522 if ($needsLogin) {
1523 $loginUrl = wp_login_url($this->loginRedirectUrl());
1524 ?>
1525 <a href="<?php echo esc_url($loginUrl); ?>" class="fct-review-cta-btn fct-reviews-summary-write-btn">
1526 <?php
1527 $loginText = trim((string) Arr::get($this->renderOptions, 'ctaLoginText', ''));
1528 echo $loginText !== '' ? esc_html($loginText) : esc_html__('Log in to Review', 'fluent-cart');
1529 ?>
1530 </a>
1531 <?php
1532 } else {
1533 $existingReview = $this->existingReviewFor($userId);
1534 $isEditMode = !empty($existingReview);
1535 ?>
1536 <?php
1537 // Both labels are rendered and one is hidden, so the script can
1538 // flip the button the moment a review is saved — the page is not
1539 // reloaded, and the visitor's next click must open their review
1540 // for editing, not offer to write another.
1541 $addText = trim((string) Arr::get($this->renderOptions, 'ctaAddText', ''));
1542 $editText = trim((string) Arr::get($this->renderOptions, 'ctaEditText', ''));
1543 ?>
1544 <?php // Names the form this button owns — see $instanceId. ?>
1545 <button type="button" class="fct-review-cta-btn fct-reviews-summary-write-btn" data-open-review-drawer data-post-id="<?php echo esc_attr($this->postId); ?>" data-review-form-target="<?php echo esc_attr($this->instanceId); ?>" data-item-id="<?php echo esc_attr($this->itemId()); ?>">
1546 <span class="fct-review-cta-label" data-cta-add<?php echo $isEditMode ? ' hidden' : ''; ?>>
1547 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="currentColor" width="16" height="16" aria-hidden="true" focusable="false"><path d="M19.431 1.648a1.12 1.12 0 0 0-1.53.416l-.001.002l-3.972 6.88l-.48-.209a5.42 5.42 0 0 0-6.752 2.05l-.007.011l-.007.012l-.002-.001l-5.2 8.68l-.005.007v.008h-.002a3.506 3.506 0 0 0 1.279 4.78a3.44 3.44 0 0 0 2.05.464l-.058.102l-.001.001a2.8 2.8 0 0 0-.366 1.162l-.38 3.443v.008c-.063.813.848 1.326 1.511.87l.01-.006l2.752-2.082c.286-.202.535-.46.725-.754v.01a3.5 3.5 0 0 0 6.322 2.07q.19.18.405.34c1.04.768 2.399 1.09 3.783 1.09h9.24a2.24 2.24 0 0 0 2.226-1.99h.024V27.06h.002V18.2a2.82 2.82 0 0 0-1.704-2.585l-10.75-4.666l3.685-6.387l.002-.003a1.12 1.12 0 0 0-.416-1.53l-.003-.001l-2.375-1.378zm-6.508 9.038l-2.656 4.6a2.85 2.85 0 0 0-1.246 1.169l-3.208 5.551a1.52 1.52 0 0 1-1.308.757a1.45 1.45 0 0 1-.742-.2l-.004-.002A1.5 1.5 0 0 1 3.2 20.5l5.18-8.646a3.42 3.42 0 0 1 4.261-1.289h.004zm2.173 6.238l2.443-4.234L28.5 17.45l-.003-.004a.81.81 0 0 1 .5.753v.794h-.002v8.02h-5.81q-.382-.001-.748-.056l-.643-.141a4.57 4.57 0 0 1-2.421-1.693c-.8-1.094-2.27-1.37-3.414-.735l-.001.001c-.69.385-1.506.84-2.148 1.2l-1.113.62l-.011.007a2.005 2.005 0 0 1-2.735-.734a2.006 2.006 0 0 1 .736-2.735l4.225-2.44a1 1 0 0 0 .663-.857a4.3 4.3 0 0 0-.479-2.526m-9.567 8.58l2.585 1.506a1.8 1.8 0 0 1-.427.424l-.007.005l-1.515 1.146l-1.001-.583l.208-1.884v-.011c.02-.214.073-.418.157-.603M19.392 7.477l-2.6-1.492l.976-1.692l2.595 1.5z"/></svg>
1548 <?php echo $addText !== '' ? esc_html($addText) : esc_html__('Write a Review', 'fluent-cart'); ?>
1549 </span>
1550 <span class="fct-review-cta-label" data-cta-edit<?php echo $isEditMode ? '' : ' hidden'; ?>>
1551 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16" aria-hidden="true" focusable="false"><path d="M12 20h9"/><path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z"/></svg>
1552 <?php echo $editText !== '' ? esc_html($editText) : esc_html__('Edit your review', 'fluent-cart'); ?>
1553 </span>
1554 </button>
1555 <?php
1556 }
1557 }
1558
1559 protected function renderControls($defaultSort = 'created_at-DESC', $showChips = true, $showSort = true)
1560 {
1561 $minRating = (int) Arr::get($this->renderOptions, 'minRating', 0);
1562 $minRating = ($minRating >= 1 && $minRating <= 5) ? $minRating : 0;
1563 $sortOptions = [
1564 'created_at-DESC' => __('Newest', 'fluent-cart'),
1565 'created_at-ASC' => __('Oldest', 'fluent-cart'),
1566 'rating-DESC' => __('Highest Rating', 'fluent-cart'),
1567 'rating-ASC' => __('Lowest Rating', 'fluent-cart'),
1568 ];
1569 $sortOptions = apply_filters('fluent_cart/review/sort_options', $sortOptions);
1570 ?>
1571 <div class="fct-reviews-controls" data-reviews-controls>
1572 <?php if ($showChips) : ?>
1573 <div class="fct-reviews-filter-chips" data-reviews-filter-chips>
1574 <button type="button" class="fct-filter-chip active" data-filter-chip="all" aria-pressed="true"><?php esc_html_e('All', 'fluent-cart'); ?></button>
1575 <?php foreach ([5, 4, 3, 2, 1] as $star) : ?>
1576 <?php if ($star < $minRating) { continue; } ?>
1577 <button type="button" class="fct-filter-chip" data-filter-chip="<?php echo esc_attr($star); ?>" aria-pressed="false"><?php echo esc_html($star); ?> <?php echo ReviewThreadMarkup::starSvg(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></button>
1578 <?php endforeach; ?>
1579 <?php do_action('fluent_cart/review/filter_chips', $this->postId); ?>
1580 </div>
1581 <?php endif; ?>
1582
1583 <?php if ($showSort) : ?>
1584 <div class="fct-reviews-sort">
1585 <select data-reviews-sort aria-label="<?php esc_attr_e('Sort reviews', 'fluent-cart'); ?>">
1586 <?php foreach ($sortOptions as $value => $label) : ?>
1587 <option value="<?php echo esc_attr($value); ?>" <?php selected($defaultSort, $value); ?>><?php echo esc_html($label); ?></option>
1588 <?php endforeach; ?>
1589 </select>
1590 </div>
1591 <?php endif; ?>
1592 </div>
1593 <?php
1594 }
1595
1596 /**
1597 * The average-star row as a string — the summary endpoint returns it so
1598 * the storefront script swaps markup instead of assembling it.
1599 *
1600 * @param float|int $rating
1601 * @return string
1602 */
1603 public function starsHtml($rating): string
1604 {
1605 ob_start();
1606 $this->renderStars($rating);
1607
1608 return (string) ob_get_clean();
1609 }
1610
1611 protected function renderStars($rating)
1612 {
1613 $fullStars = floor($rating);
1614 $halfStar = ($rating - $fullStars) >= 0.5;
1615 $emptyStars = 5 - $fullStars - ($halfStar ? 1 : 0);
1616
1617 for ($i = 0; $i < $fullStars; $i++) {
1618 echo '<span class="fct-star fct-star-filled">' . ReviewThreadMarkup::starSvg() . '</span>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1619 }
1620 if ($halfStar) {
1621 echo '<span class="fct-star fct-star-half"><span class="fct-star-half-empty">' . ReviewThreadMarkup::starSvg() . '</span><span class="fct-star-half-fill">' . ReviewThreadMarkup::starSvg() . '</span></span>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1622 }
1623 for ($i = 0; $i < $emptyStars; $i++) {
1624 echo '<span class="fct-star fct-star-empty">' . ReviewThreadMarkup::starSvg() . '</span>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
1625 }
1626 }
1627
1628 /**
1629 * One tile dimension in pixels, or 0 to leave it to the stylesheet.
1630 *
1631 * @param mixed $value
1632 * @return int
1633 */
1634 public static function mediaTileSize($value): int
1635 {
1636 $size = (int) $value;
1637
1638 if ($size < 1) {
1639 return 0;
1640 }
1641
1642 return min(static::MAX_MEDIA_TILE_PX, max(16, $size));
1643 }
1644
1645 /**
1646 * How many tiles a gallery shows before the + tile, 0 for all of them.
1647 *
1648 * Capped at what a review may actually hold: showing 40 of a store that
1649 * allows 5 photos is a limit that never comes into play, and the number
1650 * an editor is offered should mean something.
1651 *
1652 * @param mixed $value
1653 * @return int
1654 */
1655 public static function mediaVisibleCount($value): int
1656 {
1657 return max(0, min(static::maxPhotosPerReview(), (int) $value));
1658 }
1659
1660 /**
1661 * The store's photos-per-review limit.
1662 *
1663 * PRO owns the setting and enforces it on upload; this reads the same
1664 * value so the display side agrees with it without depending on PRO being
1665 * installed. Floored at 1, the way PRO floors its own.
1666 *
1667 * @return int
1668 */
1669 public static function maxPhotosPerReview(): int
1670 {
1671 $settings = ProductReviewService::getReviewSettings();
1672
1673 return max(1, (int) Arr::get(
1674 $settings,
1675 'max_photos_per_review',
1676 static::DEFAULT_MAX_PHOTOS_PER_REVIEW
1677 ));
1678 }
1679 }
1680