*/ protected $showTitle = true; /** * The item of the row being rendered right now, read by the * renderer_options filter injected around the item loop. 0 for a row * that reviews the product as a whole. * * @var int */ protected $currentItemId = 0; /** * @param string $orderHash * @param array $options showTitle: false when a WP page already prints a * title above this markup (the shortcode path), so * the visitor does not read the same heading twice. */ public function __construct($orderHash, array $options = []) { $this->orderHash = sanitize_text_field((string) $orderHash); if (array_key_exists('showTitle', $options)) { $this->showTitle = (bool) $options['showTitle']; } } /** * Echoes the page body. The caller wraps it in FrontendView. * * @return bool whether an order was found and rendered; the route * answers 404 otherwise, the shortcode page keeps its own status */ public function render() { // The URL carries a bearer credential, and the page is reached from // an email: nothing about it should be indexed. On the query route // wp_head() has not run yet, so this filter still reaches the head. // The store's chosen page prints its head before the shortcode runs; // AssetLoader::markOrderReviewPageNoIndex() flags that page at // template_redirect instead. add_filter('wp_robots', 'wp_robots_no_robots'); // Before the guard: renderNotFound() prints .fct-order-review-* markup // too, and those classes live only in this stylesheet — enqueueing // after the early return left every bad link completely unstyled. Vite::enqueueStyle('fluent-cart-order-review', 'public/order-review/order-review.scss'); if (!$this->resolveOrder()) { $this->renderNotFound(); return false; } // Enqueued before FrontendView::make() runs, so wp_head() and // wp_footer() still have them to print. The form's own script and // stylesheet only: this page has no gallery, product card or review // list to drive, so the full single-product bundle stays off it. AssetLoader::loadReviewSubmissionFormAssets(); // Swaps a row to its "already reviewed" state when ReviewForm.js // reports a success, so the overlay does not close onto a stale button. Vite::enqueueScript( 'fluent-cart-order-review', 'public/order-review/order-review.js', [], null, true ); $items = $this->reviewableItems(); // Only the rows that still offer a form. Counting every row would // promise "3 products are waiting for a review" above three lines each // saying the visitor already reviewed it. $pendingCount = 0; foreach ($items as $item) { if ($item['enabled'] && !$item['reviewed']) { $pendingCount++; } } ?>
renderHeader($pendingCount); ?>

injectGrantIntoRenderers(); ?> releaseGrantInjection($grantInjection); ?>
orderHash === '') { return false; } $order = Order::query() ->with(['order_items', 'customer']) ->where('uuid', $this->orderHash) ->first(); if (!$order) { return false; } // Reviews off store-wide means the page has nothing to offer at all // — said as that, not as a missing order: the link is fine. $settings = ProductReviewService::getReviewSettings(); if (Arr::get($settings, 'reviews_enabled') !== 'yes') { $this->unavailable = true; return false; } $canView = apply_filters('fluent_cart/order_review/can_view', true, [ 'order' => $order, ]); if (!$canView) { return false; } $this->order = $order; // The rows below each ask whether the hash covers their product; the // order and its lines are already in hand, so the grant memo is // seeded from them and no row issues a query to find out. ProductReviewService::primeOrderGrant($order); return true; } /** * The same not-found view the receipt page shows for a bad link — the * shared frontend/not-found.php template with the 404 illustration and * home button — so the two emailed-link pages fail the same way. * * The receipt goes through FrontendView::renderNotFoundPage(), which * prints a whole document. This page already has its chrome from the * route or the shortcode, so the same template is rendered here as a * fragment with the same data shape that helper builds. */ protected function renderNotFound() { FrontendView::enqueueNotFoundPageAssets(); $assetBase = Vite::getAssetUrl(); $notFoundImg = $assetBase . 'images/404.svg'; // The link is fine but the store is not taking reviews: say that, // rather than claim the order is missing. if ($this->unavailable) { $title = __('Reviews are not available right now', 'fluent-cart'); $text = __('This store is not accepting product reviews at the moment. Thank you for your order.', 'fluent-cart'); } else { $title = __('Sorry, no order found.', 'fluent-cart'); $text = __('The link appears to be invalid or expired. Check the link in your order email, or contact us if it keeps happening.', 'fluent-cart'); } $buttonText = __('Go Back to Home Page', 'fluent-cart'); ?>
make('frontend/not-found.php', [ 'title' => $title, 'text' => $text, 'buttonText' => $buttonText, 'notFoundImg' => $notFoundImg, 'buttonUrl' => home_url(), ]); ?>
order->invoice_no; ?>
showTitle) : ?>

*/ protected function reviewableItems(): array { $items = []; // The distinct (product, variation) lines on the order, in the order // they were bought, with the title each line carried. Every lookup // below is one query for this whole set. A line whose variation // turns out not to be an item collapses to the product slot below. $lines = []; foreach ($this->order->order_items as $orderItem) { $postId = (int) $orderItem->post_id; if (!$postId || in_array($orderItem->payment_type, self::NON_PRODUCT_LINES, true)) { continue; } $key = $postId . ':' . (int) $orderItem->object_id; if (!isset($lines[$key])) { $lines[$key] = [ 'post_id' => $postId, 'object_id' => (int) $orderItem->object_id, 'title' => (string) $orderItem->post_title, ]; } } $pageLines = array_values($lines); $postIds = []; foreach ($pageLines as $line) { $postIds[$line['post_id']] = true; } $postIds = array_keys($postIds); $reviewedSlots = $this->reviewedSlots($postIds); // The per-product reviews toggle for every product on the order in // one query; each row's check, and the block each row renders, then // answer from memory. ProductReviewService::primeReviewsEnabled($postIds); $products = []; $variableProducts = []; if ($postIds) { $found = Product::query() ->where('post_status', 'publish') ->whereIn('ID', $postIds) ->get(); foreach ($found as $foundProduct) { $products[(int) $foundProduct->ID] = $foundProduct; } // The loop below asks WordPress for each product's permalink and // thumbnail. The ORM model does not fill the WP post cache, so // without this every row costs a post, a meta and an attachment // lookup of its own: the posts and their meta in one pass each, // then the thumbnails those meta name, the same way. _prime_post_caches($postIds, false, true); $thumbnailIds = []; foreach ($postIds as $primedId) { $thumbnailId = (int) get_post_thumbnail_id($primedId); if ($thumbnailId) { $thumbnailIds[] = $thumbnailId; } } if ($thumbnailIds) { _prime_post_caches(array_values(array_unique($thumbnailIds)), false, true); } // Which of them have items worth telling apart — one query for // the page, the same positive-only rule as // ProductReviewService::productHasItems(). $variationTypes = ProductDetail::query() ->whereIn('post_id', $postIds) ->pluck('variation_type', 'post_id'); foreach ($variationTypes as $detailPostId => $variationType) { if ($variationType && $variationType !== 'simple') { $variableProducts[(int) $detailPostId] = true; } } } // The name of each item bought, read from the variation row the way // the product name is read from the post — one query for the page. // A variation that no longer exists simply has no name to show. $itemNames = []; $objectIds = []; foreach ($pageLines as $line) { if (isset($variableProducts[$line['post_id']]) && $line['object_id']) { $objectIds[] = $line['object_id']; } } if ($objectIds) { $itemNames = ProductVariation::query() ->whereIn('id', array_values(array_unique($objectIds))) ->pluck('variation_title', 'id') ->all(); } foreach ($pageLines as $line) { $postId = $line['post_id']; // A product that is gone or unpublished cannot be reviewed and has // no page to link to, so it does not belong on this list at all. if (!isset($products[$postId])) { continue; } // An item slot only for a variation that still exists. Orders // outlive catalogue variations; a line whose variation is gone // files at product level rather than offering a form the server // would refuse, since the item can no longer be verified. $objectId = $line['object_id']; $itemId = isset($variableProducts[$postId], $itemNames[$objectId]) ? $objectId : 0; $slot = $postId . ':' . $itemId; if (isset($items[$slot])) { continue; } $product = $products[$postId]; $items[$slot] = [ 'post_id' => $postId, 'item_id' => $itemId, 'title' => $line['title'] !== '' ? $line['title'] : $product->post_title, // The item's current name, from the variation row. 'item_label' => $itemId ? trim((string) ($itemNames[$itemId] ?? '')) : '', 'image' => $product->getMediaUrl('thumbnail') ?: Helper::getProductPlaceholderUrl(), 'url' => get_permalink($postId), 'reviewed' => isset($reviewedSlots[$slot]), 'enabled' => ProductReviewService::isReviewEnabledForProduct($postId), ]; } return array_values($items); } /** * The (product, item) slots on this page the reviewing identity has * already filled, keyed "post:item" — item 0 for the product as a whole. * * @param int[] $postIds the products on the page being rendered * @return array */ protected function reviewedSlots(array $postIds): array { if (!$postIds) { return []; } // The identity a submission from this page is filed under — the // buyer, unless the visitor IS the buyer — so the reviewed state // here and the duplicate guard on submit can never disagree. An // order whose customer row is gone yields no identity for a guest; // that visitor gets the form and its typed fields. $identity = ProductReviewService::effectiveReviewerIdentity($this->order); // An order whose customer row is gone has no user and no email, but // its slot is the order itself — a review already filed from this // link must still read as reviewed, or the page offers a form the // duplicate guard is about to refuse. if (!$identity['user_id'] && !$identity['emails'] && !$identity['order_id']) { return []; } $query = ProductReviewService::scopeToIdentity(ProductReview::query(), $identity) ->whereIn('post_id', $postIds) ->whereIn('status', Status::getReviewDuplicateStatuses()); $reviewed = []; foreach ($query->get(['post_id', 'item_id']) as $review) { $reviewed[(int) $review->post_id . ':' . (int) $review->item_id] = true; } return $reviewed; } protected function renderItem(array $item) { ?>

renderItemForm($item['post_id'], $item['item_id']); ?>
currentItemId = max(0, (int) $itemId); $attributes = apply_filters('fluent_cart/order_review/block_attributes', [ // 'custom' is what makes the block honour product_id — on its // default setting it would look for a current product, and this // page is not a product page. 'query_type' => 'custom', 'product_id' => (int) $postId, 'container' => 'modal', // Every field at once: the customer came here to write, not // to be walked through a wizard. 'layout' => 'inline', ], [ 'post_id' => $postId, 'item_id' => $this->currentItemId, 'order' => $this->order, ]); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- render_block() output is escaped by the block's own render callback echo render_block([ 'blockName' => 'fluent-cart/write-a-review-button', 'attrs' => $attributes, 'innerBlocks' => [], 'innerHTML' => '', 'innerContent' => [], ]); $this->currentItemId = 0; } /** * Hand the order hash to every ProductReviewRenderer built while this page * renders. * * The block owns its own attributes and has no notion of an order, so the * grant reaches the renderer the way any other placement-level option * would — through the renderer_options filter the constructor already * applies. Added around the item loop and removed straight after, so it * cannot leak into anything else in the request. * * @return callable the filter callback, to pass back to releaseGrantInjection() */ protected function injectGrantIntoRenderers(): callable { $orderHash = $this->orderHash; $page = $this; // Two parameters because the filter passes two (options, post id); // the post id is not needed here, but the registration below declares // two accepted args and the callback has to match that contract. $callback = static function ($options, $postId = 0) use ($orderHash, $page) { // resolveOrderGrant() re-checks that the hash actually covers the // product — and, with an item, the exact variation — being // rendered, so handing both to every renderer on this page grants // nothing extra: an unrelated product or item resolves null. $options['orderHash'] = $orderHash; $options['itemId'] = $page->currentItemId(); // A row is rendered with a form only when reviewedProductIds() // found no review in its slot for the identity a submission is // filed under — which is the identity the form's edit-mode // lookup would ask about, on the same statuses. The answer is // therefore already known to be "none": saying so spares the // block one review query per row for its CTA and its form. $options['existingReview'] = null; return $options; }; add_filter('fluent_cart/review/renderer_options', $callback, 10, 2); return $callback; } protected function releaseGrantInjection(callable $callback): void { remove_filter('fluent_cart/review/renderer_options', $callback, 10); } /** * The item of the row currently rendering — public only so the filter * closure above, which cannot see protected state, can read it. */ public function currentItemId(): int { return $this->currentItemId; } }