Commit b024e89d316 for woocommerce
commit b024e89d316065ec13996f014f493e1ffaa9f2f2
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date: Wed May 13 01:05:47 2026 +0300
Render empty-state thank-you when nothing is left to review (#64530)
* Add accessible star-rating control for Review Order page
Native `<input type="radio">` ✕ 5 wrapped in a `role="radiogroup"` container,
visually replaced by SVG stars. Progressive enhancement adds:
- Keyboard nav: ArrowLeft/Up, ArrowRight/Down, Home, End. Selecting also
moves focus and dispatches `change`, so any future submission listener
reacts naturally.
- Dynamic caption underneath the stars, populated from the focused/checked
radio's `data-label`. Defaults match the existing WC product-review
labels (Very poor / Not that bad / Average / Good / Perfect) and are
filterable via `woocommerce_review_order_rating_labels`.
- High-contrast focus ring on the wrapping label whenever its input is
`:focus-visible`.
- Hover preview lights stars left of the cursor; selection persists when
the cursor leaves.
Without JavaScript the underlying radio group still works as a plain
form field, so the page stays functional.
Files:
- `src/Internal/OrderReviews/StarRating.php` — server-side renderer.
Builds the markup and exposes `get_labels()` (filter-aware, with safe
fallbacks for buggy filters that drop slot keys).
- `templates/order/star-rating.php` — theme-overridable partial.
- `client/legacy/js/frontend/order-review.js` — vanilla JS, no jQuery.
- `client/legacy/css/order-review.scss` — visual states.
- `Internal/OrderReviews/Endpoint::enqueue_assets()` — script + style
enqueued only when the review-order endpoint is actually rendering.
- `templates/order/customer-review-order.php` — temporary preview render
per item so reviewers can interact with the control. M4 (WOOPLUG-6595)
swaps this preview for the real per-item form row.
Tests:
- `Internal\OrderReviews\StarRatingTest` — markup, defaults, filter
override, filter fallback for missing keys.
Closes #64308 (WOOPLUG-6594).
* Add changefile(s) from automation for the following project(s): woocommerce
* Remove duplicate changelog file (workflow generates the canonical one)
* Address Copilot feedback on star-rating control PR
- Route enqueue URLs through woocommerce_get_asset_url filter so
CDN/asset-rewrite setups continue to work.
- Add screen-reader-only 'Required' text alongside the visual asterisk
on the rating label.
- Correct the JS file header comment: only Arrow keys + Home/End are
handled, not Space/Enter.
* Fix StarRating docblock to use flat @param syntax
The PHPDoc shape syntax (array{...}) trips the WC PHPCS sniff, which
expects a single @param line per parameter. Reverted to plain
@param array with the key descriptions in the body.
* Drop the planning comment + minor tidy on star-rating styles
* Load the RTL stylesheet variant on RTL sites
wp_style_add_data( 'wc-order-review', 'rtl', 'replace' ) tells the
classic-assets pipeline to swap order-review.css for order-review-rtl.css
on right-to-left locales.
* Trim defensive fallbacks and dedupe asset enqueue helpers
- StarRating::get_labels(): replace the manual 1-5 fallback loop and
reorder block with array_replace + array_intersect_key.
- StarRating::render(): drop the unused $selected pre-cast; read it
inline only when it's used.
- Endpoint::enqueue_assets(): factor a local closure for the
woocommerce_get_asset_url filter so we don't repeat the same call
for the style and the script.
- order/star-rating.php template: drop the data-caption-id attribute
that duplicated aria-describedby.
- order-review.js: read the caption id from aria-describedby and drop
the empty-input early-out (init() already filters by class).
No behaviour change. All six StarRating tests still pass.
* Address review feedback on the star-rating control
- Add @since 10.8.0 annotations on StarRating::render() and
StarRating::get_labels() docblocks (per ayushpahwa).
- Bound the `selected` argument: values outside 0-5 fall back to
no-selection rather than passing an unrenderable value to the
template (per ayushpahwa).
- Cover the bound with a regression test.
* Address remaining review feedback on the star-rating control
- CSS: drop every `:has()` selector. The template now renders the
inputs in reverse DOM order (5..1) and uses `flex-direction:
row-reverse`; selected/hover/focus states are driven by the `~`
sibling combinator and `:focus-visible + label`. Caption gets
`order: -1` so it stays on the visual right.
- JS: keyboard navigation flips so ArrowRight/Down still moves
visually right (DOM-previous) and Home/End map to the visual
leftmost/rightmost stars.
- Template: pre-compute `$initial_caption` so the caption element no
longer mixes selection logic, escaping, and output in a single
expression.
- Endpoint: enqueue the JS with `strategy => defer` (in addition to
`in_footer => true`) since it only attaches DOM-ready listeners.
* Add accessible star-rating control for Review Order page
Native `<input type="radio">` ✕ 5 wrapped in a `role="radiogroup"` container,
visually replaced by SVG stars. Progressive enhancement adds:
- Keyboard nav: ArrowLeft/Up, ArrowRight/Down, Home, End. Selecting also
moves focus and dispatches `change`, so any future submission listener
reacts naturally.
- Dynamic caption underneath the stars, populated from the focused/checked
radio's `data-label`. Defaults match the existing WC product-review
labels (Very poor / Not that bad / Average / Good / Perfect) and are
filterable via `woocommerce_review_order_rating_labels`.
- High-contrast focus ring on the wrapping label whenever its input is
`:focus-visible`.
- Hover preview lights stars left of the cursor; selection persists when
the cursor leaves.
Without JavaScript the underlying radio group still works as a plain
form field, so the page stays functional.
Files:
- `src/Internal/OrderReviews/StarRating.php` — server-side renderer.
Builds the markup and exposes `get_labels()` (filter-aware, with safe
fallbacks for buggy filters that drop slot keys).
- `templates/order/star-rating.php` — theme-overridable partial.
- `client/legacy/js/frontend/order-review.js` — vanilla JS, no jQuery.
- `client/legacy/css/order-review.scss` — visual states.
- `Internal/OrderReviews/Endpoint::enqueue_assets()` — script + style
enqueued only when the review-order endpoint is actually rendering.
- `templates/order/customer-review-order.php` — temporary preview render
per item so reviewers can interact with the control. M4 (WOOPLUG-6595)
swaps this preview for the real per-item form row.
Tests:
- `Internal\OrderReviews\StarRatingTest` — markup, defaults, filter
override, filter fallback for missing keys.
Closes #64308 (WOOPLUG-6594).
* Add per-item review form row and submit gate
Replaces the temporary preview render from #64525 with the real per-item
form that the Customer Review Request page submits to. Submission handler
itself lands in M4 (WOOPLUG-6596).
`templates/order/customer-review-order-row.php` (new, theme-overridable):
- Linked product title that opens the product page in a new tab.
- 120×120 thumbnail.
- Hidden `product_id` and `order_item_id` so the row is identifiable
on submit.
- The accessible star-rating control from #64525, indexed by `row_index`
for stable POST data.
- Textarea labelled "Your review" with the i18n placeholder
"Share your experience with this product...".
- `do_action( 'woocommerce_review_order_form_fields', $item, $product,
$order, $row_index )` after the textarea so extensions can inject
fields without overriding the whole template.
`templates/order/customer-review-order.php`:
- Wraps the item list in `<form class="woocommerce-review-order__form"
method="post" action="">` with a hidden `order_id`, hidden `key`
(echoed back from the URL the Endpoint already validated), and the
`woocommerce_submit_order_reviews` nonce.
- "Submit reviews" button rendered disabled by default. The JS module
enables it as soon as any row has a rating selected.
`client/legacy/js/frontend/order-review.js` gains `initSubmitGate(form)`,
which listens for `change` events on `.woocommerce-star-rating__input`
inside the form and toggles the button's `disabled` state.
- StarRating's docblock simplified to plain `@param array` so the WC
PHPCS sniffs don't misparse the structured array shorthand.
Closes #64309 (WOOPLUG-6595).
* Add changefile(s) from automation for the following project(s): woocommerce
* Drop M-numbered milestone references from per-item row templates
* Strip non-layout styling from per-item row; let theme drive typography
* Stack the per-item row vertically on viewports under 600px
Folded in from the (now-closed) design-tokens-responsive PR; the only
contribution that survived the simplification was the 12-line media
query, which fits naturally with the per-item layout introduced here.
* Add changefile(s) from automation for the following project(s): woocommerce
* Wire customer-review-order.php to the per-item row partial
The 6595 changes had been dropped during cascading rebases: the form
wrapper, hidden inputs, foreach loop calling
wc_get_template('order/customer-review-order-row.php', ...), and the
submit button were all missing. Restored so each item actually renders
the row partial and the submit gate's selectors exist in the DOM.
* Strengthen the new-tab product link
- rel='noopener noreferrer' (was just 'noopener') to also prevent
referrer leakage in some browsers/configurations.
- Add a .screen-reader-text 'opens in a new tab' suffix so assistive
tech users are warned the link spawns a new window.
* Address per-item-row review feedback
- Collapse the .woocommerce-review-order__item top border to a single
declaration; the previous form layered border-top-color over a
currentColor border, which was redundant.
- Log a console.warn from initSubmitGate when the submit button is
missing so theme overrides that drop it surface the cause.
* Wrap the long console.warn message to satisfy eslint max-len
* Add AJAX submission handler for the Review Order form
`Internal\OrderReviews\SubmissionHandler` registers
`wp_ajax{,_nopriv}_woocommerce_submit_order_reviews`. For each rated row
it inserts a `comment_type='review'` comment against the product, with
`rating` (1-5) and `verified=1` commentmeta. Author fields are pulled
from the order; logged-in customers must own the order, while guests
authenticate via the order key.
Per-row outcome is `ok`, `pending_moderation`, or `error`. One row's
failure does not block the rest. The response shape:
```json
{
"success": true,
"data": {
"results": {
"<row_index>": {
"product_id": 123,
"status": "ok",
"comment_id": 456
}
}
}
}
```
`get_option('comment_moderation')` is honoured: when on, comments are
inserted with `comment_approved=0` and the row reports
`pending_moderation`.
`woocommerce_review_order_submitted` action fires once per submission
with the order and the per-row results array.
`_wc_review_request_completed_at` order meta is set when every line
item on the order has at least one comment by the customer's billing
email — no matter whether those reviews were posted in this submission
or earlier.
JS:
- `client/legacy/js/frontend/order-review.js` intercepts the form's
`submit`, posts via `fetch` to admin-ajax, renders per-row status
messages from the JSON response.
- The form's `action` falls back to admin-ajax.php for the no-JS case
(handler still works server-side).
Wrapper:
- `OrderReviews::init()` now lists `SubmissionHandler` as a third arg,
so the container resolves and auto-init's it. No `class-woocommerce.php`
changes needed.
Tests:
- `Internal\OrderReviews\SubmissionHandlerTest` covers bad nonce, bad
key, valid insert + meta, empty-rating skip, `comment_moderation`,
per-row isolation when one row references a product not on the
order, and the completed-at meta on full vs partial submission.
Closes #64310 (WOOPLUG-6596).
* Add changefile(s) from automation for the following project(s): woocommerce
* Address Copilot feedback on AJAX submission handler PR
- Gate submissions on the same woocommerce_review_order_eligible_statuses
filter the page-load endpoint uses, so a tampered POST with a valid key
cannot bypass status restrictions.
- Stop double-unslashing the row text. handle() already unslashes the
whole reviews array; process_rows() trusted that.
- Make maybe_mark_order_complete() idempotent: skip when the meta is
already set, and replace the per-product N+1 lookup with a single
grouped get_comments() call.
- Localize the JS status messages via wp_localize_script() instead of
hardcoding English strings client-side.
- Test housekeeping: remove the AJAX/JSON filters in tearDown(), and add
a test for the woocommerce_review_order_submitted action plus an
ineligible-status rejection test.
- Sanitize key and nonce inputs explicitly so the WordPress sniffs pass
on the changed lines.
* Drop empty meta_query arg that triggers WC slow-query lint
The empty meta_query => array() option was redundant and the WC sniff
flags the key's presence as a possible slow query.
* Address Copilot feedback (round 2) on AJAX submission handler PR
- Out-of-range ratings (e.g. 6) no longer disappear: only rating==0 is
treated as a deliberate skip; values <1 or >5 now report an
invalid_rating error per row.
- maybe_mark_order_complete() fetches comment objects directly so the
comment_post_ID lookup doesn't need a follow-up get_comment() per
row (was a regression of the original N+1).
- Submit gate is now reused after AJAX completion: expose syncSubmit
on the form, and initAjaxSubmit calls it instead of unconditionally
enabling the button. The button stays disabled when no rows have a
rating selected (e.g. after a failed submit + cleared rating).
* Add per-row status note style; source colors from WC --wc-green / --wc-red
Avoids introducing new --wc-review-order-* tokens. The active theme
controls everything else; we only need ok/error tinting on the inline
status message and we use the WC variables that already exist at :root.
* Point the Review Order form at admin-ajax for the new submission handler
Adds action + data-ajax-url + the action hidden input so the JS submit
gate's fetch() target and the no-JS POST both reach the handler.
* Accept variation_id; exclude spam/trash from completion check
- The Review Order row template posts the variation id for variable
products, but $item->get_product_id() returns the parent. Accept
either match and store the review against the parent so it shows on
the product page regardless of which variation was bought.
- Switch the completion lookup from status=>all to status=>[approve,hold]
so spam/trash reviews can't trip _wc_review_request_completed_at.
- Fix the maybe_mark_order_complete() docblock so its 'verified review'
wording matches the actual behaviour (any non-spam/non-trash review
by the customer counts).
* Filter the network-failure error path to rows with a rating
The catch handler renders an error on every item row, but the earlier
'success: false' branch only flags rows that were actually rated. A
network failure mid-submit now incorrectly tells the customer the rows
they intentionally skipped also failed; mirror the existing 'has a
checked rating' guard so only rated rows surface the error.
* Add already-reviewed locked row state
When the customer has already reviewed a product on the order (matched
by billing email), the row renders a static "Reviewed" summary instead
of the form. Reviews disabled on a product cause the row to be skipped
entirely.
Files:
- `src/Internal/OrderReviews/ItemEligibility.php` — `describe()` returns
one of `STATUS_FORM | STATUS_REVIEWED | STATUS_SKIP` plus the matched
comment when applicable. Wraps the decision in
`woocommerce_review_order_item_already_reviewed` so extensions can
flip either direction.
- `templates/order/customer-review-order-row-reviewed.php` — locked row
partial: linked product title, thumbnail, "Reviewed" badge with
posted-on date, locked star rating + label, truncated review text,
optional "View on product page" link.
- `templates/order/customer-review-order.php` — dispatch on the
`ItemEligibility` decision; calls the existing form-row partial for
pending items and the new reviewed-row partial for matches.
- `client/legacy/css/order-review.scss` — reviewed-row styles
(badge, locked rating layout, blockquote-style review text).
- `tests/php/src/Internal/OrderReviews/ItemEligibilityTest.php` —
defaults, comments-closed, existing-match, different-author, filter
override, filter-args context.
Drive-by: tightened lint warnings in `SubmissionHandler` so branch lint
passes against trunk (alignment, yoda conditions, end-comments).
Closes #64312 (WOOPLUG-6598).
* Add changefile(s) from automation for the following project(s): woocommerce
* Address Copilot feedback on already-reviewed locked row PR
- Eliminate the N+1 query: ItemEligibility::prime() pre-fills a
per-request cache with a single get_comments() call covering every
product on the order, and find_existing_review() now reads that
cache. The template calls prime() once before iterating items.
- Tolerate the filter-only reviewed state: when the
woocommerce_review_order_item_already_reviewed filter forces true
without a backing comment, the locked row template renders the
reviewed state without trying to access comment fields.
- Add tests for both behaviors (prime-caches-results and
filter_forced_reviewed_with_no_comment).
* Remove only this test's comments_pre_query filter, not all of them
remove_all_filters( 'comments_pre_query' ) wiped any other callbacks
on a core hook, which could leak state to neighbouring tests. Hold a
reference to the counter and remove only that one.
* Add quote indent for the locked Reviewed row text
* Close server-side gaps in the Review Order submission handler
Previously the handler validated the row's order_item_id and that the
submitted product_id matched the line item's product_id, but it did
not consult any of the page-side eligibility logic. That left three
attack vectors open to anyone with a valid order key:
1. Submit a duplicate review for a product they already reviewed on
this order (stacks verified-buyer comments).
2. Submit a review for a fully-refunded line item.
3. Submit a review for a product whose owner has disabled comments.
process_rows() now mirrors the page's decision flow:
- index_eligible_order_items() runs the items through the same
woocommerce_review_order_eligible_items filter the page uses, so the
default fully-refunded filter strips refunded items from the index
(rows for them now report invalid_row).
- For each row, ItemEligibility::describe() decides STATUS_FORM /
STATUS_REVIEWED / STATUS_SKIP. REVIEWED returns the new
already_reviewed error, SKIP returns reviews_not_open. Only
STATUS_FORM proceeds to wp_insert_comment().
Three new tests cover the three rejection paths and assert no
duplicate comment is created in any of them. tearDown now also
removes the eligible-items and already_reviewed filters so test
fixtures cannot leak across cases.
* Wire ItemEligibility::describe() dispatch into the Review Order page
Calls prime() before the foreach, then per item:
- STATUS_SKIP -> skip the row entirely (reviews disabled on product)
- STATUS_REVIEWED -> render the locked variant (customer-review-order-row-reviewed.php)
- STATUS_FORM -> render the form row
* Document woocommerce_review_order_eligible_items at the handler call; soften reviewed quote border
- Add a hook docblock at the handler's apply_filters call so the
contract is discoverable from both the template and the API path.
- Drop the hardcoded rgba(0,0,0,0.1) on the reviewed-row left border;
use a plain 'border-left: 2px solid' so currentColor is honoured and
dark themes get a visible accent.
* Tighten already-reviewed lookups and the locked-row link
- ItemEligibility: get_comments() now uses status=>approve plus
include_unapproved=>[customer_email] so spam/trash reviews can't
trip STATUS_REVIEWED and re-block a legitimate retry, while the
customer's own pending review still counts. Both find_existing_review()
and prime() are updated to match.
- customer-review-order-row-reviewed.php: link to the parent product
permalink (via $item->get_product_id()) instead of the variation
permalink. Reviews are stored against the parent, so the link should
match for consistency.
- ItemEligibilityTest::test_prime_caches_results: wrap the describe()
call in try/finally so the comments_pre_query filter is always
removed even if an assertion throws.
* Fix PHPStan parameter.phpDocType: fully-qualify WC_Order_Item
The hook docblock declared @param WC_Order_Item[] without a leading
backslash, so PHPStan resolved it to
Automattic\WooCommerce\Internal\OrderReviews\WC_Order_Item which
doesn't exist. Use \WC_Order_Item[] so PHPStan picks up the global
WC class.
* Wire ItemEligibility::describe() dispatch into the Review Order page
Calls prime() before the foreach, then per item:
- STATUS_SKIP -> skip the row entirely (reviews disabled on product)
- STATUS_REVIEWED -> render the locked variant (customer-review-order-row-reviewed.php)
- STATUS_FORM -> render the form row
* Close server-side gaps in the Review Order submission handler
Previously the handler validated the row's order_item_id and that the
submitted product_id matched the line item's product_id, but it did
not consult any of the page-side eligibility logic. That left three
attack vectors open to anyone with a valid order key:
1. Submit a duplicate review for a product they already reviewed on
this order (stacks verified-buyer comments).
2. Submit a review for a fully-refunded line item.
3. Submit a review for a product whose owner has disabled comments.
process_rows() now mirrors the page's decision flow:
- index_eligible_order_items() runs the items through the same
woocommerce_review_order_eligible_items filter the page uses, so the
default fully-refunded filter strips refunded items from the index
(rows for them now report invalid_row).
- For each row, ItemEligibility::describe() decides STATUS_FORM /
STATUS_REVIEWED / STATUS_SKIP. REVIEWED returns the new
already_reviewed error, SKIP returns reviews_not_open. Only
STATUS_FORM proceeds to wp_insert_comment().
Three new tests cover the three rejection paths and assert no
duplicate comment is created in any of them. tearDown now also
removes the eligible-items and already_reviewed filters so test
fixtures cannot leak across cases.
* Add changefile(s) from automation for the following project(s): woocommerce
* Render empty-state thank-you when nothing is left to review
Pre-computes ItemEligibility decisions once, then dispatches:
- If the resulting set has no `STATUS_FORM` items, render the new empty
state template instead of the form.
- The empty state shows a thank-you heading, a short body, a summary of
reviews left on this order (count + average stars), and a "Continue
shopping" link to the shop page.
- If the customer has reviewed at least one item but the submission
handler hasn't already set `_wc_review_request_completed_at`, the
template sets it now — the empty-state visit doubles as completion
proof for late-arriving reviews from other channels.
Files:
- `templates/order/customer-review-order-empty.php` — new partial.
- `templates/order/customer-review-order.php` — pre-compute decisions,
branch on form-rows / empty-state, drop the inline `apply_filters`
loop in favour of the cached `$decisions` array.
- `client/legacy/css/order-review.scss` — empty-state card styling.
Closes #64313 (WOOPLUG-6599).
* Fix lint in empty-state thank-you template
* Add changefile(s) from automation for the following project(s): woocommerce
* Address Copilot feedback on empty-state thank-you PR
- Set the order completed-at meta whenever the empty-state renders, not
only when reviews were counted. The empty-state can be reached via
STATUS_SKIP (reviews disabled) or filter-forced STATUS_REVIEWED with
no backing comment, and back-button completion should still record
the moment.
- Switch the headline and lead paragraph based on whether any reviews
were left; rendering 'Thanks for your reviews!' with 0 reviews was
misleading.
* Move empty-state side effects out of template into Endpoint
- Endpoint::render() now stamps the no-actionable-rows meta before
delegating to the template, keeping the template purely view-side.
- Template primes the comment-meta cache via update_meta_cache() so the
per-comment rating reads no longer hit one query each.
- COMPLETED_META_KEY docblock updated to document both completion
paths (submission handler + Endpoint stamp on empty-state load).
* Wire empty-state thank-you dispatch into the Review Order page
The template now precomputes a array, falls through to the
customer-review-order-empty.php partial when no STATUS_FORM rows
remain, and uses the precomputed decisions inside the form loop so
describe() runs at most once per item.
* Drop stale 64528 changelog and tighten the empty-state wording
PR #64528 was closed without merging, so the auto-generated changelog
file that landed on this branch is irrelevant; remove it. Update the
empty-state changelog wording so it covers the no-actionable-rows
case (already-reviewed or skipped), not just already-reviewed.
* Add EndpointTest coverage for maybe_mark_no_actionable_rows
- Stamp meta when no STATUS_FORM rows remain on page load.
- Skip when at least one actionable row is present.
- Never overwrite an existing value (idempotency).
* Polish empty-state path: cleaner i18n, single-loop has_form_rows, tearDown cache reset
- customer-review-order.php: fold the has_form_rows check into the
decisions-building foreach so we don't iterate $decisions twice.
- customer-review-order-empty.php: replace the hardcoded ' (' / ')'
rating-suffix concatenation with two separate translatable
templates so locales can re-order the rating phrase freely.
- Endpoint::maybe_mark_no_actionable_rows(): drop the unused
$item->get_product() hydration; describe() handles non-product/
missing-product items via STATUS_SKIP already.
- EndpointTest::tearDown(): always call ItemEligibility::reset_cache()
so a failing assertion doesn't leak the per-request cache into
subsequent tests; remove the per-test reset_cache() calls.
* Bump template @version stamps to match the 10.9.0 release
Both templates were modified materially in this branch; bump the
@version header so theme overrides flag stale copies on update.
* Match the template's empty-state guards in the controller and dedupe
Two issues with the empty-state path:
1. maybe_mark_no_actionable_rows iterated the eligible items but didn't
skip rows where get_product() returns null. The template does (a
deleted product renders nothing). For an order whose only items are
deleted products, the page would show the empty state while
_wc_review_request_completed_at stayed unset because describe()
reports STATUS_FORM for deleted products with comments_open === true.
Mirror the template's hydration check so the controller stays
consistent with what the customer actually sees.
2. The empty-state summary counted reviewed rows, not underlying
comments. Multiple line items mapping to the same parent (variations,
quantity-split lines) all point at the same review, so a single
review showed as 'You left 2 reviews' and the rating got averaged
into itself. Track seen comment IDs so each review is counted once.
* Pin template @version stamps to 10.8.0
Earlier this branch bumped the customer-review-order.php and
customer-review-order-empty.php template headers to 10.9.0 in
anticipation of the next release. The Customer Review Request feature
ships in 10.8.0 instead, so revert both stamps.
* Add changefile(s) from automation for the following project(s): woocommerce
* Address review feedback on the AJAX submission handler
- maybe_mark_order_complete(): build the eligible-row set through the
same `woocommerce_review_order_eligible_items` filter the page uses,
count required reviews per parent product (so duplicate or
multi-variation rows need their full count of reviews), and pass
number=>0 to get_comments() so the default 20-row cap doesn't
produce false negatives on large orders.
- process_rows(): only attribute the inserted comment to a WP user
when the current request is authenticated as that user. Guests
reaching the page via the order key now get user_id=0 instead of
the order's customer id.
- customer-review-order.php: wrap the hidden `action` value in
esc_attr() for consistency with adjacent hidden inputs.
- SubmissionHandlerTest: add coverage for the `invalid_rating` and
`product_mismatch` error codes (40 tests, was 38).
* Resolve ItemEligibility from the container
ItemEligibility was introduced as a static utility, but a follow-up PR
(#64531) makes it register a default filter callback on
woocommerce_review_order_eligible_items. The container resolution is
where that registration is wired in, so introducing it alongside the
class itself is the natural home.
* Pre-fill the form row with the existing review instead of locking it
Reshapes WOOPLUG-6598 to match the Figma intent and customer behaviour
we want:
- No separate "locked Reviewed" row template, no badge, no
"Reviewed on X" sentence. Drops
templates/order/customer-review-order-row-reviewed.php.
- The regular form row is pre-filled with the customer's existing
rating + text when they already have a review tied to *this order*.
Customers can edit and resubmit.
- Submissions update the matching comment in place (rating meta +
content) when one exists for this order; otherwise insert as before.
Server-side lookup uses (product_id, author_email, _review_order_id)
so a tampered POST cannot edit someone else's comment.
- A review tied to a *different* order doesn't surface here, so a
repeat purchase of the same product gets a fresh empty form. The
customer's experience may legitimately differ between purchases.
- New commentmeta `_review_order_id` stamps each inserted review with
its source order. Legacy reviews without the meta are treated as
"from a different / unknown order" — they don't pre-fill or block.
- ItemEligibility drops STATUS_REVIEWED (no consumer left) and gains
prefill_for_item(). decide() returns STATUS_FORM | STATUS_SKIP plus
the existing comment (or null) for the row.
- The woocommerce_review_order_item_already_reviewed filter is gone:
the locked-row decision it gated no longer exists.
Tests: 24 across ItemEligibility + SubmissionHandler pass, including
new coverage for same-order update, previous-order non-blocking, and
legacy-meta-missing scenarios.
* Add changefile(s) from automation for the following project(s): woocommerce
* Fix branch-lint warnings: array alignment, docblock params, equals alignment
* Address PR #64530 review feedback
- Make ItemEligibility::preload_for_items idempotent per order+email so
the Endpoint + template double-call doesn't issue two identical queries
- Reset the eligibility cache at the start of SubmissionHandler::process_rows
so multi-submission tests get fresh state
- Clarify maybe_mark_no_actionable_rows docblock: scope differs from
SubmissionHandler::maybe_mark_order_complete (order-scoped vs. lifetime)
- Document that all-skip orders intentionally get stamped (matches what
the empty-state view renders)
- Wrap the completion-meta save() in try/catch + wc_get_logger, like
Scheduler logs its skips
- Dedup the inline "side effect lives in the controller" comment from
both call sites; the helper docblock now carries that explanation
- SubmissionHandler::COMPLETED_META_KEY docblock: clarify that
pending-moderation reviews also count toward completion
- EndpointTest::test_completed_meta_is_not_overwritten: drop order items
so the foreach reaches the stamping step and the guard is exercised
- Drop redundant \$customer_email re-assignment in customer-review-order.php
- Empty-state template: fall back to home_url('/') when shop page is
missing, mirroring Endpoint::gate_request
- Drop the always-true if (\$has_unreviewed_row) wrapper around the form;
the early return above guarantees it
- Changelog wording: cover the skipped-because-reviews-disabled path too
* Add changefile(s) from automation for the following project(s): woocommerce
* fix: apply CodeRabbit auto-fixes
Fixed 1 file(s) based on 1 unresolved review comment.
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
diff --git a/plugins/woocommerce/changelog/64530-wooplug-6599-empty-state-thank-you b/plugins/woocommerce/changelog/64530-wooplug-6599-empty-state-thank-you
new file mode 100644
index 00000000000..c508079871d
--- /dev/null
+++ b/plugins/woocommerce/changelog/64530-wooplug-6599-empty-state-thank-you
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Render an empty-state thank-you view on the customer Review Order page when no actionable rows remain (every eligible item is already reviewed or skipped because reviews are disabled).
\ No newline at end of file
diff --git a/plugins/woocommerce/client/legacy/css/order-review.scss b/plugins/woocommerce/client/legacy/css/order-review.scss
index 42e7051ed52..b1a5c792e10 100644
--- a/plugins/woocommerce/client/legacy/css/order-review.scss
+++ b/plugins/woocommerce/client/legacy/css/order-review.scss
@@ -66,6 +66,14 @@
}
}
+ // Locked "Reviewed" row variant: indent the quoted review with a
+ // subtle left rule that inherits the theme's text color.
+ &__item-reviewed-text {
+ margin: 0.5em 0 0;
+ padding: 0 0 0 0.75em;
+ border-left: 2px solid;
+ }
+
@media (max-width: 600px) {
&__item-row {
flex-direction: column;
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php b/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php
index 3d3cadcd73b..6007decc9fe 100644
--- a/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php
+++ b/plugins/woocommerce/src/Internal/OrderReviews/Endpoint.php
@@ -253,6 +253,10 @@ class Endpoint {
exit;
}
+ if ( $order instanceof WC_Order ) {
+ $this->maybe_mark_no_actionable_rows( $order );
+ }
+
// template_redirect fires after wp_enqueue_scripts but before
// wp_head, so styles registered here are still output in <head>.
$this->enqueue_assets();
@@ -304,9 +308,71 @@ class Endpoint {
return;
}
+ if ( $order instanceof WC_Order ) {
+ $this->maybe_mark_no_actionable_rows( $order );
+ }
+
wc_get_template( 'order/customer-review-order.php', array( 'order' => $order ) );
}
+ /**
+ * Stamp the completed-at meta when the Review Order page would render the
+ * empty-state, so back-button visits and direct revisits also record
+ * completion. The persistent write lives here, in the controller, so the
+ * page template stays read-only.
+ *
+ * Scope differs from `SubmissionHandler::maybe_mark_order_complete()`:
+ * that one counts the customer's reviews per product across all of their
+ * history, while this one walks the per-item decisions ItemEligibility
+ * produces (order-scoped, mirroring exactly what the page renders).
+ *
+ * @param WC_Order $order Order being reviewed.
+ */
+ private function maybe_mark_no_actionable_rows( WC_Order $order ): void {
+ $completed_meta_key = SubmissionHandler::COMPLETED_META_KEY;
+ if ( $order->get_meta( $completed_meta_key ) ) {
+ return;
+ }
+
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- documented on customer-review-order.php template.
+ $items = (array) apply_filters( 'woocommerce_review_order_eligible_items', $order->get_items(), $order );
+ ItemEligibility::preload_for_items( $items, $order );
+
+ foreach ( $items as $item ) {
+ if ( ! $item instanceof \WC_Order_Item_Product ) {
+ continue;
+ }
+ $decision = ItemEligibility::decide( $item, $order );
+ // Skip rows are intentionally treated as "done": an order whose
+ // items all have reviews disabled renders the empty-state, so we
+ // stamp completion to match what the customer sees on the page.
+ if ( ItemEligibility::STATUS_SKIP === $decision['status'] ) {
+ continue;
+ }
+ // Any non-skip row without a review tied to this order means the
+ // customer still has something to submit — order isn't complete.
+ if ( ! ( $decision['comment'] instanceof \WP_Comment ) ) {
+ return;
+ }
+ }
+
+ $order->update_meta_data( $completed_meta_key, (string) time() );
+
+ try {
+ $order->save();
+ } catch ( \Exception $e ) {
+ wc_get_logger()->warning(
+ sprintf(
+ /* translators: 1: order ID, 2: error message */
+ __( 'Could not stamp Review Order completion meta on order %1$d: %2$s.', 'woocommerce' ),
+ $order->get_id(),
+ $e->getMessage()
+ ),
+ array( 'source' => 'order-reviews' )
+ );
+ }
+ }
+
/**
* Build the public, tokenised URL for an order's review-order page.
*
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/ItemEligibility.php b/plugins/woocommerce/src/Internal/OrderReviews/ItemEligibility.php
index bfa561adac3..220fe10aba9 100644
--- a/plugins/woocommerce/src/Internal/OrderReviews/ItemEligibility.php
+++ b/plugins/woocommerce/src/Internal/OrderReviews/ItemEligibility.php
@@ -64,6 +64,15 @@ class ItemEligibility {
*/
private static array $review_cache = array();
+ /**
+ * Set of `order_id|email` pairs that have already been bulk-preloaded in
+ * this request, so a repeated `preload_for_items()` call (e.g. once from
+ * the Endpoint and once from the page template) doesn't re-run the query.
+ *
+ * @var array<string, true>
+ */
+ private static array $preloaded = array();
+
/**
* Register the default filter callbacks the OrderReviews feature ships with.
*
@@ -99,6 +108,11 @@ class ItemEligibility {
return;
}
+ $preload_key = $order_id . '|' . $email;
+ if ( isset( self::$preloaded[ $preload_key ] ) ) {
+ return;
+ }
+
$product_ids = array();
foreach ( $items as $item ) {
if ( $item instanceof WC_Order_Item_Product ) {
@@ -113,6 +127,8 @@ class ItemEligibility {
return;
}
+ self::$preloaded[ $preload_key ] = true;
+
// Scope to this order's reviews only: a customer who buys the same
// product on a later order shouldn't see their old review here.
$comments = get_comments(
@@ -159,6 +175,7 @@ class ItemEligibility {
*/
public static function reset_cache(): void {
self::$review_cache = array();
+ self::$preloaded = array();
}
/**
diff --git a/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php b/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
index cd91ca5f859..37b46c0a2ce 100644
--- a/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
+++ b/plugins/woocommerce/src/Internal/OrderReviews/SubmissionHandler.php
@@ -29,8 +29,13 @@ class SubmissionHandler {
public const ACTION = 'woocommerce_submit_order_reviews';
/**
- * Order meta set when every eligible item has been reviewed by the
- * matching author.
+ * Order meta stamped with the time the Review Order page first had no
+ * actionable rows left.
+ *
+ * Set by the submission handler once every eligible item has a review by
+ * this customer (approved or pending moderation), and also by the Endpoint
+ * when the page is loaded with no actionable rows (e.g. all items are
+ * already-reviewed or skipped because reviews are disabled on the products).
*/
public const COMPLETED_META_KEY = '_wc_review_request_completed_at';
@@ -124,8 +129,12 @@ class SubmissionHandler {
$author_agent = $order->get_customer_user_agent();
$require_mod = (bool) get_option( 'comment_moderation' );
- // Preload the eligibility cache so the per-row decide() calls below
- // don't issue one already-reviewed query each.
+ // Drop any per-request memoisation a prior caller may have populated,
+ // then preload the eligibility cache so the per-row decide() calls
+ // below don't issue one already-reviewed query each. Reset matters
+ // inside the suite (multiple submissions in one PHP process) and is
+ // a no-op in production (admin-ajax runs in a fresh process).
+ ItemEligibility::reset_cache();
ItemEligibility::preload_for_items( $item_index, $order );
foreach ( $rows_in as $row_index => $row ) {
diff --git a/plugins/woocommerce/templates/order/customer-review-order-empty.php b/plugins/woocommerce/templates/order/customer-review-order-empty.php
new file mode 100644
index 00000000000..cdb9ea72063
--- /dev/null
+++ b/plugins/woocommerce/templates/order/customer-review-order-empty.php
@@ -0,0 +1,93 @@
+<?php
+/**
+ * Customer Review Order — empty-state thank-you view.
+ *
+ * Theme-overridable. Copy to `yourtheme/woocommerce/order/customer-review-order-empty.php`.
+ *
+ * Rendered when every eligible line item on the order is either already
+ * reviewed by the customer or skipped (reviews disabled on the product),
+ * so there is nothing left to do on the form.
+ *
+ * @see https://woocommerce.com/document/template-structure/
+ * @package WooCommerce\Templates
+ * @version 10.8.0
+ *
+ * @var WC_Order $order Order being reviewed.
+ * @var int $reviewed_count Number of reviews this customer left on this order.
+ * @var float $average_rating Average rating across those reviews (0.0 if none).
+ */
+
+defined( 'ABSPATH' ) || exit;
+
+if ( ! $order instanceof WC_Order ) {
+ return;
+}
+
+// Fall back to the site home if the shop page is missing, mirroring how
+// `Endpoint::gate_request()` handles a missing host page.
+$shop_url = wc_get_page_permalink( 'shop' );
+$cta_url = $shop_url ? $shop_url : home_url( '/' );
+?>
+<div class="woocommerce-review-order woocommerce-review-order--empty">
+ <div class="woocommerce-review-order__empty-card">
+ <h1 class="woocommerce-review-order__empty-title">
+ <?php
+ if ( $reviewed_count > 0 ) {
+ esc_html_e( 'Thanks for your reviews!', 'woocommerce' );
+ } else {
+ esc_html_e( 'Nothing to review here', 'woocommerce' );
+ }
+ ?>
+ </h1>
+
+ <p class="woocommerce-review-order__empty-body">
+ <?php
+ if ( $reviewed_count > 0 ) {
+ esc_html_e( 'You have nothing left to review on this order. Your feedback helps other shoppers make better decisions.', 'woocommerce' );
+ } else {
+ esc_html_e( 'There are no products on this order that are open for reviews right now.', 'woocommerce' );
+ }
+ ?>
+ </p>
+
+ <?php if ( $reviewed_count > 0 ) : ?>
+ <p class="woocommerce-review-order__empty-summary">
+ <?php
+ if ( $average_rating > 0 ) {
+ $avg = number_format_i18n( $average_rating, 1 );
+ /* translators: 1: number of reviews left, 2: average rating with one decimal, e.g. "4.5" */
+ $summary_template = _n(
+ 'You left %1$d review on this order (average rating %2$s out of 5).',
+ 'You left %1$d reviews on this order (average rating %2$s out of 5).',
+ (int) $reviewed_count,
+ 'woocommerce'
+ );
+ printf(
+ esc_html( $summary_template ),
+ (int) $reviewed_count,
+ esc_html( $avg )
+ );
+ } else {
+ /* translators: %d: number of reviews left */
+ $summary_template = _n(
+ 'You left %d review on this order.',
+ 'You left %d reviews on this order.',
+ (int) $reviewed_count,
+ 'woocommerce'
+ );
+ printf(
+ esc_html( $summary_template ),
+ (int) $reviewed_count
+ );
+ }//end if
+ ?>
+ </p>
+ <?php endif; ?>
+
+ <p class="woocommerce-review-order__empty-actions">
+ <a class="button" href="<?php echo esc_url( $cta_url ); ?>">
+ <?php esc_html_e( 'Continue shopping', 'woocommerce' ); ?>
+ </a>
+ </p>
+ </div>
+</div>
diff --git a/plugins/woocommerce/templates/order/customer-review-order.php b/plugins/woocommerce/templates/order/customer-review-order.php
index 59f9c0a8e9b..3b832e048ba 100644
--- a/plugins/woocommerce/templates/order/customer-review-order.php
+++ b/plugins/woocommerce/templates/order/customer-review-order.php
@@ -69,9 +69,16 @@ $meta_parts = array_filter(
*/
$items = (array) apply_filters( 'woocommerce_review_order_eligible_items', $order->get_items(), $order );
-// Pre-filter to the rows we can actually render so the <form> doesn't open
-// when every item is non-product or has a deleted product.
-$renderable_rows = array();
+// Single batched lookup of every existing review by this customer for the
+// items below. Without this each decide() call would issue its own query.
+\Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility::preload_for_items( $items, $order );
+
+// Pre-compute one decision per item so we know whether to render the form
+// (any item still missing a review for this order) or fall through to the
+// empty-state thank-you (every renderable item already has a review tied
+// to this order).
+$decisions = array();
+$has_unreviewed_row = false;
foreach ( $items as $item ) {
if ( ! $item instanceof WC_Order_Item_Product ) {
continue;
@@ -80,11 +87,78 @@ foreach ( $items as $item ) {
if ( ! $product instanceof WC_Product ) {
continue;
}
- $renderable_rows[] = array(
- 'item' => $item,
- 'product' => $product,
+
+ $decision = \Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility::decide( $item, $order );
+ if ( \Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility::STATUS_SKIP === $decision['status'] ) {
+ continue;
+ }
+
+ if ( ! ( $decision['comment'] instanceof WP_Comment ) ) {
+ $has_unreviewed_row = true;
+ }
+
+ $decisions[] = array(
+ 'item' => $item,
+ 'product' => $product,
+ 'decision' => $decision,
);
-}
+}//end foreach
+
+// Empty-state: no actionable rows remain. The Endpoint already stamped the
+// completion meta before we got here, so this branch is purely the view.
+if ( ! $has_unreviewed_row ) {
+ $reviewed_count = 0;
+ $rating_total = 0;
+ $rating_n = 0;
+
+ if ( '' !== $customer_email ) {
+ $comment_ids = array();
+ foreach ( $decisions as $entry ) {
+ $existing_review = $entry['decision']['comment'] ?? null;
+ if ( $existing_review instanceof WP_Comment ) {
+ $comment_ids[] = (int) $existing_review->comment_ID;
+ }
+ }
+ if ( ! empty( $comment_ids ) ) {
+ update_meta_cache( 'comment', $comment_ids );
+ }
+
+ // Multiple line items can map to the same review (same parent
+ // product on different variations or quantity-split lines). Count
+ // each underlying comment once so the customer-facing summary
+ // matches what they actually wrote.
+ $counted = array();
+ foreach ( $decisions as $entry ) {
+ $existing_review = $entry['decision']['comment'] ?? null;
+ if ( ! $existing_review instanceof WP_Comment ) {
+ continue;
+ }
+ $cid = (int) $existing_review->comment_ID;
+ if ( isset( $counted[ $cid ] ) ) {
+ continue;
+ }
+ $counted[ $cid ] = true;
+ ++$reviewed_count;
+ $rating = (int) get_comment_meta( $cid, 'rating', true );
+ if ( $rating > 0 ) {
+ $rating_total += $rating;
+ ++$rating_n;
+ }
+ }//end foreach
+ }//end if
+
+ $average_rating = $rating_n > 0 ? round( $rating_total / $rating_n, 1 ) : 0.0;
+
+ wc_get_template(
+ 'order/customer-review-order-empty.php',
+ array(
+ 'order' => $order,
+ 'reviewed_count' => $reviewed_count,
+ 'average_rating' => $average_rating,
+ )
+ );
+ return;
+}//end if
// Single batched lookup of every existing review by this customer for the
// items below. Without this each decide() call would issue its own query.
@@ -111,55 +185,51 @@ $order_key = (string) $order->get_order_key();
<?php esc_html_e( '* Mandatory fields', 'woocommerce' ); ?>
</p>
- <?php if ( ! empty( $renderable_rows ) ) : ?>
- <form
- class="woocommerce-review-order__form"
- method="post"
- action="<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>"
- data-ajax-url="<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>"
- novalidate
- >
- <input type="hidden" name="action" value="<?php echo esc_attr( 'woocommerce_submit_order_reviews' ); ?>" />
- <input type="hidden" name="order_id" value="<?php echo esc_attr( (string) $order->get_id() ); ?>" />
- <input type="hidden" name="key" value="<?php echo esc_attr( $order_key ); ?>" />
- <?php wp_nonce_field( 'woocommerce_submit_order_reviews', '_wcnonce' ); ?>
-
- <ul class="woocommerce-review-order__items">
- <?php
- $row_index = 0;
- foreach ( $renderable_rows as $row ) {
- $decision = \Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility::decide( $row['item'], $order );
-
- if ( \Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility::STATUS_SKIP === $decision['status'] ) {
- continue;
- }
-
- $prefill = \Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility::prefill_for_item( $row['item'], $order );
-
- wc_get_template(
- 'order/customer-review-order-row.php',
- array(
- 'item' => $row['item'],
- 'product' => $row['product'],
- 'order' => $order,
- 'row_index' => $row_index,
- 'existing_rating' => $prefill['rating'],
- 'existing_text' => $prefill['text'],
- )
- );
- ++$row_index;
- }
- ?>
- </ul>
-
- <div class="woocommerce-review-order__actions">
- <button
- type="submit"
- class="woocommerce-review-order__submit button"
- >
- <?php esc_html_e( 'Submit reviews', 'woocommerce' ); ?>
- </button>
- </div>
- </form>
- <?php endif; ?>
+ <form
+ class="woocommerce-review-order__form"
+ method="post"
+ action="<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>"
+ data-ajax-url="<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>"
+ novalidate
+ >
+ <input type="hidden" name="action" value="<?php echo esc_attr( 'woocommerce_submit_order_reviews' ); ?>" />
+ <input type="hidden" name="order_id" value="<?php echo esc_attr( (string) $order->get_id() ); ?>" />
+ <input type="hidden" name="key" value="<?php echo esc_attr( $order_key ); ?>" />
+ <?php wp_nonce_field( 'woocommerce_submit_order_reviews', '_wcnonce' ); ?>
+
+ <ul class="woocommerce-review-order__items">
+ <?php
+ $row_index = 0;
+ foreach ( $decisions as $entry ) {
+ $item = $entry['item'];
+ $product = $entry['product'];
+ $decision = $entry['decision'];
+
+ $prefill = \Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility::prefill_for_item( $item, $order );
+
+ wc_get_template(
+ 'order/customer-review-order-row.php',
+ array(
+ 'item' => $item,
+ 'product' => $product,
+ 'order' => $order,
+ 'row_index' => $row_index,
+ 'existing_rating' => $prefill['rating'],
+ 'existing_text' => $prefill['text'],
+ )
+ );
+ ++$row_index;
+ }
+ ?>
+ </ul>
+
+ <div class="woocommerce-review-order__actions">
+ <button
+ type="submit"
+ class="woocommerce-review-order__submit button"
+ >
+ <?php esc_html_e( 'Submit reviews', 'woocommerce' ); ?>
+ </button>
+ </div>
+ </form>
</div>
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/EndpointTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/EndpointTest.php
index 3f2b6bf7d5a..dd149d3f192 100644
--- a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/EndpointTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/EndpointTest.php
@@ -5,7 +5,10 @@ namespace Automattic\WooCommerce\Tests\Internal\OrderReviews;
use Automattic\WooCommerce\Enums\OrderStatus;
use Automattic\WooCommerce\Internal\OrderReviews\Endpoint;
+use Automattic\WooCommerce\Internal\OrderReviews\ItemEligibility;
+use Automattic\WooCommerce\Internal\OrderReviews\SubmissionHandler;
use Automattic\WooCommerce\RestApi\UnitTests\Helpers\OrderHelper;
+use WC_Helper_Product;
use WC_Unit_Test_Case;
use WP_Query;
@@ -235,4 +238,88 @@ class EndpointTest extends WC_Unit_Test_Case {
$this->assertFalse( $wp_query->is_404 );
$this->assertStringContainsString( 'woocommerce-review-order', $html );
}
+
+ /**
+ * @testdox Loading the page when no actionable rows remain stamps the completed-at meta.
+ */
+ public function test_no_actionable_rows_stamps_completed_meta(): void {
+ $order = OrderHelper::create_order();
+ $product = WC_Helper_Product::create_simple_product();
+ $order->set_billing_email( 'reviewed@example.test' );
+ $order->set_status( OrderStatus::COMPLETED );
+ // Wipe the helper's default item, attach our reviewable product.
+ foreach ( $order->get_items() as $item ) {
+ $order->remove_item( $item->get_id() );
+ }
+ $order->add_product( $product, 1 );
+ $order->save();
+
+ // Pre-create a matching review tied to this order so decide() surfaces
+ // the existing comment and the page treats every row as already reviewed.
+ $comment_id = (int) wp_insert_comment(
+ array(
+ 'comment_post_ID' => $product->get_id(),
+ 'comment_author' => 'Already',
+ 'comment_author_email' => 'reviewed@example.test',
+ 'comment_content' => 'Was good.',
+ 'comment_type' => 'review',
+ 'comment_approved' => 1,
+ )
+ );
+ add_comment_meta( $comment_id, ItemEligibility::ORDER_META_KEY, (int) $order->get_id(), true );
+
+ $_GET = array( 'key' => $order->get_order_key() );
+
+ $this->render( $order->get_id() );
+
+ $fresh = wc_get_order( $order->get_id() );
+ $this->assertNotEmpty( $fresh->get_meta( SubmissionHandler::COMPLETED_META_KEY ) );
+ }
+
+ /**
+ * @testdox Loading the page with at least one actionable row leaves the completed-at meta unset.
+ */
+ public function test_actionable_row_does_not_stamp_completed_meta(): void {
+ $order = OrderHelper::create_order();
+ $product = WC_Helper_Product::create_simple_product();
+ $order->set_billing_email( 'fresh@example.test' );
+ $order->set_status( OrderStatus::COMPLETED );
+ foreach ( $order->get_items() as $item ) {
+ $order->remove_item( $item->get_id() );
+ }
+ $order->add_product( $product, 1 );
+ $order->save();
+
+ $_GET = array( 'key' => $order->get_order_key() );
+
+ $this->render( $order->get_id() );
+
+ $fresh = wc_get_order( $order->get_id() );
+ $this->assertEmpty( $fresh->get_meta( SubmissionHandler::COMPLETED_META_KEY ) );
+ }
+
+ /**
+ * @testdox The completed-at meta is never overwritten on subsequent loads.
+ */
+ public function test_completed_meta_is_not_overwritten(): void {
+ $order = OrderHelper::create_order();
+ $order->set_status( OrderStatus::COMPLETED );
+ // Empty the order so the no-actionable-rows path falls through and
+ // would re-stamp `time()` if the early-return guard were removed;
+ // keeping items here lets the loop's unreviewed-item bail-out hide
+ // the guard's effect and the test would pass without it.
+ foreach ( $order->get_items() as $item ) {
+ $order->remove_item( $item->get_id() );
+ }
+ $preset = (string) ( time() - 3600 );
+ $order->update_meta_data( SubmissionHandler::COMPLETED_META_KEY, $preset );
+ $order->save();
+
+ $_GET = array( 'key' => $order->get_order_key() );
+
+ $this->render( $order->get_id() );
+
+ $fresh = wc_get_order( $order->get_id() );
+ $this->assertSame( $preset, (string) $fresh->get_meta( SubmissionHandler::COMPLETED_META_KEY ) );
+ }
}