Commit 45076b80178 for woocommerce

commit 45076b80178cd2337b9470d7c0816d62907704ce
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date:   Wed May 13 02:03:04 2026 +0300

    Exclude fully-refunded items from Review Order page (#64531)

    * 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

    * 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

    * Add changefile(s) from automation for the following project(s): woocommerce

    * Apply the eligibility filter inside the completion check

    maybe_mark_order_complete() walked raw $order->get_items() to decide
    whether every product had a review. After this branch ships the refund
    exclusion via woocommerce_review_order_eligible_items, refunded items
    were still counted as 'missing', so a customer who reviewed every
    visible row would never trip the completed-at meta from the submit
    path. Run the same filter the page and submission rows use.

    * 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).

    * Exclude fully-refunded items from Review Order page

    Adds a default callback on woocommerce_review_order_eligible_items so
    the page never lists a row for a product the customer no longer owns.
    A line item is considered fully refunded when the absolute refunded
    quantity equals the item's quantity. Partial refunds keep the row.

    * Resolve ItemEligibility from the container so it can register filters

    * Address Copilot feedback on refunded-item exclusion PR

    - Compare refunded vs ordered quantity as floats so fractional-quantity
      line items (e.g. weight-priced products) are correctly excluded when
      fully refunded.
    - Update the docblock so the wording matches the implementation
      ('greater than or equal to' rather than 'equals').

    * 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

    ---------

    Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/64530-wooplug-6599-empty-state-thank-you b/plugins/woocommerce/changelog/64530-wooplug-6599-empty-state-thank-you
index c508079871d..b7f4bc566e4 100644
--- a/plugins/woocommerce/changelog/64530-wooplug-6599-empty-state-thank-you
+++ b/plugins/woocommerce/changelog/64530-wooplug-6599-empty-state-thank-you
@@ -1,4 +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
+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).
diff --git a/plugins/woocommerce/changelog/64531-wooplug-6600-phpunit-coverage b/plugins/woocommerce/changelog/64531-wooplug-6600-phpunit-coverage
new file mode 100644
index 00000000000..b2705624e83
--- /dev/null
+++ b/plugins/woocommerce/changelog/64531-wooplug-6600-phpunit-coverage
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Exclude fully-refunded line items from the customer Review Order page so customers can't review products they no longer own.
\ No newline at end of file
diff --git a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/ItemEligibilityTest.php b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/ItemEligibilityTest.php
index 89b3d95ea8e..ce8dd2e5ecc 100644
--- a/plugins/woocommerce/tests/php/src/Internal/OrderReviews/ItemEligibilityTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/OrderReviews/ItemEligibilityTest.php
@@ -139,6 +139,71 @@ class ItemEligibilityTest extends WC_Unit_Test_Case {
 		$this->assertNull( $decision['comment'], 'Reviews from a different order must not pre-fill the current row.' );
 	}

+	/**
+	 * @testdox exclude_fully_refunded_items drops items whose full quantity has been refunded.
+	 */
+	public function test_exclude_fully_refunded_items_drops_full_refunds(): void {
+		$built = $this->make_order();
+		$order = $built['order'];
+		$item  = $built['item'];
+
+		wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => $item->get_total(),
+				'line_items' => array(
+					$item->get_id() => array(
+						'qty'          => $item->get_quantity(),
+						'refund_total' => $item->get_total(),
+					),
+				),
+			)
+		);
+
+		$fresh    = wc_get_order( $order->get_id() );
+		$filtered = ItemEligibility::exclude_fully_refunded_items( $fresh->get_items(), $fresh );
+
+		$this->assertCount( 0, $filtered, 'Fully refunded line item should be excluded.' );
+	}
+
+	/**
+	 * @testdox exclude_fully_refunded_items keeps partially-refunded items.
+	 */
+	public function test_exclude_fully_refunded_items_keeps_partial_refunds(): void {
+		$order = OrderHelper::create_order();
+		foreach ( $order->get_items() as $line ) {
+			$order->remove_item( $line->get_id() );
+		}
+		$order->set_billing_email( 'jane@example.test' );
+		$order->set_status( OrderStatus::COMPLETED );
+
+		$product = WC_Helper_Product::create_simple_product();
+		$order->add_product( $product, 3 );
+		$order->save();
+
+		$items = $order->get_items();
+		/** @var WC_Order_Item_Product $item */
+		$item = reset( $items );
+
+		wc_create_refund(
+			array(
+				'order_id'   => $order->get_id(),
+				'amount'     => (float) $item->get_total() / 3,
+				'line_items' => array(
+					$item->get_id() => array(
+						'qty'          => 1,
+						'refund_total' => (float) $item->get_total() / 3,
+					),
+				),
+			)
+		);
+
+		$fresh    = wc_get_order( $order->get_id() );
+		$filtered = ItemEligibility::exclude_fully_refunded_items( $fresh->get_items(), $fresh );
+
+		$this->assertCount( 1, $filtered, 'Partially refunded line item should still be eligible.' );
+	}
+
 	/**
 	 * @testdox decide() ignores reviews without the order meta (default for legacy reviews).
 	 */