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 ) );
+	}
 }