hex, 'empty' => hex] */ public static function starColors(): array { $defaults = [ 'filled' => self::DEFAULT_STAR_COLOR, 'empty' => self::DEFAULT_EMPTY_STAR_COLOR, ]; /** * Filter the store-wide review star colours. * * @param array $colors ['filled' => hex, 'empty' => hex] */ $filtered = apply_filters('fluent_cart/reviews/star_colors', $defaults); if (!is_array($filtered)) { return $defaults; } $colors = []; foreach ($defaults as $key => $default) { $hex = sanitize_hex_color((string) Arr::get($filtered, $key, '')); $colors[$key] = $hex ? strtolower($hex) : $default; } return $colors; } /** * The star colours the filter changed, as the variables every star reads. * * `:root:root` rather than `:root`: reviews.scss and product-card.scss * declare the defaults on `:root`, and either can load after the head, so * source order alone would not decide it. * * @return string CSS, or '' while the defaults stand. */ public static function starColorCss(): string { $colors = self::starColors(); $vars = [ 'filled' => ['--fct-review-star-color', self::DEFAULT_STAR_COLOR], 'empty' => ['--fct-review-star-empty-color', self::DEFAULT_EMPTY_STAR_COLOR], ]; $declarations = ''; foreach ($vars as $key => $var) { if ($colors[$key] !== $var[1]) { $declarations .= $var[0] . ':' . $colors[$key] . ';'; } } return $declarations === '' ? '' : ':root:root{' . $declarations . '}'; } protected $postId; protected $settings; protected $renderOptions; /** * Identifies this renderer's trigger and its form to each other. * * Two Write a Review blocks can point at the same product with different * settings — one a modal, one a drawer. Without a name tying each button * to its own form, every button for a product opens whichever form * happened to initialise first, so the modal button opens the drawer. * * Both call sites that emit a trigger render the matching form from the * same instance, so an instance-scoped id pairs them. * * @var string */ protected $instanceId; /** @var bool renderForm() has printed this instance's form */ protected $formRendered = false; protected static $instanceCount = 0; public function __construct($postId, $options = []) { $this->postId = $postId; $this->instanceId = 'fct-review-form-' . $postId . '-' . (++static::$instanceCount); $this->settings = ProductReviewService::getReviewSettings(); $showVerifiedBadge = !isset($this->settings['show_verified_badge']) || $this->settings['show_verified_badge'] === 'yes'; $this->renderOptions = apply_filters('fluent_cart/review/renderer_options', wp_parse_args($options, [ 'showSummary' => true, 'summaryMode' => 'side', 'showCount' => true, 'showSortControls' => true, // The star chips, separately from the sort control beside them. // One flag covered both because the blocks only ever wanted both; // the shortcode can ask for a list that filters but does not sort, // or the reverse. Without an explicit chips option, preserve the // old flag's behavior for existing renderer and block callers. 'showFilterChips' => Arr::get($options, 'showSortControls', true), // Only reviews carrying photos. A query filter, not a display one: // the rest never reach the page, so the count and the pager agree // with what is on screen. 'hasMedia' => false, 'showVerifiedBadge' => $showVerifiedBadge, 'showReviewDate' => true, 'showReviewerName' => true, 'showAvatar' => true, 'showTitle' => true, 'showContent' => true, 'showPhotos' => true, 'showFooter' => true, 'showVariation' => true, 'starColor' => '#f59e0b', 'defaultSortBy' => 'created_at', 'defaultSortOrder' => 'DESC', 'perPage' => 0, 'showViewReply' => true, // The form has two independent axes. 'layout' paces the fields: // 'steps' walks a wizard, 'inline' shows everything at once. // 'container' is the chrome: a side 'drawer', a centred 'modal', // or 'none' — printed straight onto the page with no trigger. 'layout' => 'inline', 'container' => 'drawer', // An order uuid that proves the visitor bought this product. Set // by the public order-review page, where a guest reaches the form // through an emailed link and has no account to be logged into. 'orderHash' => '', // The variation this placement reviews, 0 for the product. Set // only by the order-review page, one row per line item; the // form prints it as data-item-id and ReviewForm.js sends it back // in the request body. 'itemId' => 0, // A caller that builds the rows itself — the Review List block when // an editor has filled it with blocks. Unset, the renderer draws // its own rows, which is what the shortcode and the product page // get. 'rows_renderer' => null, // The lowest rating the list shows, lifted off the Review Item // block. 0 is every review. 'minRating' => 0, // 'numbers' | 'fraction' | 'bullets'. Printed on the container so // the script can send it back: it re-renders the pager on every // page change, and without this the first click would replace the // chosen pager with the default one. 'paginationType' => 'numbers', // Words a review shows before a Read more, 0 for all of them. // Printed on the container beside paginationType and for the same // reason: the script re-renders the rows on every page change. 'maxWords' => 0, // The review attachments on a row this renderer draws // itself. mediaVisible is how many tiles show before the + tile // takes over (0 for all of them), mediaWidth and mediaHeight the // tile size in pixels (0 for the stylesheet's own). Printed on the // container alongside maxWords and for the same reason: the script // re-renders the rows on every page change. 'mediaVisible' => 0, 'mediaWidth' => 0, 'mediaHeight' => 0, 'mediaFullWidth' => false, // Pro. A flush tile becomes the top of the card and a backdrop // fills it; ReviewThreadMarkup refuses both without Pro, so these // travel as intent and the gate stays in one place. 'mediaFlush' => false, 'mediaBackdrop' => false, 'mediaMore' => 'overlay', // How a row this renderer draws is arranged, mirroring // LayoutPresets::row(). Printed on the container beside the media // settings and for the same reason: the script re-renders the // rows on every page change. 'photosFirst' => false, 'ratingFirst' => false, 'badgeLast' => false, 'showMeta' => true, 'itemClass' => '', // 'list' stacks the rows full width; 'grid' lays them out in // gridColumns columns; 'slider' puts that same row of columns in a // Swiper. The class goes on the list element itself, which the // storefront script empties and refills rather than replaces — so // the layout survives a page change without having to travel with // the request the way paginationType does. 'viewMode' => 'list', 'gridColumns' => 2, 'sliderSettings' => [ 'pagination' => 'no', 'paginationType'=> 'bullets', ], // Where the reviews endpoint can find the same composed row // again. Set only by the Review List block, and only when it has // children to compose. ]), $this->postId); } /** * The item this placement is bound to, 0 for the product as a whole. */ /** * @var array existingReviewFor() results by user */ protected $existingReviewMemo = []; protected function itemId(): int { return max(0, (int) Arr::get($this->renderOptions, 'itemId', 0)); } /** * The signed-in visitor's own review in this placement's (product, item) * slot — what decides between "Write a review" and "Edit your review". * Scoped to the slot the same way the duplicate guard is, so the two * never disagree: a review of Red does not put Blue's form in edit mode, * and neither puts the product-level form there. * * @param int $userId * @return ProductReview|null */ protected function existingReviewFor($userId) { // A page that has already settled the answer for every row it // renders — the order-review page, whose rows are pending precisely // because no review exists in the slot — hands it in and no query // runs at all. if (array_key_exists('existingReview', $this->renderOptions)) { return $this->renderOptions['existingReview']; } $userId = $this->editableReviewUserId($userId); if (!$userId) { return null; } // The CTA and the form of one placement ask the same question; one // query per renderer answers both. $memoKey = (int) $userId; if (array_key_exists($memoKey, $this->existingReviewMemo)) { return $this->existingReviewMemo[$memoKey]; } // topLevel() is load-bearing, not tidiness. A reply is a row on the // same product carrying the same user_id, so without it the first // match can be the visitor's reply to someone else's review — a store // owner who has answered a review being the ordinary case. The form // then opens bound to that reply: prefilled with its text, no rating, // and saving edits the reply instead of the review. // // whereNull('parent_id') is the topLevel() scope spelled out: model // scopes resolve through Builder::__call(), which static analysis // cannot see. Keep in step with that scope's predicate. return $this->existingReviewMemo[$memoKey] = ProductReviewService::scopeToItem(ProductReview::query(), $this->itemId()) ->whereNull('parent_id') ->where('post_id', $this->postId) ->where('user_id', $userId) ->whereIn('status', [Status::REVIEW_APPROVED, Status::REVIEW_PENDING]) ->first(); } /** * Whether this product's reviews should be rendered. * * Reviews have to be enabled — the store switch and the product's own * toggle. On a single product page the page's display switch applies as * well, so turning it off clears the section however the page renders * it. Elsewhere the switch does not apply: a review block on another page * is a deliberate placement, which is how the relevant-products shortcode * behaves too. * * @return bool */ protected function shouldRenderReviews(): bool { return static::isVisibleFor($this->postId); } /** * Whether the current page shows a product's reviews at all. The one * policy for everything that speaks about reviews on a page — the list * and the form here, the JSON-LD in ProductSchema — so none of them can * name a review the page does not render. * * @param int $postId */ public static function isVisibleFor($postId): bool { // isReviewEnabledForProduct checks Enable Product Reviews — the module // switch in store settings — first, then the product's own toggle. if (!ProductReviewService::isReviewEnabledForProduct($postId)) { return false; } if (is_singular(FluentProducts::CPT_NAME)) { return TemplateActions::shouldShowReviewsOnProductPage($postId); } return true; } public function render() { if (!$this->shouldRenderReviews()) { return; } $summary = ProductReviewService::getProductRatingSummary($this->postId); $restInfo = Helper::getRestInfo(); $perPageOption = (int) Arr::get($this->renderOptions, 'perPage', 0); $perPage = $perPageOption > 0 ? $perPageOption : $this->settings['reviews_per_page']; $starColor = sanitize_hex_color(Arr::get($this->renderOptions, 'starColor')) ?: self::DEFAULT_STAR_COLOR; $defaultSort = Arr::get($this->renderOptions, 'defaultSortBy', 'created_at') . '-' . Arr::get($this->renderOptions, 'defaultSortOrder', 'DESC'); $showSummary = (bool) Arr::get($this->renderOptions, 'showSummary', true); // showReviewCount was used briefly by the first adapter draft. Keep it // as a fallback for callers that already passed it, while the shared // builder contract uses the shorter showCount name. $showCount = (bool) Arr::get( $this->renderOptions, 'showCount', Arr::get($this->renderOptions, 'showReviewCount', true) ); $summaryMode = Arr::get($this->renderOptions, 'summaryMode', 'side'); $summaryMode = in_array($summaryMode, ['side', 'top', 'cta'], true) ? $summaryMode : 'side'; $showSortControls = (bool) Arr::get($this->renderOptions, 'showSortControls', true); $showFilterChips = (bool) Arr::get($this->renderOptions, 'showFilterChips', true); $hasMedia = (bool) Arr::get($this->renderOptions, 'hasMedia', false); $showVerifiedBadge = (bool) Arr::get($this->renderOptions, 'showVerifiedBadge', true); $showReviewDate = (bool) Arr::get($this->renderOptions, 'showReviewDate', true); $showReviewerName = (bool) Arr::get($this->renderOptions, 'showReviewerName', true); $showViewReply = (bool) Arr::get($this->renderOptions, 'showViewReply', true); $minRating = (int) Arr::get($this->renderOptions, 'minRating', 0); $minRating = ($minRating >= 1 && $minRating <= 5) ? $minRating : 0; $viewMode = Arr::get($this->renderOptions, 'viewMode'); $viewMode = ProductReviewService::resolveViewMode($viewMode); // The slider lays its slides out in columns too, so both modes carry // the column count and both drop the row's own bottom margin. $isGrid = $viewMode === 'grid'; $isSlider = $viewMode === 'slider'; // Masonry is columns too — CSS columns rather than grid ones, but it // reads the same --fct-review-columns and wants the same modifier. $hasColumns = $isGrid || $isSlider || $viewMode === 'masonry'; // Two to six. One column is the list view. Four is as many as a card // carrying words can be cut into before a review is too narrow to read, // and the column control offers no more than that — but a row of // photographs is not read, and Photo Strip asks for six. A preset can // therefore ask past what the control offers; a merchant cannot. $gridColumns = min(6, max(2, (int) Arr::get($this->renderOptions, 'gridColumns', 2))); // In grid view the page has to hold whole rows. Three per row with a // page of two leaves a row that is permanently one short and a pager // that looks broken — the reader sees two of three reviews with an // empty column beside them. Rounded down, so a page never shows more // than was asked for, but never below a single full row. if ($isGrid) { $perPage = max($gridColumns, $perPage - ($perPage % $gridColumns)); } // A slider with nothing to slide is the default case to avoid: two per // slide against the store's page of two puts every review on screen at // once and the arrows do nothing. So when no page length was chosen, // the slider takes three screens' worth instead of the store's number. // // Only when none was chosen. A page length set on the Review // Pagination block, in the shortcode or on the widget is the editor // saying how many reviews a page holds, and raising it past that gives // them a page they did not ask for while leaving the pager with fewer // pages than it should have — the slider swallowing its own // pagination. A slider that ends up with nothing to slide says so // instead: the arrows stay and mark themselves disabled. // // Three screens' worth, and no further. Taking the endpoint's maximum // would have every product page fetch, process and render up to a // hundred reviews to guarantee a second slide. if ($isSlider && $perPageOption <= 0) { $perPage = max($perPage, $gridColumns * 3); } $sliderSettings = static::normalizeSliderSettings( (array) Arr::get($this->renderOptions, 'sliderSettings', []) ); // Swiper is 150kb of JS and CSS, so it loads only where a slider // actually is — not on every product page that shows reviews. if ($isSlider) { static::enqueueSliderAssets(); } $maxWords = max(0, (int) Arr::get($this->renderOptions, 'maxWords', 0)); $mediaVisible = static::mediaVisibleCount(Arr::get($this->renderOptions, 'mediaVisible', 0)); $mediaWidth = static::mediaTileSize(Arr::get($this->renderOptions, 'mediaWidth', 0)); $mediaHeight = static::mediaTileSize(Arr::get($this->renderOptions, 'mediaHeight', 0)); $mediaFullWidth = (bool) Arr::get($this->renderOptions, 'mediaFullWidth', false); $mediaFlush = (bool) Arr::get($this->renderOptions, 'mediaFlush', false); $mediaBackdrop = (bool) Arr::get($this->renderOptions, 'mediaBackdrop', false); $photosFirst = (bool) Arr::get($this->renderOptions, 'photosFirst', false); $ratingFirst = (bool) Arr::get($this->renderOptions, 'ratingFirst', false); $badgeLast = (bool) Arr::get($this->renderOptions, 'badgeLast', false); $showMeta = (bool) Arr::get($this->renderOptions, 'showMeta', true); $itemClass = ReviewListRenderer::itemClass(Arr::get($this->renderOptions, 'itemClass', '')); $mediaMore = ReviewThreadMarkup::moreTilePlacement(Arr::get($this->renderOptions, 'mediaMore', 'overlay')); $paginationType = (string) Arr::get($this->renderOptions, 'paginationType', 'numbers'); $paginationType = in_array($paginationType, ReviewListRenderer::paginationTypes(), true) ? $paginationType : 'numbers'; // The first page renders on the server, so the list is real content // with scripting disabled and the script only takes over for sort, // filters and pagination. Same payload pipeline as the REST endpoint. $sortParts = explode('-', (string) $defaultSort); $initialPayload = ProductReviewService::getPublicReviewsPayload([ 'post_id' => $this->postId, 'status' => 'approved', 'sort_by' => $sortParts[0] ?: 'created_at', 'sort_order' => isset($sortParts[1]) && $sortParts[1] ? $sortParts[1] : 'DESC', 'per_page' => $perPage, 'min_rating' => $minRating, 'has_media' => $hasMedia, // Explicit first page — without it the paginator resolves `page` // from the ambient request, so a stray ?page=2 on the product URL // would render a later review page while the script starts at 1. 'page' => 1, ], $this->postId); // What the count says, matching what the list is showing. The summary's // own total is the product's and stays that way for the breakdown // aside; a floor changes which reviews this list holds, so "N Reviews" // has to follow it. Reviews.js sets the same number from the list // response on every sort, filter and page, so the two agree from the // first render onward. $displayTotal = $minRating || $hasMedia ? (int) Arr::get($initialPayload, 'reviews.total', 0) : (int) Arr::get((array) $summary, 'total', 0); $initialListRenderer = new ReviewListRenderer([ 'show_reviewer' => $showReviewerName, 'show_date' => $showReviewDate, 'show_verified' => $showVerifiedBadge, 'show_view_reply' => $showViewReply, 'show_avatar' => (bool) Arr::get($this->renderOptions, 'showAvatar', true), 'show_title' => (bool) Arr::get($this->renderOptions, 'showTitle', true), 'show_content' => (bool) Arr::get($this->renderOptions, 'showContent', true), 'show_photos' => (bool) Arr::get($this->renderOptions, 'showPhotos', true), 'show_footer' => (bool) Arr::get($this->renderOptions, 'showFooter', true), 'show_variation' => (bool) Arr::get($this->renderOptions, 'showVariation', true), 'can_thread' => ProductReviewService::isMultipleRepliesAllowed() && is_user_logged_in(), 'pagination_type' => $paginationType, 'max_words' => $maxWords, 'media_visible' => $mediaVisible, 'media_width' => $mediaWidth, 'media_height' => $mediaHeight, 'media_full_width' => $mediaFullWidth, 'media_flush' => $mediaFlush, 'media_backdrop' => $mediaBackdrop, 'media_more' => $mediaMore, 'photos_first' => $photosFirst, 'rating_first' => $ratingFirst, 'badge_last' => $badgeLast, 'show_meta' => $showMeta, 'item_class' => $itemClass, ]); // Computed before the blocks render, because the pager block draws this // and the blocks are what render next. $initialPagination = $initialListRenderer->paginationHtml(Arr::get($initialPayload, 'reviews', [])); // The blocks an editor placed, rendered once each in the order they sit // in. What they drew is read back afterwards so the renderer can fill // in the parts they left out. $composedBody = ''; $composedDrew = ['header' => false, 'list' => false, 'pagination' => false]; $rowsRenderer = Arr::get($this->renderOptions, 'rows_renderer'); /** * Whether an editor built this list out of blocks. * * The difference between a part that is missing and a part that was * taken away. A list nobody has composed — the shortcode, the * single-product template, a Review List block left empty — has no * blocks to say what it wants, so the renderer draws the whole thing. * A composed list has already said: its blocks are the answer, and a * header absent from them is a header an editor removed. */ $isComposed = is_callable($rowsRenderer); if ($isComposed) { $composedBody = (string) call_user_func( $rowsRenderer, Arr::get($initialPayload, 'reviews.data', []), [ 'header' => [ 'post_id' => $this->postId, 'total' => $displayTotal, 'min_rating' => $minRating, 'default_sort' => (string) $defaultSort, ], 'pagination' => [ 'inner_html' => $initialPagination, ], ] ); foreach (array_keys($composedDrew) as $part) { $composedDrew[$part] = InnerBlocks::wasDrawn($part); } } $initialRows = $composedDrew['list'] ? '' : $initialListRenderer->renderReviewItems(Arr::get($initialPayload, 'reviews.data', [])); $initialLastPage = (int) Arr::get($initialPayload, 'reviews.last_page', 1); ?> postId, $this->renderOptions); $extraAttrHtml = ''; foreach ($extraDataAttrs as $attrKey => $attrVal) { $extraAttrHtml .= ' data-' . esc_attr($attrKey) . '="' . esc_attr($attrVal) . '"'; } ?>
data-only-with-photos="" isDefaultStarColor($starColor)) : ?> style="--fct-star-color: " >

' . esc_html($displayTotal) . '' ); ?>

renderControls($defaultSort, $showFilterChips, $showSortControls); ?>
style="--fct-review-columns: " data-reviews-slider="" data-slider-settings="" data-reviews-list>
renderOptions, 'showPagination', true) && (!$isSlider || $initialLastPage > 1)) : ?>
renderForm(); ?> renderThreadModalShell(); ?> postId); ?>
in_array($autoplay, ['no', 'yes', 'hover'], true) ? $autoplay : 'no', // Floored at 300ms: below that the slides move faster than they // can be read, which is a carousel nobody can use. 'autoplayDelay' => min(10000, max(300, (int) Arr::get($settings, 'autoplayDelay', 3000))), 'arrows' => Arr::get($settings, 'arrows') === 'no' ? 'no' : 'yes', 'arrowsSize' => in_array($arrowsSize, ['sm', 'md', 'lg'], true) ? $arrowsSize : 'md', // Where the arrows sit: over the first and last card, clear of // the track on either side, or in a row beneath it. 'arrowsPosition' => in_array(Arr::get($settings, 'arrowsPosition'), ['overlap', 'outside', 'bottom'], true) ? Arr::get($settings, 'arrowsPosition') : 'overlap', 'pagination' => Arr::get($settings, 'pagination') === 'yes' ? 'yes' : 'no', 'paginationType' => in_array(Arr::get($settings, 'paginationType'), ['bullets', 'fraction', 'progressbar', 'segmented'], true) ? Arr::get($settings, 'paginationType') : 'bullets', 'infinite' => Arr::get($settings, 'infinite') === 'yes' ? 'yes' : 'no', ]; } /** * Swiper, for the slider view only. * * The same bundle the product carousel uses, enqueued the same way — one * copy of the library, whichever block asks for it first. */ protected static function enqueueSliderAssets(): void { static $enqueued = false; if ($enqueued) { return; } $enqueued = true; $slug = fluentCart()->config->get('app.slug'); Vite::enqueueStaticScript( $slug . '-fluentcart-swiper-js', 'public/lib/swiper/swiper-bundle.min.js', [$slug . '-app'] ); Vite::enqueueStaticStyle( $slug . '-fluentcart-swiper-css', 'public/lib/swiper/swiper-bundle.min.css' ); } /** * Empty shell for the review thread modal. * * Printed hidden and shown by the storefront script, the same split the * cart drawer uses for its loader: the markup lives in PHP, the script * only toggles it and swaps in the panel fetched from the modal * endpoint. It carries no review data, so nothing here can drift from * ReviewModalRenderer. */ protected function renderThreadModalShell() { ?> shouldRenderReviews()) { return; } // One form per renderer. The single-product template asks twice — // once through render(), whose summary CTA needs the drawer it // opens, and once on its own — and both carry this instance's id, // so a second copy would leave the trigger bound to whichever // registered last while the other sat unused in the page. if ($this->formRendered) { return; } $this->formRendered = true; $restInfo = Helper::getRestInfo(); $starColor = sanitize_hex_color(Arr::get($this->renderOptions, 'starColor')) ?: self::DEFAULT_STAR_COLOR; $permissionMode = $this->settings['review_permission_mode'] ?? 'verified_buyers'; $userId = get_current_user_id(); // A valid order grant is proof of purchase, so it answers the same // question logging in would — see ProductReviewService::resolveOrderGrant(). $needsLogin = (!$this->orderGrant() && $permissionMode !== 'anyone' && !$userId); $layout = $this->formLayout(); $container = $this->formContainer(); $isStepped = ($layout === 'steps'); // Only compute form state when the user can actually use it $extraFieldsHtml = ''; $hasPhotosStep = false; $starEnabled = ProductReviewService::isStarRatingEnabled(); $existingReview = null; $isEditMode = false; // Photos are attached after the review submission is authorized, // including submissions authorized by an order grant. if (!$needsLogin) { // Check if Pro photo step has content. // // The review itself rides along as a third argument: a consumer // that has to re-derive "the visitor's review" from (post, user) // will get it wrong in the two ways this renderer already handles // — a reply matches those columns, and so does the same person's // review of a different variation. Handing over the row this form // is actually bound to removes the question. Memoized, so asking // here costs nothing; null when the form is writing a new review. ob_start(); do_action( 'fluent_cart/review/form_extra_fields', $this->postId, $this->editableReviewUserId($userId), $this->existingReviewFor($userId) ); $extraFieldsHtml = trim(ob_get_clean()); $hasPhotosStep = !empty($extraFieldsHtml); } // Calculate total steps: Rating (if enabled) + Details + Photos (if Pro) $totalSteps = 1; // Details is always present if ($starEnabled) { $totalSteps++; } if ($hasPhotosStep) { $totalSteps++; } // Inline has no wizard: every field is on screen at once, so the whole // form is a single step and ReviewForm.js validates it as one. if (!$isStepped) { $totalSteps = 1; } // Check if logged-in user already has a review for this product if (!$needsLogin) { $existingReview = $this->existingReviewFor($userId); } $isEditMode = !empty($existingReview); ?>
data-edit-mode="1" data-review-id="id); ?>" data-existing-rating="rating); ?>" data-existing-title="title); ?>" data-existing-content="content); ?>" isDefaultStarColor($starColor)) : ?> style="--fct-star-color: " > renderLoginRequired(); ?> renderStandalone($userId, $extraFieldsHtml, $hasPhotosStep, $totalSteps, $isStepped, $isEditMode); ?> renderOverlay($container, $userId, $extraFieldsHtml, $hasPhotosStep, $totalSteps, $isStepped, $isEditMode); ?> postId); ?>
instanceId; ?> instanceId; ?>
renderStepper($hasPhotos, $totalSteps, $isStepped); ?> renderFields($userId, $extraFieldsHtml, $hasPhotos, $isStepped, $idSuffix); ?>
loginRedirectUrl()); ?>
renderOptions, 'ctaLoginText', '')); echo $loginText !== '' ? esc_html($loginText) : esc_html__('Log in to Review', 'fluent-cart'); ?>
1
2
3
1
2
itself. Stepped wraps each group in a data-review-step div * the wizard shows and hides; inline drops the same groups in flat. */ protected function renderFields($userId, $extraFieldsHtml, $hasPhotos, $isStepped, $idSuffix) { $currentUser = $userId ? get_userdata($userId) : null; // Through the service, not the settings array: isStarRatingRequired() // already folds in "ratings are off entirely", and reading the two // keys here is how the storefront and the server paths drifted apart // before. $starEnabled = ProductReviewService::isStarRatingEnabled(); $starRequired = ProductReviewService::isStarRatingRequired(); $groupClass = $isStepped ? 'fct-review-step' : 'fct-review-inline-group'; ?>
orderGrant() ? (string) Arr::get($this->renderOptions, 'orderHash', '') : ''; ?>
> renderRatingField($starRequired); ?>
data-review-step="" > renderDetailsFields($userId, $currentUser, $idSuffix); ?>
data-review-step="" style="display:none;" >

__('Poor', 'fluent-cart'), 2 => __('Average', 'fluent-cart'), 3 => __('Good', 'fluent-cart'), 4 => __('Very Good', 'fluent-cart'), 5 => __('Excellent', 'fluent-cart'), ]; // Poor reads as a failure, Average a caution, Good neither, the top // two a success. $ratingTypes = [1 => 'danger', 2 => 'warning', 3 => 'info', 4 => 'success', 5 => 'success']; ?>
orderGrant(); $grantOwns = ProductReviewService::grantOwnsSubmission($grant); $grantIdentity = $grantOwns ? ProductReviewService::orderGrantIdentity($grant) : null; $grantName = $grantIdentity ? Arr::get($grantIdentity, 'name', '') : ''; // An order whose customer row is gone resolves to no email, and the // submit path then falls back to the typed fields — so the form has to // show them rather than a byline the visitor cannot supply. Signed in // or not: the submit path files that review under nobody's account, // so the visitor's own name and address are not offered either. if ($grantIdentity && trim((string) Arr::get($grantIdentity, 'email', '')) === '') { $grantIdentity = null; } $typedIdentity = !$userId || ($grantOwns && !$grantIdentity); ?>

' . esc_html($grantName) . ''); ?>

0 / 80
0 / 1500
renderOptions, 'layout'); return $layout === 'inline' ? 'inline' : 'steps'; } /** * The chrome around the fields: a side drawer, a centred modal, or none * at all (printed straight onto the page). * * @return string */ /** * The default in any letter case is still the default: a colour picker * may hand back #F59E0B for the value the block stored as #f59e0b. * * @param string $starColor * @return bool */ protected function isDefaultStarColor($starColor): bool { return strtolower((string) $starColor) === self::DEFAULT_STAR_COLOR; } protected function formContainer(): string { $container = Arr::get($this->renderOptions, 'container'); return in_array($container, ['drawer', 'modal', 'none'], true) ? $container : 'drawer'; } /** * The order this placement's hash names, when that order contains this * product. Non-null means the visitor may review it whatever the store's * permission mode says and whether or not they are logged in. * * @return \FluentCart\App\Models\Order|null */ /** * Where a login link brings the visitor back to. The page they are on * when one exists — the order-review page in particular is reached from * an email and is not the product page, so a link there must not drop * them on the product afterwards — otherwise the product. * * @return string */ protected function loginRedirectUrl(): string { if (Arr::get($this->renderOptions, 'orderHash', '') !== '') { $current = ProductReviewService::currentRequestUrl(); if ($current !== '') { return $current; } } $queriedId = is_singular() ? get_queried_object_id() : 0; $redirect = $queriedId ? get_permalink($queriedId) : get_permalink($this->postId); return $redirect ?: home_url('/'); } /** * The account whose existing review the form would open for editing. * * When an order grant owns the submission the review posts as the buyer, * whoever is signed in — so the visitor's own review of this product is * not the one this form edits. Seeding it would put the form in edit * mode and have it update the visitor's review under a byline naming the * buyer. The buyer signed in to their own account keeps their edit flow. * * @param int $userId * @return int 0 when no account's review is editable here */ protected function editableReviewUserId($userId) { if ($userId && ProductReviewService::grantOwnsSubmission($this->orderGrant())) { return 0; } return (int) $userId; } protected function orderGrant() { return ProductReviewService::resolveOrderGrant( $this->postId, Arr::get($this->renderOptions, 'orderHash', ''), $this->itemId() ); } /** * The rating summary card on its own — the standalone Rating Summary * block. Its summary-only marker refreshes these numbers without * initializing a review list. */ public function renderSummarySection() { if (!$this->shouldRenderReviews()) { return; } $summary = ProductReviewService::getProductRatingSummary($this->postId); $restInfo = Helper::getRestInfo(); $starColor = sanitize_hex_color(Arr::get($this->renderOptions, 'starColor')) ?: self::DEFAULT_STAR_COLOR; ?>
isDefaultStarColor($starColor)) : ?> style="--fct-star-color: " > renderRatingSummary($summary, false); ?>
/5
0 ? round(($count / $summary['total']) * 100) : 0; ?>
renderWriteReviewCta(); ?>
shouldRenderReviews()) { return; } $permissionMode = $this->settings['review_permission_mode'] ?? 'verified_buyers'; $userId = get_current_user_id(); $needsLogin = (!$this->orderGrant() && $permissionMode !== 'anyone' && !$userId); if ($needsLogin) { $loginUrl = wp_login_url($this->loginRedirectUrl()); ?> renderOptions, 'ctaLoginText', '')); echo $loginText !== '' ? esc_html($loginText) : esc_html__('Log in to Review', 'fluent-cart'); ?> existingReviewFor($userId); $isEditMode = !empty($existingReview); ?> renderOptions, 'ctaAddText', '')); $editText = trim((string) Arr::get($this->renderOptions, 'ctaEditText', '')); ?> renderOptions, 'minRating', 0); $minRating = ($minRating >= 1 && $minRating <= 5) ? $minRating : 0; $sortOptions = [ 'created_at-DESC' => __('Newest', 'fluent-cart'), 'created_at-ASC' => __('Oldest', 'fluent-cart'), 'rating-DESC' => __('Highest Rating', 'fluent-cart'), 'rating-ASC' => __('Lowest Rating', 'fluent-cart'), ]; $sortOptions = apply_filters('fluent_cart/review/sort_options', $sortOptions); ?>
postId); ?>
renderStars($rating); return (string) ob_get_clean(); } protected function renderStars($rating) { $fullStars = floor($rating); $halfStar = ($rating - $fullStars) >= 0.5; $emptyStars = 5 - $fullStars - ($halfStar ? 1 : 0); for ($i = 0; $i < $fullStars; $i++) { echo '' . ReviewThreadMarkup::starSvg() . ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } if ($halfStar) { echo '' . ReviewThreadMarkup::starSvg() . '' . ReviewThreadMarkup::starSvg() . ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } for ($i = 0; $i < $emptyStars; $i++) { echo '' . ReviewThreadMarkup::starSvg() . ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } } /** * One tile dimension in pixels, or 0 to leave it to the stylesheet. * * @param mixed $value * @return int */ public static function mediaTileSize($value): int { $size = (int) $value; if ($size < 1) { return 0; } return min(static::MAX_MEDIA_TILE_PX, max(16, $size)); } /** * How many tiles a gallery shows before the + tile, 0 for all of them. * * Capped at what a review may actually hold: showing 40 of a store that * allows 5 photos is a limit that never comes into play, and the number * an editor is offered should mean something. * * @param mixed $value * @return int */ public static function mediaVisibleCount($value): int { return max(0, min(static::maxPhotosPerReview(), (int) $value)); } /** * The store's photos-per-review limit. * * PRO owns the setting and enforces it on upload; this reads the same * value so the display side agrees with it without depending on PRO being * installed. Floored at 1, the way PRO floors its own. * * @return int */ public static function maxPhotosPerReview(): int { $settings = ProductReviewService::getReviewSettings(); return max(1, (int) Arr::get( $settings, 'max_photos_per_review', static::DEFAULT_MAX_PHOTOS_PER_REVIEW )); } }