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

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

668 lines 27.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Services\Renderer;
4
5 use FluentCart\App\Helpers\Helper;
6 use FluentCart\App\App;
7 use FluentCart\App\Helpers\Status;
8 use FluentCart\App\Models\Order;
9 use FluentCart\App\Models\Product;
10 use FluentCart\App\Models\ProductDetail;
11 use FluentCart\App\Models\ProductReview;
12 use FluentCart\App\Models\ProductVariation;
13 use FluentCart\App\Modules\Templating\AssetLoader;
14 use FluentCart\App\Services\FrontendView;
15 use FluentCart\App\Services\ProductReviewService;
16 use FluentCart\App\Vite;
17 use FluentCart\Framework\Support\Arr;
18
19 /**
20 * The public "review your order" page: everything a customer bought on one
21 * order, each line with a way to review it.
22 *
23 * Reached the way the receipt is — ?fluent-cart=order-review&order_hash={uuid}
24 * — so the emailed link works for a guest who has no account. The hash is the
25 * credential; ProductReviewService::resolveOrderGrant() explains why holding
26 * one is treated as proof of purchase.
27 *
28 * The per-product markup is NOT written here. Each row renders the real
29 * fluent-cart/write-a-review-button block through render_block(), so this page
30 * gets exactly what a page built in the editor gets — the block's attribute
31 * validation, its wrapper attributes and supports, its render_block filters,
32 * and underneath all of it ProductReviewRenderer with the store's permission
33 * mode, star settings and Pro's photo fields.
34 */
35 class OrderReviewRenderer
36 {
37 /**
38 * Line types that are not a product and so cannot be reviewed. Mirrors
39 * Order::getProductItems() — a signup_fee line carries a post_id, so
40 * excluding only 'fee' would let it win the per-product dedupe and title
41 * the row from a fee line.
42 */
43 const NON_PRODUCT_LINES = ['fee', 'signup_fee'];
44
45 /**
46 * The list is rendered whole, deliberately: it is not paged and has no
47 * cap. An order is a handful of products — five to ten at the very most
48 * in practice — so every product the customer bought is on the one page
49 * their email link opens, and nothing about the page depends on a query
50 * variable. The grant, reviewed-state, toggle and post-cache lookups are
51 * each one query for the whole order, so the cost of a long order is
52 * the rows themselves.
53 */
54
55 /**
56 * Set when the link is fine but the store is not taking reviews, so the
57 * notice can say that rather than claim the order is missing.
58 *
59 * @var bool
60 */
61 protected $unavailable = false;
62
63 protected $orderHash;
64
65 /**
66 * @var Order|null
67 */
68 protected $order = null;
69
70 /**
71 * @var bool whether to print the page's own <h1>
72 */
73 protected $showTitle = true;
74
75 /**
76 * The item of the row being rendered right now, read by the
77 * renderer_options filter injected around the item loop. 0 for a row
78 * that reviews the product as a whole.
79 *
80 * @var int
81 */
82 protected $currentItemId = 0;
83
84 /**
85 * @param string $orderHash
86 * @param array $options showTitle: false when a WP page already prints a
87 * title above this markup (the shortcode path), so
88 * the visitor does not read the same heading twice.
89 */
90 public function __construct($orderHash, array $options = [])
91 {
92 $this->orderHash = sanitize_text_field((string) $orderHash);
93
94 if (array_key_exists('showTitle', $options)) {
95 $this->showTitle = (bool) $options['showTitle'];
96 }
97 }
98
99 /**
100 * Echoes the page body. The caller wraps it in FrontendView.
101 *
102 * @return bool whether an order was found and rendered; the route
103 * answers 404 otherwise, the shortcode page keeps its own status
104 */
105 public function render()
106 {
107 // The URL carries a bearer credential, and the page is reached from
108 // an email: nothing about it should be indexed. On the query route
109 // wp_head() has not run yet, so this filter still reaches the head.
110 // The store's chosen page prints its head before the shortcode runs;
111 // AssetLoader::markOrderReviewPageNoIndex() flags that page at
112 // template_redirect instead.
113 add_filter('wp_robots', 'wp_robots_no_robots');
114
115 // Before the guard: renderNotFound() prints .fct-order-review-* markup
116 // too, and those classes live only in this stylesheet — enqueueing
117 // after the early return left every bad link completely unstyled.
118 Vite::enqueueStyle('fluent-cart-order-review', 'public/order-review/order-review.scss');
119
120 if (!$this->resolveOrder()) {
121 $this->renderNotFound();
122 return false;
123 }
124
125 // Enqueued before FrontendView::make() runs, so wp_head() and
126 // wp_footer() still have them to print. The form's own script and
127 // stylesheet only: this page has no gallery, product card or review
128 // list to drive, so the full single-product bundle stays off it.
129 AssetLoader::loadReviewSubmissionFormAssets();
130
131 // Swaps a row to its "already reviewed" state when ReviewForm.js
132 // reports a success, so the overlay does not close onto a stale button.
133 Vite::enqueueScript(
134 'fluent-cart-order-review',
135 'public/order-review/order-review.js',
136 [],
137 null,
138 true
139 );
140
141 $items = $this->reviewableItems();
142
143 // Only the rows that still offer a form. Counting every row would
144 // promise "3 products are waiting for a review" above three lines each
145 // saying the visitor already reviewed it.
146 $pendingCount = 0;
147 foreach ($items as $item) {
148 if ($item['enabled'] && !$item['reviewed']) {
149 $pendingCount++;
150 }
151 }
152
153 ?>
154 <div class="fct-order-review-page">
155 <?php $this->renderHeader($pendingCount); ?>
156
157 <?php if (!$items) : ?>
158 <p class="fct-order-review-empty">
159 <?php esc_html_e('There is nothing to review on this order.', 'fluent-cart'); ?>
160 </p>
161 <?php else : ?>
162 <?php $grantInjection = $this->injectGrantIntoRenderers(); ?>
163 <ul class="fct-order-review-items">
164 <?php foreach ($items as $item) : ?>
165 <li class="fct-order-review-item"><?php $this->renderItem($item); ?></li>
166 <?php endforeach; ?>
167 </ul>
168 <?php $this->releaseGrantInjection($grantInjection); ?>
169 <?php endif; ?>
170 </div>
171 <?php
172 return true;
173 }
174
175 /**
176 * The order the hash names, if the store is willing to show it.
177 *
178 * A valid hash is enough to SEE the page. Being allowed to review from it
179 * is a separate, stricter question that resolveOrderGrant() answers per
180 * product — it additionally requires the order to have completed.
181 *
182 * Two rules rather than one, deliberately. The per-item CTA already
183 * degrades correctly when no grant applies: a signed-in buyer still gets a
184 * working button (they pass verified_buyers on their own), and everyone
185 * else gets a log-in link rather than a button that would be rejected. So
186 * an order still awaiting payment shows its products with a way in, rather
187 * than a 404 that tells a paying customer nothing.
188 *
189 * @return bool
190 */
191 protected function resolveOrder(): bool
192 {
193 if ($this->orderHash === '') {
194 return false;
195 }
196
197 $order = Order::query()
198 ->with(['order_items', 'customer'])
199 ->where('uuid', $this->orderHash)
200 ->first();
201
202 if (!$order) {
203 return false;
204 }
205
206 // Reviews off store-wide means the page has nothing to offer at all
207 // — said as that, not as a missing order: the link is fine.
208 $settings = ProductReviewService::getReviewSettings();
209 if (Arr::get($settings, 'reviews_enabled') !== 'yes') {
210 $this->unavailable = true;
211 return false;
212 }
213
214 $canView = apply_filters('fluent_cart/order_review/can_view', true, [
215 'order' => $order,
216 ]);
217
218 if (!$canView) {
219 return false;
220 }
221
222 $this->order = $order;
223
224 // The rows below each ask whether the hash covers their product; the
225 // order and its lines are already in hand, so the grant memo is
226 // seeded from them and no row issues a query to find out.
227 ProductReviewService::primeOrderGrant($order);
228
229 return true;
230 }
231
232 /**
233 * The same not-found view the receipt page shows for a bad link — the
234 * shared frontend/not-found.php template with the 404 illustration and
235 * home button — so the two emailed-link pages fail the same way.
236 *
237 * The receipt goes through FrontendView::renderNotFoundPage(), which
238 * prints a whole document. This page already has its chrome from the
239 * route or the shortcode, so the same template is rendered here as a
240 * fragment with the same data shape that helper builds.
241 */
242 protected function renderNotFound()
243 {
244 FrontendView::enqueueNotFoundPageAssets();
245
246 $assetBase = Vite::getAssetUrl();
247 $notFoundImg = $assetBase . 'images/404.svg';
248
249 // The link is fine but the store is not taking reviews: say that,
250 // rather than claim the order is missing.
251 if ($this->unavailable) {
252 $title = __('Reviews are not available right now', 'fluent-cart');
253 $text = __('This store is not accepting product reviews at the moment. Thank you for your order.', 'fluent-cart');
254 } else {
255 $title = __('Sorry, no order found.', 'fluent-cart');
256 $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');
257 }
258 $buttonText = __('Go Back to Home Page', 'fluent-cart');
259
260 ?>
261 <div class="fct-order-review-page">
262 <?php
263 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in the view template
264 echo App::view()->make('frontend/not-found.php', [
265 'title' => $title,
266 'text' => $text,
267 'buttonText' => $buttonText,
268 'notFoundImg' => $notFoundImg,
269 'buttonUrl' => home_url(),
270 ]);
271 ?>
272 </div>
273 <?php
274 }
275
276 protected function renderHeader($itemCount)
277 {
278 $invoiceNo = $this->order->invoice_no;
279 ?>
280 <header class="fct-order-review-header">
281 <?php if ($this->showTitle) : ?>
282 <h1 class="fct-order-review-title"><?php esc_html_e('Review your order', 'fluent-cart'); ?></h1>
283 <?php endif; ?>
284 <p class="fct-order-review-subtitle" data-order-review-subtitle data-after-submission="<?php esc_attr_e('Tell us what you thought of the remaining products.', 'fluent-cart'); ?>">
285 <?php
286 if ($itemCount) {
287 echo esc_html(
288 sprintf(
289 /* translators: %1$d: number of products on the order awaiting a review */
290 _n(
291 'Tell us what you thought of your purchase. %1$d product is waiting for a review.',
292 'Tell us what you thought of your purchase. %1$d products are waiting for a review.',
293 $itemCount,
294 'fluent-cart'
295 ),
296 $itemCount
297 )
298 );
299 } else {
300 esc_html_e('Thanks for your order.', 'fluent-cart');
301 }
302 ?>
303 </p>
304
305 <?php if ($itemCount) : ?>
306 <?php // Swapped in by order-review.js once the last row is done. ?>
307 <p class="fct-order-review-subtitle" data-order-review-all-done hidden>
308 <?php esc_html_e('Thanks for your order.', 'fluent-cart'); ?>
309 </p>
310 <?php endif; ?>
311
312 <?php if ($invoiceNo) : ?>
313 <p class="fct-order-review-order-no">
314 <?php
315 /* translators: %1$s: the order's invoice number */
316 printf(esc_html__('Order #%1$s', 'fluent-cart'), esc_html($invoiceNo));
317 ?>
318 </p>
319 <?php endif; ?>
320
321 </header>
322 <?php
323 }
324
325 /**
326 * One row per reviewable thing on the order, with the state it is in.
327 *
328 * A variable product gets a row per distinct item bought — two variants
329 * of one product are two purchases and two opinions, and each row's form
330 * is bound to its item so the review records which one. A simple product
331 * has nothing to tell apart: its lone default variation IS the product,
332 * so it stays one row at product level (item 0) however many lines it
333 * spans. ReviewForm.js keys its controllers on (product, item), so two
334 * rows for one product never fight over a drawer.
335 *
336 * @return array<int, array{post_id:int, item_id:int, title:string, item_label:string, image:string, reviewed:bool, enabled:bool}>
337 */
338 protected function reviewableItems(): array
339 {
340 $items = [];
341
342 // The distinct (product, variation) lines on the order, in the order
343 // they were bought, with the title each line carried. Every lookup
344 // below is one query for this whole set. A line whose variation
345 // turns out not to be an item collapses to the product slot below.
346 $lines = [];
347 foreach ($this->order->order_items as $orderItem) {
348 $postId = (int) $orderItem->post_id;
349 if (!$postId || in_array($orderItem->payment_type, self::NON_PRODUCT_LINES, true)) {
350 continue;
351 }
352 $key = $postId . ':' . (int) $orderItem->object_id;
353 if (!isset($lines[$key])) {
354 $lines[$key] = [
355 'post_id' => $postId,
356 'object_id' => (int) $orderItem->object_id,
357 'title' => (string) $orderItem->post_title,
358 ];
359 }
360 }
361
362 $pageLines = array_values($lines);
363
364 $postIds = [];
365 foreach ($pageLines as $line) {
366 $postIds[$line['post_id']] = true;
367 }
368 $postIds = array_keys($postIds);
369
370 $reviewedSlots = $this->reviewedSlots($postIds);
371
372 // The per-product reviews toggle for every product on the order in
373 // one query; each row's check, and the block each row renders, then
374 // answer from memory.
375 ProductReviewService::primeReviewsEnabled($postIds);
376
377 $products = [];
378 $variableProducts = [];
379 if ($postIds) {
380 $found = Product::query()
381 ->where('post_status', 'publish')
382 ->whereIn('ID', $postIds)
383 ->get();
384
385 foreach ($found as $foundProduct) {
386 $products[(int) $foundProduct->ID] = $foundProduct;
387 }
388
389 // The loop below asks WordPress for each product's permalink and
390 // thumbnail. The ORM model does not fill the WP post cache, so
391 // without this every row costs a post, a meta and an attachment
392 // lookup of its own: the posts and their meta in one pass each,
393 // then the thumbnails those meta name, the same way.
394 _prime_post_caches($postIds, false, true);
395 $thumbnailIds = [];
396 foreach ($postIds as $primedId) {
397 $thumbnailId = (int) get_post_thumbnail_id($primedId);
398 if ($thumbnailId) {
399 $thumbnailIds[] = $thumbnailId;
400 }
401 }
402 if ($thumbnailIds) {
403 _prime_post_caches(array_values(array_unique($thumbnailIds)), false, true);
404 }
405
406 // Which of them have items worth telling apart — one query for
407 // the page, the same positive-only rule as
408 // ProductReviewService::productHasItems().
409 $variationTypes = ProductDetail::query()
410 ->whereIn('post_id', $postIds)
411 ->pluck('variation_type', 'post_id');
412
413 foreach ($variationTypes as $detailPostId => $variationType) {
414 if ($variationType && $variationType !== 'simple') {
415 $variableProducts[(int) $detailPostId] = true;
416 }
417 }
418 }
419
420 // The name of each item bought, read from the variation row the way
421 // the product name is read from the post — one query for the page.
422 // A variation that no longer exists simply has no name to show.
423 $itemNames = [];
424 $objectIds = [];
425 foreach ($pageLines as $line) {
426 if (isset($variableProducts[$line['post_id']]) && $line['object_id']) {
427 $objectIds[] = $line['object_id'];
428 }
429 }
430 if ($objectIds) {
431 $itemNames = ProductVariation::query()
432 ->whereIn('id', array_values(array_unique($objectIds)))
433 ->pluck('variation_title', 'id')
434 ->all();
435 }
436
437 foreach ($pageLines as $line) {
438 $postId = $line['post_id'];
439
440 // A product that is gone or unpublished cannot be reviewed and has
441 // no page to link to, so it does not belong on this list at all.
442 if (!isset($products[$postId])) {
443 continue;
444 }
445
446 // An item slot only for a variation that still exists. Orders
447 // outlive catalogue variations; a line whose variation is gone
448 // files at product level rather than offering a form the server
449 // would refuse, since the item can no longer be verified.
450 $objectId = $line['object_id'];
451 $itemId = isset($variableProducts[$postId], $itemNames[$objectId]) ? $objectId : 0;
452 $slot = $postId . ':' . $itemId;
453
454 if (isset($items[$slot])) {
455 continue;
456 }
457
458 $product = $products[$postId];
459
460 $items[$slot] = [
461 'post_id' => $postId,
462 'item_id' => $itemId,
463 'title' => $line['title'] !== '' ? $line['title'] : $product->post_title,
464 // The item's current name, from the variation row.
465 'item_label' => $itemId ? trim((string) ($itemNames[$itemId] ?? '')) : '',
466 'image' => $product->getMediaUrl('thumbnail') ?: Helper::getProductPlaceholderUrl(),
467 'url' => get_permalink($postId),
468 'reviewed' => isset($reviewedSlots[$slot]),
469 'enabled' => ProductReviewService::isReviewEnabledForProduct($postId),
470 ];
471 }
472
473 return array_values($items);
474 }
475
476 /**
477 * The (product, item) slots on this page the reviewing identity has
478 * already filled, keyed "post:item" — item 0 for the product as a whole.
479 *
480 * @param int[] $postIds the products on the page being rendered
481 * @return array<string, true>
482 */
483 protected function reviewedSlots(array $postIds): array
484 {
485 if (!$postIds) {
486 return [];
487 }
488
489 // The identity a submission from this page is filed under — the
490 // buyer, unless the visitor IS the buyer — so the reviewed state
491 // here and the duplicate guard on submit can never disagree. An
492 // order whose customer row is gone yields no identity for a guest;
493 // that visitor gets the form and its typed fields.
494 $identity = ProductReviewService::effectiveReviewerIdentity($this->order);
495
496 // An order whose customer row is gone has no user and no email, but
497 // its slot is the order itself — a review already filed from this
498 // link must still read as reviewed, or the page offers a form the
499 // duplicate guard is about to refuse.
500 if (!$identity['user_id'] && !$identity['emails'] && !$identity['order_id']) {
501 return [];
502 }
503
504 $query = ProductReviewService::scopeToIdentity(ProductReview::query(), $identity)
505 ->whereIn('post_id', $postIds)
506 ->whereIn('status', Status::getReviewDuplicateStatuses());
507
508 $reviewed = [];
509 foreach ($query->get(['post_id', 'item_id']) as $review) {
510 $reviewed[(int) $review->post_id . ':' . (int) $review->item_id] = true;
511 }
512
513 return $reviewed;
514 }
515
516 protected function renderItem(array $item)
517 {
518 ?>
519 <div class="fct-order-review-item-media">
520 <img src="<?php echo esc_url($item['image']); ?>" alt="" width="72" height="72" loading="lazy"/>
521 </div>
522 <div class="fct-order-review-item-body">
523 <h2 class="fct-order-review-item-title">
524 <?php if ($item['url']) : ?>
525 <a href="<?php echo esc_url($item['url']); ?>"><?php echo esc_html($item['title']); ?></a>
526 <?php else : ?>
527 <?php echo esc_html($item['title']); ?>
528 <?php endif; ?>
529 <?php if ($item['item_label'] !== '') : ?>
530 <span class="fct-order-review-item-variant"><?php echo esc_html($item['item_label']); ?></span>
531 <?php endif; ?>
532 </h2>
533
534 <?php if (!$item['enabled']) : ?>
535 <p class="fct-order-review-item-note">
536 <?php esc_html_e('Reviews are turned off for this product.', 'fluent-cart'); ?>
537 </p>
538 <?php elseif ($item['reviewed']) : ?>
539 <p class="fct-order-review-item-note">
540 <?php esc_html_e('You’ve already reviewed this product. Thank you for your feedback!', 'fluent-cart'); ?>
541 </p>
542 <?php else : ?>
543 <div class="fct-order-review-item-action" data-order-review-pending data-post-id="<?php echo esc_attr($item['post_id']); ?>" data-item-id="<?php echo esc_attr($item['item_id']); ?>">
544 <?php $this->renderItemForm($item['post_id'], $item['item_id']); ?>
545 </div>
546 <?php
547 // Pre-rendered so the confirmation needs no strings in JS —
548 // ReviewForm.js closes the overlay on success and the row
549 // behind it would otherwise still offer a button that the
550 // duplicate guard is about to refuse.
551 ?>
552 <p class="fct-order-review-item-note" data-order-review-done role="status" tabindex="-1" hidden>
553 <?php esc_html_e('You’ve already reviewed this product. Thank you for your feedback!', 'fluent-cart'); ?>
554 </p>
555 <?php endif; ?>
556 </div>
557 <?php
558 }
559
560 /**
561 * The trigger and the form for one product — the real Write a Review
562 * block, rendered through render_block().
563 *
564 * Not a direct ProductReviewRenderer call, deliberately. Going through the
565 * block means this page gets the same pipeline a page built in the editor
566 * gets: the block's own attribute validation, its wrapper attributes and
567 * block supports, and any render_block filter a site or add-on has
568 * registered. A direct call would quietly diverge from the block the
569 * moment either gained a feature.
570 *
571 * @param int $postId
572 * @param int $itemId the order line's variation, 0 for the product
573 * @return void
574 */
575 protected function renderItemForm($postId, $itemId = 0)
576 {
577 // Read by the renderer_options filter injected around the loop, so
578 // the block — which has no item attribute — still hands the item to
579 // the renderer it builds.
580 $this->currentItemId = max(0, (int) $itemId);
581
582 $attributes = apply_filters('fluent_cart/order_review/block_attributes', [
583 // 'custom' is what makes the block honour product_id — on its
584 // default setting it would look for a current product, and this
585 // page is not a product page.
586 'query_type' => 'custom',
587 'product_id' => (int) $postId,
588 'container' => 'modal',
589 // Every field at once: the customer came here to write, not
590 // to be walked through a wizard.
591 'layout' => 'inline',
592 ], [
593 'post_id' => $postId,
594 'item_id' => $this->currentItemId,
595 'order' => $this->order,
596 ]);
597
598 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- render_block() output is escaped by the block's own render callback
599 echo render_block([
600 'blockName' => 'fluent-cart/write-a-review-button',
601 'attrs' => $attributes,
602 'innerBlocks' => [],
603 'innerHTML' => '',
604 'innerContent' => [],
605 ]);
606
607 $this->currentItemId = 0;
608 }
609
610 /**
611 * Hand the order hash to every ProductReviewRenderer built while this page
612 * renders.
613 *
614 * The block owns its own attributes and has no notion of an order, so the
615 * grant reaches the renderer the way any other placement-level option
616 * would — through the renderer_options filter the constructor already
617 * applies. Added around the item loop and removed straight after, so it
618 * cannot leak into anything else in the request.
619 *
620 * @return callable the filter callback, to pass back to releaseGrantInjection()
621 */
622 protected function injectGrantIntoRenderers(): callable
623 {
624 $orderHash = $this->orderHash;
625 $page = $this;
626
627 // Two parameters because the filter passes two (options, post id);
628 // the post id is not needed here, but the registration below declares
629 // two accepted args and the callback has to match that contract.
630 $callback = static function ($options, $postId = 0) use ($orderHash, $page) {
631 // resolveOrderGrant() re-checks that the hash actually covers the
632 // product — and, with an item, the exact variation — being
633 // rendered, so handing both to every renderer on this page grants
634 // nothing extra: an unrelated product or item resolves null.
635 $options['orderHash'] = $orderHash;
636 $options['itemId'] = $page->currentItemId();
637
638 // A row is rendered with a form only when reviewedProductIds()
639 // found no review in its slot for the identity a submission is
640 // filed under — which is the identity the form's edit-mode
641 // lookup would ask about, on the same statuses. The answer is
642 // therefore already known to be "none": saying so spares the
643 // block one review query per row for its CTA and its form.
644 $options['existingReview'] = null;
645
646 return $options;
647 };
648
649 add_filter('fluent_cart/review/renderer_options', $callback, 10, 2);
650
651 return $callback;
652 }
653
654 protected function releaseGrantInjection(callable $callback): void
655 {
656 remove_filter('fluent_cart/review/renderer_options', $callback, 10);
657 }
658
659 /**
660 * The item of the row currently rendering — public only so the filter
661 * closure above, which cannot see protected state, can read it.
662 */
663 public function currentItemId(): int
664 {
665 return $this->currentItemId;
666 }
667 }
668