*/
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;
?>
*/
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;
}
}