PluginProbe
WooCommerce / 11.1.0-rc.2
WooCommerce v11.1.0-rc.2
11.1.0 11.1.0-rc.2 11.1.0-rc.1 11.1.0-beta.2 11.1.0-beta.1 11.0.1 11.0.0 11.0.0-rc.3 11.0.0-rc.2 11.0.0-rc.1 11.0.0-beta.2 11.0.0-beta.1 10.9.4 10.9.3 10.9.2 10.9.1 10.9.0 10.9.0-rc.1 10.9.0-beta.2 10.9.0-beta.1 10.8.1 10.8.0 10.8.0-rc.1 10.8.0-beta.2 10.8.0-beta.1 All 648 releases
woocommerce / src / Internal / OrderReviews / ItemEligibility.php
ItemEligibility.php
516 lines 15.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * ItemEligibility class file.
4 */
5
6 declare( strict_types = 1 );
7
8 namespace Automattic\WooCommerce\Internal\OrderReviews;
9
10 use WC_Order;
11 use WC_Order_Item;
12 use WC_Order_Item_Product;
13 use WP_Comment;
14
15 /**
16 * Decides how each Review Order line item should be rendered and supplies
17 * any pre-fill data for the row form.
18 *
19 * Two outcomes for a row:
20 *
21 * - `form` — render the editable form row (`customer-review-order-row.php`),
22 * optionally pre-filled with the rating + text the customer has already
23 * submitted for this product **on this order**.
24 * - `skip` — render nothing (e.g. the product has reviews disabled).
25 *
26 * Reviews left for a *different* order are not surfaced here: a customer who
27 * buys the same product again gets a fresh form row, because their experience
28 * the second time around may be different from the first.
29 *
30 * @internal Just for internal use.
31 *
32 * @since 10.8.0
33 */
34 class ItemEligibility {
35
36 /**
37 * Render the editable form row.
38 *
39 * @since 10.8.0
40 */
41 public const STATUS_FORM = 'form';
42
43 /**
44 * Render nothing (e.g. comments closed on the product).
45 *
46 * @since 10.8.0
47 */
48 public const STATUS_SKIP = 'skip';
49
50 /**
51 * Commentmeta key storing the order this review was submitted for.
52 *
53 * @since 10.8.0
54 */
55 public const ORDER_META_KEY = '_review_order_id';
56
57 /**
58 * Commentmeta key storing the variation id this review was submitted for.
59 *
60 * Always present on reviews written through the Review Order page. Simple
61 * products store `0`. Lets variable-product orders distinguish "Small" from
62 * "Medium" rows that share a parent product.
63 *
64 * @since 10.9.0
65 */
66 public const VARIATION_META_KEY = '_review_variation_id';
67
68 /**
69 * Commentmeta key storing a snapshot of the variation's attribute summary
70 * (e.g. `"Size: Small, Colour: Red"`) at the moment the review was written.
71 *
72 * Captured at write time so historical reviews stay readable even if the
73 * variation is later retired or its attribute taxonomies change.
74 *
75 * @since 10.9.0
76 */
77 public const VARIATION_SUMMARY_META_KEY = '_review_variation_summary';
78
79 /**
80 * Per-request cache for the "did this email review this product (and this
81 * variation) on this order" lookup, keyed by
82 * `order_id|product_id|variation_id|email`. Value is a `WP_Comment` when
83 * one matches, or `null` when the slot has been checked and nothing
84 * matches (so a second call doesn't re-query).
85 *
86 * @var array<string, ?WP_Comment>
87 */
88 private static array $review_cache = array();
89
90 /**
91 * Set of `order_id|email` pairs that have already been bulk-preloaded in
92 * this request, so a repeated `preload_for_items()` call (e.g. once from
93 * the Endpoint and once from the page template) doesn't re-run the query.
94 *
95 * @var array<string, true>
96 */
97 private static array $preloaded = array();
98
99 /**
100 * Register the default filter callbacks the OrderReviews feature ships with.
101 *
102 * Auto-called by the WC dependency container after instantiation.
103 *
104 * @internal
105 */
106 final public function init(): void {
107 add_filter(
108 'woocommerce_review_order_eligible_items',
109 array( self::class, 'exclude_fully_refunded_items' ),
110 10,
111 2
112 );
113
114 // Surface the variation summary captured at submission time on the
115 // single-product Reviews tab, so a review for "Size: Small" doesn't
116 // render indistinguishably from one for "Size: Medium" on the parent
117 // product page.
118 add_action(
119 'woocommerce_review_before_comment_text',
120 array( self::class, 'render_variation_summary' )
121 );
122 }
123
124 /**
125 * Echo the variation summary snapshot for a review comment, when present.
126 *
127 * Wired onto `woocommerce_review_before_comment_text` so the snapshot
128 * stored in `_review_variation_summary` (set by the Customer Review
129 * Request submission flow) appears immediately above the review body on
130 * the single-product Reviews tab. Comments without the meta render
131 * unchanged.
132 *
133 * @since 10.9.0
134 *
135 * @param \WP_Comment $comment Review comment being rendered.
136 */
137 public static function render_variation_summary( \WP_Comment $comment ): void {
138 $summary = (string) get_comment_meta( (int) $comment->comment_ID, self::VARIATION_SUMMARY_META_KEY, true );
139 if ( '' === $summary ) {
140 return;
141 }
142
143 echo '<p class="woocommerce-review__variation-summary">' . esc_html( $summary ) . '</p>';
144 }
145
146 /**
147 * Pre-fill the per-request review cache for a set of items in one query.
148 *
149 * Call this from the template before iterating items so each subsequent
150 * `decide()` / `prefill_for_item()` call hits the cache instead of running
151 * its own `get_comments()` query.
152 *
153 * @since 10.8.0
154 *
155 * @param iterable<WC_Order_Item_Product|mixed> $items Order line items.
156 * @param WC_Order $order Order being reviewed.
157 */
158 public static function preload_for_items( iterable $items, WC_Order $order ): void {
159 $email = $order->get_billing_email();
160 $order_id = $order->get_id();
161 if ( '' === $email || $order_id <= 0 ) {
162 return;
163 }
164
165 $preload_key = $order_id . '|' . $email;
166 if ( isset( self::$preloaded[ $preload_key ] ) ) {
167 return;
168 }
169
170 $product_ids = array();
171 $slots = array();
172 foreach ( $items as $item ) {
173 if ( $item instanceof WC_Order_Item_Product ) {
174 $pid = (int) $item->get_product_id();
175 $vid = (int) $item->get_variation_id();
176 if ( $pid > 0 ) {
177 $product_ids[ $pid ] = $pid;
178 $slots[ self::cache_key( $order_id, $pid, $vid, $email ) ] = true;
179 }
180 }
181 }
182
183 if ( empty( $product_ids ) ) {
184 return;
185 }
186
187 self::$preloaded[ $preload_key ] = true;
188
189 // Default every (product, variation) slot to null so subsequent reads don't re-query.
190 foreach ( $slots as $slot_key => $_ ) {
191 self::$review_cache[ $slot_key ] = null;
192 }
193
194 // Scope to this order's reviews only: a customer who buys the same
195 // product on a later order shouldn't see their old review here.
196 $comments = get_comments(
197 array(
198 'post__in' => array_values( $product_ids ),
199 'author_email' => $email,
200 'type' => 'review',
201 'status' => 'approve',
202 'include_unapproved' => array( $email ),
203 'orderby' => 'comment_date_gmt',
204 'order' => 'DESC',
205 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded by post__in + author_email.
206 array(
207 'key' => self::ORDER_META_KEY,
208 'value' => (string) $order_id,
209 ),
210 ),
211 )
212 );
213
214 if ( is_array( $comments ) ) {
215 foreach ( $comments as $comment ) {
216 if ( ! $comment instanceof WP_Comment ) {
217 continue;
218 }
219 $vid = (int) get_comment_meta( (int) $comment->comment_ID, self::VARIATION_META_KEY, true );
220 $key = self::cache_key( $order_id, (int) $comment->comment_post_ID, $vid, $email );
221 if ( isset( $slots[ $key ] ) && null === self::$review_cache[ $key ] ) {
222 self::$review_cache[ $key ] = $comment;
223 }
224 }
225 }
226 }
227
228 /**
229 * Reset the per-request cache. Test helper.
230 *
231 * @since 10.8.0
232 * @internal
233 */
234 public static function reset_cache(): void {
235 self::$review_cache = array();
236 self::$preloaded = array();
237 }
238
239 /**
240 * Decide how an order line item should render on the Review Order page.
241 *
242 * Returns one of the STATUS_* constants plus the matched comment (when
243 * one exists for this order) and the product id.
244 *
245 * @since 10.8.0
246 *
247 * @param WC_Order_Item_Product $item Order line item.
248 * @param WC_Order $order Order being reviewed.
249 * @return array{status:string, comment:?WP_Comment, product_id:int, variation_id:int}
250 */
251 public static function decide( WC_Order_Item_Product $item, WC_Order $order ): array {
252 $product_id = (int) $item->get_product_id();
253 $variation_id = (int) $item->get_variation_id();
254 $result = array(
255 'status' => self::STATUS_FORM,
256 'comment' => null,
257 'product_id' => $product_id,
258 'variation_id' => $variation_id,
259 );
260
261 if ( $product_id <= 0 || ! comments_open( $product_id ) ) {
262 $result['status'] = self::STATUS_SKIP;
263 return $result;
264 }
265
266 $result['comment'] = self::find_existing_review( $product_id, $variation_id, $order );
267 return $result;
268 }
269
270 /**
271 * Pre-fill payload for a line item: rating, text, and comment id.
272 *
273 * Returns zero/empty values when no review exists for this order's row,
274 * so callers can use it unconditionally.
275 *
276 * @since 10.8.0
277 *
278 * @param WC_Order_Item_Product $item Order line item.
279 * @param WC_Order $order Order being reviewed.
280 * @return array{rating:int, text:string, comment_id:int}
281 */
282 public static function prefill_for_item( WC_Order_Item_Product $item, WC_Order $order ): array {
283 $existing = self::find_existing_review(
284 (int) $item->get_product_id(),
285 (int) $item->get_variation_id(),
286 $order
287 );
288 if ( ! $existing instanceof WP_Comment ) {
289 return array(
290 'rating' => 0,
291 'text' => '',
292 'comment_id' => 0,
293 );
294 }
295
296 $rating = (int) get_comment_meta( (int) $existing->comment_ID, 'rating', true );
297 if ( $rating < 0 || $rating > 5 ) {
298 $rating = 0;
299 }
300
301 return array(
302 'rating' => $rating,
303 'text' => (string) $existing->comment_content,
304 'comment_id' => (int) $existing->comment_ID,
305 );
306 }
307
308 /**
309 * Render the variation's attribute summary as a single flat line.
310 *
311 * Used both at write time (snapshotted into `_review_variation_summary`)
312 * and at render time by the Review Order row template, so the two places
313 * always agree on what label the customer sees and what the comment
314 * stores. Restricted to actual variation attribute slugs so personalisation
315 * / add-on / engraving / gift-message meta from third-party plugins isn't
316 * accidentally folded into the public review snapshot. Returns an empty
317 * string for simple products or when the variation product can no longer
318 * be loaded to identify its attribute slugs.
319 *
320 * Keys in the line item meta are stored without the `attribute_` prefix
321 * (see `WC_Order_Item_Product::set_variation()`), so we strip the prefix
322 * from the live variation's attribute keys to match.
323 *
324 * @since 10.9.0
325 *
326 * @param WC_Order_Item_Product $item Order line item.
327 */
328 public static function format_variation_summary( WC_Order_Item_Product $item ): string {
329 $variation_id = (int) $item->get_variation_id();
330 if ( $variation_id <= 0 ) {
331 return '';
332 }
333
334 $variation = wc_get_product( $variation_id );
335 if ( ! $variation instanceof \WC_Product_Variation ) {
336 return '';
337 }
338
339 $attributes = array();
340 foreach ( array_keys( (array) $variation->get_variation_attributes() ) as $attribute_key ) {
341 $slug = str_replace( 'attribute_', '', (string) $attribute_key );
342 if ( '' === $slug ) {
343 continue;
344 }
345 $value = $item->get_meta( $slug, true );
346 if ( '' === $value || null === $value ) {
347 continue;
348 }
349 $attributes[ $slug ] = $value;
350 }
351
352 if ( empty( $attributes ) ) {
353 return '';
354 }
355
356 return (string) wc_get_formatted_variation( $attributes, true );
357 }
358
359 /**
360 * Whether an order has at least one item the customer can still review.
361 *
362 * Walks the same eligible-items list and per-item decisions the page
363 * renders, so the answer matches what `customer-review-order.php` would
364 * show: items with `STATUS_SKIP` (reviews disabled on the product, or
365 * site-wide via `woocommerce_enable_reviews`) and items already reviewed
366 * on this order are excluded. Any remaining `STATUS_FORM` row without a
367 * matching review counts as actionable.
368 *
369 * Callers in the email pipeline use this to short-circuit scheduling and
370 * sending when the customer would otherwise land on the empty-state page.
371 *
372 * @since 10.9.0
373 *
374 * @param WC_Order $order Order being inspected.
375 * @return bool True when at least one item is still reviewable.
376 */
377 public static function has_actionable_items( WC_Order $order ): bool {
378 /**
379 * Filter the eligible items considered when deciding whether the
380 * Customer Review Request email should fire for an order.
381 *
382 * Same hook the page template, submission handler, and endpoint use,
383 * so all four entry points agree on the eligible-items set.
384 *
385 * @since 10.9.0
386 *
387 * @param WC_Order_Item[] $items Order line items.
388 * @param WC_Order $order The order being inspected.
389 */
390 $items = (array) apply_filters( 'woocommerce_review_order_eligible_items', $order->get_items(), $order );
391 self::preload_for_items( $items, $order );
392
393 foreach ( $items as $item ) {
394 if ( ! $item instanceof WC_Order_Item_Product ) {
395 continue;
396 }
397 $decision = self::decide( $item, $order );
398 if ( self::STATUS_SKIP === $decision['status'] ) {
399 continue;
400 }
401 if ( ! ( $decision['comment'] instanceof WP_Comment ) ) {
402 return true;
403 }
404 }
405
406 return false;
407 }
408
409 /**
410 * Drop fully-refunded line items from the eligible-items list.
411 *
412 * Default callback wired onto `woocommerce_review_order_eligible_items`
413 * so the page never shows a row for a product the customer no longer
414 * owns. A line item is considered fully refunded when the absolute
415 * refunded quantity is greater than or equal to the item's ordered
416 * quantity. Fractional quantities are honoured.
417 *
418 * @since 10.8.0
419 *
420 * @param WC_Order_Item[] $items Order line items.
421 * @param WC_Order $order Order being reviewed.
422 * @return WC_Order_Item[]
423 */
424 public static function exclude_fully_refunded_items( array $items, WC_Order $order ): array {
425 $filtered = array();
426 foreach ( $items as $key => $item ) {
427 if ( ! $item instanceof WC_Order_Item_Product ) {
428 $filtered[ $key ] = $item;
429 continue;
430 }
431
432 $refunded_qty = (float) abs( (float) $order->get_qty_refunded_for_item( $item->get_id() ) );
433 $ordered_qty = (float) $item->get_quantity();
434
435 if ( $ordered_qty > 0 && $refunded_qty >= $ordered_qty ) {
436 continue;
437 }
438
439 $filtered[ $key ] = $item;
440 }
441
442 return $filtered;
443 }
444
445 /**
446 * Look up the customer's review for a specific (product, variation) row on
447 * this order.
448 *
449 * @since 10.8.0
450 *
451 * @param int $product_id Product id.
452 * @param int $variation_id Variation id (0 for simple products).
453 * @param WC_Order $order Order being reviewed.
454 * @return WP_Comment|null
455 */
456 private static function find_existing_review( int $product_id, int $variation_id, WC_Order $order ): ?WP_Comment {
457 $email = $order->get_billing_email();
458 $order_id = (int) $order->get_id();
459 if ( '' === $email || $order_id <= 0 || $product_id <= 0 ) {
460 return null;
461 }
462
463 $key = self::cache_key( $order_id, $product_id, $variation_id, $email );
464 if ( array_key_exists( $key, self::$review_cache ) ) {
465 return self::$review_cache[ $key ];
466 }
467
468 $comments = get_comments(
469 array(
470 'post_id' => $product_id,
471 'author_email' => $email,
472 'type' => 'review',
473 'status' => 'approve',
474 'include_unapproved' => array( $email ),
475 'number' => 1,
476 'orderby' => 'comment_date_gmt',
477 'order' => 'DESC',
478 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- bounded by post_id + author_email.
479 'relation' => 'AND',
480 array(
481 'key' => self::ORDER_META_KEY,
482 'value' => (string) $order_id,
483 ),
484 array(
485 'key' => self::VARIATION_META_KEY,
486 'value' => (string) $variation_id,
487 ),
488 ),
489 )
490 );
491
492 if ( ! is_array( $comments ) || empty( $comments ) ) {
493 self::$review_cache[ $key ] = null;
494 return null;
495 }
496
497 $first = reset( $comments );
498 $found = $first instanceof WP_Comment ? $first : null;
499
500 self::$review_cache[ $key ] = $found;
501 return $found;
502 }
503
504 /**
505 * Build the per-request cache key.
506 *
507 * @param int $order_id Order id.
508 * @param int $product_id Product id.
509 * @param int $variation_id Variation id (0 for simple products).
510 * @param string $email Customer email.
511 */
512 private static function cache_key( int $order_id, int $product_id, int $variation_id, string $email ): string {
513 return $order_id . '|' . $product_id . '|' . $variation_id . '|' . $email;
514 }
515 }
516