PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.1
1.7.1 1.7.0 1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 All 51 releases
fluent-cart / app / Services / Renderer / ProductReviewRenderer.php

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

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