PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.8.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.8.0
1.8.0 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 All 52 releases
fluent-cart / app / Services / Renderer / ProductReviewRenderer.php

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

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