From 89db98c3e40be6654da7f5fb9be08ba87071d2cc Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 17:15:17 -0300 Subject: [PATCH 01/56] feat(fast-checkout): add grouped/NYP attributes and provided context --- src/blocks/fast-checkout/block.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/blocks/fast-checkout/block.json b/src/blocks/fast-checkout/block.json index 18c347fc3..0bbe1acf1 100644 --- a/src/blocks/fast-checkout/block.json +++ b/src/blocks/fast-checkout/block.json @@ -11,11 +11,17 @@ "product": { "type": "string" }, "variation": { "type": "string" }, "is_variable": { "type": "boolean", "default": false }, + "is_grouped": { "type": "boolean", "default": false }, + "is_nyp": { "type": "boolean", "default": false }, + "grouped_child": { "type": "string" }, + "nyp_price": { "type": "string" }, "afterSuccessURL": { "type": "string" } }, "providesContext": { "newspack-blocks/fastCheckoutProductId": "product", - "newspack-blocks/fastCheckoutVariationId": "variation" + "newspack-blocks/fastCheckoutVariationId": "variation", + "newspack-blocks/fastCheckoutGroupedChild": "grouped_child", + "newspack-blocks/fastCheckoutNypPrice": "nyp_price" }, "supports": { "anchor": true, From 930008c94756fe6d717fecb17e161b16cba4c03c Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 17:18:32 -0300 Subject: [PATCH 02/56] feat(fast-checkout): resolve grouped child product ID from attrs --- includes/class-fast-checkout.php | 11 ++++++++--- tests/test-fast-checkout.php | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php index 9221fa4b3..28a39ee1b 100644 --- a/includes/class-fast-checkout.php +++ b/includes/class-fast-checkout.php @@ -96,10 +96,15 @@ public static function resolve_product_id_from_attrs( $attrs ) { if ( empty( $attrs['product'] ) ) { return null; } - $product_id = (int) $attrs['product']; - $is_variable = ! empty( $attrs['is_variable'] ); - $variation = ! empty( $attrs['variation'] ) ? (int) $attrs['variation'] : 0; + $product_id = (int) $attrs['product']; + $is_variable = ! empty( $attrs['is_variable'] ); + $is_grouped = ! empty( $attrs['is_grouped'] ); + $variation = ! empty( $attrs['variation'] ) ? (int) $attrs['variation'] : 0; + $grouped_child = ! empty( $attrs['grouped_child'] ) ? (int) $attrs['grouped_child'] : 0; + if ( $is_grouped && $grouped_child ) { + return $grouped_child; + } if ( $is_variable && $variation ) { return $variation; } diff --git a/tests/test-fast-checkout.php b/tests/test-fast-checkout.php index dc76a1b22..b42ed0714 100644 --- a/tests/test-fast-checkout.php +++ b/tests/test-fast-checkout.php @@ -426,6 +426,38 @@ public function test_return_url_override_ignores_unrelated_orders() { $this->assertSame( 'https://default.com', $result ); } + // ---- Grouped product resolution tests ---- + + /** + * Test that a grouped product with grouped_child resolves to the child ID. + */ + public function test_resolve_grouped_prefers_child() { + $result = Fast_Checkout::resolve_product_id_from_attrs( + [ + 'product' => '42', + 'grouped_child' => '88', + 'is_grouped' => true, + ] + ); + $this->assertSame( 88, $result ); + } + + /** + * Test that a grouped product without grouped_child returns null + * (server-side will resolve first child via wc_get_product at runtime). + */ + public function test_resolve_grouped_without_child_returns_parent() { + $result = Fast_Checkout::resolve_product_id_from_attrs( + [ + 'product' => '42', + 'is_grouped' => true, + ] + ); + // Without runtime WC lookup, the helper returns the parent ID. + // Runtime resolution to the first child happens in maybe_replace_cart. + $this->assertSame( 42, $result ); + } + /** * Test that a mismatched product in the cart is replaced. */ From 4c7c9496f248c3ce6d1564705d9bddb2c2cea940 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 17:22:16 -0300 Subject: [PATCH 03/56] docs(fast-checkout): clarify resolve_product_id_from_attrs docblocks --- includes/class-fast-checkout.php | 5 +++-- tests/test-fast-checkout.php | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php index 28a39ee1b..31870874d 100644 --- a/includes/class-fast-checkout.php +++ b/includes/class-fast-checkout.php @@ -86,11 +86,12 @@ public static function reset_cache() { /** * Resolve the effective product ID from block attributes. * - * Returns the variation ID when the product is variable and a variation is set, + * Returns the grouped child ID when the product is grouped and a child is set, + * the variation ID when the product is variable and a variation is set, * otherwise the product ID. Returns null when no product attribute is present. * * @param array $attrs Block attributes. - * @return int|null Product or variation ID, or null. + * @return int|null Product, variation, or grouped child ID, or null. */ public static function resolve_product_id_from_attrs( $attrs ) { if ( empty( $attrs['product'] ) ) { diff --git a/tests/test-fast-checkout.php b/tests/test-fast-checkout.php index b42ed0714..ea70e6e7e 100644 --- a/tests/test-fast-checkout.php +++ b/tests/test-fast-checkout.php @@ -443,8 +443,8 @@ public function test_resolve_grouped_prefers_child() { } /** - * Test that a grouped product without grouped_child returns null - * (server-side will resolve first child via wc_get_product at runtime). + * Test that a grouped product without grouped_child returns the parent ID. + * Server-side runtime resolution to the first child happens in maybe_replace_cart. */ public function test_resolve_grouped_without_child_returns_parent() { $result = Fast_Checkout::resolve_product_id_from_attrs( From efa150b59276dd023ad0a614d40c75c711b5508e Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 17:40:46 -0300 Subject: [PATCH 04/56] feat(fast-checkout): read fc_grouped_child query param --- includes/class-fast-checkout.php | 20 ++++++++++++------ tests/test-fast-checkout.php | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php index 31870874d..4a15dde11 100644 --- a/includes/class-fast-checkout.php +++ b/includes/class-fast-checkout.php @@ -47,12 +47,13 @@ final class Fast_Checkout { /** * Query parameter names. */ - const QP_EMAIL = 'fc_email'; - const QP_QTY = 'fc_qty'; - const QP_COUPON = 'fc_coupon'; - const QP_VARIATION = 'fc_variation'; - const QP_PRICE = 'fc_price'; - const QP_SUCCESS = 'fc_success'; + const QP_EMAIL = 'fc_email'; + const QP_QTY = 'fc_qty'; + const QP_COUPON = 'fc_coupon'; + const QP_VARIATION = 'fc_variation'; + const QP_GROUPED_CHILD = 'fc_grouped_child'; + const QP_PRICE = 'fc_price'; + const QP_SUCCESS = 'fc_success'; /** * Cache of post ID → product ID lookups. @@ -305,6 +306,13 @@ private static function get_query_params() { $params['variation'] = (int) $variation; } + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $grouped_child_raw = isset( $_GET[ self::QP_GROUPED_CHILD ] ) ? sanitize_text_field( wp_unslash( $_GET[ self::QP_GROUPED_CHILD ] ) ) : ''; + $grouped_child = filter_var( $grouped_child_raw, FILTER_SANITIZE_NUMBER_INT ); + if ( $grouped_child && (int) $grouped_child > 0 ) { + $params['grouped_child'] = (int) $grouped_child; + } + $price = filter_input( INPUT_GET, self::QP_PRICE, FILTER_SANITIZE_SPECIAL_CHARS ); if ( $price && is_numeric( $price ) && (float) $price > 0 ) { $params['price'] = (float) $price; diff --git a/tests/test-fast-checkout.php b/tests/test-fast-checkout.php index ea70e6e7e..2df12f460 100644 --- a/tests/test-fast-checkout.php +++ b/tests/test-fast-checkout.php @@ -458,6 +458,41 @@ public function test_resolve_grouped_without_child_returns_parent() { $this->assertSame( 42, $result ); } + // ---- Query param tests ---- + + /** + * Test that fc_grouped_child query param is read into params. + */ + public function test_get_query_params_reads_grouped_child() { + $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] = '123'; + + // Reflect-call the private static method. + $reflection = new ReflectionClass( Fast_Checkout::class ); + $method = $reflection->getMethod( 'get_query_params' ); + $method->setAccessible( true ); + $params = $method->invoke( null ); + + $this->assertSame( 123, $params['grouped_child'] ?? null ); + + unset( $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] ); + } + + /** + * Test that an invalid (non-numeric) fc_grouped_child is dropped. + */ + public function test_get_query_params_rejects_invalid_grouped_child() { + $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] = 'not-a-number'; + + $reflection = new ReflectionClass( Fast_Checkout::class ); + $method = $reflection->getMethod( 'get_query_params' ); + $method->setAccessible( true ); + $params = $method->invoke( null ); + + $this->assertArrayNotHasKey( 'grouped_child', $params ); + + unset( $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] ); + } + /** * Test that a mismatched product in the cart is replaced. */ From d1816774548e4749b58e6ee130cac36c6a601bc4 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 17:47:37 -0300 Subject: [PATCH 05/56] refactor(fast-checkout): tidy fc_grouped_child sanitization and test helper --- includes/class-fast-checkout.php | 10 +++++----- tests/test-fast-checkout.php | 22 +++++++++++++--------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php index 4a15dde11..14837e4d7 100644 --- a/includes/class-fast-checkout.php +++ b/includes/class-fast-checkout.php @@ -306,11 +306,11 @@ private static function get_query_params() { $params['variation'] = (int) $variation; } - // phpcs:ignore WordPress.Security.NonceVerification.Recommended - $grouped_child_raw = isset( $_GET[ self::QP_GROUPED_CHILD ] ) ? sanitize_text_field( wp_unslash( $_GET[ self::QP_GROUPED_CHILD ] ) ) : ''; - $grouped_child = filter_var( $grouped_child_raw, FILTER_SANITIZE_NUMBER_INT ); - if ( $grouped_child && (int) $grouped_child > 0 ) { - $params['grouped_child'] = (int) $grouped_child; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $grouped_child_raw = isset( $_GET[ self::QP_GROUPED_CHILD ] ) ? wp_unslash( $_GET[ self::QP_GROUPED_CHILD ] ) : ''; + $grouped_child = (int) filter_var( $grouped_child_raw, FILTER_SANITIZE_NUMBER_INT ); + if ( $grouped_child > 0 ) { + $params['grouped_child'] = $grouped_child; } $price = filter_input( INPUT_GET, self::QP_PRICE, FILTER_SANITIZE_SPECIAL_CHARS ); diff --git a/tests/test-fast-checkout.php b/tests/test-fast-checkout.php index 2df12f460..f1c9cb9df 100644 --- a/tests/test-fast-checkout.php +++ b/tests/test-fast-checkout.php @@ -21,6 +21,17 @@ public function set_up() { Fast_Checkout::reset_cache(); } + /** + * Invoke the private static get_query_params method via Reflection. + * + * @return array + */ + private function invoke_get_query_params(): array { + $method = ( new ReflectionClass( Fast_Checkout::class ) )->getMethod( 'get_query_params' ); + $method->setAccessible( true ); + return $method->invoke( null ); + } + /** * Test that a simple product attribute resolves to the product ID. */ @@ -466,11 +477,7 @@ public function test_resolve_grouped_without_child_returns_parent() { public function test_get_query_params_reads_grouped_child() { $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] = '123'; - // Reflect-call the private static method. - $reflection = new ReflectionClass( Fast_Checkout::class ); - $method = $reflection->getMethod( 'get_query_params' ); - $method->setAccessible( true ); - $params = $method->invoke( null ); + $params = $this->invoke_get_query_params(); $this->assertSame( 123, $params['grouped_child'] ?? null ); @@ -483,10 +490,7 @@ public function test_get_query_params_reads_grouped_child() { public function test_get_query_params_rejects_invalid_grouped_child() { $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] = 'not-a-number'; - $reflection = new ReflectionClass( Fast_Checkout::class ); - $method = $reflection->getMethod( 'get_query_params' ); - $method->setAccessible( true ); - $params = $method->invoke( null ); + $params = $this->invoke_get_query_params(); $this->assertArrayNotHasKey( 'grouped_child', $params ); From 190a6a7a4e5ccaa53fb3c907c66e5949b9644118 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 18:41:47 -0300 Subject: [PATCH 06/56] feat(fast-checkout): resolve grouped child for cart replacement --- includes/class-fast-checkout.php | 86 ++++++++++++++++++---- tests/test-fast-checkout.php | 122 +++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 13 deletions(-) diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php index 14837e4d7..54a1cfcd8 100644 --- a/includes/class-fast-checkout.php +++ b/includes/class-fast-checkout.php @@ -156,6 +156,68 @@ private static function find_fast_checkout_block_product( $blocks ) { return null; } + /** + * Get the parsed attributes of the first Fast Checkout block in the post. + * + * @param \WP_Post $post Post object. + * @return array Block attributes. + */ + private static function get_block_attributes( $post ) { + $blocks = parse_blocks( $post->post_content ); + $block = self::find_fast_checkout_block( $blocks ); + return $block['attrs'] ?? []; + } + + /** + * Resolve the effective product ID for cart replacement, applying query + * param overrides and runtime first-child fallback for grouped products. + * + * @param array $attrs Block attributes. + * @param array $qp Sanitized query params. + * @return int|null Resolved product/variation ID, or null. + */ + private static function resolve_effective_product_id( $attrs, $qp ) { + if ( empty( $attrs['product'] ) ) { + return null; + } + $is_grouped = ! empty( $attrs['is_grouped'] ); + $is_variable = ! empty( $attrs['is_variable'] ); + + // Grouped: query param override → attribute → first child (runtime). + if ( $is_grouped ) { + $parent = (int) $attrs['product']; + $valid_children = []; + if ( function_exists( 'wc_get_product' ) ) { + $parent_product = wc_get_product( $parent ); + if ( $parent_product && method_exists( $parent_product, 'get_children' ) ) { + $valid_children = array_map( 'intval', $parent_product->get_children() ); + } + } + if ( ! empty( $qp['grouped_child'] ) && in_array( (int) $qp['grouped_child'], $valid_children, true ) ) { + return (int) $qp['grouped_child']; + } + if ( ! empty( $attrs['grouped_child'] ) && in_array( (int) $attrs['grouped_child'], $valid_children, true ) ) { + return (int) $attrs['grouped_child']; + } + if ( ! empty( $valid_children ) ) { + return (int) $valid_children[0]; + } + return $parent; + } + + // Variable: existing logic, with query param override. + if ( $is_variable ) { + if ( ! empty( $qp['variation'] ) ) { + return (int) $qp['variation']; + } + if ( ! empty( $attrs['variation'] ) ) { + return (int) $attrs['variation']; + } + } + + return (int) $attrs['product']; + } + /** * Register the block bindings source. */ @@ -344,14 +406,10 @@ public static function maybe_replace_cart() { return; } - $qp = self::get_query_params(); - $product_id = self::get_block_product_id( $post ); - $quantity = $qp['qty'] ?? 1; - - // fc_variation overrides the block attribute. - if ( ! empty( $qp['variation'] ) ) { - $product_id = $qp['variation']; - } + $qp = self::get_query_params(); + $attrs = self::get_block_attributes( $post ); + $product_id = self::resolve_effective_product_id( $attrs, $qp ); + $quantity = $qp['qty'] ?? 1; if ( ! $product_id ) { return; @@ -368,11 +426,13 @@ public static function maybe_replace_cart() { // Idempotency: if the cart already has exactly the right item, do nothing. $cart_contents = $cart->get_cart(); if ( 1 === count( $cart_contents ) && empty( $qp ) ) { - $item = reset( $cart_contents ); - $matches_id = ( $product->is_type( 'variation' ) ) - ? (int) $item['variation_id'] === $product_id - : (int) $item['product_id'] === $product_id; - if ( $matches_id && (int) $quantity === (int) $item['quantity'] ) { + $item = reset( $cart_contents ); + $cart_pid = $product->is_type( 'variation' ) + ? (int) $item['variation_id'] + : (int) $item['product_id']; + $matches_id = $cart_pid === $product_id; + $matches_qty = (int) $quantity === (int) $item['quantity']; + if ( $matches_id && $matches_qty ) { return; } } diff --git a/tests/test-fast-checkout.php b/tests/test-fast-checkout.php index f1c9cb9df..c9dc3cbf6 100644 --- a/tests/test-fast-checkout.php +++ b/tests/test-fast-checkout.php @@ -236,6 +236,21 @@ private function create_simple_product() { return $product; } + /** + * Create a grouped WC product with the given children. + * + * @param int[] $child_ids Existing child product IDs. + * @return \WC_Product_Grouped + */ + private function create_grouped_product( $child_ids ) { + $product = new \WC_Product_Grouped(); + $product->set_name( 'Test Grouped Product' ); + $product->set_status( 'publish' ); + $product->set_children( $child_ids ); + $product->save(); + return $product; + } + /** * Test that maybe_replace_cart does nothing when no block is present. */ @@ -522,4 +537,111 @@ public function test_maybe_replace_cart_replaces_mismatched() { $item = reset( $cart_contents ); $this->assertSame( $product_b->get_id(), (int) $item['product_id'] ); } + + // ---- Cart replacement: grouped product tests ---- + + /** + * Test that maybe_replace_cart adds grouped_child product to cart when set. + */ + public function test_maybe_replace_cart_adds_grouped_child() { + $this->skip_without_wc(); + + $child = $this->create_simple_product(); + $grouped = $this->create_grouped_product( [ $child->get_id() ] ); + $post_id = $this->make_fast_checkout_post( + $grouped->get_id(), + [ + 'is_grouped' => true, + 'grouped_child' => (string) $child->get_id(), + ] + ); + $this->go_to( get_permalink( $post_id ) ); + + Fast_Checkout::maybe_replace_cart(); + + $cart_contents = WC()->cart->get_cart(); + $this->assertCount( 1, $cart_contents ); + $item = reset( $cart_contents ); + $this->assertSame( $child->get_id(), (int) $item['product_id'] ); + } + + /** + * Test that maybe_replace_cart resolves to the first child when grouped_child is empty. + */ + public function test_maybe_replace_cart_grouped_falls_back_to_first_child() { + $this->skip_without_wc(); + + $first = $this->create_simple_product(); + $second = $this->create_simple_product(); + $grouped = $this->create_grouped_product( [ $first->get_id(), $second->get_id() ] ); + $post_id = $this->make_fast_checkout_post( + $grouped->get_id(), + [ 'is_grouped' => true ] + ); + $this->go_to( get_permalink( $post_id ) ); + + Fast_Checkout::maybe_replace_cart(); + + $cart_contents = WC()->cart->get_cart(); + $this->assertCount( 1, $cart_contents ); + $item = reset( $cart_contents ); + $this->assertSame( $first->get_id(), (int) $item['product_id'] ); + } + + /** + * Test that fc_grouped_child query param overrides grouped_child attribute. + */ + public function test_maybe_replace_cart_grouped_query_param_override() { + $this->skip_without_wc(); + + $first = $this->create_simple_product(); + $second = $this->create_simple_product(); + $grouped = $this->create_grouped_product( [ $first->get_id(), $second->get_id() ] ); + $post_id = $this->make_fast_checkout_post( + $grouped->get_id(), + [ + 'is_grouped' => true, + 'grouped_child' => (string) $first->get_id(), + ] + ); + + $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] = (string) $second->get_id(); + $this->go_to( get_permalink( $post_id ) ); + + Fast_Checkout::maybe_replace_cart(); + + $cart_contents = WC()->cart->get_cart(); + $this->assertCount( 1, $cart_contents ); + $item = reset( $cart_contents ); + $this->assertSame( $second->get_id(), (int) $item['product_id'] ); + + unset( $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] ); + } + + /** + * Test that fc_grouped_child referencing a non-child is rejected (falls back). + */ + public function test_maybe_replace_cart_grouped_query_param_rejects_foreign_id() { + $this->skip_without_wc(); + + $first = $this->create_simple_product(); + $foreign = $this->create_simple_product(); + $grouped = $this->create_grouped_product( [ $first->get_id() ] ); + $post_id = $this->make_fast_checkout_post( + $grouped->get_id(), + [ 'is_grouped' => true ] + ); + + $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] = (string) $foreign->get_id(); + $this->go_to( get_permalink( $post_id ) ); + + Fast_Checkout::maybe_replace_cart(); + + $cart_contents = WC()->cart->get_cart(); + $this->assertCount( 1, $cart_contents ); + $item = reset( $cart_contents ); + $this->assertSame( $first->get_id(), (int) $item['product_id'] ); + + unset( $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] ); + } } From 606939e4911a4eb6e6f2e8f70421b1d014ad9aba Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 18:56:00 -0300 Subject: [PATCH 07/56] refactor(fast-checkout): cache parsed blocks once per post --- includes/class-fast-checkout.php | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php index 54a1cfcd8..9faad0dbf 100644 --- a/includes/class-fast-checkout.php +++ b/includes/class-fast-checkout.php @@ -62,6 +62,13 @@ final class Fast_Checkout { */ private static $post_product_cache = []; + /** + * Cache of post ID → parsed block array. + * + * @var array + */ + private static $post_blocks_cache = []; + /** * Initialize hooks. */ @@ -82,6 +89,20 @@ public static function init() { */ public static function reset_cache() { self::$post_product_cache = []; + self::$post_blocks_cache = []; + } + + /** + * Parse the post's blocks once, caching the result per post ID. + * + * @param \WP_Post $post Post object. + * @return array Parsed block tree. + */ + private static function get_parsed_blocks( $post ) { + if ( ! isset( self::$post_blocks_cache[ $post->ID ] ) ) { + self::$post_blocks_cache[ $post->ID ] = parse_blocks( $post->post_content ); + } + return self::$post_blocks_cache[ $post->ID ]; } /** @@ -129,7 +150,7 @@ public static function get_block_product_id( $post ) { if ( isset( self::$post_product_cache[ $post->ID ] ) ) { return self::$post_product_cache[ $post->ID ]; } - $blocks = parse_blocks( $post->post_content ); + $blocks = self::get_parsed_blocks( $post ); $result = self::find_fast_checkout_block_product( $blocks ); self::$post_product_cache[ $post->ID ] = $result; return $result; @@ -163,7 +184,7 @@ private static function find_fast_checkout_block_product( $blocks ) { * @return array Block attributes. */ private static function get_block_attributes( $post ) { - $blocks = parse_blocks( $post->post_content ); + $blocks = self::get_parsed_blocks( $post ); $block = self::find_fast_checkout_block( $blocks ); return $block['attrs'] ?? []; } @@ -553,7 +574,7 @@ private static function get_after_success_url( $post_id ) { if ( ! $post ) { return ''; } - $blocks = parse_blocks( $post->post_content ); + $blocks = self::get_parsed_blocks( $post ); $block = self::find_fast_checkout_block( $blocks ); if ( ! $block ) { return ''; From 513bfc5bea79f80b802e674ad952a715031e27d0 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 19:03:44 -0300 Subject: [PATCH 08/56] refactor(fast-checkout): unify get_query_params to read from $_GET --- includes/class-fast-checkout.php | 75 ++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 23 deletions(-) diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php index 9faad0dbf..3fdb50439 100644 --- a/includes/class-fast-checkout.php +++ b/includes/class-fast-checkout.php @@ -369,41 +369,70 @@ public static function filter_checkout_actions_block( $parsed_block ) { private static function get_query_params() { $params = []; - $email = filter_input( INPUT_GET, self::QP_EMAIL, FILTER_SANITIZE_EMAIL ); - if ( $email && is_email( $email ) ) { - $params['email'] = $email; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( isset( $_GET[ self::QP_EMAIL ] ) ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $email = sanitize_email( wp_unslash( $_GET[ self::QP_EMAIL ] ) ); + if ( $email && is_email( $email ) ) { + $params['email'] = $email; + } } - $qty = filter_input( INPUT_GET, self::QP_QTY, FILTER_SANITIZE_NUMBER_INT ); - if ( $qty && (int) $qty > 0 ) { - $params['qty'] = (int) $qty; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( isset( $_GET[ self::QP_QTY ] ) ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $qty_raw = wp_unslash( $_GET[ self::QP_QTY ] ); + $qty = (int) filter_var( $qty_raw, FILTER_SANITIZE_NUMBER_INT ); + if ( $qty > 0 ) { + $params['qty'] = $qty; + } } - $coupon = filter_input( INPUT_GET, self::QP_COUPON, FILTER_SANITIZE_SPECIAL_CHARS ); - if ( $coupon ) { - $params['coupon'] = $coupon; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( isset( $_GET[ self::QP_COUPON ] ) ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $coupon = sanitize_text_field( wp_unslash( $_GET[ self::QP_COUPON ] ) ); + if ( $coupon ) { + $params['coupon'] = $coupon; + } } - $variation = filter_input( INPUT_GET, self::QP_VARIATION, FILTER_SANITIZE_NUMBER_INT ); - if ( $variation && (int) $variation > 0 ) { - $params['variation'] = (int) $variation; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( isset( $_GET[ self::QP_VARIATION ] ) ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $variation_raw = wp_unslash( $_GET[ self::QP_VARIATION ] ); + $variation = (int) filter_var( $variation_raw, FILTER_SANITIZE_NUMBER_INT ); + if ( $variation > 0 ) { + $params['variation'] = $variation; + } } - // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - $grouped_child_raw = isset( $_GET[ self::QP_GROUPED_CHILD ] ) ? wp_unslash( $_GET[ self::QP_GROUPED_CHILD ] ) : ''; - $grouped_child = (int) filter_var( $grouped_child_raw, FILTER_SANITIZE_NUMBER_INT ); - if ( $grouped_child > 0 ) { - $params['grouped_child'] = $grouped_child; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( isset( $_GET[ self::QP_GROUPED_CHILD ] ) ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $grouped_child_raw = wp_unslash( $_GET[ self::QP_GROUPED_CHILD ] ); + $grouped_child = (int) filter_var( $grouped_child_raw, FILTER_SANITIZE_NUMBER_INT ); + if ( $grouped_child > 0 ) { + $params['grouped_child'] = $grouped_child; + } } - $price = filter_input( INPUT_GET, self::QP_PRICE, FILTER_SANITIZE_SPECIAL_CHARS ); - if ( $price && is_numeric( $price ) && (float) $price > 0 ) { - $params['price'] = (float) $price; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( isset( $_GET[ self::QP_PRICE ] ) ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $price_raw = sanitize_text_field( wp_unslash( $_GET[ self::QP_PRICE ] ) ); + if ( is_numeric( $price_raw ) && (float) $price_raw > 0 ) { + $params['price'] = (float) $price_raw; + } } - $success = filter_input( INPUT_GET, self::QP_SUCCESS, FILTER_SANITIZE_URL ); - if ( $success ) { - $params['success'] = $success; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( isset( $_GET[ self::QP_SUCCESS ] ) ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $success = esc_url_raw( wp_unslash( $_GET[ self::QP_SUCCESS ] ) ); + if ( $success ) { + $params['success'] = $success; + } } return $params; From d958124bea0c2b1481237f009d8cc0065077ffa6 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 19:06:10 -0300 Subject: [PATCH 09/56] feat(fast-checkout): apply nyp_price attribute as cart price fallback --- includes/class-fast-checkout.php | 17 ++++++++++------ tests/test-fast-checkout.php | 34 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php index 3fdb50439..be3ab68f9 100644 --- a/includes/class-fast-checkout.php +++ b/includes/class-fast-checkout.php @@ -497,14 +497,19 @@ public static function maybe_replace_cart() { $cart_item_data['_fc_success_url'] = $qp['success']; } - // Handle Name Your Price via fc_price. - if ( ! empty( $qp['price'] ) ) { - if ( class_exists( '\WC_Name_Your_Price_Helpers' ) && \WC_Name_Your_Price_Helpers::is_nyp( $product_id ) ) { - $price = $qp['price']; + // Handle Name Your Price: query param > attribute > suggested. + if ( class_exists( '\WC_Name_Your_Price_Helpers' ) && \WC_Name_Your_Price_Helpers::is_nyp( $product_id ) ) { + $price = null; + if ( ! empty( $qp['price'] ) ) { + $price = (float) $qp['price']; + } elseif ( ! empty( $attrs['nyp_price'] ) && is_numeric( $attrs['nyp_price'] ) ) { + $price = (float) $attrs['nyp_price']; + } + if ( null !== $price ) { $min_price = \WC_Name_Your_Price_Helpers::get_minimum_price( $product_id ); $max_price = \WC_Name_Your_Price_Helpers::get_maximum_price( $product_id ); - $price = ! empty( $max_price ) ? min( $price, $max_price ) : $price; - $price = ! empty( $min_price ) ? max( $price, $min_price ) : $price; + $price = ! empty( $max_price ) ? min( $price, (float) $max_price ) : $price; + $price = ! empty( $min_price ) ? max( $price, (float) $min_price ) : $price; $cart_item_data['nyp'] = (float) \WC_Name_Your_Price_Helpers::standardize_number( $price ); } } diff --git a/tests/test-fast-checkout.php b/tests/test-fast-checkout.php index c9dc3cbf6..1f668ced3 100644 --- a/tests/test-fast-checkout.php +++ b/tests/test-fast-checkout.php @@ -644,4 +644,38 @@ public function test_maybe_replace_cart_grouped_query_param_rejects_foreign_id() unset( $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] ); } + + /** + * Test that maybe_replace_cart applies nyp_price attribute when fc_price is absent. + * + * Skips when WC Name Your Price plugin isn't active. + */ + public function test_maybe_replace_cart_applies_nyp_attribute() { + $this->skip_without_wc(); + if ( ! class_exists( '\WC_Name_Your_Price_Helpers' ) ) { + $this->markTestSkipped( 'WC Name Your Price not available.' ); + } + + $product = $this->create_simple_product(); + update_post_meta( $product->get_id(), '_nyp', 'yes' ); + update_post_meta( $product->get_id(), '_min_price', '5' ); + update_post_meta( $product->get_id(), '_max_price', '50' ); + update_post_meta( $product->get_id(), '_suggested_price', '15' ); + + $post_id = $this->make_fast_checkout_post( + $product->get_id(), + [ + 'is_nyp' => true, + 'nyp_price' => '20.00', + ] + ); + $this->go_to( get_permalink( $post_id ) ); + + Fast_Checkout::maybe_replace_cart(); + + $cart_contents = WC()->cart->get_cart(); + $this->assertCount( 1, $cart_contents ); + $item = reset( $cart_contents ); + $this->assertSame( 20.0, (float) $item['nyp'] ); + } } From 763f87d8e3057f7747df5d17a3a2fcb3f0d336ef Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 19:11:14 -0300 Subject: [PATCH 10/56] test(fast-checkout): cover fc_price overrides nyp_price attribute --- tests/test-fast-checkout.php | 38 ++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/test-fast-checkout.php b/tests/test-fast-checkout.php index 1f668ced3..be44543b5 100644 --- a/tests/test-fast-checkout.php +++ b/tests/test-fast-checkout.php @@ -678,4 +678,42 @@ public function test_maybe_replace_cart_applies_nyp_attribute() { $item = reset( $cart_contents ); $this->assertSame( 20.0, (float) $item['nyp'] ); } + + /** + * Test that fc_price query param overrides nyp_price attribute when both set. + * + * Skips when WC Name Your Price plugin isn't active. + */ + public function test_maybe_replace_cart_nyp_query_param_overrides_attribute() { + $this->skip_without_wc(); + if ( ! class_exists( '\WC_Name_Your_Price_Helpers' ) ) { + $this->markTestSkipped( 'WC Name Your Price not available.' ); + } + + $product = $this->create_simple_product(); + update_post_meta( $product->get_id(), '_nyp', 'yes' ); + update_post_meta( $product->get_id(), '_min_price', '5' ); + update_post_meta( $product->get_id(), '_max_price', '50' ); + update_post_meta( $product->get_id(), '_suggested_price', '15' ); + + $post_id = $this->make_fast_checkout_post( + $product->get_id(), + [ + 'is_nyp' => true, + 'nyp_price' => '20.00', + ] + ); + + $_GET[ Fast_Checkout::QP_PRICE ] = '35.00'; + $this->go_to( get_permalink( $post_id ) ); + + Fast_Checkout::maybe_replace_cart(); + + $cart_contents = WC()->cart->get_cart(); + $this->assertCount( 1, $cart_contents ); + $item = reset( $cart_contents ); + $this->assertSame( 35.0, (float) $item['nyp'] ); + + unset( $_GET[ Fast_Checkout::QP_PRICE ] ); + } } From d30518e2341b840cc97e59e10d965c0281a6244c Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 19:14:58 -0300 Subject: [PATCH 11/56] refactor(fast-checkout): model grouped/NYP attributes in types --- src/blocks/fast-checkout/types.ts | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/blocks/fast-checkout/types.ts b/src/blocks/fast-checkout/types.ts index dfddf89d4..047abc9e9 100644 --- a/src/blocks/fast-checkout/types.ts +++ b/src/blocks/fast-checkout/types.ts @@ -5,10 +5,20 @@ export interface StoreApiProduct { name: string; short_description: string; price_html: string; - prices?: { price?: string }; + prices?: { price?: string; currency_minor_unit?: number }; images?: { src: string }[]; permalink: string; + type?: 'simple' | 'variable' | 'grouped' | 'variation' | string; variations?: number[]; + grouped_products?: number[]; + extensions?: { + nyp?: { + minimum_price?: string; + maximum_price?: string; + suggested_price?: string; + is_nyp?: boolean; + }; + }; } export interface StoreApiVariation { @@ -21,10 +31,26 @@ export interface Variation { label: string; } +export interface GroupedChild { + id: number; + name: string; + priceHtml: string; +} + +export interface NypConfig { + min: number; + max: number; + suggested: number; +} + export interface FastCheckoutAttributes { product?: string; variation?: string; is_variable: boolean; + is_grouped: boolean; + is_nyp: boolean; + grouped_child?: string; + nyp_price?: string; afterSuccessURL?: string; } @@ -39,4 +65,6 @@ export interface Binding { export interface BindingsContext { 'newspack-blocks/fastCheckoutProductId'?: string | number; 'newspack-blocks/fastCheckoutVariationId'?: string | number; + 'newspack-blocks/fastCheckoutGroupedChild'?: string | number; + 'newspack-blocks/fastCheckoutNypPrice'?: string | number; } From 74af06ee0de5af9dc2b0b174a6e733eb7f68c821 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 19:17:20 -0300 Subject: [PATCH 12/56] build(fast-checkout): support view.tsx in webpack block discovery --- webpack.config.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/webpack.config.js b/webpack.config.js index 6a70d284e..d36f1f377 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -46,17 +46,18 @@ const blocks = fs .filter( block => isDevelopment || blockList.production.includes( block ) ) .filter( hasEditorEntry ); -// Helps split up each block into its own folder view script +// Helps split up each block into its own folder view script. const viewBlocksScripts = blocks.reduce( ( viewBlocks, block ) => { const pathToBlock = [ __dirname, 'src', 'blocks', block ]; - let viewScriptPath = path.join( ...pathToBlock, 'view.js' ); - let fileExists = fs.existsSync( viewScriptPath ); - if ( ! fileExists ) { - // Try TS. - viewScriptPath = path.join( ...pathToBlock, 'view.ts' ); - fileExists = fs.existsSync( viewScriptPath ); + let viewScriptPath; + for ( const ext of [ 'view.js', 'view.ts', 'view.tsx' ] ) { + const candidate = path.join( ...pathToBlock, ext ); + if ( fs.existsSync( candidate ) ) { + viewScriptPath = candidate; + break; + } } - if ( fileExists ) { + if ( viewScriptPath ) { viewBlocks[ block + '/view' ] = viewScriptPath; } return viewBlocks; From cbfd07763295b6db01e46a21f95dbae3ab567f5e Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 19:20:25 -0300 Subject: [PATCH 13/56] feat(fast-checkout): scaffold variation-selector block.json and view.php --- .../block.json | 25 +++++++++ .../editor.scss | 14 +++++ .../fast-checkout-variation-selector/view.php | 54 +++++++++++++++++++ .../view.scss | 33 ++++++++++++ 4 files changed, 126 insertions(+) create mode 100644 src/blocks/fast-checkout-variation-selector/block.json create mode 100644 src/blocks/fast-checkout-variation-selector/editor.scss create mode 100644 src/blocks/fast-checkout-variation-selector/view.php create mode 100644 src/blocks/fast-checkout-variation-selector/view.scss diff --git a/src/blocks/fast-checkout-variation-selector/block.json b/src/blocks/fast-checkout-variation-selector/block.json new file mode 100644 index 000000000..e715e6fcc --- /dev/null +++ b/src/blocks/fast-checkout-variation-selector/block.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "newspack-blocks/fast-checkout-variation-selector", + "title": "Fast Checkout — Variation Selector", + "category": "newspack", + "description": "Lets readers pick a variation for the parent Fast Checkout product.", + "keywords": [ "woocommerce", "checkout", "variation", "fast" ], + "textdomain": "newspack-blocks", + "parent": [ "newspack-blocks/fast-checkout" ], + "usesContext": [ + "newspack-blocks/fastCheckoutProductId", + "newspack-blocks/fastCheckoutVariationId", + "newspack-blocks/fastCheckoutGroupedChild", + "newspack-blocks/fastCheckoutNypPrice" + ], + "attributes": {}, + "supports": { + "html": false, + "reusable": false, + "spacing": { "padding": true, "margin": true } + }, + "editorStyle": "newspack-blocks-editor", + "style": "newspack-blocks-fast-checkout-variation-selector" +} diff --git a/src/blocks/fast-checkout-variation-selector/editor.scss b/src/blocks/fast-checkout-variation-selector/editor.scss new file mode 100644 index 000000000..8787f30b6 --- /dev/null +++ b/src/blocks/fast-checkout-variation-selector/editor.scss @@ -0,0 +1,14 @@ +.wp-block-newspack-blocks-fast-checkout-variation-selector { + pointer-events: none; + opacity: 0.85; + + &::before { + content: attr( data-editor-hint ); + display: block; + font-size: 0.75em; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.6; + margin-bottom: 0.5em; + } +} diff --git a/src/blocks/fast-checkout-variation-selector/view.php b/src/blocks/fast-checkout-variation-selector/view.php new file mode 100644 index 000000000..00436a6be --- /dev/null +++ b/src/blocks/fast-checkout-variation-selector/view.php @@ -0,0 +1,54 @@ + __NAMESPACE__ . '\\render_block', + ] + ); +} +add_action( 'init', __NAMESPACE__ . '\\register_block' ); + +/** + * Enqueue the frontend view bundle when this block is present on the page. + */ +function enqueue_assets() { + if ( is_admin() || ! is_singular() ) { + return; + } + $post = get_post(); + if ( ! $post || ! has_block( BLOCK_NAME, $post ) ) { + return; + } + \Newspack_Blocks::enqueue_view_assets( BLOCK_SLUG ); +} +add_action( 'wp_enqueue_scripts', __NAMESPACE__ . '\\enqueue_assets' ); + +/** + * Render the variation selector SSR shell. + * + * @param array $attrs Block attributes (currently none defined). + * @param string $content Inner content (unused). + * @param object $block Block instance with context. + * @return string Rendered HTML. + */ +function render_block( $attrs, $content, $block ) { + // Implemented in Task 2.2. + return ''; +} diff --git a/src/blocks/fast-checkout-variation-selector/view.scss b/src/blocks/fast-checkout-variation-selector/view.scss new file mode 100644 index 000000000..a7a737505 --- /dev/null +++ b/src/blocks/fast-checkout-variation-selector/view.scss @@ -0,0 +1,33 @@ +.wp-block-newspack-blocks-fast-checkout-variation-selector { + display: flex; + flex-direction: column; + gap: 1em; + + fieldset { + border: 1px solid var(--wp--preset--color--secondary, #ddd); + padding: 0.75em 1em; + + legend { + padding: 0 0.5em; + font-weight: 600; + } + + label { + display: inline-flex; + align-items: center; + gap: 0.4em; + margin-right: 1em; + cursor: pointer; + } + + input[ type="radio" ]:disabled + span { + text-decoration: line-through; + opacity: 0.5; + } + } + + .wp-block-newspack-blocks-fast-checkout-variation-selector__notice { + color: var(--wp--preset--color--vivid-red, #cc1818); + font-size: 0.875em; + } +} From 904e55dfdf1553c05e898ff57ba01bf2e925304c Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 19:27:04 -0300 Subject: [PATCH 14/56] feat(fast-checkout): ssr variation selector with attribute fieldsets --- .../fast-checkout-variation-selector/view.php | 101 ++++++++++++++- tests/test-fast-checkout-selectors.php | 120 ++++++++++++++++++ 2 files changed, 216 insertions(+), 5 deletions(-) create mode 100644 tests/test-fast-checkout-selectors.php diff --git a/src/blocks/fast-checkout-variation-selector/view.php b/src/blocks/fast-checkout-variation-selector/view.php index 00436a6be..1b199268c 100644 --- a/src/blocks/fast-checkout-variation-selector/view.php +++ b/src/blocks/fast-checkout-variation-selector/view.php @@ -43,12 +43,103 @@ function enqueue_assets() { /** * Render the variation selector SSR shell. * - * @param array $attrs Block attributes (currently none defined). - * @param string $content Inner content (unused). - * @param object $block Block instance with context. + * @param array $attrs Block attributes (currently none defined). + * @param string $content Inner content (unused). + * @param WP_Block $block Block instance with context. * @return string Rendered HTML. */ function render_block( $attrs, $content, $block ) { - // Implemented in Task 2.2. - return ''; + if ( ! function_exists( 'wc_get_product' ) ) { + return ''; + } + + $product_id = $block->context['newspack-blocks/fastCheckoutProductId'] ?? 0; + $product_id = (int) $product_id; + if ( ! $product_id ) { + return ''; + } + + $product = wc_get_product( $product_id ); + if ( ! $product || ! $product->is_type( 'variable' ) ) { + return ''; + } + + $variations = $product->get_available_variations(); + $current_variation = (int) ( $block->context['newspack-blocks/fastCheckoutVariationId'] ?? 0 ); + // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + if ( isset( $_GET[ Fast_Checkout::QP_VARIATION ] ) ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $qp_raw = wp_unslash( $_GET[ Fast_Checkout::QP_VARIATION ] ); + $qp = (int) filter_var( $qp_raw, FILTER_SANITIZE_NUMBER_INT ); + if ( $qp > 0 ) { + $current_variation = $qp; + } + } + + $attributes = $product->get_variation_attributes(); + if ( empty( $attributes ) ) { + return ''; + } + + $current_attrs = []; + if ( $current_variation ) { + $variation_obj = wc_get_product( $current_variation ); + if ( $variation_obj ) { + $current_attrs = $variation_obj->get_attributes(); + } + } + + $wrapper_attributes = get_block_wrapper_attributes( + [ + 'data-product-id' => (string) $product_id, + 'data-current-variation' => (string) $current_variation, + 'data-variations' => wp_json_encode( + array_map( + function ( $v ) { + return [ + 'id' => $v['variation_id'], + 'attributes' => $v['attributes'], + 'is_in_stock' => $v['is_in_stock'], + ]; + }, + $variations + ) + ), + ] + ); + + ob_start(); + ?> +
> + $options ) : ?> + +
+ + + + + +
+ + +
+ markTestSkipped( 'WooCommerce is not available.' ); + } + } + + /** + * Create a variable product with two attributes (color, size). + * + * @return array { 'parent': WC_Product_Variable, 'variations': WC_Product_Variation[] } + */ + private function create_variable_product() { + $parent = new \WC_Product_Variable(); + $parent->set_name( 'Test Variable Product' ); + $parent->set_status( 'publish' ); + + $attribute_color = new \WC_Product_Attribute(); + $attribute_color->set_name( 'Color' ); + $attribute_color->set_options( [ 'Red', 'Blue' ] ); + $attribute_color->set_visible( true ); + $attribute_color->set_variation( true ); + + $attribute_size = new \WC_Product_Attribute(); + $attribute_size->set_name( 'Size' ); + $attribute_size->set_options( [ 'S', 'M' ] ); + $attribute_size->set_visible( true ); + $attribute_size->set_variation( true ); + + $parent->set_attributes( [ $attribute_color, $attribute_size ] ); + $parent->save(); + + $variations = []; + foreach ( [ + [ 'Red', 'S' ], + [ 'Red', 'M' ], + [ 'Blue', 'S' ], + [ 'Blue', 'M' ], + ] as [ $color, $size ] ) { + $v = new \WC_Product_Variation(); + $v->set_parent_id( $parent->get_id() ); + $v->set_attributes( [ 'color' => $color, 'size' => $size ] ); + $v->set_regular_price( '10.00' ); + $v->set_status( 'publish' ); + $v->save(); + $variations[] = $v; + } + + // Reload parent so it picks up the variations. + $parent = wc_get_product( $parent->get_id() ); + return [ 'parent' => $parent, 'variations' => $variations ]; + } + + /** + * Test that the variation-selector renders one fieldset per attribute. + */ + public function test_variation_selector_renders_attribute_fieldsets() { + $this->skip_without_wc(); + $fixture = $this->create_variable_product(); + + $block_html = sprintf( + ' +
+ +
+ ', + $fixture['parent']->get_id() + ); + + $rendered = do_blocks( $block_html ); + + $this->assertStringContainsString( 'assertStringContainsString( 'Color', $rendered ); + $this->assertStringContainsString( 'Size', $rendered ); + $this->assertStringContainsString( 'value="red"', $rendered ); + $this->assertStringContainsString( 'value="blue"', $rendered ); + $this->assertStringContainsString( 'value="s"', $rendered ); + $this->assertStringContainsString( 'value="m"', $rendered ); + } + + /** + * Test that the variation-selector pre-checks the editor's chosen variation. + */ + public function test_variation_selector_pre_checks_editor_variation() { + $this->skip_without_wc(); + $fixture = $this->create_variable_product(); + $blue_m = $fixture['variations'][3]; // [ 'Blue', 'M' ] + + $block_html = sprintf( + ' +
+ +
+ ', + $fixture['parent']->get_id(), + $blue_m->get_id() + ); + + $rendered = do_blocks( $block_html ); + + // Both 'blue' and 'm' radios should be checked. + $this->assertMatchesRegularExpression( '/value="blue"[^>]*checked/', $rendered ); + $this->assertMatchesRegularExpression( '/value="m"[^>]*checked/', $rendered ); + } +} From f0a370f730de81b7925a802944b1a2611e40bd69 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 21:42:43 -0300 Subject: [PATCH 15/56] fix(fast-checkout): pre-check correct radio in variation-selector SSR --- src/blocks/fast-checkout-variation-selector/view.php | 10 ++++------ tests/test-fast-checkout-selectors.php | 4 ++++ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/blocks/fast-checkout-variation-selector/view.php b/src/blocks/fast-checkout-variation-selector/view.php index 1b199268c..931812eac 100644 --- a/src/blocks/fast-checkout-variation-selector/view.php +++ b/src/blocks/fast-checkout-variation-selector/view.php @@ -82,11 +82,8 @@ function render_block( $attrs, $content, $block ) { } $current_attrs = []; - if ( $current_variation ) { - $variation_obj = wc_get_product( $current_variation ); - if ( $variation_obj ) { - $current_attrs = $variation_obj->get_attributes(); - } + if ( $current_variation && function_exists( 'wc_get_product_variation_attributes' ) ) { + $current_attrs = wc_get_product_variation_attributes( $current_variation ); } $wrapper_attributes = get_block_wrapper_attributes( @@ -115,7 +112,8 @@ function ( $v ) {
diff --git a/tests/test-fast-checkout-selectors.php b/tests/test-fast-checkout-selectors.php index bcf1436a4..0e0bbc877 100644 --- a/tests/test-fast-checkout-selectors.php +++ b/tests/test-fast-checkout-selectors.php @@ -116,5 +116,9 @@ public function test_variation_selector_pre_checks_editor_variation() { // Both 'blue' and 'm' radios should be checked. $this->assertMatchesRegularExpression( '/value="blue"[^>]*checked/', $rendered ); $this->assertMatchesRegularExpression( '/value="m"[^>]*checked/', $rendered ); + + // Negative assertions: the unchecked radios should not have `checked`. + $this->assertDoesNotMatchRegularExpression( '/value="red"[^>]*checked/', $rendered ); + $this->assertDoesNotMatchRegularExpression( '/value="s"[^>]*checked/', $rendered ); } } From b32f27cec8d8940c8db46ee9acbf4354fd1081a3 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 21:45:38 -0300 Subject: [PATCH 16/56] feat(fast-checkout): variation-selector editor preview component --- .../fast-checkout-variation-selector/edit.tsx | 100 ++++++++++++++++++ .../editor.ts | 9 ++ .../index.tsx | 17 +++ 3 files changed, 126 insertions(+) create mode 100644 src/blocks/fast-checkout-variation-selector/edit.tsx create mode 100644 src/blocks/fast-checkout-variation-selector/editor.ts create mode 100644 src/blocks/fast-checkout-variation-selector/index.tsx diff --git a/src/blocks/fast-checkout-variation-selector/edit.tsx b/src/blocks/fast-checkout-variation-selector/edit.tsx new file mode 100644 index 000000000..aa2575c54 --- /dev/null +++ b/src/blocks/fast-checkout-variation-selector/edit.tsx @@ -0,0 +1,100 @@ +import { __ } from '@wordpress/i18n'; +import { useEffect, useState } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; +import { useBlockProps } from '@wordpress/block-editor'; +import { Spinner } from '@wordpress/components'; + +import type { StoreApiProduct, StoreApiVariation } from '../fast-checkout/types'; + +interface AttributeGroup { + name: string; + options: string[]; +} + +interface EditProps { + context: { + 'newspack-blocks/fastCheckoutProductId'?: string | number; + }; +} + +export default function Edit( { context }: EditProps ) { + const productId = parseInt( String( context?.[ 'newspack-blocks/fastCheckoutProductId' ] ?? 0 ), 10 ); + const blockProps = useBlockProps( { 'data-editor-hint': __( 'Reader-facing variation selector', 'newspack-blocks' ) } ); + const [ groups, setGroups ] = useState< AttributeGroup[] | null >( null ); + + useEffect( () => { + if ( ! productId ) { + setGroups( null ); + return; + } + apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ productId }` } ) + .then( product => { + if ( ! product?.variations?.length ) { + setGroups( [] ); + return; + } + return apiFetch< StoreApiVariation[] >( { + path: `/wc/v2/products/${ productId }/variations?per_page=100`, + } ).then( variations => { + const map = new Map< string, Set< string > >(); + variations.forEach( v => { + v.attributes.forEach( a => { + if ( ! map.has( a.name ) ) { + map.set( a.name, new Set() ); + } + map.get( a.name )!.add( a.option ); + } ); + } ); + const next: AttributeGroup[] = []; + map.forEach( ( opts, name ) => { + next.push( { name, options: Array.from( opts ) } ); + } ); + setGroups( next ); + } ); + } ) + .catch( () => setGroups( [] ) ); + }, [ productId ] ); + + if ( ! productId ) { + return ( +
+ { __( 'Pick a product on the parent Fast Checkout block.', 'newspack-blocks' ) } +
+ ); + } + + if ( null === groups ) { + return ( +
+ +
+ ); + } + + if ( ! groups.length ) { + return ( +
+ { __( 'No variations found for this product.', 'newspack-blocks' ) } +
+ ); + } + + return ( +
+ { groups.map( group => ( +
+ { group.name } + { group.options.map( option => { + const inputId = `preview_${ group.name }_${ option }`; + return ( + + ); + } ) } +
+ ) ) } +
+ ); +} diff --git a/src/blocks/fast-checkout-variation-selector/editor.ts b/src/blocks/fast-checkout-variation-selector/editor.ts new file mode 100644 index 000000000..62b93733a --- /dev/null +++ b/src/blocks/fast-checkout-variation-selector/editor.ts @@ -0,0 +1,9 @@ +/** + * Fast Checkout Variation Selector — editor bundle entry. + */ + +import { registerBlockType } from '@wordpress/blocks'; +import { name, settings } from './index'; +import './editor.scss'; + +registerBlockType( name, settings ); diff --git a/src/blocks/fast-checkout-variation-selector/index.tsx b/src/blocks/fast-checkout-variation-selector/index.tsx new file mode 100644 index 000000000..f68a573aa --- /dev/null +++ b/src/blocks/fast-checkout-variation-selector/index.tsx @@ -0,0 +1,17 @@ +import { __ } from '@wordpress/i18n'; +import metadata from './block.json'; +import edit from './edit'; + +export const name: string = metadata.name; +export const title: string = __( 'Fast Checkout — Variation Selector', 'newspack-blocks' ); + +function save() { + return null; +} + +export const settings = { + ...metadata, + title, + edit, + save, +}; From c8dd64d1db6889dd00938721ca14debec7ba9a3a Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 21:56:30 -0300 Subject: [PATCH 17/56] feat(fast-checkout): variation-selector frontend cart sync --- block-list.json | 1 + .../fast-checkout-variation-selector/view.tsx | 218 ++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 src/blocks/fast-checkout-variation-selector/view.tsx diff --git a/block-list.json b/block-list.json index 39f9015bc..9efc99a98 100644 --- a/block-list.json +++ b/block-list.json @@ -6,6 +6,7 @@ "checkout-button", "donate", "fast-checkout", + "fast-checkout-variation-selector", "homepage-articles", "video-playlist", "iframe" diff --git a/src/blocks/fast-checkout-variation-selector/view.tsx b/src/blocks/fast-checkout-variation-selector/view.tsx new file mode 100644 index 000000000..dc72e5b93 --- /dev/null +++ b/src/blocks/fast-checkout-variation-selector/view.tsx @@ -0,0 +1,218 @@ +/** + * Fast Checkout Variation Selector — frontend. + * + * Hydrates SSR-rendered
elements, listens to radio changes, resolves + * to a variation ID once all attributes are picked, and swaps the cart + * line via the WC Store API cart store. + * + * NOTE on attribute key shape: WooCommerce's get_available_variation() calls + * $variation->get_variation_attributes() with $with_prefix=true (default), + * so the data-variations JSON has PREFIXED keys (e.g. "attribute_color"). + * Radio inputs also have prefixed names (e.g. name="attribute_color"). + * Keys match directly — no extra prefix translation needed. + */ + +import { createRoot, useEffect, useMemo, useState } from '@wordpress/element'; +import { dispatch, select } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import './view.scss'; + +const STORE = 'wc/store/cart' as const; + +interface VariationData { + id: number; + attributes: Record< string, string >; + is_in_stock: boolean; +} + +interface RootProps { + host: HTMLFormElement; + productId: number; + variations: VariationData[]; + currentVariationId: number; +} + +function readCurrentSelections( host: HTMLFormElement ): Record< string, string > { + const result: Record< string, string > = {}; + host.querySelectorAll< HTMLInputElement >( 'input[type="radio"]:checked' ).forEach( input => { + result[ input.name ] = input.value; + } ); + return result; +} + +/** + * Check whether a variation's attributes match the current DOM selection. + * + * Both the variation attributes (from data-variations JSON) and the radio input + * names have the "attribute_" prefix, so keys align directly. + */ +function attributesMatch( variation: VariationData, selection: Record< string, string > ): boolean { + return Object.entries( variation.attributes ).every( ( [ key, value ] ) => selection[ key ] === value ); +} + +function resolveVariationId( variations: VariationData[], selection: Record< string, string > ): number | null { + const required = new Set< string >(); + variations.forEach( v => Object.keys( v.attributes ).forEach( k => required.add( k ) ) ); + for ( const key of required ) { + if ( ! selection[ key ] ) { + return null; + } + } + const match = variations.find( v => attributesMatch( v, selection ) ); + return match ? match.id : null; +} + +function VariationSelector( { host, productId, variations, currentVariationId }: RootProps ) { + const [ pendingId, setPendingId ] = useState< number >( currentVariationId ); + const [ inFlight, setInFlight ] = useState( false ); + const [ error, setError ] = useState< string >( '' ); + + const noticeNode = useMemo< HTMLElement | null >( + () => host.querySelector( '.wp-block-newspack-blocks-fast-checkout-variation-selector__notice' ), + [ host ] + ); + + useEffect( () => { + if ( noticeNode ) { + if ( error ) { + noticeNode.textContent = error; + noticeNode.removeAttribute( 'hidden' ); + } else { + noticeNode.textContent = ''; + noticeNode.setAttribute( 'hidden', '' ); + } + } + }, [ error, noticeNode ] ); + + useEffect( () => { + const onChange = async () => { + setError( '' ); + const selection = readCurrentSelections( host ); + const resolvedId = resolveVariationId( variations, selection ); + if ( ! resolvedId || resolvedId === pendingId ) { + return; + } + setInFlight( true ); + try { + await swapCartItem( productId, pendingId, resolvedId ); + setPendingId( resolvedId ); + updateUrlParam( 'fc_variation', String( resolvedId ) ); + } catch ( e: unknown ) { + setError( ( e as Error )?.message || __( 'Could not update selection.', 'newspack-blocks' ) ); + revertSelection( host, pendingId, variations ); + } finally { + setInFlight( false ); + } + }; + host.addEventListener( 'change', onChange ); + return () => host.removeEventListener( 'change', onChange ); + }, [ host, productId, variations, pendingId ] ); + + useEffect( () => { + const onPopstate = () => { + const params = new URLSearchParams( window.location.search ); + const next = parseInt( params.get( 'fc_variation' ) || '', 10 ); + if ( next && next !== pendingId ) { + const variation = variations.find( v => v.id === next ); + if ( variation ) { + setPendingId( next ); + applySelectionToDom( host, variation ); + } + } + }; + window.addEventListener( 'popstate', onPopstate ); + return () => window.removeEventListener( 'popstate', onPopstate ); + }, [ host, variations, pendingId ] ); + + useEffect( () => { + host.querySelectorAll< HTMLInputElement >( 'input[type="radio"]' ).forEach( input => { + input.disabled = inFlight; + } ); + }, [ host, inFlight ] ); + + return null; +} + +/** + * Apply a variation's attribute selections to the DOM radio inputs. + * + * Attribute keys in VariationData already carry the "attribute_" prefix + * (matching the radio input name attributes), so no prefix translation needed. + */ +function applySelectionToDom( host: HTMLFormElement, variation: VariationData ) { + Object.entries( variation.attributes ).forEach( ( [ key, value ] ) => { + const radio = host.querySelector< HTMLInputElement >( `input[type="radio"][name="${ key }"][value="${ value }"]` ); + if ( radio ) { + radio.checked = true; + } + } ); +} + +function revertSelection( host: HTMLFormElement, currentId: number, variations: VariationData[] ) { + const variation = variations.find( v => v.id === currentId ); + if ( variation ) { + applySelectionToDom( host, variation ); + } +} + +async function swapCartItem( parentProductId: number, oldVariationId: number, newVariationId: number ) { + const cartActions = dispatch( STORE ); + const cartSelectors = select( STORE ); + const cart = cartSelectors.getCartData(); + const items = cart?.items || []; + const existing = items.find( ( item: { id?: number; variation?: { value?: string }[] } ) => { + const itemVariationId = Number( + item.variation?.find( ( v: { attribute?: string; value?: string } ) => v.attribute === 'variation_id' )?.value + ); + return itemVariationId === oldVariationId || item.id === parentProductId; + } ); + + if ( existing ) { + await ( + cartActions as { + removeItemFromCart: ( key: string ) => Promise< unknown >; + } + ).removeItemFromCart( ( existing as { key: string } ).key ); + } + + await ( + cartActions as { + addItemToCart: ( productId: number, quantity: number, variation?: { attribute: string; value: string }[] ) => Promise< unknown >; + } + ).addItemToCart( parentProductId, 1, [ { attribute: 'variation_id', value: String( newVariationId ) } ] ); +} + +function updateUrlParam( key: string, value: string ) { + const url = new URL( window.location.href ); + url.searchParams.set( key, value ); + window.history.replaceState( {}, '', url.toString() ); +} + +function init() { + document.querySelectorAll< HTMLFormElement >( '.wp-block-newspack-blocks-fast-checkout-variation-selector' ).forEach( host => { + const productId = parseInt( host.dataset.productId || '0', 10 ); + let variations: VariationData[] = []; + try { + variations = JSON.parse( host.dataset.variations || '[]' ); + } catch { + variations = []; + } + if ( ! productId || ! variations.length ) { + return; + } + const currentVariationId = parseInt( host.dataset.currentVariation || '0', 10 ); + const sentinel = document.createElement( 'span' ); + sentinel.style.display = 'none'; + host.appendChild( sentinel ); + const root = createRoot( sentinel ); + root.render( + + ); + } ); +} + +if ( document.readyState === 'loading' ) { + document.addEventListener( 'DOMContentLoaded', init ); +} else { + init(); +} From b27b4ef86fbf325049bcbff57926e11ffaab540a Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 22:00:22 -0300 Subject: [PATCH 18/56] feat(fast-checkout): scaffold grouped-selector block.json and view.php --- .../fast-checkout-grouped-selector/block.json | 25 +++++++++ .../editor.scss | 4 ++ .../fast-checkout-grouped-selector/view.php | 54 +++++++++++++++++++ .../fast-checkout-grouped-selector/view.scss | 24 +++++++++ 4 files changed, 107 insertions(+) create mode 100644 src/blocks/fast-checkout-grouped-selector/block.json create mode 100644 src/blocks/fast-checkout-grouped-selector/editor.scss create mode 100644 src/blocks/fast-checkout-grouped-selector/view.php create mode 100644 src/blocks/fast-checkout-grouped-selector/view.scss diff --git a/src/blocks/fast-checkout-grouped-selector/block.json b/src/blocks/fast-checkout-grouped-selector/block.json new file mode 100644 index 000000000..6521a8925 --- /dev/null +++ b/src/blocks/fast-checkout-grouped-selector/block.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "newspack-blocks/fast-checkout-grouped-selector", + "title": "Fast Checkout — Grouped Product Selector", + "category": "newspack", + "description": "Lets readers pick which child product to buy from a grouped Fast Checkout product.", + "keywords": [ "woocommerce", "checkout", "grouped", "fast" ], + "textdomain": "newspack-blocks", + "parent": [ "newspack-blocks/fast-checkout" ], + "usesContext": [ + "newspack-blocks/fastCheckoutProductId", + "newspack-blocks/fastCheckoutVariationId", + "newspack-blocks/fastCheckoutGroupedChild", + "newspack-blocks/fastCheckoutNypPrice" + ], + "attributes": {}, + "supports": { + "html": false, + "reusable": false, + "spacing": { "padding": true, "margin": true } + }, + "editorStyle": "newspack-blocks-editor", + "style": "newspack-blocks-fast-checkout-grouped-selector" +} diff --git a/src/blocks/fast-checkout-grouped-selector/editor.scss b/src/blocks/fast-checkout-grouped-selector/editor.scss new file mode 100644 index 000000000..965c4243b --- /dev/null +++ b/src/blocks/fast-checkout-grouped-selector/editor.scss @@ -0,0 +1,4 @@ +.wp-block-newspack-blocks-fast-checkout-grouped-selector { + pointer-events: none; + opacity: 0.85; +} diff --git a/src/blocks/fast-checkout-grouped-selector/view.php b/src/blocks/fast-checkout-grouped-selector/view.php new file mode 100644 index 000000000..864c423e4 --- /dev/null +++ b/src/blocks/fast-checkout-grouped-selector/view.php @@ -0,0 +1,54 @@ + __NAMESPACE__ . '\\render_block', + ] + ); +} +add_action( 'init', __NAMESPACE__ . '\\register_block' ); + +/** + * Enqueue the frontend view bundle when this block is present on the page. + */ +function enqueue_assets() { + if ( is_admin() || ! is_singular() ) { + return; + } + $post = get_post(); + if ( ! $post || ! has_block( BLOCK_NAME, $post ) ) { + return; + } + \Newspack_Blocks::enqueue_view_assets( BLOCK_SLUG ); +} +add_action( 'wp_enqueue_scripts', __NAMESPACE__ . '\\enqueue_assets' ); + +/** + * Render the grouped selector SSR shell. + * + * @param array $attrs Block attributes. + * @param string $content Inner content (unused). + * @param object $block Block instance with context. + * @return string Rendered HTML. + */ +function render_block( $attrs, $content, $block ) { + // Implemented in Task 3.2. + return ''; +} diff --git a/src/blocks/fast-checkout-grouped-selector/view.scss b/src/blocks/fast-checkout-grouped-selector/view.scss new file mode 100644 index 000000000..38e95cab9 --- /dev/null +++ b/src/blocks/fast-checkout-grouped-selector/view.scss @@ -0,0 +1,24 @@ +.wp-block-newspack-blocks-fast-checkout-grouped-selector { + display: flex; + flex-direction: column; + gap: 0.5em; + border: 1px solid var(--wp--preset--color--secondary, #ddd); + padding: 0.75em 1em; + + label { + display: flex; + align-items: center; + gap: 0.6em; + cursor: pointer; + + .price { + margin-left: auto; + font-weight: 600; + } + } + + .wp-block-newspack-blocks-fast-checkout-grouped-selector__notice { + color: var(--wp--preset--color--vivid-red, #cc1818); + font-size: 0.875em; + } +} From e4eb5b57db597910ee3776fce602368f1080079f Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 22:05:42 -0300 Subject: [PATCH 19/56] feat(fast-checkout): ssr grouped selector with per-child radios --- .../fast-checkout-grouped-selector/view.php | 81 +++++++++++++- tests/test-fast-checkout-selectors.php | 103 ++++++++++++++++++ 2 files changed, 179 insertions(+), 5 deletions(-) diff --git a/src/blocks/fast-checkout-grouped-selector/view.php b/src/blocks/fast-checkout-grouped-selector/view.php index 864c423e4..bb255bd10 100644 --- a/src/blocks/fast-checkout-grouped-selector/view.php +++ b/src/blocks/fast-checkout-grouped-selector/view.php @@ -43,12 +43,83 @@ function enqueue_assets() { /** * Render the grouped selector SSR shell. * - * @param array $attrs Block attributes. - * @param string $content Inner content (unused). - * @param object $block Block instance with context. + * @param array $attrs Block attributes (currently none defined). + * @param string $content Inner content (unused). + * @param WP_Block $block Block instance with context. * @return string Rendered HTML. */ function render_block( $attrs, $content, $block ) { - // Implemented in Task 3.2. - return ''; + if ( ! function_exists( 'wc_get_product' ) ) { + return ''; + } + + $product_id = (int) ( $block->context['newspack-blocks/fastCheckoutProductId'] ?? 0 ); + if ( ! $product_id ) { + return ''; + } + $product = wc_get_product( $product_id ); + if ( ! $product || ! $product->is_type( 'grouped' ) ) { + return ''; + } + + $child_ids = array_map( 'intval', $product->get_children() ); + if ( count( $child_ids ) < 2 ) { + return ''; + } + + $current_child = (int) ( $block->context['newspack-blocks/fastCheckoutGroupedChild'] ?? 0 ); + // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + if ( isset( $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] ) ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $qp_raw = wp_unslash( $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] ); + $qp = (int) filter_var( $qp_raw, FILTER_SANITIZE_NUMBER_INT ); + if ( $qp > 0 && in_array( $qp, $child_ids, true ) ) { + $current_child = $qp; + } + } + if ( ! $current_child || ! in_array( $current_child, $child_ids, true ) ) { + $current_child = $child_ids[0]; + } + + $wrapper_attributes = get_block_wrapper_attributes( + [ + 'data-product-id' => (string) $product_id, + 'data-current-child' => (string) $current_child, + ] + ); + + ob_start(); + ?> + > + + is_purchasable() && $child->is_in_stock(); + $input_id = 'fc-grouped-child-' . $child_id; + ?> + + + + + assertDoesNotMatchRegularExpression( '/value="red"[^>]*checked/', $rendered ); $this->assertDoesNotMatchRegularExpression( '/value="s"[^>]*checked/', $rendered ); } + + /** + * Create a grouped product with two children. + * + * @return array { 'parent': WC_Product_Grouped, 'children': WC_Product_Simple[] } + */ + private function create_grouped_product_fixture() { + $first = new \WC_Product_Simple(); + $first->set_name( 'Annual' ); + $first->set_regular_price( '50.00' ); + $first->set_status( 'publish' ); + $first->save(); + + $second = new \WC_Product_Simple(); + $second->set_name( 'Monthly' ); + $second->set_regular_price( '5.00' ); + $second->set_status( 'publish' ); + $second->save(); + + $parent = new \WC_Product_Grouped(); + $parent->set_name( 'Membership' ); + $parent->set_status( 'publish' ); + $parent->set_children( [ $first->get_id(), $second->get_id() ] ); + $parent->save(); + + return [ 'parent' => wc_get_product( $parent->get_id() ), 'children' => [ $first, $second ] ]; + } + + /** + * Test that the grouped-selector renders one radio per child. + */ + public function test_grouped_selector_renders_children() { + $this->skip_without_wc(); + $fixture = $this->create_grouped_product_fixture(); + + $block_html = sprintf( + ' +
+ +
+ ', + $fixture['parent']->get_id() + ); + + $rendered = do_blocks( $block_html ); + + $this->assertStringContainsString( 'Annual', $rendered ); + $this->assertStringContainsString( 'Monthly', $rendered ); + $this->assertSame( 2, substr_count( $rendered, 'skip_without_wc(); + $fixture = $this->create_grouped_product_fixture(); + $monthly = $fixture['children'][1]; + + $block_html = sprintf( + ' +
+ +
+ ', + $fixture['parent']->get_id(), + $monthly->get_id() + ); + + $rendered = do_blocks( $block_html ); + + $this->assertMatchesRegularExpression( + '/value="' . $monthly->get_id() . '"[^>]*checked/', + $rendered + ); + } + + /** + * Test that the grouped-selector pre-checks the first child when none set. + */ + public function test_grouped_selector_falls_back_to_first_child() { + $this->skip_without_wc(); + $fixture = $this->create_grouped_product_fixture(); + $annual = $fixture['children'][0]; + + $block_html = sprintf( + ' +
+ +
+ ', + $fixture['parent']->get_id() + ); + + $rendered = do_blocks( $block_html ); + + $this->assertMatchesRegularExpression( + '/value="' . $annual->get_id() . '"[^>]*checked/', + $rendered + ); + } } From 818f8066022dd2927161d1a026fb5f182693e155 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 22:12:50 -0300 Subject: [PATCH 20/56] feat(fast-checkout): grouped-selector editor preview and frontend cart sync --- block-list.json | 1 + .../fast-checkout-grouped-selector/edit.tsx | 104 ++++++++++++++ .../fast-checkout-grouped-selector/editor.ts | 9 ++ .../fast-checkout-grouped-selector/index.tsx | 17 +++ .../fast-checkout-grouped-selector/view.tsx | 136 ++++++++++++++++++ 5 files changed, 267 insertions(+) create mode 100644 src/blocks/fast-checkout-grouped-selector/edit.tsx create mode 100644 src/blocks/fast-checkout-grouped-selector/editor.ts create mode 100644 src/blocks/fast-checkout-grouped-selector/index.tsx create mode 100644 src/blocks/fast-checkout-grouped-selector/view.tsx diff --git a/block-list.json b/block-list.json index 9efc99a98..2f005e8ff 100644 --- a/block-list.json +++ b/block-list.json @@ -6,6 +6,7 @@ "checkout-button", "donate", "fast-checkout", + "fast-checkout-grouped-selector", "fast-checkout-variation-selector", "homepage-articles", "video-playlist", diff --git a/src/blocks/fast-checkout-grouped-selector/edit.tsx b/src/blocks/fast-checkout-grouped-selector/edit.tsx new file mode 100644 index 000000000..0cff8e92e --- /dev/null +++ b/src/blocks/fast-checkout-grouped-selector/edit.tsx @@ -0,0 +1,104 @@ +import { __ } from '@wordpress/i18n'; +import { useEffect, useState } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; +import { useBlockProps } from '@wordpress/block-editor'; +import { Spinner } from '@wordpress/components'; + +import type { StoreApiProduct } from '../fast-checkout/types'; + +interface Child { + id: number; + name: string; + priceHtml: string; +} + +interface EditProps { + context: { + 'newspack-blocks/fastCheckoutProductId'?: string | number; + }; +} + +export default function Edit( { context }: EditProps ) { + const productId = parseInt( String( context?.[ 'newspack-blocks/fastCheckoutProductId' ] ?? 0 ), 10 ); + const blockProps = useBlockProps(); + const [ children, setChildren ] = useState< Child[] | null >( null ); + + useEffect( () => { + if ( ! productId ) { + setChildren( null ); + return; + } + apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ productId }` } ) + .then( product => { + const ids = product?.grouped_products || []; + if ( ! ids.length ) { + setChildren( [] ); + return; + } + return Promise.all( + ids.map( id => apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ id }` } ).catch( () => null ) ) + ).then( fetched => { + setChildren( + fetched + .filter( ( c ): c is StoreApiProduct => Boolean( c ) ) + .map( c => ( { + id: c.id, + name: c.name, + priceHtml: c.price_html, + } ) ) + ); + } ); + } ) + .catch( () => setChildren( [] ) ); + }, [ productId ] ); + + if ( ! productId ) { + return ( +
+ { __( 'Pick a grouped product on the parent Fast Checkout block.', 'newspack-blocks' ) } +
+ ); + } + + if ( null === children ) { + return ( +
+ +
+ ); + } + + if ( ! children.length ) { + return ( +
+ { __( 'No grouped children found for this product.', 'newspack-blocks' ) } +
+ ); + } + + const visible = children.slice( 0, 5 ); + const overflow = children.length - visible.length; + + return ( +
+ { visible.map( child => { + const id = `preview-grouped-${ child.id }`; + return ( + + ); + } ) } + { overflow > 0 && ( + + { + /* translators: %d is the count of additional grouped children. */ + __( '+%d more', 'newspack-blocks' ).replace( '%d', String( overflow ) ) + } + + ) } +
+ ); +} diff --git a/src/blocks/fast-checkout-grouped-selector/editor.ts b/src/blocks/fast-checkout-grouped-selector/editor.ts new file mode 100644 index 000000000..bcce9fe6c --- /dev/null +++ b/src/blocks/fast-checkout-grouped-selector/editor.ts @@ -0,0 +1,9 @@ +/** + * Fast Checkout Grouped Selector — editor bundle entry. + */ + +import { registerBlockType } from '@wordpress/blocks'; +import { name, settings } from './index'; +import './editor.scss'; + +registerBlockType( name, settings ); diff --git a/src/blocks/fast-checkout-grouped-selector/index.tsx b/src/blocks/fast-checkout-grouped-selector/index.tsx new file mode 100644 index 000000000..6fa390eaf --- /dev/null +++ b/src/blocks/fast-checkout-grouped-selector/index.tsx @@ -0,0 +1,17 @@ +import { __ } from '@wordpress/i18n'; +import metadata from './block.json'; +import edit from './edit'; + +export const name: string = metadata.name; +export const title: string = __( 'Fast Checkout — Grouped Product Selector', 'newspack-blocks' ); + +function save() { + return null; +} + +export const settings = { + ...metadata, + title, + edit, + save, +}; diff --git a/src/blocks/fast-checkout-grouped-selector/view.tsx b/src/blocks/fast-checkout-grouped-selector/view.tsx new file mode 100644 index 000000000..9f6fc3fdb --- /dev/null +++ b/src/blocks/fast-checkout-grouped-selector/view.tsx @@ -0,0 +1,136 @@ +/** + * Fast Checkout Grouped Selector — frontend. + * + * Hydrates the SSR shell, listens for radio changes, and swaps the cart + * line via the WC Store API cart store. + */ + +import { createRoot, useEffect, useMemo, useState } from '@wordpress/element'; +import { dispatch, select } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import './view.scss'; + +const STORE = 'wc/store/cart' as const; + +interface RootProps { + host: HTMLFormElement; + currentChildId: number; +} + +function GroupedSelector( { host, currentChildId }: RootProps ) { + const [ pendingId, setPendingId ] = useState< number >( currentChildId ); + const [ inFlight, setInFlight ] = useState( false ); + const [ error, setError ] = useState< string >( '' ); + + const noticeNode = useMemo< HTMLElement | null >( + () => host.querySelector( '.wp-block-newspack-blocks-fast-checkout-grouped-selector__notice' ), + [ host ] + ); + + useEffect( () => { + if ( noticeNode ) { + if ( error ) { + noticeNode.textContent = error; + noticeNode.removeAttribute( 'hidden' ); + } else { + noticeNode.textContent = ''; + noticeNode.setAttribute( 'hidden', '' ); + } + } + }, [ error, noticeNode ] ); + + useEffect( () => { + const onChange = async ( e: Event ) => { + const target = e.target as HTMLInputElement; + if ( target?.type !== 'radio' ) { + return; + } + const nextId = parseInt( target.value, 10 ); + if ( ! nextId || nextId === pendingId ) { + return; + } + setError( '' ); + setInFlight( true ); + try { + await swapCartItem( pendingId, nextId ); + setPendingId( nextId ); + updateUrlParam( 'fc_grouped_child', String( nextId ) ); + } catch ( ex: unknown ) { + setError( ( ex as Error )?.message || __( 'Could not update selection.', 'newspack-blocks' ) ); + const previous = host.querySelector< HTMLInputElement >( `input[type="radio"][value="${ pendingId }"]` ); + if ( previous ) { + previous.checked = true; + } + } finally { + setInFlight( false ); + } + }; + host.addEventListener( 'change', onChange ); + return () => host.removeEventListener( 'change', onChange ); + }, [ host, pendingId ] ); + + useEffect( () => { + const onPopstate = () => { + const params = new URLSearchParams( window.location.search ); + const next = parseInt( params.get( 'fc_grouped_child' ) || '', 10 ); + if ( next && next !== pendingId ) { + const radio = host.querySelector< HTMLInputElement >( `input[type="radio"][value="${ next }"]` ); + if ( radio ) { + radio.checked = true; + setPendingId( next ); + } + } + }; + window.addEventListener( 'popstate', onPopstate ); + return () => window.removeEventListener( 'popstate', onPopstate ); + }, [ host, pendingId ] ); + + useEffect( () => { + host.querySelectorAll< HTMLInputElement >( 'input[type="radio"]' ).forEach( input => { + if ( ! input.disabled ) { + input.disabled = inFlight; + } + } ); + }, [ host, inFlight ] ); + + return null; +} + +async function swapCartItem( oldChildId: number, newChildId: number ) { + const cartActions = dispatch( STORE ); + const cartSelectors = select( STORE ); + const items = cartSelectors.getCartData()?.items || []; + const existing = items.find( ( item: { id?: number; key?: string } ) => item.id === oldChildId ); + if ( existing ) { + await ( cartActions as { removeItemFromCart: ( key: string ) => Promise< unknown > } ).removeItemFromCart( + ( existing as { key: string } ).key + ); + } + await ( cartActions as { addItemToCart: ( id: number, qty: number ) => Promise< unknown > } ).addItemToCart( newChildId, 1 ); +} + +function updateUrlParam( key: string, value: string ) { + const url = new URL( window.location.href ); + url.searchParams.set( key, value ); + window.history.replaceState( {}, '', url.toString() ); +} + +function init() { + document.querySelectorAll< HTMLFormElement >( '.wp-block-newspack-blocks-fast-checkout-grouped-selector' ).forEach( host => { + const currentChildId = parseInt( host.dataset.currentChild || '0', 10 ); + if ( ! currentChildId ) { + return; + } + const sentinel = document.createElement( 'span' ); + sentinel.style.display = 'none'; + host.appendChild( sentinel ); + const root = createRoot( sentinel ); + root.render( ); + } ); +} + +if ( document.readyState === 'loading' ) { + document.addEventListener( 'DOMContentLoaded', init ); +} else { + init(); +} From 2929625b7abc1b20301ab0b5afb06e07ea605f18 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 22:14:29 -0300 Subject: [PATCH 21/56] feat(fast-checkout): scaffold nyp-input block.json and view.php --- src/blocks/fast-checkout-nyp-input/block.json | 25 +++++++++ .../fast-checkout-nyp-input/editor.scss | 4 ++ src/blocks/fast-checkout-nyp-input/view.php | 54 +++++++++++++++++++ src/blocks/fast-checkout-nyp-input/view.scss | 30 +++++++++++ 4 files changed, 113 insertions(+) create mode 100644 src/blocks/fast-checkout-nyp-input/block.json create mode 100644 src/blocks/fast-checkout-nyp-input/editor.scss create mode 100644 src/blocks/fast-checkout-nyp-input/view.php create mode 100644 src/blocks/fast-checkout-nyp-input/view.scss diff --git a/src/blocks/fast-checkout-nyp-input/block.json b/src/blocks/fast-checkout-nyp-input/block.json new file mode 100644 index 000000000..a9716b903 --- /dev/null +++ b/src/blocks/fast-checkout-nyp-input/block.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "newspack-blocks/fast-checkout-nyp-input", + "title": "Fast Checkout — Name Your Price", + "category": "newspack", + "description": "Lets readers set their own price for a Name Your Price Fast Checkout product.", + "keywords": [ "woocommerce", "checkout", "name your price", "nyp", "fast" ], + "textdomain": "newspack-blocks", + "parent": [ "newspack-blocks/fast-checkout" ], + "usesContext": [ + "newspack-blocks/fastCheckoutProductId", + "newspack-blocks/fastCheckoutVariationId", + "newspack-blocks/fastCheckoutGroupedChild", + "newspack-blocks/fastCheckoutNypPrice" + ], + "attributes": {}, + "supports": { + "html": false, + "reusable": false, + "spacing": { "padding": true, "margin": true } + }, + "editorStyle": "newspack-blocks-editor", + "style": "newspack-blocks-fast-checkout-nyp-input" +} diff --git a/src/blocks/fast-checkout-nyp-input/editor.scss b/src/blocks/fast-checkout-nyp-input/editor.scss new file mode 100644 index 000000000..846b5e812 --- /dev/null +++ b/src/blocks/fast-checkout-nyp-input/editor.scss @@ -0,0 +1,4 @@ +.wp-block-newspack-blocks-fast-checkout-nyp-input { + pointer-events: none; + opacity: 0.85; +} diff --git a/src/blocks/fast-checkout-nyp-input/view.php b/src/blocks/fast-checkout-nyp-input/view.php new file mode 100644 index 000000000..03d89cf6f --- /dev/null +++ b/src/blocks/fast-checkout-nyp-input/view.php @@ -0,0 +1,54 @@ + __NAMESPACE__ . '\\render_block', + ] + ); +} +add_action( 'init', __NAMESPACE__ . '\\register_block' ); + +/** + * Enqueue the frontend view bundle when this block is present on the page. + */ +function enqueue_assets() { + if ( is_admin() || ! is_singular() ) { + return; + } + $post = get_post(); + if ( ! $post || ! has_block( BLOCK_NAME, $post ) ) { + return; + } + \Newspack_Blocks::enqueue_view_assets( BLOCK_SLUG ); +} +add_action( 'wp_enqueue_scripts', __NAMESPACE__ . '\\enqueue_assets' ); + +/** + * Render the NYP input SSR shell. + * + * @param array $attrs Block attributes. + * @param string $content Inner content (unused). + * @param object $block Block instance with context. + * @return string Rendered HTML. + */ +function render_block( $attrs, $content, $block ) { + // Implemented in Task 4.2. + return ''; +} diff --git a/src/blocks/fast-checkout-nyp-input/view.scss b/src/blocks/fast-checkout-nyp-input/view.scss new file mode 100644 index 000000000..4f9d7e2e0 --- /dev/null +++ b/src/blocks/fast-checkout-nyp-input/view.scss @@ -0,0 +1,30 @@ +.wp-block-newspack-blocks-fast-checkout-nyp-input { + display: flex; + flex-direction: column; + gap: 0.4em; + max-width: 16em; + + label { + font-weight: 600; + } + + input[ type="number" ] { + font-size: 1.25em; + padding: 0.4em 0.6em; + } + + .wp-block-newspack-blocks-fast-checkout-nyp-input__hint { + font-size: 0.8125em; + opacity: 0.75; + } + + .wp-block-newspack-blocks-fast-checkout-nyp-input__notice { + color: var(--wp--preset--color--vivid-red, #cc1818); + font-size: 0.8125em; + } + + &[ data-status="busy" ] input[ type="number" ] { + opacity: 0.6; + pointer-events: none; + } +} From 0512637287cdfa64acde158a1f870b05a8e9744d Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 22:17:03 -0300 Subject: [PATCH 22/56] feat(fast-checkout): ssr nyp input with min/max/suggested constraints --- src/blocks/fast-checkout-nyp-input/view.php | 107 +++++++++++++++++++- tests/test-fast-checkout-selectors.php | 37 +++++++ 2 files changed, 142 insertions(+), 2 deletions(-) diff --git a/src/blocks/fast-checkout-nyp-input/view.php b/src/blocks/fast-checkout-nyp-input/view.php index 03d89cf6f..9166949c2 100644 --- a/src/blocks/fast-checkout-nyp-input/view.php +++ b/src/blocks/fast-checkout-nyp-input/view.php @@ -49,6 +49,109 @@ function enqueue_assets() { * @return string Rendered HTML. */ function render_block( $attrs, $content, $block ) { - // Implemented in Task 4.2. - return ''; + if ( ! function_exists( 'wc_get_product' ) ) { + return ''; + } + if ( ! class_exists( '\WC_Name_Your_Price_Helpers' ) ) { + return ''; + } + + $product_id = (int) ( $block->context['newspack-blocks/fastCheckoutProductId'] ?? 0 ); + if ( ! $product_id ) { + return ''; + } + $product = wc_get_product( $product_id ); + if ( ! $product ) { + return ''; + } + if ( ! \WC_Name_Your_Price_Helpers::is_nyp( $product_id ) ) { + return ''; + } + + $min = (float) \WC_Name_Your_Price_Helpers::get_minimum_price( $product_id ); + $max = (float) \WC_Name_Your_Price_Helpers::get_maximum_price( $product_id ); + $suggested = (float) \WC_Name_Your_Price_Helpers::get_suggested_price( $product_id ); + + $attr_price = $block->context['newspack-blocks/fastCheckoutNypPrice'] ?? ''; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $qp_price = ''; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( isset( $_GET[ Fast_Checkout::QP_PRICE ] ) ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $qp_price = sanitize_text_field( wp_unslash( $_GET[ Fast_Checkout::QP_PRICE ] ) ); + } + + $current = $suggested; + if ( $attr_price && is_numeric( $attr_price ) ) { + $current = (float) $attr_price; + } + if ( $qp_price && is_numeric( $qp_price ) ) { + $current = (float) $qp_price; + } + if ( $max > 0 ) { + $current = min( $current, $max ); + } + if ( $min > 0 ) { + $current = max( $current, $min ); + } + + $wrapper_attributes = get_block_wrapper_attributes( + [ + 'data-product-id' => (string) $product_id, + 'data-min' => (string) $min, + 'data-max' => (string) $max, + 'data-suggested' => (string) $suggested, + ] + ); + + $min_price_html = $min ? wc_price( $min ) : ''; + $max_price_html = $max ? wc_price( $max ) : ''; + $suggested_html = $suggested ? wc_price( $suggested ) : ''; + + $range_label = ''; + if ( $min && $max ) { + $range_label = sprintf( + /* translators: 1: min price, 2: max price, 3: suggested price */ + __( '%1$s – %2$s · suggested %3$s', 'newspack-blocks' ), + $min_price_html, + $max_price_html, + $suggested_html + ); + } + + $input_id = 'fc-nyp-input-' . $product_id; + + ob_start(); + ?> +
> + + + min="" + + + max="" + + value="" + /> + +

+ +

+ + +
+ skip_without_wc(); + if ( ! class_exists( '\WC_Name_Your_Price_Helpers' ) ) { + $this->markTestSkipped( 'WC Name Your Price not available.' ); + } + + $product = new \WC_Product_Simple(); + $product->set_name( 'Donate' ); + $product->set_status( 'publish' ); + $product->save(); + update_post_meta( $product->get_id(), '_nyp', 'yes' ); + update_post_meta( $product->get_id(), '_min_price', '5' ); + update_post_meta( $product->get_id(), '_max_price', '500' ); + update_post_meta( $product->get_id(), '_suggested_price', '20' ); + + $block_html = sprintf( + ' +
+ +
+ ', + $product->get_id() + ); + + $rendered = do_blocks( $block_html ); + + $this->assertStringContainsString( 'data-min="5"', $rendered ); + $this->assertStringContainsString( 'data-max="500"', $rendered ); + $this->assertStringContainsString( 'data-suggested="20"', $rendered ); + $this->assertStringContainsString( 'min="5"', $rendered ); + $this->assertStringContainsString( 'max="500"', $rendered ); + $this->assertStringContainsString( 'value="20"', $rendered ); + } } From 086715c73524071563a15dcbc3f372e239979ff7 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 22:21:00 -0300 Subject: [PATCH 23/56] feat(fast-checkout): nyp-input editor preview and frontend cart sync --- block-list.json | 1 + src/blocks/fast-checkout-nyp-input/edit.tsx | 72 ++++++++ src/blocks/fast-checkout-nyp-input/editor.ts | 9 + src/blocks/fast-checkout-nyp-input/index.tsx | 17 ++ src/blocks/fast-checkout-nyp-input/view.tsx | 171 +++++++++++++++++++ 5 files changed, 270 insertions(+) create mode 100644 src/blocks/fast-checkout-nyp-input/edit.tsx create mode 100644 src/blocks/fast-checkout-nyp-input/editor.ts create mode 100644 src/blocks/fast-checkout-nyp-input/index.tsx create mode 100644 src/blocks/fast-checkout-nyp-input/view.tsx diff --git a/block-list.json b/block-list.json index 2f005e8ff..1a67f8640 100644 --- a/block-list.json +++ b/block-list.json @@ -7,6 +7,7 @@ "donate", "fast-checkout", "fast-checkout-grouped-selector", + "fast-checkout-nyp-input", "fast-checkout-variation-selector", "homepage-articles", "video-playlist", diff --git a/src/blocks/fast-checkout-nyp-input/edit.tsx b/src/blocks/fast-checkout-nyp-input/edit.tsx new file mode 100644 index 000000000..cb3bba555 --- /dev/null +++ b/src/blocks/fast-checkout-nyp-input/edit.tsx @@ -0,0 +1,72 @@ +import { __ } from '@wordpress/i18n'; +import { useEffect, useState } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; +import { useBlockProps } from '@wordpress/block-editor'; +import { Spinner } from '@wordpress/components'; + +import type { StoreApiProduct } from '../fast-checkout/types'; + +interface EditProps { + context: { + 'newspack-blocks/fastCheckoutProductId'?: string | number; + 'newspack-blocks/fastCheckoutNypPrice'?: string; + }; +} + +export default function Edit( { context }: EditProps ) { + const productId = parseInt( String( context?.[ 'newspack-blocks/fastCheckoutProductId' ] ?? 0 ), 10 ); + const overridePrice = String( context?.[ 'newspack-blocks/fastCheckoutNypPrice' ] || '' ); + const blockProps = useBlockProps(); + const [ nyp, setNyp ] = useState< { min?: string; max?: string; suggested?: string } | null >( null ); + + useEffect( () => { + if ( ! productId ) { + setNyp( null ); + return; + } + apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ productId }` } ) + .then( product => { + const ext = product?.extensions?.nyp; + if ( ! ext?.is_nyp ) { + setNyp( null ); + return; + } + setNyp( { + min: ext.minimum_price, + max: ext.maximum_price, + suggested: ext.suggested_price, + } ); + } ) + .catch( () => setNyp( null ) ); + }, [ productId ] ); + + if ( ! productId ) { + return ( +
+ { __( 'Pick a Name Your Price product on the parent Fast Checkout block.', 'newspack-blocks' ) } +
+ ); + } + + if ( null === nyp ) { + return ( +
+ +
+ ); + } + + const inputId = 'fc-nyp-edit-input'; + + return ( +
+ + +

+ { nyp.min && nyp.max + ? `${ nyp.min } – ${ nyp.max } · suggested ${ nyp.suggested }` + : __( 'Reader can set any amount.', 'newspack-blocks' ) } +

+
+ ); +} diff --git a/src/blocks/fast-checkout-nyp-input/editor.ts b/src/blocks/fast-checkout-nyp-input/editor.ts new file mode 100644 index 000000000..f06b2dd69 --- /dev/null +++ b/src/blocks/fast-checkout-nyp-input/editor.ts @@ -0,0 +1,9 @@ +/** + * Fast Checkout NYP Input — editor bundle entry. + */ + +import { registerBlockType } from '@wordpress/blocks'; +import { name, settings } from './index'; +import './editor.scss'; + +registerBlockType( name, settings ); diff --git a/src/blocks/fast-checkout-nyp-input/index.tsx b/src/blocks/fast-checkout-nyp-input/index.tsx new file mode 100644 index 000000000..260f2783e --- /dev/null +++ b/src/blocks/fast-checkout-nyp-input/index.tsx @@ -0,0 +1,17 @@ +import { __ } from '@wordpress/i18n'; +import metadata from './block.json'; +import edit from './edit'; + +export const name: string = metadata.name; +export const title: string = __( 'Fast Checkout — Name Your Price', 'newspack-blocks' ); + +function save() { + return null; +} + +export const settings = { + ...metadata, + title, + edit, + save, +}; diff --git a/src/blocks/fast-checkout-nyp-input/view.tsx b/src/blocks/fast-checkout-nyp-input/view.tsx new file mode 100644 index 000000000..95929fda2 --- /dev/null +++ b/src/blocks/fast-checkout-nyp-input/view.tsx @@ -0,0 +1,171 @@ +/** + * Fast Checkout NYP Input — frontend. + * + * Hydrates the SSR input element. On blur (debounced 300 ms) clamps the + * value and dispatches a cart update via the WC Store API cart store. + */ + +import { createRoot, useEffect, useMemo, useRef, useState } from '@wordpress/element'; +import { dispatch, select } from '@wordpress/data'; +import { __, sprintf } from '@wordpress/i18n'; +import './view.scss'; + +const STORE = 'wc/store/cart' as const; +const DEBOUNCE_MS = 300; + +interface RootProps { + host: HTMLElement; + productId: number; + min: number; + max: number; +} + +function NypInput( { host, productId, min, max }: RootProps ) { + const input = useMemo< HTMLInputElement | null >( () => host.querySelector< HTMLInputElement >( 'input[type="number"]' ), [ host ] ); + const noticeNode = useMemo< HTMLElement | null >( + () => host.querySelector( '.wp-block-newspack-blocks-fast-checkout-nyp-input__notice' ), + [ host ] + ); + const [ inFlight, setInFlight ] = useState( false ); + const [ error, setError ] = useState< string >( '' ); + const lastApplied = useRef< number >( parseFloat( input?.value || '0' ) || 0 ); + const timer = useRef< ReturnType< typeof setTimeout > | null >( null ); + + useEffect( () => { + if ( noticeNode ) { + if ( error ) { + noticeNode.textContent = error; + noticeNode.removeAttribute( 'hidden' ); + } else { + noticeNode.textContent = ''; + noticeNode.setAttribute( 'hidden', '' ); + } + } + }, [ error, noticeNode ] ); + + useEffect( () => { + host.dataset.status = inFlight ? 'busy' : 'idle'; + }, [ host, inFlight ] ); + + useEffect( () => { + if ( ! input ) { + return; + } + const onBlur = () => { + if ( timer.current ) { + clearTimeout( timer.current ); + } + timer.current = setTimeout( apply, DEBOUNCE_MS ); + }; + + const apply = async () => { + const raw = parseFloat( input.value ); + if ( ! isFinite( raw ) || raw <= 0 ) { + setError( __( 'Enter a valid amount.', 'newspack-blocks' ) ); + input.value = String( lastApplied.current ); + return; + } + let clamped = raw; + if ( max > 0 ) { + clamped = Math.min( clamped, max ); + } + if ( min > 0 ) { + clamped = Math.max( clamped, min ); + } + if ( clamped !== raw ) { + input.value = String( clamped ); + setError( + sprintf( + /* translators: %s is the price actually applied. */ + __( 'Adjusted to %s to meet limits.', 'newspack-blocks' ), + String( clamped ) + ) + ); + } else { + setError( '' ); + } + if ( clamped === lastApplied.current ) { + return; + } + + setInFlight( true ); + try { + await applyNypPrice( productId, clamped ); + lastApplied.current = clamped; + updateUrlParam( 'fc_price', String( clamped ) ); + } catch ( ex: unknown ) { + setError( ( ex as Error )?.message || __( 'Could not update price.', 'newspack-blocks' ) ); + input.value = String( lastApplied.current ); + } finally { + setInFlight( false ); + } + }; + + input.addEventListener( 'blur', onBlur ); + return () => { + input.removeEventListener( 'blur', onBlur ); + if ( timer.current ) { + clearTimeout( timer.current ); + } + }; + }, [ input, min, max, productId ] ); + + useEffect( () => { + const onPopstate = () => { + const params = new URLSearchParams( window.location.search ); + const next = parseFloat( params.get( 'fc_price' ) || '' ); + if ( isFinite( next ) && next > 0 && input && next !== lastApplied.current ) { + input.value = String( next ); + lastApplied.current = next; + } + }; + window.addEventListener( 'popstate', onPopstate ); + return () => window.removeEventListener( 'popstate', onPopstate ); + }, [ input ] ); + + return null; +} + +async function applyNypPrice( productId: number, price: number ) { + const cartActions = dispatch( STORE ); + const cartSelectors = select( STORE ); + const items = cartSelectors.getCartData()?.items || []; + const existing = items.find( ( item: { id?: number; key?: string } ) => item.id === productId ) as { key?: string } | undefined; + + if ( existing?.key ) { + await ( cartActions as { removeItemFromCart: ( key: string ) => Promise< unknown > } ).removeItemFromCart( existing.key ); + } + await ( + cartActions as { + addItemToCart: ( id: number, qty: number, variation?: unknown[], cartItemData?: Record< string, unknown > ) => Promise< unknown >; + } + ).addItemToCart( productId, 1, [], { nyp: price } ); +} + +function updateUrlParam( key: string, value: string ) { + const url = new URL( window.location.href ); + url.searchParams.set( key, value ); + window.history.replaceState( {}, '', url.toString() ); +} + +function init() { + document.querySelectorAll< HTMLElement >( '.wp-block-newspack-blocks-fast-checkout-nyp-input' ).forEach( host => { + const productId = parseInt( host.dataset.productId || '0', 10 ); + const min = parseFloat( host.dataset.min || '0' ) || 0; + const max = parseFloat( host.dataset.max || '0' ) || 0; + if ( ! productId ) { + return; + } + const sentinel = document.createElement( 'span' ); + sentinel.style.display = 'none'; + host.appendChild( sentinel ); + const root = createRoot( sentinel ); + root.render( ); + } ); +} + +if ( document.readyState === 'loading' ) { + document.addEventListener( 'DOMContentLoaded', init ); +} else { + init(); +} From 4320b4fbfb0ae09dce729b126ca904e7ccf0f535 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 22:23:55 -0300 Subject: [PATCH 24/56] feat(fast-checkout): bridge NYP cart_item_data through Store API --- includes/class-fast-checkout.php | 56 ++++++++++++++++++++++++++++++++ tests/test-fast-checkout.php | 35 ++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php index be3ab68f9..362e15f5b 100644 --- a/includes/class-fast-checkout.php +++ b/includes/class-fast-checkout.php @@ -82,6 +82,7 @@ public static function init() { add_filter( 'block_type_metadata', [ __CLASS__, 'add_context_to_core_blocks' ] ); add_filter( 'woocommerce_is_checkout', [ __CLASS__, 'maybe_flag_as_checkout' ] ); add_filter( 'render_block_data', [ __CLASS__, 'filter_checkout_actions_block' ] ); + add_filter( 'woocommerce_store_api_add_to_cart_data', [ __CLASS__, 'store_api_nyp_bridge_handler' ], 10, 2 ); } /** @@ -658,6 +659,61 @@ public static function attach_line_item_meta( $item, $cart_item_key, $values, $o } } + /** + * Filter handler that adapts the WC Store API add-to-cart payload to inject + * NYP cart_item_data when the reader-set price is present in the request. + * + * @param array $request_data WC's normalized cart_item_data array. + * @param \WP_REST_Request $request Underlying REST request. + * @return array + */ + public static function store_api_nyp_bridge_handler( $request_data, $request ) { + $product_id = isset( $request_data['product_id'] ) ? (int) $request_data['product_id'] : 0; + if ( ! $product_id && method_exists( $request, 'get_param' ) ) { + $product_id = (int) $request->get_param( 'id' ); + } + $request_data['cart_item_data'] = self::store_api_nyp_bridge( + $request_data['cart_item_data'] ?? [], + $product_id, + $request + ); + return $request_data; + } + + /** + * Pure helper: inject the NYP value into cart_item_data when the request + * body carries one and the target product is NYP-eligible. Clamps to min/max. + * + * @param array $cart_item_data Existing cart item data. + * @param int $product_id Target product. + * @param \WP_REST_Request $request REST request. + * @return array + */ + public static function store_api_nyp_bridge( $cart_item_data, $product_id, $request ) { + if ( ! class_exists( '\WC_Name_Your_Price_Helpers' ) ) { + return $cart_item_data; + } + if ( ! \WC_Name_Your_Price_Helpers::is_nyp( $product_id ) ) { + return $cart_item_data; + } + $body = method_exists( $request, 'get_body_params' ) ? $request->get_body_params() : []; + $raw = $body['cart_item_data']['nyp'] ?? null; + if ( null === $raw && method_exists( $request, 'get_json_params' ) ) { + $json = $request->get_json_params(); + $raw = is_array( $json ) ? ( $json['cart_item_data']['nyp'] ?? null ) : null; + } + if ( null === $raw || ! is_numeric( $raw ) ) { + return $cart_item_data; + } + $price = (float) $raw; + $min_price = \WC_Name_Your_Price_Helpers::get_minimum_price( $product_id ); + $max_price = \WC_Name_Your_Price_Helpers::get_maximum_price( $product_id ); + $price = ! empty( $max_price ) ? min( $price, (float) $max_price ) : $price; + $price = ! empty( $min_price ) ? max( $price, (float) $min_price ) : $price; + $cart_item_data['nyp'] = (float) \WC_Name_Your_Price_Helpers::standardize_number( $price ); + return $cart_item_data; + } + /** * Add product context keys to core block metadata. * diff --git a/tests/test-fast-checkout.php b/tests/test-fast-checkout.php index be44543b5..0332c9cfd 100644 --- a/tests/test-fast-checkout.php +++ b/tests/test-fast-checkout.php @@ -679,6 +679,41 @@ public function test_maybe_replace_cart_applies_nyp_attribute() { $this->assertSame( 20.0, (float) $item['nyp'] ); } + /** + * Test that the Store API NYP bridge filter applies nyp from request body. + */ + public function test_store_api_nyp_bridge_applies_request_value() { + $this->skip_without_wc(); + if ( ! class_exists( '\WC_Name_Your_Price_Helpers' ) ) { + $this->markTestSkipped( 'WC Name Your Price not available.' ); + } + + $product = $this->create_simple_product(); + update_post_meta( $product->get_id(), '_nyp', 'yes' ); + update_post_meta( $product->get_id(), '_min_price', '5' ); + update_post_meta( $product->get_id(), '_max_price', '50' ); + update_post_meta( $product->get_id(), '_suggested_price', '15' ); + + // Simulate Store API add_to_cart payload with cart_item_data.nyp. + $request = new \WP_REST_Request( 'POST', '/wc/store/v1/cart/add-item' ); + $request->set_body_params( + [ + 'id' => $product->get_id(), + 'quantity' => 1, + 'cart_item_data' => [ 'nyp' => 22.5 ], + ] + ); + + $cart_item_data = []; + $cart_item_data = Fast_Checkout::store_api_nyp_bridge( + $cart_item_data, + $product->get_id(), + $request + ); + + $this->assertSame( 22.5, $cart_item_data['nyp'] ?? null ); + } + /** * Test that fc_price query param overrides nyp_price attribute when both set. * From edb09da6748bb6cfa14298fcfca0e1f82d7354b5 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 22:26:38 -0300 Subject: [PATCH 25/56] feat(fast-checkout): detect grouped and NYP product types in editor --- src/blocks/fast-checkout/edit.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/blocks/fast-checkout/edit.tsx b/src/blocks/fast-checkout/edit.tsx index 9bed70c65..422e7d54b 100644 --- a/src/blocks/fast-checkout/edit.tsx +++ b/src/blocks/fast-checkout/edit.tsx @@ -155,16 +155,21 @@ export default function Edit( { attributes, setAttributes, clientId }: EditProps .forEach( b => updateBlockAttributes( b.clientId, { showReturnToCart: false } ) ); }, [ allDescendants.length ] ); - // Detect variable product when product changes. + // Detect product type and set flags when product changes. useEffect( () => { if ( ! product ) { return; } apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ product }` } ) .then( p => { - setAttributes( { is_variable: !! p?.variations?.length } ); + const flags = { + is_variable: !! p?.variations?.length, + is_grouped: p?.type === 'grouped' || !! p?.grouped_products?.length, + is_nyp: !! p?.extensions?.nyp?.is_nyp, + }; + setAttributes( flags ); } ) - .catch( () => setAttributes( { is_variable: false } ) ); + .catch( () => setAttributes( { is_variable: false, is_grouped: false, is_nyp: false } ) ); }, [ product ] ); if ( ! product ) { From 6e29e4bc3cc666bd6687500d910b938f5a39571c Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 22:29:46 -0300 Subject: [PATCH 26/56] feat(fast-checkout): auto-insert and auto-clean selectors on product type change --- src/blocks/fast-checkout/edit.tsx | 90 ++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 2 deletions(-) diff --git a/src/blocks/fast-checkout/edit.tsx b/src/blocks/fast-checkout/edit.tsx index 422e7d54b..52955cee8 100644 --- a/src/blocks/fast-checkout/edit.tsx +++ b/src/blocks/fast-checkout/edit.tsx @@ -3,9 +3,10 @@ */ import { __ } from '@wordpress/i18n'; -import { useEffect, useState } from '@wordpress/element'; +import { useEffect, useRef, useState } from '@wordpress/element'; import apiFetch from '@wordpress/api-fetch'; import { debounce } from 'lodash'; +import { createBlock } from '@wordpress/blocks'; import { InnerBlocks, InspectorControls, useBlockProps } from '@wordpress/block-editor'; import { useSelect, useDispatch } from '@wordpress/data'; import { PanelBody, BaseControl, TextControl, Button, Spinner, FormTokenField, SelectControl, Placeholder } from '@wordpress/components'; @@ -129,6 +130,13 @@ function VariationPicker( { productId, variationId, onChange }: VariationPickerP ); } +interface Block { + name: string; + clientId: string; + attributes: Record< string, unknown >; + innerBlocks: Block[]; +} + interface EditProps { attributes: FastCheckoutAttributes; setAttributes: ( attrs: Partial< FastCheckoutAttributes > ) => void; @@ -138,7 +146,7 @@ interface EditProps { export default function Edit( { attributes, setAttributes, clientId }: EditProps ) { const { product, variation, is_variable: isVariable, afterSuccessURL } = attributes; const blockProps = useBlockProps(); - const { updateBlockAttributes } = useDispatch( 'core/block-editor' ); + const { updateBlockAttributes, insertBlocks, removeBlocks } = useDispatch( 'core/block-editor' ); // On first insert, find the checkout-actions-block and disable "Return to Cart". const allDescendants = useSelect( @@ -149,6 +157,13 @@ export default function Edit( { attributes, setAttributes, clientId }: EditProps }, [ clientId ] ); + + // Direct children of this block — used for selector auto-insert/auto-clean. + const innerBlocks = useSelect( + select => ( select( 'core/block-editor' ) as { getBlocks: ( id: string ) => Block[] } ).getBlocks( clientId ), + [ clientId ] + ); + useEffect( () => { allDescendants .filter( b => b.name === 'woocommerce/checkout-actions-block' && b.attributes.showReturnToCart ) @@ -172,6 +187,77 @@ export default function Edit( { attributes, setAttributes, clientId }: EditProps .catch( () => setAttributes( { is_variable: false, is_grouped: false, is_nyp: false } ) ); }, [ product ] ); + // Track previous type flags so we only react to transitions, not every render. + const previousFlags = useRef< { v: boolean; g: boolean; n: boolean } >( { + v: !! attributes.is_variable, + g: !! attributes.is_grouped, + n: !! attributes.is_nyp, + } ); + + // Auto-insert or auto-clean selector inner blocks when the product type changes. + useEffect( () => { + const prev = previousFlags.current; + const next = { + v: !! attributes.is_variable, + g: !! attributes.is_grouped, + n: !! attributes.is_nyp, + }; + + const transitions: { type: 'v' | 'g' | 'n'; on: boolean }[] = []; + if ( prev.v !== next.v ) { + transitions.push( { type: 'v', on: next.v } ); + } + if ( prev.g !== next.g ) { + transitions.push( { type: 'g', on: next.g } ); + } + if ( prev.n !== next.n ) { + transitions.push( { type: 'n', on: next.n } ); + } + + if ( ! transitions.length ) { + previousFlags.current = next; + return; + } + + const slugFor: Record< 'v' | 'g' | 'n', string > = { + v: 'newspack-blocks/fast-checkout-variation-selector', + g: 'newspack-blocks/fast-checkout-grouped-selector', + n: 'newspack-blocks/fast-checkout-nyp-input', + }; + + const findClientIds = ( name: string ): string[] => innerBlocks.filter( ( b: Block ) => b.name === name ).map( ( b: Block ) => b.clientId ); + + const checkoutIndex = innerBlocks.findIndex( ( b: Block ) => b.name === 'woocommerce/checkout' ); + const insertIndex = checkoutIndex >= 0 ? checkoutIndex : innerBlocks.length; + + transitions.forEach( ( { type, on } ) => { + const slug = slugFor[ type ]; + if ( on ) { + if ( findClientIds( slug ).length === 0 ) { + insertBlocks( createBlock( slug ), insertIndex, clientId, false ); + } + } else { + const ids = findClientIds( slug ); + if ( ids.length ) { + removeBlocks( ids, false ); + } + } + } ); + + previousFlags.current = next; + + // Reset child/price defaults when the product type changes. + if ( prev.v && ! next.v && attributes.variation ) { + setAttributes( { variation: '' } ); + } + if ( prev.g && ! next.g && attributes.grouped_child ) { + setAttributes( { grouped_child: '' } ); + } + if ( prev.n && ! next.n && attributes.nyp_price ) { + setAttributes( { nyp_price: '' } ); + } + }, [ attributes.is_variable, attributes.is_grouped, attributes.is_nyp ] ); + if ( ! product ) { return (
From 18e9ae2bdaeedf185109ee792137d193aceeee13 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 22:32:47 -0300 Subject: [PATCH 27/56] feat(fast-checkout): inspector controls for grouped child and NYP default price --- src/blocks/fast-checkout/edit.tsx | 113 ++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/src/blocks/fast-checkout/edit.tsx b/src/blocks/fast-checkout/edit.tsx index 52955cee8..7900ca60e 100644 --- a/src/blocks/fast-checkout/edit.tsx +++ b/src/blocks/fast-checkout/edit.tsx @@ -130,6 +130,105 @@ function VariationPicker( { productId, variationId, onChange }: VariationPickerP ); } +interface GroupedChildPickerProps { + productId: string; + childId?: string; + onChange: ( id: string ) => void; +} + +function GroupedChildPicker( { productId, childId, onChange }: GroupedChildPickerProps ) { + const [ children, setChildren ] = useState< { id: number; name: string }[] >( [] ); + + useEffect( () => { + if ( ! productId ) { + setChildren( [] ); + return; + } + apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ productId }` } ) + .then( product => { + const ids = product?.grouped_products || []; + if ( ! ids.length ) { + setChildren( [] ); + return; + } + return Promise.all( + ids.map( id => apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ id }` } ).catch( () => null ) ) + ).then( results => { + setChildren( results.filter( ( c ): c is StoreApiProduct => Boolean( c ) ).map( c => ( { id: c.id, name: c.name } ) ) ); + } ); + } ) + .catch( () => setChildren( [] ) ); + }, [ productId ] ); + + if ( ! children.length ) { + return null; + } + + return ( + ( { label: c.name, value: String( c.id ) } ) ), + ] } + onChange={ onChange } + /> + ); +} + +interface NypDefaultPriceProps { + productId: string; + value?: string; + onChange: ( value: string ) => void; +} + +function NypDefaultPrice( { productId, value, onChange }: NypDefaultPriceProps ) { + const [ help, setHelp ] = useState< string >( '' ); + + useEffect( () => { + if ( ! productId ) { + return; + } + apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ productId }` } ) + .then( product => { + const nyp = product?.extensions?.nyp; + if ( ! nyp?.is_nyp ) { + setHelp( '' ); + return; + } + const min = nyp.minimum_price; + const max = nyp.maximum_price; + const suggested = nyp.suggested_price; + const parts: string[] = []; + if ( min && max ) { + parts.push( + /* translators: 1: min price, 2: max price */ + __( 'Allowed: %1$s – %2$s.', 'newspack-blocks' ).replace( '%1$s', min ).replace( '%2$s', max ) + ); + } + if ( suggested ) { + parts.push( + /* translators: %s: suggested price */ + __( 'Suggested: %s.', 'newspack-blocks' ).replace( '%s', suggested ) + ); + } + setHelp( parts.join( ' ' ) || __( 'Reader can set any amount.', 'newspack-blocks' ) ); + } ) + .catch( () => setHelp( '' ) ); + }, [ productId ] ); + + return ( + + ); +} + interface Block { name: string; clientId: string; @@ -301,6 +400,20 @@ export default function Edit( { attributes, setAttributes, clientId }: EditProps onChange={ newVariation => setAttributes( { variation: newVariation } ) } /> ) } + { attributes.is_grouped && ( + setAttributes( { grouped_child: value } ) } + /> + ) } + { attributes.is_nyp && ( + setAttributes( { nyp_price: value } ) } + /> + ) } Date: Thu, 30 Apr 2026 22:35:37 -0300 Subject: [PATCH 28/56] feat(fast-checkout): warn when grouped product has variable or NYP children --- src/blocks/fast-checkout/edit.tsx | 35 +++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/blocks/fast-checkout/edit.tsx b/src/blocks/fast-checkout/edit.tsx index 7900ca60e..a926b9843 100644 --- a/src/blocks/fast-checkout/edit.tsx +++ b/src/blocks/fast-checkout/edit.tsx @@ -9,7 +9,7 @@ import { debounce } from 'lodash'; import { createBlock } from '@wordpress/blocks'; import { InnerBlocks, InspectorControls, useBlockProps } from '@wordpress/block-editor'; import { useSelect, useDispatch } from '@wordpress/data'; -import { PanelBody, BaseControl, TextControl, Button, Spinner, FormTokenField, SelectControl, Placeholder } from '@wordpress/components'; +import { PanelBody, BaseControl, TextControl, Button, Spinner, FormTokenField, SelectControl, Placeholder, Notice } from '@wordpress/components'; import { DEFAULT_TEMPLATE } from './template'; import { fetchProduct } from './bindings-source'; @@ -244,6 +244,7 @@ interface EditProps { export default function Edit( { attributes, setAttributes, clientId }: EditProps ) { const { product, variation, is_variable: isVariable, afterSuccessURL } = attributes; + const [ groupedWarning, setGroupedWarning ] = useState< string >( '' ); const blockProps = useBlockProps(); const { updateBlockAttributes, insertBlocks, removeBlocks } = useDispatch( 'core/block-editor' ); @@ -282,8 +283,33 @@ export default function Edit( { attributes, setAttributes, clientId }: EditProps is_nyp: !! p?.extensions?.nyp?.is_nyp, }; setAttributes( flags ); + + if ( flags.is_grouped && p?.grouped_products?.length ) { + Promise.all( + p.grouped_products.map( id => apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ id }` } ).catch( () => null ) ) + ).then( children => { + const unsupported = children.filter( + ( c ): c is StoreApiProduct => !! c && ( ( c.variations?.length ?? 0 ) > 0 || !! c.extensions?.nyp?.is_nyp ) + ); + if ( unsupported.length ) { + setGroupedWarning( + __( + 'This grouped product contains variable or Name Your Price children. Fast Checkout treats children as simple products; those types are not supported.', + 'newspack-blocks' + ) + ); + } else { + setGroupedWarning( '' ); + } + } ); + } else { + setGroupedWarning( '' ); + } } ) - .catch( () => setAttributes( { is_variable: false, is_grouped: false, is_nyp: false } ) ); + .catch( () => { + setAttributes( { is_variable: false, is_grouped: false, is_nyp: false } ); + setGroupedWarning( '' ); + } ); }, [ product ] ); // Track previous type flags so we only react to transitions, not every render. @@ -414,6 +440,11 @@ export default function Edit( { attributes, setAttributes, clientId }: EditProps onChange={ value => setAttributes( { nyp_price: value } ) } /> ) } + { groupedWarning && ( + + { groupedWarning } + + ) } Date: Thu, 30 Apr 2026 22:42:21 -0300 Subject: [PATCH 29/56] test(fast-checkout): unit-test variation resolution helper --- .../resolve.test.js | 25 ++++++++++++++++ .../resolve.ts | 25 ++++++++++++++++ .../fast-checkout-variation-selector/view.tsx | 30 ++----------------- 3 files changed, 52 insertions(+), 28 deletions(-) create mode 100644 src/blocks/fast-checkout-variation-selector/resolve.test.js create mode 100644 src/blocks/fast-checkout-variation-selector/resolve.ts diff --git a/src/blocks/fast-checkout-variation-selector/resolve.test.js b/src/blocks/fast-checkout-variation-selector/resolve.test.js new file mode 100644 index 000000000..5740f68a7 --- /dev/null +++ b/src/blocks/fast-checkout-variation-selector/resolve.test.js @@ -0,0 +1,25 @@ +import { resolveVariationId } from './resolve'; + +describe( 'resolveVariationId', () => { + const variations = [ + { id: 1, attributes: { attribute_color: 'red', attribute_size: 's' }, is_in_stock: true }, + { id: 2, attributes: { attribute_color: 'red', attribute_size: 'm' }, is_in_stock: true }, + { id: 3, attributes: { attribute_color: 'blue', attribute_size: 's' }, is_in_stock: true }, + { id: 4, attributes: { attribute_color: 'blue', attribute_size: 'm' }, is_in_stock: true }, + ]; + + it( 'returns null when not all attributes are picked', () => { + const selection = { attribute_color: 'red' }; + expect( resolveVariationId( variations, selection ) ).toBeNull(); + } ); + + it( 'returns matching variation id when all attributes picked', () => { + const selection = { attribute_color: 'blue', attribute_size: 'm' }; + expect( resolveVariationId( variations, selection ) ).toBe( 4 ); + } ); + + it( 'returns null when selection has no matching variation', () => { + const selection = { attribute_color: 'green', attribute_size: 'l' }; + expect( resolveVariationId( variations, selection ) ).toBeNull(); + } ); +} ); diff --git a/src/blocks/fast-checkout-variation-selector/resolve.ts b/src/blocks/fast-checkout-variation-selector/resolve.ts new file mode 100644 index 000000000..03a364b57 --- /dev/null +++ b/src/blocks/fast-checkout-variation-selector/resolve.ts @@ -0,0 +1,25 @@ +/** + * Pure helpers for variation resolution. + */ + +export interface VariationData { + id: number; + attributes: Record< string, string >; + is_in_stock: boolean; +} + +export function attributesMatch( variation: VariationData, selection: Record< string, string > ): boolean { + return Object.entries( variation.attributes ).every( ( [ key, value ] ) => selection[ key ] === value ); +} + +export function resolveVariationId( variations: VariationData[], selection: Record< string, string > ): number | null { + const required = new Set< string >(); + variations.forEach( v => Object.keys( v.attributes ).forEach( k => required.add( k ) ) ); + for ( const key of required ) { + if ( ! selection[ key ] ) { + return null; + } + } + const match = variations.find( v => attributesMatch( v, selection ) ); + return match ? match.id : null; +} diff --git a/src/blocks/fast-checkout-variation-selector/view.tsx b/src/blocks/fast-checkout-variation-selector/view.tsx index dc72e5b93..70007c171 100644 --- a/src/blocks/fast-checkout-variation-selector/view.tsx +++ b/src/blocks/fast-checkout-variation-selector/view.tsx @@ -16,15 +16,11 @@ import { createRoot, useEffect, useMemo, useState } from '@wordpress/element'; import { dispatch, select } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import './view.scss'; +import { resolveVariationId } from './resolve'; +import type { VariationData } from './resolve'; const STORE = 'wc/store/cart' as const; -interface VariationData { - id: number; - attributes: Record< string, string >; - is_in_stock: boolean; -} - interface RootProps { host: HTMLFormElement; productId: number; @@ -40,28 +36,6 @@ function readCurrentSelections( host: HTMLFormElement ): Record< string, string return result; } -/** - * Check whether a variation's attributes match the current DOM selection. - * - * Both the variation attributes (from data-variations JSON) and the radio input - * names have the "attribute_" prefix, so keys align directly. - */ -function attributesMatch( variation: VariationData, selection: Record< string, string > ): boolean { - return Object.entries( variation.attributes ).every( ( [ key, value ] ) => selection[ key ] === value ); -} - -function resolveVariationId( variations: VariationData[], selection: Record< string, string > ): number | null { - const required = new Set< string >(); - variations.forEach( v => Object.keys( v.attributes ).forEach( k => required.add( k ) ) ); - for ( const key of required ) { - if ( ! selection[ key ] ) { - return null; - } - } - const match = variations.find( v => attributesMatch( v, selection ) ); - return match ? match.id : null; -} - function VariationSelector( { host, productId, variations, currentVariationId }: RootProps ) { const [ pendingId, setPendingId ] = useState< number >( currentVariationId ); const [ inFlight, setInFlight ] = useState( false ); From 00650aef8837dea0379e9655f26583474f440fe7 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 23:12:07 -0300 Subject: [PATCH 30/56] fix(fast-checkout): fall back to first variation for variable products --- includes/class-fast-checkout.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php index 362e15f5b..5994bff34 100644 --- a/includes/class-fast-checkout.php +++ b/includes/class-fast-checkout.php @@ -227,7 +227,7 @@ private static function resolve_effective_product_id( $attrs, $qp ) { return $parent; } - // Variable: existing logic, with query param override. + // Variable: query param > attribute > first variation (runtime). if ( $is_variable ) { if ( ! empty( $qp['variation'] ) ) { return (int) $qp['variation']; @@ -235,6 +235,15 @@ private static function resolve_effective_product_id( $attrs, $qp ) { if ( ! empty( $attrs['variation'] ) ) { return (int) $attrs['variation']; } + if ( function_exists( 'wc_get_product' ) ) { + $parent = wc_get_product( (int) $attrs['product'] ); + if ( $parent && method_exists( $parent, 'get_children' ) ) { + $children = array_map( 'intval', $parent->get_children() ); + if ( ! empty( $children ) ) { + return $children[0]; + } + } + } } return (int) $attrs['product']; From 252ce19df2288e4505a1d7da0c3d69f68efa5ca8 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 23:13:11 -0300 Subject: [PATCH 31/56] fix(fast-checkout): read NYP value from top-level Store API request body --- includes/class-fast-checkout.php | 4 ++-- tests/test-fast-checkout.php | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php index 5994bff34..5bab5f42b 100644 --- a/includes/class-fast-checkout.php +++ b/includes/class-fast-checkout.php @@ -706,10 +706,10 @@ public static function store_api_nyp_bridge( $cart_item_data, $product_id, $requ return $cart_item_data; } $body = method_exists( $request, 'get_body_params' ) ? $request->get_body_params() : []; - $raw = $body['cart_item_data']['nyp'] ?? null; + $raw = $body['nyp'] ?? null; if ( null === $raw && method_exists( $request, 'get_json_params' ) ) { $json = $request->get_json_params(); - $raw = is_array( $json ) ? ( $json['cart_item_data']['nyp'] ?? null ) : null; + $raw = is_array( $json ) ? ( $json['nyp'] ?? null ) : null; } if ( null === $raw || ! is_numeric( $raw ) ) { return $cart_item_data; diff --git a/tests/test-fast-checkout.php b/tests/test-fast-checkout.php index 0332c9cfd..2c04316fc 100644 --- a/tests/test-fast-checkout.php +++ b/tests/test-fast-checkout.php @@ -698,9 +698,9 @@ public function test_store_api_nyp_bridge_applies_request_value() { $request = new \WP_REST_Request( 'POST', '/wc/store/v1/cart/add-item' ); $request->set_body_params( [ - 'id' => $product->get_id(), - 'quantity' => 1, - 'cart_item_data' => [ 'nyp' => 22.5 ], + 'id' => $product->get_id(), + 'quantity' => 1, + 'nyp' => 22.5, ] ); From 1d69a97f08541eb24f65c3e525ceb3145e2eba83 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 23:13:54 -0300 Subject: [PATCH 32/56] fix(fast-checkout): match multi-line input markup in grouped-selector test --- tests/test-fast-checkout-selectors.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test-fast-checkout-selectors.php b/tests/test-fast-checkout-selectors.php index 19ce27831..8aa0235ae 100644 --- a/tests/test-fast-checkout-selectors.php +++ b/tests/test-fast-checkout-selectors.php @@ -171,7 +171,7 @@ public function test_grouped_selector_renders_children() { $this->assertStringContainsString( 'Annual', $rendered ); $this->assertStringContainsString( 'Monthly', $rendered ); - $this->assertSame( 2, substr_count( $rendered, 'assertSame( 2, preg_match_all( '/]+type="radio"/', $rendered ) ); } /** From 402cfae7733f712d6a19f03b622ad89ec366f4cb Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 23:18:22 -0300 Subject: [PATCH 33/56] fix(fast-checkout): correct variation cart-swap, concurrency, and stock handling --- .../fast-checkout-variation-selector/view.php | 27 +++++- .../fast-checkout-variation-selector/view.tsx | 83 +++++++++++-------- 2 files changed, 74 insertions(+), 36 deletions(-) diff --git a/src/blocks/fast-checkout-variation-selector/view.php b/src/blocks/fast-checkout-variation-selector/view.php index 931812eac..3830e5287 100644 --- a/src/blocks/fast-checkout-variation-selector/view.php +++ b/src/blocks/fast-checkout-variation-selector/view.php @@ -66,6 +66,9 @@ function render_block( $attrs, $content, $block ) { $variations = $product->get_available_variations(); $current_variation = (int) ( $block->context['newspack-blocks/fastCheckoutVariationId'] ?? 0 ); + if ( count( $variations ) < 2 ) { + return ''; + } // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized if ( isset( $_GET[ Fast_Checkout::QP_VARIATION ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized @@ -105,6 +108,20 @@ function ( $v ) { ] ); + // Map each (attribute_name, option_value) to is-available across in-stock variations. + $option_availability = []; + foreach ( $variations as $v ) { + if ( empty( $v['is_in_stock'] ) ) { + continue; + } + foreach ( $v['attributes'] as $key => $value ) { + // $key is e.g. "attribute_color"; strip the prefix to match $attribute_name iteration below. + $attr = preg_replace( '/^attribute_/', '', $key ); + $value = sanitize_title( $value ); + $option_availability[ $attr ][ $value ] = true; + } + } + ob_start(); ?>
> @@ -118,15 +135,23 @@ function ( $v ) {
- +
diff --git a/src/blocks/fast-checkout-variation-selector/view.tsx b/src/blocks/fast-checkout-variation-selector/view.tsx index 70007c171..c80dafdec 100644 --- a/src/blocks/fast-checkout-variation-selector/view.tsx +++ b/src/blocks/fast-checkout-variation-selector/view.tsx @@ -4,20 +4,14 @@ * Hydrates SSR-rendered elements, listens to radio changes, resolves * to a variation ID once all attributes are picked, and swaps the cart * line via the WC Store API cart store. - * - * NOTE on attribute key shape: WooCommerce's get_available_variation() calls - * $variation->get_variation_attributes() with $with_prefix=true (default), - * so the data-variations JSON has PREFIXED keys (e.g. "attribute_color"). - * Radio inputs also have prefixed names (e.g. name="attribute_color"). - * Keys match directly — no extra prefix translation needed. */ -import { createRoot, useEffect, useMemo, useState } from '@wordpress/element'; +import { createRoot, useEffect, useMemo, useRef, useState } from '@wordpress/element'; import { dispatch, select } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; -import './view.scss'; import { resolveVariationId } from './resolve'; import type { VariationData } from './resolve'; +import './view.scss'; const STORE = 'wc/store/cart' as const; @@ -40,12 +34,26 @@ function VariationSelector( { host, productId, variations, currentVariationId }: const [ pendingId, setPendingId ] = useState< number >( currentVariationId ); const [ inFlight, setInFlight ] = useState( false ); const [ error, setError ] = useState< string >( '' ); + // Track in-flight via ref so concurrent change events early-return without racing on state. + const inFlightRef = useRef< boolean >( false ); + // Remember which radios were SSR-disabled (out of stock) so we don't re-enable them later. + const ssrDisabled = useRef< Set< HTMLInputElement > | null >( null ); const noticeNode = useMemo< HTMLElement | null >( () => host.querySelector( '.wp-block-newspack-blocks-fast-checkout-variation-selector__notice' ), [ host ] ); + // Capture SSR-disabled radios on mount so disabled-state toggling preserves them. + useEffect( () => { + if ( ssrDisabled.current === null ) { + ssrDisabled.current = new Set(); + host.querySelectorAll< HTMLInputElement >( 'input[type="radio"][disabled]' ).forEach( input => { + ssrDisabled.current!.add( input ); + } ); + } + }, [ host ] ); + useEffect( () => { if ( noticeNode ) { if ( error ) { @@ -60,21 +68,36 @@ function VariationSelector( { host, productId, variations, currentVariationId }: useEffect( () => { const onChange = async () => { + if ( inFlightRef.current ) { + return; + } setError( '' ); const selection = readCurrentSelections( host ); const resolvedId = resolveVariationId( variations, selection ); - if ( ! resolvedId || resolvedId === pendingId ) { + if ( ! resolvedId ) { + return; + } + // Verify the resolved variation is in stock before attempting cart swap. + const resolved = variations.find( v => v.id === resolvedId ); + if ( resolved && ! resolved.is_in_stock ) { + setError( __( 'That combination is out of stock.', 'newspack-blocks' ) ); + revertSelection( host, pendingId, variations ); return; } + if ( resolvedId === pendingId ) { + return; + } + inFlightRef.current = true; setInFlight( true ); try { - await swapCartItem( productId, pendingId, resolvedId ); + await swapCartItem( pendingId, resolvedId ); setPendingId( resolvedId ); updateUrlParam( 'fc_variation', String( resolvedId ) ); } catch ( e: unknown ) { setError( ( e as Error )?.message || __( 'Could not update selection.', 'newspack-blocks' ) ); revertSelection( host, pendingId, variations ); } finally { + inFlightRef.current = false; setInFlight( false ); } }; @@ -99,7 +122,13 @@ function VariationSelector( { host, productId, variations, currentVariationId }: }, [ host, variations, pendingId ] ); useEffect( () => { + host.dataset.status = inFlight ? 'busy' : 'idle'; + // Disable all non-SSR-disabled radios while in-flight; restore the original disabled state when idle. host.querySelectorAll< HTMLInputElement >( 'input[type="radio"]' ).forEach( input => { + if ( ssrDisabled.current?.has( input ) ) { + input.disabled = true; + return; + } input.disabled = inFlight; } ); }, [ host, inFlight ] ); @@ -107,12 +136,6 @@ function VariationSelector( { host, productId, variations, currentVariationId }: return null; } -/** - * Apply a variation's attribute selections to the DOM radio inputs. - * - * Attribute keys in VariationData already carry the "attribute_" prefix - * (matching the radio input name attributes), so no prefix translation needed. - */ function applySelectionToDom( host: HTMLFormElement, variation: VariationData ) { Object.entries( variation.attributes ).forEach( ( [ key, value ] ) => { const radio = host.querySelector< HTMLInputElement >( `input[type="radio"][name="${ key }"][value="${ value }"]` ); @@ -129,31 +152,21 @@ function revertSelection( host: HTMLFormElement, currentId: number, variations: } } -async function swapCartItem( parentProductId: number, oldVariationId: number, newVariationId: number ) { +async function swapCartItem( oldVariationId: number, newVariationId: number ) { const cartActions = dispatch( STORE ); const cartSelectors = select( STORE ); - const cart = cartSelectors.getCartData(); - const items = cart?.items || []; - const existing = items.find( ( item: { id?: number; variation?: { value?: string }[] } ) => { - const itemVariationId = Number( - item.variation?.find( ( v: { attribute?: string; value?: string } ) => v.attribute === 'variation_id' )?.value - ); - return itemVariationId === oldVariationId || item.id === parentProductId; - } ); + const items = cartSelectors.getCartData()?.items || []; + // For variation cart items, item.id is the variation_id. + const existing = items.find( ( item: { id?: number; key?: string } ) => item.id === oldVariationId ); if ( existing ) { - await ( - cartActions as { - removeItemFromCart: ( key: string ) => Promise< unknown >; - } - ).removeItemFromCart( ( existing as { key: string } ).key ); + await ( cartActions as { removeItemFromCart: ( key: string ) => Promise< unknown > } ).removeItemFromCart( + ( existing as { key: string } ).key + ); } - await ( - cartActions as { - addItemToCart: ( productId: number, quantity: number, variation?: { attribute: string; value: string }[] ) => Promise< unknown >; - } - ).addItemToCart( parentProductId, 1, [ { attribute: 'variation_id', value: String( newVariationId ) } ] ); + // The Store API accepts a variation ID directly as the cart item id; no need to spell out attributes. + await ( cartActions as { addItemToCart: ( id: number, qty: number ) => Promise< unknown > } ).addItemToCart( newVariationId, 1 ); } function updateUrlParam( key: string, value: string ) { From 3e9222d3b14f00dec73f9b1a9a9a17d9b6423c21 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 23:21:18 -0300 Subject: [PATCH 34/56] fix(fast-checkout): grouped-selector concurrency guard and disabled-state preservation --- .../fast-checkout-grouped-selector/view.tsx | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/blocks/fast-checkout-grouped-selector/view.tsx b/src/blocks/fast-checkout-grouped-selector/view.tsx index 9f6fc3fdb..b6ea0cdd1 100644 --- a/src/blocks/fast-checkout-grouped-selector/view.tsx +++ b/src/blocks/fast-checkout-grouped-selector/view.tsx @@ -5,7 +5,7 @@ * line via the WC Store API cart store. */ -import { createRoot, useEffect, useMemo, useState } from '@wordpress/element'; +import { createRoot, useEffect, useMemo, useRef, useState } from '@wordpress/element'; import { dispatch, select } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; import './view.scss'; @@ -21,12 +21,24 @@ function GroupedSelector( { host, currentChildId }: RootProps ) { const [ pendingId, setPendingId ] = useState< number >( currentChildId ); const [ inFlight, setInFlight ] = useState( false ); const [ error, setError ] = useState< string >( '' ); + const inFlightRef = useRef< boolean >( false ); + // Remember which radios were SSR-disabled (out of stock / unpurchasable). + const ssrDisabled = useRef< Set< HTMLInputElement > | null >( null ); const noticeNode = useMemo< HTMLElement | null >( () => host.querySelector( '.wp-block-newspack-blocks-fast-checkout-grouped-selector__notice' ), [ host ] ); + useEffect( () => { + if ( ssrDisabled.current === null ) { + ssrDisabled.current = new Set(); + host.querySelectorAll< HTMLInputElement >( 'input[type="radio"][disabled]' ).forEach( input => { + ssrDisabled.current!.add( input ); + } ); + } + }, [ host ] ); + useEffect( () => { if ( noticeNode ) { if ( error ) { @@ -41,6 +53,9 @@ function GroupedSelector( { host, currentChildId }: RootProps ) { useEffect( () => { const onChange = async ( e: Event ) => { + if ( inFlightRef.current ) { + return; + } const target = e.target as HTMLInputElement; if ( target?.type !== 'radio' ) { return; @@ -50,6 +65,7 @@ function GroupedSelector( { host, currentChildId }: RootProps ) { return; } setError( '' ); + inFlightRef.current = true; setInFlight( true ); try { await swapCartItem( pendingId, nextId ); @@ -62,6 +78,7 @@ function GroupedSelector( { host, currentChildId }: RootProps ) { previous.checked = true; } } finally { + inFlightRef.current = false; setInFlight( false ); } }; @@ -86,10 +103,13 @@ function GroupedSelector( { host, currentChildId }: RootProps ) { }, [ host, pendingId ] ); useEffect( () => { + host.dataset.status = inFlight ? 'busy' : 'idle'; host.querySelectorAll< HTMLInputElement >( 'input[type="radio"]' ).forEach( input => { - if ( ! input.disabled ) { - input.disabled = inFlight; + if ( ssrDisabled.current?.has( input ) ) { + input.disabled = true; + return; } + input.disabled = inFlight; } ); }, [ host, inFlight ] ); From 88a9373eb1981c5ee0dbbb70e7b4acb91370e1ae Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 23:22:01 -0300 Subject: [PATCH 35/56] fix(fast-checkout): nyp-input concurrency guard --- src/blocks/fast-checkout-nyp-input/view.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/blocks/fast-checkout-nyp-input/view.tsx b/src/blocks/fast-checkout-nyp-input/view.tsx index 95929fda2..3459eb6f4 100644 --- a/src/blocks/fast-checkout-nyp-input/view.tsx +++ b/src/blocks/fast-checkout-nyp-input/view.tsx @@ -30,6 +30,7 @@ function NypInput( { host, productId, min, max }: RootProps ) { const [ error, setError ] = useState< string >( '' ); const lastApplied = useRef< number >( parseFloat( input?.value || '0' ) || 0 ); const timer = useRef< ReturnType< typeof setTimeout > | null >( null ); + const inFlightRef = useRef< boolean >( false ); useEffect( () => { if ( noticeNode ) { @@ -59,6 +60,9 @@ function NypInput( { host, productId, min, max }: RootProps ) { }; const apply = async () => { + if ( inFlightRef.current ) { + return; + } const raw = parseFloat( input.value ); if ( ! isFinite( raw ) || raw <= 0 ) { setError( __( 'Enter a valid amount.', 'newspack-blocks' ) ); @@ -88,6 +92,7 @@ function NypInput( { host, productId, min, max }: RootProps ) { return; } + inFlightRef.current = true; setInFlight( true ); try { await applyNypPrice( productId, clamped ); @@ -97,6 +102,7 @@ function NypInput( { host, productId, min, max }: RootProps ) { setError( ( ex as Error )?.message || __( 'Could not update price.', 'newspack-blocks' ) ); input.value = String( lastApplied.current ); } finally { + inFlightRef.current = false; setInFlight( false ); } }; From 403ae6774a100bbcf60f2b99136e4c2f2bd20347 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 23:23:07 -0300 Subject: [PATCH 36/56] fix(fast-checkout): reset all type-dependent attrs on product change --- src/blocks/fast-checkout/edit.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/blocks/fast-checkout/edit.tsx b/src/blocks/fast-checkout/edit.tsx index a926b9843..1e41af501 100644 --- a/src/blocks/fast-checkout/edit.tsx +++ b/src/blocks/fast-checkout/edit.tsx @@ -381,7 +381,7 @@ export default function Edit( { attributes, setAttributes, clientId }: EditProps if ( prev.n && ! next.n && attributes.nyp_price ) { setAttributes( { nyp_price: '' } ); } - }, [ attributes.is_variable, attributes.is_grouped, attributes.is_nyp ] ); + }, [ attributes.is_variable, attributes.is_grouped, attributes.is_nyp, innerBlocks, insertBlocks, removeBlocks, clientId ] ); if ( ! product ) { return ( @@ -396,7 +396,11 @@ export default function Edit( { attributes, setAttributes, clientId }: EditProps setAttributes( { product: newId, variation: '', + grouped_child: '', + nyp_price: '', is_variable: false, + is_grouped: false, + is_nyp: false, } ) } /> @@ -415,7 +419,11 @@ export default function Edit( { attributes, setAttributes, clientId }: EditProps setAttributes( { product: newId, variation: '', + grouped_child: '', + nyp_price: '', is_variable: false, + is_grouped: false, + is_nyp: false, } ) } /> From d5f88a897ea3d410a7199bbc031f5ec378983f4d Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 23:57:11 -0300 Subject: [PATCH 37/56] test(fast-checkout): cover NYP suggested-price fallback path --- tests/test-fast-checkout.php | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test-fast-checkout.php b/tests/test-fast-checkout.php index 2c04316fc..daeb281ba 100644 --- a/tests/test-fast-checkout.php +++ b/tests/test-fast-checkout.php @@ -751,4 +751,36 @@ public function test_maybe_replace_cart_nyp_query_param_overrides_attribute() { unset( $_GET[ Fast_Checkout::QP_PRICE ] ); } + + /** + * Test that maybe_replace_cart falls back to the product's suggested price + * when neither fc_price nor nyp_price attribute is set. + * + * Skips when WC Name Your Price plugin isn't active. + */ + public function test_maybe_replace_cart_falls_back_to_suggested_nyp() { + $this->skip_without_wc(); + if ( ! class_exists( '\WC_Name_Your_Price_Helpers' ) ) { + $this->markTestSkipped( 'WC Name Your Price not available.' ); + } + + $product = $this->create_simple_product(); + update_post_meta( $product->get_id(), '_nyp', 'yes' ); + update_post_meta( $product->get_id(), '_min_price', '5' ); + update_post_meta( $product->get_id(), '_max_price', '50' ); + update_post_meta( $product->get_id(), '_suggested_price', '15' ); + + $post_id = $this->make_fast_checkout_post( + $product->get_id(), + [ 'is_nyp' => true ] + ); + $this->go_to( get_permalink( $post_id ) ); + + Fast_Checkout::maybe_replace_cart(); + + $cart_contents = WC()->cart->get_cart(); + $this->assertCount( 1, $cart_contents ); + $item = reset( $cart_contents ); + $this->assertSame( 15.0, (float) $item['nyp'] ); + } } From 1366feec389362455a217fbde23e33949fd1a292 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Fri, 1 May 2026 08:19:59 -0300 Subject: [PATCH 38/56] fix(fast-checkout): resolve grouped first child when checking purchasability --- includes/class-fast-checkout.php | 3 ++- tests/test-fast-checkout.php | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php index 0dc99e2fb..11979e4bd 100644 --- a/includes/class-fast-checkout.php +++ b/includes/class-fast-checkout.php @@ -582,7 +582,8 @@ public static function maybe_replace_cart() { */ public static function filter_render( $content, $block ) { $attrs = $block['attrs'] ?? []; - $product_id = self::resolve_product_id_from_attrs( $attrs ); + $qp = self::get_query_params(); + $product_id = self::resolve_effective_product_id( $attrs, $qp ); if ( $product_id && function_exists( 'wc_get_product' ) ) { $product = wc_get_product( $product_id ); diff --git a/tests/test-fast-checkout.php b/tests/test-fast-checkout.php index daeb281ba..43ea24fd5 100644 --- a/tests/test-fast-checkout.php +++ b/tests/test-fast-checkout.php @@ -197,6 +197,30 @@ public function test_filter_render_replaces_when_no_product_id() { $this->assertStringContainsString( 'unavailable', $filtered ); } + /** + * Test that filter_render passes through for a grouped product without + * an editor-set grouped_child, by resolving to the first purchasable child. + * + * Regression: previously called resolve_product_id_from_attrs (attrs-only), + * which returned the parent grouped product ID — and grouped parents are + * not purchasable, so the unavailable notice rendered instead of content. + */ + public function test_filter_render_passes_through_for_grouped_without_child() { + $this->skip_without_wc(); + + $child = $this->create_simple_product(); + $grouped = $this->create_grouped_product( [ $child->get_id() ] ); + $content = '
Buy now
'; + $block = [ + 'attrs' => [ + 'product' => (string) $grouped->get_id(), + 'is_grouped' => true, + ], + ]; + $filtered = Fast_Checkout::filter_render( $content, $block ); + $this->assertSame( $content, $filtered ); + } + // ---- Cart replacement tests (WC-dependent) ---- /** From ba229d490eaf502e2eb8e72b3765ea835f7e0914 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Fri, 1 May 2026 08:23:59 -0300 Subject: [PATCH 39/56] fix(fast-checkout): default NYP price to suggested in Store API bridge --- includes/class-fast-checkout.php | 11 ++++++++++- tests/test-fast-checkout.php | 34 ++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php index 11979e4bd..67788be0b 100644 --- a/includes/class-fast-checkout.php +++ b/includes/class-fast-checkout.php @@ -730,8 +730,17 @@ public static function store_api_nyp_bridge( $cart_item_data, $product_id, $requ $json = $request->get_json_params(); $raw = is_array( $json ) ? ( $json['nyp'] ?? null ) : null; } + // No explicit price provided — fall back to the product's suggested + // price, then minimum. Lets selector swaps (which don't carry an nyp + // value) succeed for grouped/variable products with NYP children. if ( null === $raw || ! is_numeric( $raw ) ) { - return $cart_item_data; + $raw = \WC_Name_Your_Price_Helpers::get_suggested_price( $product_id ); + if ( ! is_numeric( $raw ) || (float) $raw <= 0 ) { + $raw = \WC_Name_Your_Price_Helpers::get_minimum_price( $product_id ); + } + if ( ! is_numeric( $raw ) || (float) $raw <= 0 ) { + return $cart_item_data; + } } $price = (float) $raw; $min_price = \WC_Name_Your_Price_Helpers::get_minimum_price( $product_id ); diff --git a/tests/test-fast-checkout.php b/tests/test-fast-checkout.php index 43ea24fd5..614dfb4e1 100644 --- a/tests/test-fast-checkout.php +++ b/tests/test-fast-checkout.php @@ -738,6 +738,40 @@ public function test_store_api_nyp_bridge_applies_request_value() { $this->assertSame( 22.5, $cart_item_data['nyp'] ?? null ); } + /** + * Test that the bridge falls back to the suggested price when the request + * has no nyp value — covers the case of grouped-selector swaps for NYP + * children, where addItemToCart is called without an explicit price. + */ + public function test_store_api_nyp_bridge_falls_back_to_suggested() { + $this->skip_without_wc(); + if ( ! class_exists( '\WC_Name_Your_Price_Helpers' ) ) { + $this->markTestSkipped( 'WC Name Your Price not available.' ); + } + + $product = $this->create_simple_product(); + update_post_meta( $product->get_id(), '_nyp', 'yes' ); + update_post_meta( $product->get_id(), '_min_price', '5' ); + update_post_meta( $product->get_id(), '_max_price', '50' ); + update_post_meta( $product->get_id(), '_suggested_price', '15' ); + + $request = new \WP_REST_Request( 'POST', '/wc/store/v1/cart/add-item' ); + $request->set_body_params( + [ + 'id' => $product->get_id(), + 'quantity' => 1, + ] + ); + + $cart_item_data = Fast_Checkout::store_api_nyp_bridge( + [], + $product->get_id(), + $request + ); + + $this->assertSame( 15.0, (float) ( $cart_item_data['nyp'] ?? 0 ) ); + } + /** * Test that fc_price query param overrides nyp_price attribute when both set. * From 413025a422912f956dccdbac9b538ba4c6cde861 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Fri, 1 May 2026 08:27:21 -0300 Subject: [PATCH 40/56] fix(fast-checkout): swap cart by adding before removing to avoid empty flash --- .../fast-checkout-grouped-selector/view.tsx | 13 ++++++++----- src/blocks/fast-checkout-nyp-input/view.tsx | 13 ++++++++++--- .../fast-checkout-variation-selector/view.tsx | 19 +++++++++++-------- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/src/blocks/fast-checkout-grouped-selector/view.tsx b/src/blocks/fast-checkout-grouped-selector/view.tsx index b6ea0cdd1..20b8e8ee5 100644 --- a/src/blocks/fast-checkout-grouped-selector/view.tsx +++ b/src/blocks/fast-checkout-grouped-selector/view.tsx @@ -119,14 +119,17 @@ function GroupedSelector( { host, currentChildId }: RootProps ) { async function swapCartItem( oldChildId: number, newChildId: number ) { const cartActions = dispatch( STORE ); const cartSelectors = select( STORE ); + // Capture the old key BEFORE we add the new item, so the remove step + // targets the right line. const items = cartSelectors.getCartData()?.items || []; const existing = items.find( ( item: { id?: number; key?: string } ) => item.id === oldChildId ); - if ( existing ) { - await ( cartActions as { removeItemFromCart: ( key: string ) => Promise< unknown > } ).removeItemFromCart( - ( existing as { key: string } ).key - ); - } + const oldKey = ( existing as { key?: string } | undefined )?.key; + // Add new before removing old to keep the cart non-empty during the swap + // — otherwise the Checkout block flashes "Your cart is currently empty". await ( cartActions as { addItemToCart: ( id: number, qty: number ) => Promise< unknown > } ).addItemToCart( newChildId, 1 ); + if ( oldKey ) { + await ( cartActions as { removeItemFromCart: ( key: string ) => Promise< unknown > } ).removeItemFromCart( oldKey ); + } } function updateUrlParam( key: string, value: string ) { diff --git a/src/blocks/fast-checkout-nyp-input/view.tsx b/src/blocks/fast-checkout-nyp-input/view.tsx index 3459eb6f4..280a1cf55 100644 --- a/src/blocks/fast-checkout-nyp-input/view.tsx +++ b/src/blocks/fast-checkout-nyp-input/view.tsx @@ -135,17 +135,24 @@ function NypInput( { host, productId, min, max }: RootProps ) { async function applyNypPrice( productId: number, price: number ) { const cartActions = dispatch( STORE ); const cartSelectors = select( STORE ); + // Capture the existing key BEFORE adding the new line. Different nyp + // values produce a distinct cart_item_data hash, so the new add creates + // a separate line item rather than merging. const items = cartSelectors.getCartData()?.items || []; const existing = items.find( ( item: { id?: number; key?: string } ) => item.id === productId ) as { key?: string } | undefined; + const oldKey = existing?.key; - if ( existing?.key ) { - await ( cartActions as { removeItemFromCart: ( key: string ) => Promise< unknown > } ).removeItemFromCart( existing.key ); - } + // Add new before removing old to keep the cart non-empty during the swap + // — otherwise the Checkout block flashes "Your cart is currently empty". await ( cartActions as { addItemToCart: ( id: number, qty: number, variation?: unknown[], cartItemData?: Record< string, unknown > ) => Promise< unknown >; } ).addItemToCart( productId, 1, [], { nyp: price } ); + + if ( oldKey ) { + await ( cartActions as { removeItemFromCart: ( key: string ) => Promise< unknown > } ).removeItemFromCart( oldKey ); + } } function updateUrlParam( key: string, value: string ) { diff --git a/src/blocks/fast-checkout-variation-selector/view.tsx b/src/blocks/fast-checkout-variation-selector/view.tsx index c80dafdec..6301e0e05 100644 --- a/src/blocks/fast-checkout-variation-selector/view.tsx +++ b/src/blocks/fast-checkout-variation-selector/view.tsx @@ -155,18 +155,21 @@ function revertSelection( host: HTMLFormElement, currentId: number, variations: async function swapCartItem( oldVariationId: number, newVariationId: number ) { const cartActions = dispatch( STORE ); const cartSelectors = select( STORE ); + // For variation cart items, item.id is the variation_id. Capture the + // existing key BEFORE adding the new item. const items = cartSelectors.getCartData()?.items || []; - // For variation cart items, item.id is the variation_id. const existing = items.find( ( item: { id?: number; key?: string } ) => item.id === oldVariationId ); + const oldKey = ( existing as { key?: string } | undefined )?.key; - if ( existing ) { - await ( cartActions as { removeItemFromCart: ( key: string ) => Promise< unknown > } ).removeItemFromCart( - ( existing as { key: string } ).key - ); - } - - // The Store API accepts a variation ID directly as the cart item id; no need to spell out attributes. + // Add new before removing old to keep the cart non-empty during the swap + // — otherwise the Checkout block flashes "Your cart is currently empty". + // The Store API accepts a variation ID directly as the cart item id; no + // need to spell out attributes. await ( cartActions as { addItemToCart: ( id: number, qty: number ) => Promise< unknown > } ).addItemToCart( newVariationId, 1 ); + + if ( oldKey ) { + await ( cartActions as { removeItemFromCart: ( key: string ) => Promise< unknown > } ).removeItemFromCart( oldKey ); + } } function updateUrlParam( key: string, value: string ) { From a528db26bb485a85bc972ec2ef156ac025e7af10 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Fri, 1 May 2026 08:29:15 -0300 Subject: [PATCH 41/56] Revert "fix(fast-checkout): swap cart by adding before removing to avoid empty flash" This reverts commit 413025a422912f956dccdbac9b538ba4c6cde861. --- .../fast-checkout-grouped-selector/view.tsx | 13 +++++-------- src/blocks/fast-checkout-nyp-input/view.tsx | 13 +++---------- .../fast-checkout-variation-selector/view.tsx | 19 ++++++++----------- 3 files changed, 16 insertions(+), 29 deletions(-) diff --git a/src/blocks/fast-checkout-grouped-selector/view.tsx b/src/blocks/fast-checkout-grouped-selector/view.tsx index 20b8e8ee5..b6ea0cdd1 100644 --- a/src/blocks/fast-checkout-grouped-selector/view.tsx +++ b/src/blocks/fast-checkout-grouped-selector/view.tsx @@ -119,17 +119,14 @@ function GroupedSelector( { host, currentChildId }: RootProps ) { async function swapCartItem( oldChildId: number, newChildId: number ) { const cartActions = dispatch( STORE ); const cartSelectors = select( STORE ); - // Capture the old key BEFORE we add the new item, so the remove step - // targets the right line. const items = cartSelectors.getCartData()?.items || []; const existing = items.find( ( item: { id?: number; key?: string } ) => item.id === oldChildId ); - const oldKey = ( existing as { key?: string } | undefined )?.key; - // Add new before removing old to keep the cart non-empty during the swap - // — otherwise the Checkout block flashes "Your cart is currently empty". - await ( cartActions as { addItemToCart: ( id: number, qty: number ) => Promise< unknown > } ).addItemToCart( newChildId, 1 ); - if ( oldKey ) { - await ( cartActions as { removeItemFromCart: ( key: string ) => Promise< unknown > } ).removeItemFromCart( oldKey ); + if ( existing ) { + await ( cartActions as { removeItemFromCart: ( key: string ) => Promise< unknown > } ).removeItemFromCart( + ( existing as { key: string } ).key + ); } + await ( cartActions as { addItemToCart: ( id: number, qty: number ) => Promise< unknown > } ).addItemToCart( newChildId, 1 ); } function updateUrlParam( key: string, value: string ) { diff --git a/src/blocks/fast-checkout-nyp-input/view.tsx b/src/blocks/fast-checkout-nyp-input/view.tsx index 280a1cf55..3459eb6f4 100644 --- a/src/blocks/fast-checkout-nyp-input/view.tsx +++ b/src/blocks/fast-checkout-nyp-input/view.tsx @@ -135,24 +135,17 @@ function NypInput( { host, productId, min, max }: RootProps ) { async function applyNypPrice( productId: number, price: number ) { const cartActions = dispatch( STORE ); const cartSelectors = select( STORE ); - // Capture the existing key BEFORE adding the new line. Different nyp - // values produce a distinct cart_item_data hash, so the new add creates - // a separate line item rather than merging. const items = cartSelectors.getCartData()?.items || []; const existing = items.find( ( item: { id?: number; key?: string } ) => item.id === productId ) as { key?: string } | undefined; - const oldKey = existing?.key; - // Add new before removing old to keep the cart non-empty during the swap - // — otherwise the Checkout block flashes "Your cart is currently empty". + if ( existing?.key ) { + await ( cartActions as { removeItemFromCart: ( key: string ) => Promise< unknown > } ).removeItemFromCart( existing.key ); + } await ( cartActions as { addItemToCart: ( id: number, qty: number, variation?: unknown[], cartItemData?: Record< string, unknown > ) => Promise< unknown >; } ).addItemToCart( productId, 1, [], { nyp: price } ); - - if ( oldKey ) { - await ( cartActions as { removeItemFromCart: ( key: string ) => Promise< unknown > } ).removeItemFromCart( oldKey ); - } } function updateUrlParam( key: string, value: string ) { diff --git a/src/blocks/fast-checkout-variation-selector/view.tsx b/src/blocks/fast-checkout-variation-selector/view.tsx index 6301e0e05..c80dafdec 100644 --- a/src/blocks/fast-checkout-variation-selector/view.tsx +++ b/src/blocks/fast-checkout-variation-selector/view.tsx @@ -155,21 +155,18 @@ function revertSelection( host: HTMLFormElement, currentId: number, variations: async function swapCartItem( oldVariationId: number, newVariationId: number ) { const cartActions = dispatch( STORE ); const cartSelectors = select( STORE ); - // For variation cart items, item.id is the variation_id. Capture the - // existing key BEFORE adding the new item. const items = cartSelectors.getCartData()?.items || []; + // For variation cart items, item.id is the variation_id. const existing = items.find( ( item: { id?: number; key?: string } ) => item.id === oldVariationId ); - const oldKey = ( existing as { key?: string } | undefined )?.key; - // Add new before removing old to keep the cart non-empty during the swap - // — otherwise the Checkout block flashes "Your cart is currently empty". - // The Store API accepts a variation ID directly as the cart item id; no - // need to spell out attributes. - await ( cartActions as { addItemToCart: ( id: number, qty: number ) => Promise< unknown > } ).addItemToCart( newVariationId, 1 ); - - if ( oldKey ) { - await ( cartActions as { removeItemFromCart: ( key: string ) => Promise< unknown > } ).removeItemFromCart( oldKey ); + if ( existing ) { + await ( cartActions as { removeItemFromCart: ( key: string ) => Promise< unknown > } ).removeItemFromCart( + ( existing as { key: string } ).key + ); } + + // The Store API accepts a variation ID directly as the cart item id; no need to spell out attributes. + await ( cartActions as { addItemToCart: ( id: number, qty: number ) => Promise< unknown > } ).addItemToCart( newVariationId, 1 ); } function updateUrlParam( key: string, value: string ) { From 33f8821f6b78eb1802bed755399786460cba3d5e Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Fri, 1 May 2026 08:31:31 -0300 Subject: [PATCH 42/56] feat(fast-checkout): mask checkout block during selector cart swaps --- .../fast-checkout-grouped-selector/view.tsx | 11 ++++++ src/blocks/fast-checkout-nyp-input/view.tsx | 11 ++++++ .../fast-checkout-variation-selector/view.tsx | 11 ++++++ src/blocks/fast-checkout/view.scss | 36 +++++++++++++++++++ 4 files changed, 69 insertions(+) diff --git a/src/blocks/fast-checkout-grouped-selector/view.tsx b/src/blocks/fast-checkout-grouped-selector/view.tsx index b6ea0cdd1..b32a5cbf2 100644 --- a/src/blocks/fast-checkout-grouped-selector/view.tsx +++ b/src/blocks/fast-checkout-grouped-selector/view.tsx @@ -104,6 +104,17 @@ function GroupedSelector( { host, currentChildId }: RootProps ) { useEffect( () => { host.dataset.status = inFlight ? 'busy' : 'idle'; + // Toggle the swapping flag on the parent Fast Checkout block so a CSS + // overlay can mask the WC Checkout block during cart updates and hide + // the brief "Your cart is currently empty" flash. + const fastCheckout = host.closest< HTMLElement >( '.wp-block-newspack-blocks-fast-checkout' ); + if ( fastCheckout ) { + if ( inFlight ) { + fastCheckout.dataset.fcSwapping = 'true'; + } else { + delete fastCheckout.dataset.fcSwapping; + } + } host.querySelectorAll< HTMLInputElement >( 'input[type="radio"]' ).forEach( input => { if ( ssrDisabled.current?.has( input ) ) { input.disabled = true; diff --git a/src/blocks/fast-checkout-nyp-input/view.tsx b/src/blocks/fast-checkout-nyp-input/view.tsx index 3459eb6f4..db426e8fb 100644 --- a/src/blocks/fast-checkout-nyp-input/view.tsx +++ b/src/blocks/fast-checkout-nyp-input/view.tsx @@ -46,6 +46,17 @@ function NypInput( { host, productId, min, max }: RootProps ) { useEffect( () => { host.dataset.status = inFlight ? 'busy' : 'idle'; + // Toggle the swapping flag on the parent Fast Checkout block so a CSS + // overlay can mask the WC Checkout block during cart updates and hide + // the brief "Your cart is currently empty" flash. + const fastCheckout = host.closest< HTMLElement >( '.wp-block-newspack-blocks-fast-checkout' ); + if ( fastCheckout ) { + if ( inFlight ) { + fastCheckout.dataset.fcSwapping = 'true'; + } else { + delete fastCheckout.dataset.fcSwapping; + } + } }, [ host, inFlight ] ); useEffect( () => { diff --git a/src/blocks/fast-checkout-variation-selector/view.tsx b/src/blocks/fast-checkout-variation-selector/view.tsx index c80dafdec..aa4ecdc00 100644 --- a/src/blocks/fast-checkout-variation-selector/view.tsx +++ b/src/blocks/fast-checkout-variation-selector/view.tsx @@ -123,6 +123,17 @@ function VariationSelector( { host, productId, variations, currentVariationId }: useEffect( () => { host.dataset.status = inFlight ? 'busy' : 'idle'; + // Toggle the swapping flag on the parent Fast Checkout block so a CSS + // overlay can mask the WC Checkout block during cart updates and hide + // the brief "Your cart is currently empty" flash. + const fastCheckout = host.closest< HTMLElement >( '.wp-block-newspack-blocks-fast-checkout' ); + if ( fastCheckout ) { + if ( inFlight ) { + fastCheckout.dataset.fcSwapping = 'true'; + } else { + delete fastCheckout.dataset.fcSwapping; + } + } // Disable all non-SSR-disabled radios while in-flight; restore the original disabled state when idle. host.querySelectorAll< HTMLInputElement >( 'input[type="radio"]' ).forEach( input => { if ( ssrDisabled.current?.has( input ) ) { diff --git a/src/blocks/fast-checkout/view.scss b/src/blocks/fast-checkout/view.scss index f7ac13d57..b1fe47323 100644 --- a/src/blocks/fast-checkout/view.scss +++ b/src/blocks/fast-checkout/view.scss @@ -8,3 +8,39 @@ border-radius: 4px; text-align: center; } + +// Mask the descendant WC Checkout block while a selector is swapping the +// cart line, hiding the brief "Your cart is currently empty" flash that +// renders between the remove and add operations. +.wp-block-newspack-blocks-fast-checkout[ data-fc-swapping ] .wp-block-woocommerce-checkout { + position: relative; + + &::after { + content: ''; + position: absolute; + inset: 0; + background: var( --wp--preset--color--background, #fff ); + z-index: 5; + pointer-events: none; + } + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 24px; + height: 24px; + margin: -12px 0 0 -12px; + border: 2px solid var( --wp--preset--color--border, rgba( 0, 0, 0, 0.15 ) ); + border-top-color: var( --wp--preset--color--primary, #0073aa ); + border-radius: 50%; + animation: newspack-fast-checkout-spin 0.8s linear infinite; + z-index: 6; + pointer-events: none; + } +} + +@keyframes newspack-fast-checkout-spin { + to { transform: rotate( 360deg ); } +} From 5b7876c1d49f691b32df275abe9346cbba59e57f Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Fri, 1 May 2026 09:24:47 -0300 Subject: [PATCH 43/56] fix(fast-checkout): use name_your_price store API extension key --- src/blocks/fast-checkout-nyp-input/edit.tsx | 24 +++++++++++++++------ src/blocks/fast-checkout/edit.tsx | 6 +++--- src/blocks/fast-checkout/types.ts | 2 +- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/blocks/fast-checkout-nyp-input/edit.tsx b/src/blocks/fast-checkout-nyp-input/edit.tsx index cb3bba555..1f017b78c 100644 --- a/src/blocks/fast-checkout-nyp-input/edit.tsx +++ b/src/blocks/fast-checkout-nyp-input/edit.tsx @@ -13,31 +13,35 @@ interface EditProps { }; } +type NypState = { status: 'loading' } | { status: 'not-nyp' } | { status: 'ready'; min?: string; max?: string; suggested?: string }; + export default function Edit( { context }: EditProps ) { const productId = parseInt( String( context?.[ 'newspack-blocks/fastCheckoutProductId' ] ?? 0 ), 10 ); const overridePrice = String( context?.[ 'newspack-blocks/fastCheckoutNypPrice' ] || '' ); const blockProps = useBlockProps(); - const [ nyp, setNyp ] = useState< { min?: string; max?: string; suggested?: string } | null >( null ); + const [ nyp, setNyp ] = useState< NypState >( { status: 'loading' } ); useEffect( () => { if ( ! productId ) { - setNyp( null ); + setNyp( { status: 'loading' } ); return; } + setNyp( { status: 'loading' } ); apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ productId }` } ) .then( product => { - const ext = product?.extensions?.nyp; + const ext = product?.extensions?.name_your_price; if ( ! ext?.is_nyp ) { - setNyp( null ); + setNyp( { status: 'not-nyp' } ); return; } setNyp( { + status: 'ready', min: ext.minimum_price, max: ext.maximum_price, suggested: ext.suggested_price, } ); } ) - .catch( () => setNyp( null ) ); + .catch( () => setNyp( { status: 'not-nyp' } ) ); }, [ productId ] ); if ( ! productId ) { @@ -48,7 +52,7 @@ export default function Edit( { context }: EditProps ) { ); } - if ( null === nyp ) { + if ( nyp.status === 'loading' ) { return (
@@ -56,6 +60,14 @@ export default function Edit( { context }: EditProps ) { ); } + if ( nyp.status === 'not-nyp' ) { + return ( +
+ { __( 'This product is not configured for Name Your Price.', 'newspack-blocks' ) } +
+ ); + } + const inputId = 'fc-nyp-edit-input'; return ( diff --git a/src/blocks/fast-checkout/edit.tsx b/src/blocks/fast-checkout/edit.tsx index 1e41af501..43118f3ac 100644 --- a/src/blocks/fast-checkout/edit.tsx +++ b/src/blocks/fast-checkout/edit.tsx @@ -193,7 +193,7 @@ function NypDefaultPrice( { productId, value, onChange }: NypDefaultPriceProps ) } apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ productId }` } ) .then( product => { - const nyp = product?.extensions?.nyp; + const nyp = product?.extensions?.name_your_price; if ( ! nyp?.is_nyp ) { setHelp( '' ); return; @@ -280,7 +280,7 @@ export default function Edit( { attributes, setAttributes, clientId }: EditProps const flags = { is_variable: !! p?.variations?.length, is_grouped: p?.type === 'grouped' || !! p?.grouped_products?.length, - is_nyp: !! p?.extensions?.nyp?.is_nyp, + is_nyp: !! p?.extensions?.name_your_price?.is_nyp, }; setAttributes( flags ); @@ -289,7 +289,7 @@ export default function Edit( { attributes, setAttributes, clientId }: EditProps p.grouped_products.map( id => apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ id }` } ).catch( () => null ) ) ).then( children => { const unsupported = children.filter( - ( c ): c is StoreApiProduct => !! c && ( ( c.variations?.length ?? 0 ) > 0 || !! c.extensions?.nyp?.is_nyp ) + ( c ): c is StoreApiProduct => !! c && ( ( c.variations?.length ?? 0 ) > 0 || !! c.extensions?.name_your_price?.is_nyp ) ); if ( unsupported.length ) { setGroupedWarning( diff --git a/src/blocks/fast-checkout/types.ts b/src/blocks/fast-checkout/types.ts index 047abc9e9..8c6cce808 100644 --- a/src/blocks/fast-checkout/types.ts +++ b/src/blocks/fast-checkout/types.ts @@ -12,7 +12,7 @@ export interface StoreApiProduct { variations?: number[]; grouped_products?: number[]; extensions?: { - nyp?: { + name_your_price?: { minimum_price?: string; maximum_price?: string; suggested_price?: string; From d2290c8335a38484c298933f4cdb8747c626a16f Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Fri, 1 May 2026 09:52:41 -0300 Subject: [PATCH 44/56] feat(fast-checkout): add donate-selector with frequency radios and shared NYP input --- block-list.json | 1 + .../fast-checkout-donate-selector/block.json | 25 ++ .../fast-checkout-donate-selector/edit.tsx | 94 ++++++ .../fast-checkout-donate-selector/editor.scss | 4 + .../fast-checkout-donate-selector/editor.ts | 9 + .../fast-checkout-donate-selector/index.tsx | 17 + .../fast-checkout-donate-selector/view.php | 275 ++++++++++++++++ .../fast-checkout-donate-selector/view.scss | 70 ++++ .../fast-checkout-donate-selector/view.tsx | 303 ++++++++++++++++++ src/blocks/fast-checkout/edit.tsx | 10 + 10 files changed, 808 insertions(+) create mode 100644 src/blocks/fast-checkout-donate-selector/block.json create mode 100644 src/blocks/fast-checkout-donate-selector/edit.tsx create mode 100644 src/blocks/fast-checkout-donate-selector/editor.scss create mode 100644 src/blocks/fast-checkout-donate-selector/editor.ts create mode 100644 src/blocks/fast-checkout-donate-selector/index.tsx create mode 100644 src/blocks/fast-checkout-donate-selector/view.php create mode 100644 src/blocks/fast-checkout-donate-selector/view.scss create mode 100644 src/blocks/fast-checkout-donate-selector/view.tsx diff --git a/block-list.json b/block-list.json index 1a67f8640..e4bd1895d 100644 --- a/block-list.json +++ b/block-list.json @@ -6,6 +6,7 @@ "checkout-button", "donate", "fast-checkout", + "fast-checkout-donate-selector", "fast-checkout-grouped-selector", "fast-checkout-nyp-input", "fast-checkout-variation-selector", diff --git a/src/blocks/fast-checkout-donate-selector/block.json b/src/blocks/fast-checkout-donate-selector/block.json new file mode 100644 index 000000000..8a7394248 --- /dev/null +++ b/src/blocks/fast-checkout-donate-selector/block.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "newspack-blocks/fast-checkout-donate-selector", + "title": "Fast Checkout — Donate Selector", + "category": "newspack", + "description": "Frequency radios with a shared Name Your Price input for a grouped donate product.", + "keywords": [ "woocommerce", "checkout", "donate", "subscription", "fast" ], + "textdomain": "newspack-blocks", + "parent": [ "newspack-blocks/fast-checkout" ], + "usesContext": [ + "newspack-blocks/fastCheckoutProductId", + "newspack-blocks/fastCheckoutVariationId", + "newspack-blocks/fastCheckoutGroupedChild", + "newspack-blocks/fastCheckoutNypPrice" + ], + "attributes": {}, + "supports": { + "html": false, + "reusable": false, + "spacing": { "padding": true, "margin": true } + }, + "editorStyle": "newspack-blocks-editor", + "style": "newspack-blocks-fast-checkout-donate-selector" +} diff --git a/src/blocks/fast-checkout-donate-selector/edit.tsx b/src/blocks/fast-checkout-donate-selector/edit.tsx new file mode 100644 index 000000000..3ae6a2b3d --- /dev/null +++ b/src/blocks/fast-checkout-donate-selector/edit.tsx @@ -0,0 +1,94 @@ +import { __ } from '@wordpress/i18n'; +import { useEffect, useState } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; +import { useBlockProps } from '@wordpress/block-editor'; +import { Spinner } from '@wordpress/components'; + +import type { StoreApiProduct } from '../fast-checkout/types'; + +interface ChildPreview { + id: number; + name: string; +} + +interface EditProps { + context: { + 'newspack-blocks/fastCheckoutProductId'?: string | number; + }; +} + +export default function Edit( { context }: EditProps ) { + const productId = parseInt( String( context?.[ 'newspack-blocks/fastCheckoutProductId' ] ?? 0 ), 10 ); + const blockProps = useBlockProps(); + const [ children, setChildren ] = useState< ChildPreview[] | null >( null ); + + useEffect( () => { + if ( ! productId ) { + setChildren( null ); + return; + } + apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ productId }` } ) + .then( product => { + const ids = product?.grouped_products || []; + if ( ! ids.length ) { + setChildren( [] ); + return; + } + return Promise.all( + ids.map( id => apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ id }` } ).catch( () => null ) ) + ).then( fetched => { + setChildren( fetched.filter( ( c ): c is StoreApiProduct => Boolean( c ) ).map( c => ( { id: c.id, name: c.name } ) ) ); + } ); + } ) + .catch( () => setChildren( [] ) ); + }, [ productId ] ); + + if ( ! productId ) { + return ( +
+ { __( 'Pick a grouped Donate product on the parent Fast Checkout block.', 'newspack-blocks' ) } +
+ ); + } + + if ( null === children ) { + return ( +
+ +
+ ); + } + + if ( ! children.length ) { + return ( +
+ { __( 'No grouped children found for this product.', 'newspack-blocks' ) } +
+ ); + } + + const inputId = 'fc-donate-edit-amount'; + + return ( +
+
+ { children.map( child => { + const id = `preview-donate-${ child.id }`; + return ( + + ); + } ) } +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/blocks/fast-checkout-donate-selector/editor.scss b/src/blocks/fast-checkout-donate-selector/editor.scss new file mode 100644 index 000000000..2fb14bc43 --- /dev/null +++ b/src/blocks/fast-checkout-donate-selector/editor.scss @@ -0,0 +1,4 @@ +.wp-block-newspack-blocks-fast-checkout-donate-selector { + pointer-events: none; + opacity: 0.85; +} diff --git a/src/blocks/fast-checkout-donate-selector/editor.ts b/src/blocks/fast-checkout-donate-selector/editor.ts new file mode 100644 index 000000000..e87af3966 --- /dev/null +++ b/src/blocks/fast-checkout-donate-selector/editor.ts @@ -0,0 +1,9 @@ +/** + * Fast Checkout Donate Selector — editor bundle entry. + */ + +import { registerBlockType } from '@wordpress/blocks'; +import { name, settings } from './index'; +import './editor.scss'; + +registerBlockType( name, settings ); diff --git a/src/blocks/fast-checkout-donate-selector/index.tsx b/src/blocks/fast-checkout-donate-selector/index.tsx new file mode 100644 index 000000000..cfab899a0 --- /dev/null +++ b/src/blocks/fast-checkout-donate-selector/index.tsx @@ -0,0 +1,17 @@ +import { __ } from '@wordpress/i18n'; +import metadata from './block.json'; +import edit from './edit'; + +export const name: string = metadata.name; +export const title: string = __( 'Fast Checkout — Donate Selector', 'newspack-blocks' ); + +function save() { + return null; +} + +export const settings = { + ...metadata, + title, + edit, + save, +}; diff --git a/src/blocks/fast-checkout-donate-selector/view.php b/src/blocks/fast-checkout-donate-selector/view.php new file mode 100644 index 000000000..9ce97ae84 --- /dev/null +++ b/src/blocks/fast-checkout-donate-selector/view.php @@ -0,0 +1,275 @@ + __NAMESPACE__ . '\\render_block', + ] + ); +} +add_action( 'init', __NAMESPACE__ . '\\register_block' ); + +/** + * Enqueue the frontend view bundle when this block is present on the page. + */ +function enqueue_assets() { + if ( is_admin() || ! is_singular() ) { + return; + } + $post = get_post(); + if ( ! $post || ! has_block( BLOCK_NAME, $post ) ) { + return; + } + \Newspack_Blocks::enqueue_view_assets( BLOCK_SLUG ); +} +add_action( 'wp_enqueue_scripts', __NAMESPACE__ . '\\enqueue_assets' ); + +/** + * Get the WC Subscriptions period for a product, or empty string. + * + * @param int $product_id Product ID. + * @return string `month`, `year`, etc. — or empty string for one-time. + */ +function get_subscription_period( $product_id ) { + $period = get_post_meta( $product_id, '_subscription_period', true ); + return is_string( $period ) ? $period : ''; +} + +/** + * Get the NYP min/max/suggested for a product (or empty array if not NYP). + * + * @param int $product_id Product ID. + * @return array{min:string,max:string,suggested:string}|null + */ +function get_nyp_config( $product_id ) { + if ( ! class_exists( '\WC_Name_Your_Price_Helpers' ) ) { + return null; + } + if ( ! \WC_Name_Your_Price_Helpers::is_nyp( $product_id ) ) { + return null; + } + return [ + 'min' => (string) \WC_Name_Your_Price_Helpers::get_minimum_price( $product_id ), + 'max' => (string) \WC_Name_Your_Price_Helpers::get_maximum_price( $product_id ), + 'suggested' => (string) \WC_Name_Your_Price_Helpers::get_suggested_price( $product_id ), + ]; +} + +/** + * Map a WC Subscriptions period code to a "/ monthly", "/ yearly" suffix. + * + * @param string $period The period code. + * @return string Suffix or empty. + */ +function suffix_for_period( $period ) { + switch ( $period ) { + case 'day': + return ' / ' . __( 'daily', 'newspack-blocks' ); + case 'week': + return ' / ' . __( 'weekly', 'newspack-blocks' ); + case 'month': + return ' / ' . __( 'monthly', 'newspack-blocks' ); + case 'year': + return ' / ' . __( 'yearly', 'newspack-blocks' ); + default: + return ''; + } +} + +/** + * Render the donate selector SSR shell. + * + * @param array $attrs Block attributes. + * @param string $content Inner content (unused). + * @param object $block Block instance with context. + * @return string Rendered HTML. + */ +function render_block( $attrs, $content, $block ) { + if ( ! function_exists( 'wc_get_product' ) ) { + return ''; + } + + $product_id = (int) ( $block->context['newspack-blocks/fastCheckoutProductId'] ?? 0 ); + if ( ! $product_id ) { + return ''; + } + $product = wc_get_product( $product_id ); + if ( ! $product || ! $product->is_type( 'grouped' ) ) { + return ''; + } + + $child_ids = array_map( 'intval', $product->get_children() ); + if ( empty( $child_ids ) ) { + return ''; + } + + // Build per-child data and skip non-NYP / non-purchasable children. + $children = []; + foreach ( $child_ids as $child_id ) { + $child = wc_get_product( $child_id ); + if ( ! $child || ! $child->is_purchasable() ) { + continue; + } + $nyp_config = get_nyp_config( $child_id ); + if ( null === $nyp_config ) { + // Donate selector targets NYP children. Skip non-NYP. + continue; + } + $children[] = [ + 'id' => $child_id, + 'name' => $child->get_name(), + 'period' => get_subscription_period( $child_id ), + 'min' => $nyp_config['min'], + 'max' => $nyp_config['max'], + 'suggested' => $nyp_config['suggested'], + ]; + } + + if ( empty( $children ) ) { + return ''; + } + + // Resolve the currently-selected child: query param > context attribute > first. + $current_child = (int) ( $block->context['newspack-blocks/fastCheckoutGroupedChild'] ?? 0 ); + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( isset( $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] ) ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $qp_raw = wp_unslash( $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] ); + $qp = (int) filter_var( $qp_raw, FILTER_SANITIZE_NUMBER_INT ); + if ( $qp > 0 ) { + $current_child = $qp; + } + } + $valid_ids = array_column( $children, 'id' ); + if ( ! $current_child || ! in_array( $current_child, $valid_ids, true ) ) { + $current_child = (int) $valid_ids[0]; + } + + $current = null; + foreach ( $children as $c ) { + if ( $c['id'] === $current_child ) { + $current = $c; + break; + } + } + if ( null === $current ) { + return ''; + } + + // Resolve the initial input value: fc_price > attribute > current child's suggested. + $initial_amount = $current['suggested']; + $attr_price = $block->context['newspack-blocks/fastCheckoutNypPrice'] ?? ''; + if ( $attr_price && is_numeric( $attr_price ) ) { + $initial_amount = (string) $attr_price; + } + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( isset( $_GET[ Fast_Checkout::QP_PRICE ] ) ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $qp_price = sanitize_text_field( wp_unslash( $_GET[ Fast_Checkout::QP_PRICE ] ) ); + if ( $qp_price && is_numeric( $qp_price ) ) { + $initial_amount = $qp_price; + } + } + if ( $current['max'] && (float) $initial_amount > (float) $current['max'] ) { + $initial_amount = $current['max']; + } + if ( $current['min'] && (float) $initial_amount < (float) $current['min'] ) { + $initial_amount = $current['min']; + } + + $wrapper_attributes = get_block_wrapper_attributes( + [ + 'data-product-id' => (string) $product_id, + 'data-current-child' => (string) $current_child, + 'data-children' => wp_json_encode( $children ), + ] + ); + + $input_id = 'fc-donate-amount-' . $product_id; + $suffix_text = suffix_for_period( $current['period'] ); + $range_label = ''; + if ( $current['min'] && $current['max'] ) { + $range_label = sprintf( + /* translators: 1: min price, 2: max price, 3: suggested price */ + __( '%1$s – %2$s · suggested %3$s', 'newspack-blocks' ), + wc_price( $current['min'] ), + wc_price( $current['max'] ), + wc_price( $current['suggested'] ) + ); + } + + ob_start(); + ?> + > +
+ + + + +
+ +
+ +
+ + min="" + + + max="" + + value="" + /> + +
+ +

+ +

+ + +
+ + ( currentChildId ); + const [ inFlight, setInFlight ] = useState( false ); + const inFlightRef = useRef< boolean >( false ); + const [ error, setError ] = useState< string >( '' ); + + const input = useMemo< HTMLInputElement | null >( () => host.querySelector< HTMLInputElement >( 'input[type="number"]' ), [ host ] ); + const suffixNode = useMemo< HTMLElement | null >( + () => host.querySelector( '.wp-block-newspack-blocks-fast-checkout-donate-selector__suffix' ), + [ host ] + ); + const noticeNode = useMemo< HTMLElement | null >( + () => host.querySelector( '.wp-block-newspack-blocks-fast-checkout-donate-selector__notice' ), + [ host ] + ); + + const lastApplied = useRef< { childId: number; amount: number } >( { + childId: currentChildId, + amount: parseFloat( input?.value || '0' ) || 0, + } ); + const blurTimer = useRef< ReturnType< typeof setTimeout > | null >( null ); + + const findChild = ( id: number ) => children.find( c => c.id === id ); + + useEffect( () => { + if ( noticeNode ) { + if ( error ) { + noticeNode.textContent = error; + noticeNode.removeAttribute( 'hidden' ); + } else { + noticeNode.textContent = ''; + noticeNode.setAttribute( 'hidden', '' ); + } + } + }, [ error, noticeNode ] ); + + useEffect( () => { + host.dataset.status = inFlight ? 'busy' : 'idle'; + const fastCheckout = host.closest< HTMLElement >( '.wp-block-newspack-blocks-fast-checkout' ); + if ( fastCheckout ) { + if ( inFlight ) { + fastCheckout.dataset.fcSwapping = 'true'; + } else { + delete fastCheckout.dataset.fcSwapping; + } + } + }, [ host, inFlight ] ); + + // Apply the cart change for the currently-selected child + amount. + const applySelection = async ( nextChildId: number, nextAmount: number ) => { + if ( inFlightRef.current ) { + return; + } + inFlightRef.current = true; + setInFlight( true ); + try { + const cartActions = dispatch( STORE ); + const cartSelectors = select( STORE ); + const items = cartSelectors.getCartData()?.items || []; + const oldKey = ( + items.find( ( item: { id?: number; key?: string } ) => item.id === lastApplied.current.childId ) as { key?: string } | undefined + )?.key; + if ( oldKey ) { + await ( cartActions as { removeItemFromCart: ( key: string ) => Promise< unknown > } ).removeItemFromCart( oldKey ); + } + await ( + cartActions as { + addItemToCart: ( id: number, qty: number, variation?: unknown[], cartItemData?: Record< string, unknown > ) => Promise< unknown >; + } + ).addItemToCart( nextChildId, 1, [], { nyp: nextAmount } ); + lastApplied.current = { childId: nextChildId, amount: nextAmount }; + updateUrlParam( 'fc_grouped_child', String( nextChildId ) ); + updateUrlParam( 'fc_price', String( nextAmount ) ); + setError( '' ); + } catch ( ex: unknown ) { + setError( ( ex as Error )?.message || __( 'Could not update selection.', 'newspack-blocks' ) ); + } finally { + inFlightRef.current = false; + setInFlight( false ); + } + }; + + // Radio change → swap to the new child, preserving the current amount + // (clamped to the new child's min/max). Update suffix and input bounds. + useEffect( () => { + const onRadioChange = async ( e: Event ) => { + const target = e.target as HTMLInputElement; + if ( target?.type !== 'radio' || target.name !== 'fc_donate_child' ) { + return; + } + const nextId = parseInt( target.value, 10 ); + if ( ! nextId || nextId === pendingId ) { + return; + } + const child = findChild( nextId ); + if ( ! child ) { + return; + } + const minNum = parseFloat( child.min ) || 0; + const maxNum = parseFloat( child.max ) || 0; + const currentAmount = parseFloat( input?.value || '0' ) || 0; + let nextAmount = currentAmount > 0 ? currentAmount : parseFloat( child.suggested ) || 0; + if ( maxNum > 0 ) { + nextAmount = Math.min( nextAmount, maxNum ); + } + if ( minNum > 0 ) { + nextAmount = Math.max( nextAmount, minNum ); + } + if ( input ) { + input.value = String( nextAmount ); + if ( minNum > 0 ) { + input.min = String( minNum ); + } else { + input.removeAttribute( 'min' ); + } + if ( maxNum > 0 ) { + input.max = String( maxNum ); + } else { + input.removeAttribute( 'max' ); + } + } + if ( suffixNode ) { + suffixNode.textContent = suffixForPeriod( child.period ); + } + host.dataset.currentChild = String( nextId ); + setPendingId( nextId ); + await applySelection( nextId, nextAmount ); + }; + host.addEventListener( 'change', onRadioChange ); + return () => host.removeEventListener( 'change', onRadioChange ); + }, [ host, pendingId, input, suffixNode, children ] ); + + // Input blur (debounced) → apply NYP price for the currently-selected child. + useEffect( () => { + if ( ! input ) { + return; + } + const onBlur = () => { + if ( blurTimer.current ) { + clearTimeout( blurTimer.current ); + } + blurTimer.current = setTimeout( apply, DEBOUNCE_MS ); + }; + + const apply = async () => { + if ( inFlightRef.current ) { + return; + } + const child = findChild( pendingId ); + if ( ! child ) { + return; + } + const raw = parseFloat( input.value ); + if ( ! isFinite( raw ) || raw <= 0 ) { + setError( __( 'Enter a valid amount.', 'newspack-blocks' ) ); + input.value = String( lastApplied.current.amount ); + return; + } + let clamped = raw; + const maxNum = parseFloat( child.max ) || 0; + const minNum = parseFloat( child.min ) || 0; + if ( maxNum > 0 ) { + clamped = Math.min( clamped, maxNum ); + } + if ( minNum > 0 ) { + clamped = Math.max( clamped, minNum ); + } + if ( clamped !== raw ) { + input.value = String( clamped ); + setError( + sprintf( + /* translators: %s is the price actually applied. */ + __( 'Adjusted to %s to meet limits.', 'newspack-blocks' ), + String( clamped ) + ) + ); + } else { + setError( '' ); + } + if ( clamped === lastApplied.current.amount && pendingId === lastApplied.current.childId ) { + return; + } + await applySelection( pendingId, clamped ); + }; + + input.addEventListener( 'blur', onBlur ); + return () => { + input.removeEventListener( 'blur', onBlur ); + if ( blurTimer.current ) { + clearTimeout( blurTimer.current ); + } + }; + }, [ input, pendingId, children ] ); + + // popstate: re-sync from URL params on back/forward navigation. + useEffect( () => { + const onPopstate = () => { + const params = new URLSearchParams( window.location.search ); + const nextId = parseInt( params.get( 'fc_grouped_child' ) || '', 10 ); + const nextPrice = parseFloat( params.get( 'fc_price' ) || '' ); + if ( nextId && nextId !== pendingId ) { + const radio = host.querySelector< HTMLInputElement >( `input[type="radio"][value="${ nextId }"]` ); + if ( radio ) { + radio.checked = true; + setPendingId( nextId ); + const child = findChild( nextId ); + if ( child && suffixNode ) { + suffixNode.textContent = suffixForPeriod( child.period ); + } + } + } + if ( isFinite( nextPrice ) && nextPrice > 0 && input ) { + input.value = String( nextPrice ); + } + }; + window.addEventListener( 'popstate', onPopstate ); + return () => window.removeEventListener( 'popstate', onPopstate ); + }, [ host, pendingId, input, suffixNode ] ); + + return null; +} + +function updateUrlParam( key: string, value: string ) { + const url = new URL( window.location.href ); + url.searchParams.set( key, value ); + window.history.replaceState( {}, '', url.toString() ); +} + +function init() { + document.querySelectorAll< HTMLFormElement >( '.wp-block-newspack-blocks-fast-checkout-donate-selector' ).forEach( host => { + const productId = parseInt( host.dataset.productId || '0', 10 ); + const currentChildId = parseInt( host.dataset.currentChild || '0', 10 ); + let children: ChildData[] = []; + try { + children = JSON.parse( host.dataset.children || '[]' ); + } catch { + children = []; + } + if ( ! productId || ! children.length || ! currentChildId ) { + return; + } + const sentinel = document.createElement( 'span' ); + sentinel.style.display = 'none'; + host.appendChild( sentinel ); + const root = createRoot( sentinel ); + root.render( ); + } ); +} + +if ( document.readyState === 'loading' ) { + document.addEventListener( 'DOMContentLoaded', init ); +} else { + init(); +} diff --git a/src/blocks/fast-checkout/edit.tsx b/src/blocks/fast-checkout/edit.tsx index 43118f3ac..f190de71b 100644 --- a/src/blocks/fast-checkout/edit.tsx +++ b/src/blocks/fast-checkout/edit.tsx @@ -371,6 +371,16 @@ export default function Edit( { attributes, setAttributes, clientId }: EditProps previousFlags.current = next; + // Donate-selector is manually inserted; auto-clean it on grouped → not-grouped transition. + if ( prev.g && ! next.g ) { + const donateIds = innerBlocks + .filter( ( b: Block ) => b.name === 'newspack-blocks/fast-checkout-donate-selector' ) + .map( ( b: Block ) => b.clientId ); + if ( donateIds.length ) { + removeBlocks( donateIds, false ); + } + } + // Reset child/price defaults when the product type changes. if ( prev.v && ! next.v && attributes.variation ) { setAttributes( { variation: '' } ); From 2dae97346bd46ee05459151f53dad3974ecc5826 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Fri, 1 May 2026 09:55:20 -0300 Subject: [PATCH 45/56] fix(fast-checkout): import selector view styles in editor bundle --- src/blocks/fast-checkout-donate-selector/index.tsx | 1 + src/blocks/fast-checkout-grouped-selector/index.tsx | 1 + src/blocks/fast-checkout-nyp-input/index.tsx | 1 + src/blocks/fast-checkout-variation-selector/index.tsx | 1 + 4 files changed, 4 insertions(+) diff --git a/src/blocks/fast-checkout-donate-selector/index.tsx b/src/blocks/fast-checkout-donate-selector/index.tsx index cfab899a0..8f50109b3 100644 --- a/src/blocks/fast-checkout-donate-selector/index.tsx +++ b/src/blocks/fast-checkout-donate-selector/index.tsx @@ -1,6 +1,7 @@ import { __ } from '@wordpress/i18n'; import metadata from './block.json'; import edit from './edit'; +import './view.scss'; export const name: string = metadata.name; export const title: string = __( 'Fast Checkout — Donate Selector', 'newspack-blocks' ); diff --git a/src/blocks/fast-checkout-grouped-selector/index.tsx b/src/blocks/fast-checkout-grouped-selector/index.tsx index 6fa390eaf..3d5a83836 100644 --- a/src/blocks/fast-checkout-grouped-selector/index.tsx +++ b/src/blocks/fast-checkout-grouped-selector/index.tsx @@ -1,6 +1,7 @@ import { __ } from '@wordpress/i18n'; import metadata from './block.json'; import edit from './edit'; +import './view.scss'; export const name: string = metadata.name; export const title: string = __( 'Fast Checkout — Grouped Product Selector', 'newspack-blocks' ); diff --git a/src/blocks/fast-checkout-nyp-input/index.tsx b/src/blocks/fast-checkout-nyp-input/index.tsx index 260f2783e..2e0a30cba 100644 --- a/src/blocks/fast-checkout-nyp-input/index.tsx +++ b/src/blocks/fast-checkout-nyp-input/index.tsx @@ -1,6 +1,7 @@ import { __ } from '@wordpress/i18n'; import metadata from './block.json'; import edit from './edit'; +import './view.scss'; export const name: string = metadata.name; export const title: string = __( 'Fast Checkout — Name Your Price', 'newspack-blocks' ); diff --git a/src/blocks/fast-checkout-variation-selector/index.tsx b/src/blocks/fast-checkout-variation-selector/index.tsx index f68a573aa..ad2ee7f38 100644 --- a/src/blocks/fast-checkout-variation-selector/index.tsx +++ b/src/blocks/fast-checkout-variation-selector/index.tsx @@ -1,6 +1,7 @@ import { __ } from '@wordpress/i18n'; import metadata from './block.json'; import edit from './edit'; +import './view.scss'; export const name: string = metadata.name; export const title: string = __( 'Fast Checkout — Variation Selector', 'newspack-blocks' ); From e1d09f710611aa0fbfb20c25d3c174195980a7ad Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Fri, 1 May 2026 09:59:37 -0300 Subject: [PATCH 46/56] feat(fast-checkout): order donate children by frequency and strip parent name prefix --- .../fast-checkout-donate-selector/edit.tsx | 10 +++++++- .../fast-checkout-donate-selector/view.php | 24 +++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/blocks/fast-checkout-donate-selector/edit.tsx b/src/blocks/fast-checkout-donate-selector/edit.tsx index 3ae6a2b3d..1216cefa7 100644 --- a/src/blocks/fast-checkout-donate-selector/edit.tsx +++ b/src/blocks/fast-checkout-donate-selector/edit.tsx @@ -29,6 +29,7 @@ export default function Edit( { context }: EditProps ) { } apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ productId }` } ) .then( product => { + const parentPrefix = `${ product?.name || '' }: `; const ids = product?.grouped_products || []; if ( ! ids.length ) { setChildren( [] ); @@ -37,7 +38,14 @@ export default function Edit( { context }: EditProps ) { return Promise.all( ids.map( id => apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ id }` } ).catch( () => null ) ) ).then( fetched => { - setChildren( fetched.filter( ( c ): c is StoreApiProduct => Boolean( c ) ).map( c => ( { id: c.id, name: c.name } ) ) ); + setChildren( + fetched + .filter( ( c ): c is StoreApiProduct => Boolean( c ) ) + .map( c => ( { + id: c.id, + name: c.name.startsWith( parentPrefix ) ? c.name.slice( parentPrefix.length ) : c.name, + } ) ) + ); } ); } ) .catch( () => setChildren( [] ) ); diff --git a/src/blocks/fast-checkout-donate-selector/view.php b/src/blocks/fast-checkout-donate-selector/view.php index 9ce97ae84..795107879 100644 --- a/src/blocks/fast-checkout-donate-selector/view.php +++ b/src/blocks/fast-checkout-donate-selector/view.php @@ -120,7 +120,8 @@ function render_block( $attrs, $content, $block ) { } // Build per-child data and skip non-NYP / non-purchasable children. - $children = []; + $parent_prefix = $product->get_name() . ': '; + $children = []; foreach ( $child_ids as $child_id ) { $child = wc_get_product( $child_id ); if ( ! $child || ! $child->is_purchasable() ) { @@ -131,9 +132,13 @@ function render_block( $attrs, $content, $block ) { // Donate selector targets NYP children. Skip non-NYP. continue; } + $name = $child->get_name(); + if ( 0 === strpos( $name, $parent_prefix ) ) { + $name = substr( $name, strlen( $parent_prefix ) ); + } $children[] = [ 'id' => $child_id, - 'name' => $child->get_name(), + 'name' => $name, 'period' => get_subscription_period( $child_id ), 'min' => $nyp_config['min'], 'max' => $nyp_config['max'], @@ -145,6 +150,21 @@ function render_block( $attrs, $content, $block ) { return ''; } + // Sort by frequency: one-time → daily → weekly → monthly → yearly. + $period_rank = [ + '' => 0, + 'day' => 1, + 'week' => 2, + 'month' => 3, + 'year' => 4, + ]; + usort( + $children, + function ( $a, $b ) use ( $period_rank ) { + return ( $period_rank[ $a['period'] ] ?? 99 ) - ( $period_rank[ $b['period'] ] ?? 99 ); + } + ); + // Resolve the currently-selected child: query param > context attribute > first. $current_child = (int) ( $block->context['newspack-blocks/fastCheckoutGroupedChild'] ?? 0 ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended From 226f37d701878980dd65a9438fd9eb3c3752f953 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Fri, 1 May 2026 10:02:25 -0300 Subject: [PATCH 47/56] feat(fast-checkout): order donate-selector editor preview by frequency --- .../fast-checkout-donate-selector/edit.tsx | 41 +++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/src/blocks/fast-checkout-donate-selector/edit.tsx b/src/blocks/fast-checkout-donate-selector/edit.tsx index 1216cefa7..5bd90a4eb 100644 --- a/src/blocks/fast-checkout-donate-selector/edit.tsx +++ b/src/blocks/fast-checkout-donate-selector/edit.tsx @@ -11,6 +11,31 @@ interface ChildPreview { name: string; } +/** + * Best-effort frequency rank from a (prefix-stripped) child name. Mirrors + * the canonical period-meta sort the SSR uses, but operates on naming + * conventions because Store API doesn't expose subscription period. + */ +function rankFromName( name: string ): number { + const lower = name.toLowerCase(); + if ( lower.includes( 'one-time' ) || lower.includes( 'one time' ) || lower.includes( 'once' ) ) { + return 0; + } + if ( lower.includes( 'daily' ) || lower.includes( 'day' ) ) { + return 1; + } + if ( lower.includes( 'weekly' ) || lower.includes( 'week' ) ) { + return 2; + } + if ( lower.includes( 'monthly' ) || lower.includes( 'month' ) ) { + return 3; + } + if ( lower.includes( 'yearly' ) || lower.includes( 'year' ) || lower.includes( 'annual' ) ) { + return 4; + } + return 99; +} + interface EditProps { context: { 'newspack-blocks/fastCheckoutProductId'?: string | number; @@ -38,14 +63,14 @@ export default function Edit( { context }: EditProps ) { return Promise.all( ids.map( id => apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ id }` } ).catch( () => null ) ) ).then( fetched => { - setChildren( - fetched - .filter( ( c ): c is StoreApiProduct => Boolean( c ) ) - .map( c => ( { - id: c.id, - name: c.name.startsWith( parentPrefix ) ? c.name.slice( parentPrefix.length ) : c.name, - } ) ) - ); + const list: ChildPreview[] = fetched + .filter( ( c ): c is StoreApiProduct => Boolean( c ) ) + .map( c => ( { + id: c.id, + name: c.name.startsWith( parentPrefix ) ? c.name.slice( parentPrefix.length ) : c.name, + } ) ); + list.sort( ( a, b ) => rankFromName( a.name ) - rankFromName( b.name ) ); + setChildren( list ); } ); } ) .catch( () => setChildren( [] ) ); From f96eb40b420ee79ee34cde05ee2089b57e7aef06 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Fri, 1 May 2026 10:08:07 -0300 Subject: [PATCH 48/56] feat(fast-checkout): use frequency-based labels and remove radio borders in donate-selector --- .../fast-checkout-donate-selector/edit.tsx | 39 +++++++++++-------- .../fast-checkout-donate-selector/view.php | 34 ++++++++++++---- .../fast-checkout-donate-selector/view.scss | 5 +-- 3 files changed, 49 insertions(+), 29 deletions(-) diff --git a/src/blocks/fast-checkout-donate-selector/edit.tsx b/src/blocks/fast-checkout-donate-selector/edit.tsx index 5bd90a4eb..cfce08eb2 100644 --- a/src/blocks/fast-checkout-donate-selector/edit.tsx +++ b/src/blocks/fast-checkout-donate-selector/edit.tsx @@ -12,28 +12,32 @@ interface ChildPreview { } /** - * Best-effort frequency rank from a (prefix-stripped) child name. Mirrors - * the canonical period-meta sort the SSR uses, but operates on naming - * conventions because Store API doesn't expose subscription period. + * Best-effort frequency derivation from a (prefix-stripped) child name. + * Mirrors the canonical period-meta sort the SSR uses, but operates on + * naming conventions because Store API doesn't expose subscription period. + * + * @param name Child product name (with parent prefix already stripped). + * @return Rank (for sorting) and frequency-derived label, or the raw name + * for unrecognized frequencies (which sort last). */ -function rankFromName( name: string ): number { +function frequencyFromName( name: string ): { rank: number; label: string } { const lower = name.toLowerCase(); if ( lower.includes( 'one-time' ) || lower.includes( 'one time' ) || lower.includes( 'once' ) ) { - return 0; + return { rank: 0, label: __( 'One-time donation', 'newspack-blocks' ) }; } if ( lower.includes( 'daily' ) || lower.includes( 'day' ) ) { - return 1; + return { rank: 1, label: __( 'Daily donation', 'newspack-blocks' ) }; } if ( lower.includes( 'weekly' ) || lower.includes( 'week' ) ) { - return 2; + return { rank: 2, label: __( 'Weekly donation', 'newspack-blocks' ) }; } if ( lower.includes( 'monthly' ) || lower.includes( 'month' ) ) { - return 3; + return { rank: 3, label: __( 'Monthly donation', 'newspack-blocks' ) }; } if ( lower.includes( 'yearly' ) || lower.includes( 'year' ) || lower.includes( 'annual' ) ) { - return 4; + return { rank: 4, label: __( 'Yearly donation', 'newspack-blocks' ) }; } - return 99; + return { rank: 99, label: name }; } interface EditProps { @@ -63,14 +67,15 @@ export default function Edit( { context }: EditProps ) { return Promise.all( ids.map( id => apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ id }` } ).catch( () => null ) ) ).then( fetched => { - const list: ChildPreview[] = fetched + const list = fetched .filter( ( c ): c is StoreApiProduct => Boolean( c ) ) - .map( c => ( { - id: c.id, - name: c.name.startsWith( parentPrefix ) ? c.name.slice( parentPrefix.length ) : c.name, - } ) ); - list.sort( ( a, b ) => rankFromName( a.name ) - rankFromName( b.name ) ); - setChildren( list ); + .map( c => { + const stripped = c.name.startsWith( parentPrefix ) ? c.name.slice( parentPrefix.length ) : c.name; + const { rank, label } = frequencyFromName( stripped ); + return { id: c.id, name: label, rank }; + } ); + list.sort( ( a, b ) => a.rank - b.rank ); + setChildren( list.map( ( { id, name } ) => ( { id, name } ) ) ); } ); } ) .catch( () => setChildren( [] ) ); diff --git a/src/blocks/fast-checkout-donate-selector/view.php b/src/blocks/fast-checkout-donate-selector/view.php index 795107879..f91d12d25 100644 --- a/src/blocks/fast-checkout-donate-selector/view.php +++ b/src/blocks/fast-checkout-donate-selector/view.php @@ -92,6 +92,28 @@ function suffix_for_period( $period ) { } } +/** + * Map a WC Subscriptions period code to a frequency label + * ("One-time donation", "Monthly donation", etc.). + * + * @param string $period The period code. + * @return string Label. + */ +function label_for_period( $period ) { + switch ( $period ) { + case 'day': + return __( 'Daily donation', 'newspack-blocks' ); + case 'week': + return __( 'Weekly donation', 'newspack-blocks' ); + case 'month': + return __( 'Monthly donation', 'newspack-blocks' ); + case 'year': + return __( 'Yearly donation', 'newspack-blocks' ); + default: + return __( 'One-time donation', 'newspack-blocks' ); + } +} + /** * Render the donate selector SSR shell. * @@ -120,8 +142,7 @@ function render_block( $attrs, $content, $block ) { } // Build per-child data and skip non-NYP / non-purchasable children. - $parent_prefix = $product->get_name() . ': '; - $children = []; + $children = []; foreach ( $child_ids as $child_id ) { $child = wc_get_product( $child_id ); if ( ! $child || ! $child->is_purchasable() ) { @@ -132,14 +153,11 @@ function render_block( $attrs, $content, $block ) { // Donate selector targets NYP children. Skip non-NYP. continue; } - $name = $child->get_name(); - if ( 0 === strpos( $name, $parent_prefix ) ) { - $name = substr( $name, strlen( $parent_prefix ) ); - } + $period = get_subscription_period( $child_id ); $children[] = [ 'id' => $child_id, - 'name' => $name, - 'period' => get_subscription_period( $child_id ), + 'name' => label_for_period( $period ), + 'period' => $period, 'min' => $nyp_config['min'], 'max' => $nyp_config['max'], 'suggested' => $nyp_config['suggested'], diff --git a/src/blocks/fast-checkout-donate-selector/view.scss b/src/blocks/fast-checkout-donate-selector/view.scss index 4544c76d2..43a2a3ffc 100644 --- a/src/blocks/fast-checkout-donate-selector/view.scss +++ b/src/blocks/fast-checkout-donate-selector/view.scss @@ -6,15 +6,12 @@ &__frequencies { display: flex; flex-wrap: wrap; - gap: 0.5em; + gap: 0.5em 1.25em; label { display: inline-flex; align-items: center; gap: 0.4em; - padding: 0.4em 0.8em; - border: 1px solid var( --wp--preset--color--secondary, #ddd ); - border-radius: 4px; cursor: pointer; } From 0107790941f2866ee23c3332eefc1f27771e06b3 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Fri, 1 May 2026 10:10:33 -0300 Subject: [PATCH 49/56] feat(fast-checkout): rename donate amount label and stretch input full-width --- src/blocks/fast-checkout-donate-selector/edit.tsx | 2 +- src/blocks/fast-checkout-donate-selector/view.php | 2 +- src/blocks/fast-checkout-donate-selector/view.scss | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/blocks/fast-checkout-donate-selector/edit.tsx b/src/blocks/fast-checkout-donate-selector/edit.tsx index cfce08eb2..f1413f672 100644 --- a/src/blocks/fast-checkout-donate-selector/edit.tsx +++ b/src/blocks/fast-checkout-donate-selector/edit.tsx @@ -121,7 +121,7 @@ export default function Edit( { context }: EditProps ) { } ) }
- +
diff --git a/src/blocks/fast-checkout-donate-selector/view.php b/src/blocks/fast-checkout-donate-selector/view.php index f91d12d25..bc0dfdb87 100644 --- a/src/blocks/fast-checkout-donate-selector/view.php +++ b/src/blocks/fast-checkout-donate-selector/view.php @@ -277,7 +277,7 @@ function ( $a, $b ) use ( $period_rank ) {
Date: Fri, 1 May 2026 10:14:02 -0300 Subject: [PATCH 50/56] feat(fast-checkout): render donate amount suffix inside the input --- .../fast-checkout-donate-selector/view.scss | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/blocks/fast-checkout-donate-selector/view.scss b/src/blocks/fast-checkout-donate-selector/view.scss index 63b95b620..0da2fa74a 100644 --- a/src/blocks/fast-checkout-donate-selector/view.scss +++ b/src/blocks/fast-checkout-donate-selector/view.scss @@ -31,22 +31,24 @@ } &__input-wrapper { - display: flex; - align-items: baseline; - gap: 0.4em; + position: relative; + display: block; input[ type="number" ] { - flex: 1; - min-width: 0; - font-size: 1.25em; - padding: 0.4em 0.6em; width: 100%; + font-size: 1.25em; + padding: 0.4em 5em 0.4em 0.6em; } } &__suffix { - font-size: 0.9em; + position: absolute; + top: 50%; + right: 0.6em; + transform: translateY( -50% ); + font-size: 0.95em; opacity: 0.75; + pointer-events: none; } &__hint { From c8be73040672b34d9dd43def84582560102c90c9 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Mon, 4 May 2026 11:08:45 -0300 Subject: [PATCH 51/56] fix(fast-checkout): remove all stale donate items before adding new one --- .../fast-checkout-donate-selector/view.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/blocks/fast-checkout-donate-selector/view.tsx b/src/blocks/fast-checkout-donate-selector/view.tsx index 35093c89a..f16187caa 100644 --- a/src/blocks/fast-checkout-donate-selector/view.tsx +++ b/src/blocks/fast-checkout-donate-selector/view.tsx @@ -106,11 +106,18 @@ function DonateSelector( { host, children, currentChildId }: RootProps ) { const cartActions = dispatch( STORE ); const cartSelectors = select( STORE ); const items = cartSelectors.getCartData()?.items || []; - const oldKey = ( - items.find( ( item: { id?: number; key?: string } ) => item.id === lastApplied.current.childId ) as { key?: string } | undefined - )?.key; - if ( oldKey ) { - await ( cartActions as { removeItemFromCart: ( key: string ) => Promise< unknown > } ).removeItemFromCart( oldKey ); + // Remove ALL cart items matching any of this donate group's children + // (not just the one we think is there) so stale duplicates from + // earlier swaps or partial failures don't accumulate. + const childIds = new Set( children.map( c => c.id ) ); + const toRemove: string[] = []; + items.forEach( ( item: { id?: number; key?: string } ) => { + if ( item.id && item.key && childIds.has( item.id ) ) { + toRemove.push( item.key ); + } + } ); + for ( const key of toRemove ) { + await ( cartActions as { removeItemFromCart: ( key: string ) => Promise< unknown > } ).removeItemFromCart( key ); } await ( cartActions as { From f7c2395ffe51c70af5363d80c3e979e444acf5a3 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Mon, 4 May 2026 11:16:32 -0300 Subject: [PATCH 52/56] fix(fast-checkout): normalize donate amount input size in editor preview --- src/blocks/fast-checkout-donate-selector/editor.scss | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/blocks/fast-checkout-donate-selector/editor.scss b/src/blocks/fast-checkout-donate-selector/editor.scss index 2fb14bc43..43048b17e 100644 --- a/src/blocks/fast-checkout-donate-selector/editor.scss +++ b/src/blocks/fast-checkout-donate-selector/editor.scss @@ -1,4 +1,10 @@ .wp-block-newspack-blocks-fast-checkout-donate-selector { pointer-events: none; opacity: 0.85; + + // view.scss sets a 1.25em font-size on the amount input that compounds + // against the editor canvas cascade; normalize for the preview. + input[ type="number" ] { + font-size: 1em; + } } From 3316e51a2ffe0dedc5081c17eaadee663fd808a4 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Mon, 4 May 2026 11:36:10 -0300 Subject: [PATCH 53/56] fix(fast-checkout): suppress WC NYP edit-price link for Fast Checkout cart items --- includes/class-fast-checkout.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php index 67788be0b..55438866c 100644 --- a/includes/class-fast-checkout.php +++ b/includes/class-fast-checkout.php @@ -83,6 +83,7 @@ public static function init() { add_filter( 'woocommerce_is_checkout', [ __CLASS__, 'maybe_flag_as_checkout' ] ); add_filter( 'render_block_data', [ __CLASS__, 'filter_checkout_actions_block' ] ); add_filter( 'woocommerce_store_api_add_to_cart_data', [ __CLASS__, 'store_api_nyp_bridge_handler' ], 10, 2 ); + add_filter( 'wc_nyp_show_edit_link_in_cart', [ __CLASS__, 'suppress_nyp_edit_link' ], 10, 2 ); } /** @@ -751,6 +752,22 @@ public static function store_api_nyp_bridge( $cart_item_data, $product_id, $requ return $cart_item_data; } + /** + * Suppress WC Name Your Price's "Edit price" link in the checkout cart + * summary for cart items added via Fast Checkout — the reader edits the + * amount through our selector blocks instead. + * + * @param bool $show Whether the link should render. + * @param array $cart_item The cart item. + * @return bool + */ + public static function suppress_nyp_edit_link( $show, $cart_item ) { + if ( is_array( $cart_item ) && isset( $cart_item[ self::CART_ITEM_SOURCE_KEY ] ) ) { + return false; + } + return $show; + } + /** * Add product context keys to core block metadata. * From 6310fc0b8abf4258be8c726eb91b04baf0df1bae Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Mon, 4 May 2026 11:45:39 -0300 Subject: [PATCH 54/56] fix(fast-checkout): tag selector cart swaps with source post via store API --- includes/class-fast-checkout.php | 35 ++++++++++++++++--- .../fast-checkout-donate-selector/view.php | 1 + .../fast-checkout-donate-selector/view.tsx | 7 +++- .../fast-checkout-grouped-selector/view.php | 1 + .../fast-checkout-grouped-selector/view.tsx | 14 ++++++-- src/blocks/fast-checkout-nyp-input/view.php | 9 ++--- src/blocks/fast-checkout-nyp-input/view.tsx | 10 ++++-- .../fast-checkout-variation-selector/view.php | 1 + .../fast-checkout-variation-selector/view.tsx | 14 ++++++-- 9 files changed, 73 insertions(+), 19 deletions(-) diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php index 55438866c..66a0cf517 100644 --- a/includes/class-fast-checkout.php +++ b/includes/class-fast-checkout.php @@ -701,14 +701,39 @@ public static function store_api_nyp_bridge_handler( $request_data, $request ) { if ( ! $product_id && method_exists( $request, 'get_param' ) ) { $product_id = (int) $request->get_param( 'id' ); } - $request_data['cart_item_data'] = self::store_api_nyp_bridge( - $request_data['cart_item_data'] ?? [], - $product_id, - $request - ); + $cart_item_data = $request_data['cart_item_data'] ?? []; + $cart_item_data = self::propagate_source_post( $cart_item_data, $request ); + $cart_item_data = self::store_api_nyp_bridge( $cart_item_data, $product_id, $request ); + $request_data['cart_item_data'] = $cart_item_data; return $request_data; } + /** + * Propagate the Fast Checkout source-post marker from the Store API + * request into cart_item_data, so cart items added via selector swaps + * remain identifiable as Fast Checkout items (drives edit-link + * suppression, post-purchase redirect, and order line item meta). + * + * @param array $cart_item_data Existing cart item data. + * @param \WP_REST_Request $request REST request. + * @return array + */ + public static function propagate_source_post( $cart_item_data, $request ) { + if ( isset( $cart_item_data[ self::CART_ITEM_SOURCE_KEY ] ) ) { + return $cart_item_data; + } + $body = method_exists( $request, 'get_body_params' ) ? $request->get_body_params() : []; + $raw = $body[ self::CART_ITEM_SOURCE_KEY ] ?? null; + if ( null === $raw && method_exists( $request, 'get_json_params' ) ) { + $json = $request->get_json_params(); + $raw = is_array( $json ) ? ( $json[ self::CART_ITEM_SOURCE_KEY ] ?? null ) : null; + } + if ( $raw && is_numeric( $raw ) && (int) $raw > 0 ) { + $cart_item_data[ self::CART_ITEM_SOURCE_KEY ] = (int) $raw; + } + return $cart_item_data; + } + /** * Pure helper: inject the NYP value into cart_item_data when the request * body carries one and the target product is NYP-eligible. Clamps to min/max. diff --git a/src/blocks/fast-checkout-donate-selector/view.php b/src/blocks/fast-checkout-donate-selector/view.php index bc0dfdb87..9cb27041f 100644 --- a/src/blocks/fast-checkout-donate-selector/view.php +++ b/src/blocks/fast-checkout-donate-selector/view.php @@ -234,6 +234,7 @@ function ( $a, $b ) use ( $period_rank ) { $wrapper_attributes = get_block_wrapper_attributes( [ 'data-product-id' => (string) $product_id, + 'data-source-post' => (string) get_the_ID(), 'data-current-child' => (string) $current_child, 'data-children' => wp_json_encode( $children ), ] diff --git a/src/blocks/fast-checkout-donate-selector/view.tsx b/src/blocks/fast-checkout-donate-selector/view.tsx index f16187caa..c71741ef8 100644 --- a/src/blocks/fast-checkout-donate-selector/view.tsx +++ b/src/blocks/fast-checkout-donate-selector/view.tsx @@ -119,11 +119,16 @@ function DonateSelector( { host, children, currentChildId }: RootProps ) { for ( const key of toRemove ) { await ( cartActions as { removeItemFromCart: ( key: string ) => Promise< unknown > } ).removeItemFromCart( key ); } + const sourcePost = parseInt( host.dataset.sourcePost || '0', 10 ); + const cartItemData: Record< string, unknown > = { nyp: nextAmount }; + if ( sourcePost ) { + cartItemData._newspack_fast_checkout_source_post = sourcePost; + } await ( cartActions as { addItemToCart: ( id: number, qty: number, variation?: unknown[], cartItemData?: Record< string, unknown > ) => Promise< unknown >; } - ).addItemToCart( nextChildId, 1, [], { nyp: nextAmount } ); + ).addItemToCart( nextChildId, 1, [], cartItemData ); lastApplied.current = { childId: nextChildId, amount: nextAmount }; updateUrlParam( 'fc_grouped_child', String( nextChildId ) ); updateUrlParam( 'fc_price', String( nextAmount ) ); diff --git a/src/blocks/fast-checkout-grouped-selector/view.php b/src/blocks/fast-checkout-grouped-selector/view.php index bb255bd10..dffaff794 100644 --- a/src/blocks/fast-checkout-grouped-selector/view.php +++ b/src/blocks/fast-checkout-grouped-selector/view.php @@ -84,6 +84,7 @@ function render_block( $attrs, $content, $block ) { $wrapper_attributes = get_block_wrapper_attributes( [ 'data-product-id' => (string) $product_id, + 'data-source-post' => (string) get_the_ID(), 'data-current-child' => (string) $current_child, ] ); diff --git a/src/blocks/fast-checkout-grouped-selector/view.tsx b/src/blocks/fast-checkout-grouped-selector/view.tsx index b32a5cbf2..e87e305f6 100644 --- a/src/blocks/fast-checkout-grouped-selector/view.tsx +++ b/src/blocks/fast-checkout-grouped-selector/view.tsx @@ -68,7 +68,7 @@ function GroupedSelector( { host, currentChildId }: RootProps ) { inFlightRef.current = true; setInFlight( true ); try { - await swapCartItem( pendingId, nextId ); + await swapCartItem( pendingId, nextId, parseInt( host.dataset.sourcePost || '0', 10 ) ); setPendingId( nextId ); updateUrlParam( 'fc_grouped_child', String( nextId ) ); } catch ( ex: unknown ) { @@ -127,7 +127,7 @@ function GroupedSelector( { host, currentChildId }: RootProps ) { return null; } -async function swapCartItem( oldChildId: number, newChildId: number ) { +async function swapCartItem( oldChildId: number, newChildId: number, sourcePost: number ) { const cartActions = dispatch( STORE ); const cartSelectors = select( STORE ); const items = cartSelectors.getCartData()?.items || []; @@ -137,7 +137,15 @@ async function swapCartItem( oldChildId: number, newChildId: number ) { ( existing as { key: string } ).key ); } - await ( cartActions as { addItemToCart: ( id: number, qty: number ) => Promise< unknown > } ).addItemToCart( newChildId, 1 ); + const cartItemData: Record< string, unknown > = {}; + if ( sourcePost ) { + cartItemData._newspack_fast_checkout_source_post = sourcePost; + } + await ( + cartActions as { + addItemToCart: ( id: number, qty: number, variation?: unknown[], cartItemData?: Record< string, unknown > ) => Promise< unknown >; + } + ).addItemToCart( newChildId, 1, [], cartItemData ); } function updateUrlParam( key: string, value: string ) { diff --git a/src/blocks/fast-checkout-nyp-input/view.php b/src/blocks/fast-checkout-nyp-input/view.php index 9166949c2..cf4e2a1d0 100644 --- a/src/blocks/fast-checkout-nyp-input/view.php +++ b/src/blocks/fast-checkout-nyp-input/view.php @@ -97,10 +97,11 @@ function render_block( $attrs, $content, $block ) { $wrapper_attributes = get_block_wrapper_attributes( [ - 'data-product-id' => (string) $product_id, - 'data-min' => (string) $min, - 'data-max' => (string) $max, - 'data-suggested' => (string) $suggested, + 'data-product-id' => (string) $product_id, + 'data-source-post' => (string) get_the_ID(), + 'data-min' => (string) $min, + 'data-max' => (string) $max, + 'data-suggested' => (string) $suggested, ] ); diff --git a/src/blocks/fast-checkout-nyp-input/view.tsx b/src/blocks/fast-checkout-nyp-input/view.tsx index db426e8fb..0f4ad346d 100644 --- a/src/blocks/fast-checkout-nyp-input/view.tsx +++ b/src/blocks/fast-checkout-nyp-input/view.tsx @@ -106,7 +106,7 @@ function NypInput( { host, productId, min, max }: RootProps ) { inFlightRef.current = true; setInFlight( true ); try { - await applyNypPrice( productId, clamped ); + await applyNypPrice( productId, clamped, parseInt( host.dataset.sourcePost || '0', 10 ) ); lastApplied.current = clamped; updateUrlParam( 'fc_price', String( clamped ) ); } catch ( ex: unknown ) { @@ -143,7 +143,7 @@ function NypInput( { host, productId, min, max }: RootProps ) { return null; } -async function applyNypPrice( productId: number, price: number ) { +async function applyNypPrice( productId: number, price: number, sourcePost: number ) { const cartActions = dispatch( STORE ); const cartSelectors = select( STORE ); const items = cartSelectors.getCartData()?.items || []; @@ -152,11 +152,15 @@ async function applyNypPrice( productId: number, price: number ) { if ( existing?.key ) { await ( cartActions as { removeItemFromCart: ( key: string ) => Promise< unknown > } ).removeItemFromCart( existing.key ); } + const cartItemData: Record< string, unknown > = { nyp: price }; + if ( sourcePost ) { + cartItemData._newspack_fast_checkout_source_post = sourcePost; + } await ( cartActions as { addItemToCart: ( id: number, qty: number, variation?: unknown[], cartItemData?: Record< string, unknown > ) => Promise< unknown >; } - ).addItemToCart( productId, 1, [], { nyp: price } ); + ).addItemToCart( productId, 1, [], cartItemData ); } function updateUrlParam( key: string, value: string ) { diff --git a/src/blocks/fast-checkout-variation-selector/view.php b/src/blocks/fast-checkout-variation-selector/view.php index 3830e5287..137cd3fa0 100644 --- a/src/blocks/fast-checkout-variation-selector/view.php +++ b/src/blocks/fast-checkout-variation-selector/view.php @@ -92,6 +92,7 @@ function render_block( $attrs, $content, $block ) { $wrapper_attributes = get_block_wrapper_attributes( [ 'data-product-id' => (string) $product_id, + 'data-source-post' => (string) get_the_ID(), 'data-current-variation' => (string) $current_variation, 'data-variations' => wp_json_encode( array_map( diff --git a/src/blocks/fast-checkout-variation-selector/view.tsx b/src/blocks/fast-checkout-variation-selector/view.tsx index aa4ecdc00..8e3d59e0b 100644 --- a/src/blocks/fast-checkout-variation-selector/view.tsx +++ b/src/blocks/fast-checkout-variation-selector/view.tsx @@ -90,7 +90,7 @@ function VariationSelector( { host, productId, variations, currentVariationId }: inFlightRef.current = true; setInFlight( true ); try { - await swapCartItem( pendingId, resolvedId ); + await swapCartItem( pendingId, resolvedId, parseInt( host.dataset.sourcePost || '0', 10 ) ); setPendingId( resolvedId ); updateUrlParam( 'fc_variation', String( resolvedId ) ); } catch ( e: unknown ) { @@ -163,7 +163,7 @@ function revertSelection( host: HTMLFormElement, currentId: number, variations: } } -async function swapCartItem( oldVariationId: number, newVariationId: number ) { +async function swapCartItem( oldVariationId: number, newVariationId: number, sourcePost: number ) { const cartActions = dispatch( STORE ); const cartSelectors = select( STORE ); const items = cartSelectors.getCartData()?.items || []; @@ -177,7 +177,15 @@ async function swapCartItem( oldVariationId: number, newVariationId: number ) { } // The Store API accepts a variation ID directly as the cart item id; no need to spell out attributes. - await ( cartActions as { addItemToCart: ( id: number, qty: number ) => Promise< unknown > } ).addItemToCart( newVariationId, 1 ); + const cartItemData: Record< string, unknown > = {}; + if ( sourcePost ) { + cartItemData._newspack_fast_checkout_source_post = sourcePost; + } + await ( + cartActions as { + addItemToCart: ( id: number, qty: number, variation?: unknown[], cartItemData?: Record< string, unknown > ) => Promise< unknown >; + } + ).addItemToCart( newVariationId, 1, [], cartItemData ); } function updateUrlParam( key: string, value: string ) { From b98582d0cc1e32de69118fdb54d3510d90c95640 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Mon, 4 May 2026 11:51:12 -0300 Subject: [PATCH 55/56] fix: update font size for amount input in editor preview --- src/blocks/fast-checkout-donate-selector/editor.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blocks/fast-checkout-donate-selector/editor.scss b/src/blocks/fast-checkout-donate-selector/editor.scss index 43048b17e..07f35fd69 100644 --- a/src/blocks/fast-checkout-donate-selector/editor.scss +++ b/src/blocks/fast-checkout-donate-selector/editor.scss @@ -5,6 +5,6 @@ // view.scss sets a 1.25em font-size on the amount input that compounds // against the editor canvas cascade; normalize for the preview. input[ type="number" ] { - font-size: 1em; + font-size: 14px; } } From 960d9643ee09e6eb3cfcf5fdf22931ca4753c589 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Mon, 4 May 2026 11:55:30 -0300 Subject: [PATCH 56/56] feat(fast-checkout): scope branch to donate-selector and remove other inner blocks --- block-list.json | 3 - .../fast-checkout-grouped-selector/block.json | 25 -- .../fast-checkout-grouped-selector/edit.tsx | 104 ------- .../editor.scss | 4 - .../fast-checkout-grouped-selector/editor.ts | 9 - .../fast-checkout-grouped-selector/index.tsx | 18 -- .../fast-checkout-grouped-selector/view.php | 126 --------- .../fast-checkout-grouped-selector/view.scss | 24 -- .../fast-checkout-grouped-selector/view.tsx | 175 ------------ src/blocks/fast-checkout-nyp-input/block.json | 25 -- src/blocks/fast-checkout-nyp-input/edit.tsx | 84 ------ .../fast-checkout-nyp-input/editor.scss | 4 - src/blocks/fast-checkout-nyp-input/editor.ts | 9 - src/blocks/fast-checkout-nyp-input/index.tsx | 18 -- src/blocks/fast-checkout-nyp-input/view.php | 158 ----------- src/blocks/fast-checkout-nyp-input/view.scss | 30 -- src/blocks/fast-checkout-nyp-input/view.tsx | 192 ------------- .../block.json | 25 -- .../fast-checkout-variation-selector/edit.tsx | 100 ------- .../editor.scss | 14 - .../editor.ts | 9 - .../index.tsx | 18 -- .../resolve.test.js | 25 -- .../resolve.ts | 25 -- .../fast-checkout-variation-selector/view.php | 169 ----------- .../view.scss | 33 --- .../fast-checkout-variation-selector/view.tsx | 224 --------------- src/blocks/fast-checkout/edit.tsx | 50 +--- tests/test-fast-checkout-selectors.php | 264 ------------------ 29 files changed, 7 insertions(+), 1957 deletions(-) delete mode 100644 src/blocks/fast-checkout-grouped-selector/block.json delete mode 100644 src/blocks/fast-checkout-grouped-selector/edit.tsx delete mode 100644 src/blocks/fast-checkout-grouped-selector/editor.scss delete mode 100644 src/blocks/fast-checkout-grouped-selector/editor.ts delete mode 100644 src/blocks/fast-checkout-grouped-selector/index.tsx delete mode 100644 src/blocks/fast-checkout-grouped-selector/view.php delete mode 100644 src/blocks/fast-checkout-grouped-selector/view.scss delete mode 100644 src/blocks/fast-checkout-grouped-selector/view.tsx delete mode 100644 src/blocks/fast-checkout-nyp-input/block.json delete mode 100644 src/blocks/fast-checkout-nyp-input/edit.tsx delete mode 100644 src/blocks/fast-checkout-nyp-input/editor.scss delete mode 100644 src/blocks/fast-checkout-nyp-input/editor.ts delete mode 100644 src/blocks/fast-checkout-nyp-input/index.tsx delete mode 100644 src/blocks/fast-checkout-nyp-input/view.php delete mode 100644 src/blocks/fast-checkout-nyp-input/view.scss delete mode 100644 src/blocks/fast-checkout-nyp-input/view.tsx delete mode 100644 src/blocks/fast-checkout-variation-selector/block.json delete mode 100644 src/blocks/fast-checkout-variation-selector/edit.tsx delete mode 100644 src/blocks/fast-checkout-variation-selector/editor.scss delete mode 100644 src/blocks/fast-checkout-variation-selector/editor.ts delete mode 100644 src/blocks/fast-checkout-variation-selector/index.tsx delete mode 100644 src/blocks/fast-checkout-variation-selector/resolve.test.js delete mode 100644 src/blocks/fast-checkout-variation-selector/resolve.ts delete mode 100644 src/blocks/fast-checkout-variation-selector/view.php delete mode 100644 src/blocks/fast-checkout-variation-selector/view.scss delete mode 100644 src/blocks/fast-checkout-variation-selector/view.tsx delete mode 100644 tests/test-fast-checkout-selectors.php diff --git a/block-list.json b/block-list.json index e4bd1895d..001b4392a 100644 --- a/block-list.json +++ b/block-list.json @@ -7,9 +7,6 @@ "donate", "fast-checkout", "fast-checkout-donate-selector", - "fast-checkout-grouped-selector", - "fast-checkout-nyp-input", - "fast-checkout-variation-selector", "homepage-articles", "video-playlist", "iframe" diff --git a/src/blocks/fast-checkout-grouped-selector/block.json b/src/blocks/fast-checkout-grouped-selector/block.json deleted file mode 100644 index 6521a8925..000000000 --- a/src/blocks/fast-checkout-grouped-selector/block.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 3, - "name": "newspack-blocks/fast-checkout-grouped-selector", - "title": "Fast Checkout — Grouped Product Selector", - "category": "newspack", - "description": "Lets readers pick which child product to buy from a grouped Fast Checkout product.", - "keywords": [ "woocommerce", "checkout", "grouped", "fast" ], - "textdomain": "newspack-blocks", - "parent": [ "newspack-blocks/fast-checkout" ], - "usesContext": [ - "newspack-blocks/fastCheckoutProductId", - "newspack-blocks/fastCheckoutVariationId", - "newspack-blocks/fastCheckoutGroupedChild", - "newspack-blocks/fastCheckoutNypPrice" - ], - "attributes": {}, - "supports": { - "html": false, - "reusable": false, - "spacing": { "padding": true, "margin": true } - }, - "editorStyle": "newspack-blocks-editor", - "style": "newspack-blocks-fast-checkout-grouped-selector" -} diff --git a/src/blocks/fast-checkout-grouped-selector/edit.tsx b/src/blocks/fast-checkout-grouped-selector/edit.tsx deleted file mode 100644 index 0cff8e92e..000000000 --- a/src/blocks/fast-checkout-grouped-selector/edit.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { __ } from '@wordpress/i18n'; -import { useEffect, useState } from '@wordpress/element'; -import apiFetch from '@wordpress/api-fetch'; -import { useBlockProps } from '@wordpress/block-editor'; -import { Spinner } from '@wordpress/components'; - -import type { StoreApiProduct } from '../fast-checkout/types'; - -interface Child { - id: number; - name: string; - priceHtml: string; -} - -interface EditProps { - context: { - 'newspack-blocks/fastCheckoutProductId'?: string | number; - }; -} - -export default function Edit( { context }: EditProps ) { - const productId = parseInt( String( context?.[ 'newspack-blocks/fastCheckoutProductId' ] ?? 0 ), 10 ); - const blockProps = useBlockProps(); - const [ children, setChildren ] = useState< Child[] | null >( null ); - - useEffect( () => { - if ( ! productId ) { - setChildren( null ); - return; - } - apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ productId }` } ) - .then( product => { - const ids = product?.grouped_products || []; - if ( ! ids.length ) { - setChildren( [] ); - return; - } - return Promise.all( - ids.map( id => apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ id }` } ).catch( () => null ) ) - ).then( fetched => { - setChildren( - fetched - .filter( ( c ): c is StoreApiProduct => Boolean( c ) ) - .map( c => ( { - id: c.id, - name: c.name, - priceHtml: c.price_html, - } ) ) - ); - } ); - } ) - .catch( () => setChildren( [] ) ); - }, [ productId ] ); - - if ( ! productId ) { - return ( -
- { __( 'Pick a grouped product on the parent Fast Checkout block.', 'newspack-blocks' ) } -
- ); - } - - if ( null === children ) { - return ( -
- -
- ); - } - - if ( ! children.length ) { - return ( -
- { __( 'No grouped children found for this product.', 'newspack-blocks' ) } -
- ); - } - - const visible = children.slice( 0, 5 ); - const overflow = children.length - visible.length; - - return ( -
- { visible.map( child => { - const id = `preview-grouped-${ child.id }`; - return ( - - ); - } ) } - { overflow > 0 && ( - - { - /* translators: %d is the count of additional grouped children. */ - __( '+%d more', 'newspack-blocks' ).replace( '%d', String( overflow ) ) - } - - ) } -
- ); -} diff --git a/src/blocks/fast-checkout-grouped-selector/editor.scss b/src/blocks/fast-checkout-grouped-selector/editor.scss deleted file mode 100644 index 965c4243b..000000000 --- a/src/blocks/fast-checkout-grouped-selector/editor.scss +++ /dev/null @@ -1,4 +0,0 @@ -.wp-block-newspack-blocks-fast-checkout-grouped-selector { - pointer-events: none; - opacity: 0.85; -} diff --git a/src/blocks/fast-checkout-grouped-selector/editor.ts b/src/blocks/fast-checkout-grouped-selector/editor.ts deleted file mode 100644 index bcce9fe6c..000000000 --- a/src/blocks/fast-checkout-grouped-selector/editor.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Fast Checkout Grouped Selector — editor bundle entry. - */ - -import { registerBlockType } from '@wordpress/blocks'; -import { name, settings } from './index'; -import './editor.scss'; - -registerBlockType( name, settings ); diff --git a/src/blocks/fast-checkout-grouped-selector/index.tsx b/src/blocks/fast-checkout-grouped-selector/index.tsx deleted file mode 100644 index 3d5a83836..000000000 --- a/src/blocks/fast-checkout-grouped-selector/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { __ } from '@wordpress/i18n'; -import metadata from './block.json'; -import edit from './edit'; -import './view.scss'; - -export const name: string = metadata.name; -export const title: string = __( 'Fast Checkout — Grouped Product Selector', 'newspack-blocks' ); - -function save() { - return null; -} - -export const settings = { - ...metadata, - title, - edit, - save, -}; diff --git a/src/blocks/fast-checkout-grouped-selector/view.php b/src/blocks/fast-checkout-grouped-selector/view.php deleted file mode 100644 index dffaff794..000000000 --- a/src/blocks/fast-checkout-grouped-selector/view.php +++ /dev/null @@ -1,126 +0,0 @@ - __NAMESPACE__ . '\\render_block', - ] - ); -} -add_action( 'init', __NAMESPACE__ . '\\register_block' ); - -/** - * Enqueue the frontend view bundle when this block is present on the page. - */ -function enqueue_assets() { - if ( is_admin() || ! is_singular() ) { - return; - } - $post = get_post(); - if ( ! $post || ! has_block( BLOCK_NAME, $post ) ) { - return; - } - \Newspack_Blocks::enqueue_view_assets( BLOCK_SLUG ); -} -add_action( 'wp_enqueue_scripts', __NAMESPACE__ . '\\enqueue_assets' ); - -/** - * Render the grouped selector SSR shell. - * - * @param array $attrs Block attributes (currently none defined). - * @param string $content Inner content (unused). - * @param WP_Block $block Block instance with context. - * @return string Rendered HTML. - */ -function render_block( $attrs, $content, $block ) { - if ( ! function_exists( 'wc_get_product' ) ) { - return ''; - } - - $product_id = (int) ( $block->context['newspack-blocks/fastCheckoutProductId'] ?? 0 ); - if ( ! $product_id ) { - return ''; - } - $product = wc_get_product( $product_id ); - if ( ! $product || ! $product->is_type( 'grouped' ) ) { - return ''; - } - - $child_ids = array_map( 'intval', $product->get_children() ); - if ( count( $child_ids ) < 2 ) { - return ''; - } - - $current_child = (int) ( $block->context['newspack-blocks/fastCheckoutGroupedChild'] ?? 0 ); - // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - if ( isset( $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] ) ) { - // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - $qp_raw = wp_unslash( $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] ); - $qp = (int) filter_var( $qp_raw, FILTER_SANITIZE_NUMBER_INT ); - if ( $qp > 0 && in_array( $qp, $child_ids, true ) ) { - $current_child = $qp; - } - } - if ( ! $current_child || ! in_array( $current_child, $child_ids, true ) ) { - $current_child = $child_ids[0]; - } - - $wrapper_attributes = get_block_wrapper_attributes( - [ - 'data-product-id' => (string) $product_id, - 'data-source-post' => (string) get_the_ID(), - 'data-current-child' => (string) $current_child, - ] - ); - - ob_start(); - ?> -
> - - is_purchasable() && $child->is_in_stock(); - $input_id = 'fc-grouped-child-' . $child_id; - ?> - - - -
- ( currentChildId ); - const [ inFlight, setInFlight ] = useState( false ); - const [ error, setError ] = useState< string >( '' ); - const inFlightRef = useRef< boolean >( false ); - // Remember which radios were SSR-disabled (out of stock / unpurchasable). - const ssrDisabled = useRef< Set< HTMLInputElement > | null >( null ); - - const noticeNode = useMemo< HTMLElement | null >( - () => host.querySelector( '.wp-block-newspack-blocks-fast-checkout-grouped-selector__notice' ), - [ host ] - ); - - useEffect( () => { - if ( ssrDisabled.current === null ) { - ssrDisabled.current = new Set(); - host.querySelectorAll< HTMLInputElement >( 'input[type="radio"][disabled]' ).forEach( input => { - ssrDisabled.current!.add( input ); - } ); - } - }, [ host ] ); - - useEffect( () => { - if ( noticeNode ) { - if ( error ) { - noticeNode.textContent = error; - noticeNode.removeAttribute( 'hidden' ); - } else { - noticeNode.textContent = ''; - noticeNode.setAttribute( 'hidden', '' ); - } - } - }, [ error, noticeNode ] ); - - useEffect( () => { - const onChange = async ( e: Event ) => { - if ( inFlightRef.current ) { - return; - } - const target = e.target as HTMLInputElement; - if ( target?.type !== 'radio' ) { - return; - } - const nextId = parseInt( target.value, 10 ); - if ( ! nextId || nextId === pendingId ) { - return; - } - setError( '' ); - inFlightRef.current = true; - setInFlight( true ); - try { - await swapCartItem( pendingId, nextId, parseInt( host.dataset.sourcePost || '0', 10 ) ); - setPendingId( nextId ); - updateUrlParam( 'fc_grouped_child', String( nextId ) ); - } catch ( ex: unknown ) { - setError( ( ex as Error )?.message || __( 'Could not update selection.', 'newspack-blocks' ) ); - const previous = host.querySelector< HTMLInputElement >( `input[type="radio"][value="${ pendingId }"]` ); - if ( previous ) { - previous.checked = true; - } - } finally { - inFlightRef.current = false; - setInFlight( false ); - } - }; - host.addEventListener( 'change', onChange ); - return () => host.removeEventListener( 'change', onChange ); - }, [ host, pendingId ] ); - - useEffect( () => { - const onPopstate = () => { - const params = new URLSearchParams( window.location.search ); - const next = parseInt( params.get( 'fc_grouped_child' ) || '', 10 ); - if ( next && next !== pendingId ) { - const radio = host.querySelector< HTMLInputElement >( `input[type="radio"][value="${ next }"]` ); - if ( radio ) { - radio.checked = true; - setPendingId( next ); - } - } - }; - window.addEventListener( 'popstate', onPopstate ); - return () => window.removeEventListener( 'popstate', onPopstate ); - }, [ host, pendingId ] ); - - useEffect( () => { - host.dataset.status = inFlight ? 'busy' : 'idle'; - // Toggle the swapping flag on the parent Fast Checkout block so a CSS - // overlay can mask the WC Checkout block during cart updates and hide - // the brief "Your cart is currently empty" flash. - const fastCheckout = host.closest< HTMLElement >( '.wp-block-newspack-blocks-fast-checkout' ); - if ( fastCheckout ) { - if ( inFlight ) { - fastCheckout.dataset.fcSwapping = 'true'; - } else { - delete fastCheckout.dataset.fcSwapping; - } - } - host.querySelectorAll< HTMLInputElement >( 'input[type="radio"]' ).forEach( input => { - if ( ssrDisabled.current?.has( input ) ) { - input.disabled = true; - return; - } - input.disabled = inFlight; - } ); - }, [ host, inFlight ] ); - - return null; -} - -async function swapCartItem( oldChildId: number, newChildId: number, sourcePost: number ) { - const cartActions = dispatch( STORE ); - const cartSelectors = select( STORE ); - const items = cartSelectors.getCartData()?.items || []; - const existing = items.find( ( item: { id?: number; key?: string } ) => item.id === oldChildId ); - if ( existing ) { - await ( cartActions as { removeItemFromCart: ( key: string ) => Promise< unknown > } ).removeItemFromCart( - ( existing as { key: string } ).key - ); - } - const cartItemData: Record< string, unknown > = {}; - if ( sourcePost ) { - cartItemData._newspack_fast_checkout_source_post = sourcePost; - } - await ( - cartActions as { - addItemToCart: ( id: number, qty: number, variation?: unknown[], cartItemData?: Record< string, unknown > ) => Promise< unknown >; - } - ).addItemToCart( newChildId, 1, [], cartItemData ); -} - -function updateUrlParam( key: string, value: string ) { - const url = new URL( window.location.href ); - url.searchParams.set( key, value ); - window.history.replaceState( {}, '', url.toString() ); -} - -function init() { - document.querySelectorAll< HTMLFormElement >( '.wp-block-newspack-blocks-fast-checkout-grouped-selector' ).forEach( host => { - const currentChildId = parseInt( host.dataset.currentChild || '0', 10 ); - if ( ! currentChildId ) { - return; - } - const sentinel = document.createElement( 'span' ); - sentinel.style.display = 'none'; - host.appendChild( sentinel ); - const root = createRoot( sentinel ); - root.render( ); - } ); -} - -if ( document.readyState === 'loading' ) { - document.addEventListener( 'DOMContentLoaded', init ); -} else { - init(); -} diff --git a/src/blocks/fast-checkout-nyp-input/block.json b/src/blocks/fast-checkout-nyp-input/block.json deleted file mode 100644 index a9716b903..000000000 --- a/src/blocks/fast-checkout-nyp-input/block.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 3, - "name": "newspack-blocks/fast-checkout-nyp-input", - "title": "Fast Checkout — Name Your Price", - "category": "newspack", - "description": "Lets readers set their own price for a Name Your Price Fast Checkout product.", - "keywords": [ "woocommerce", "checkout", "name your price", "nyp", "fast" ], - "textdomain": "newspack-blocks", - "parent": [ "newspack-blocks/fast-checkout" ], - "usesContext": [ - "newspack-blocks/fastCheckoutProductId", - "newspack-blocks/fastCheckoutVariationId", - "newspack-blocks/fastCheckoutGroupedChild", - "newspack-blocks/fastCheckoutNypPrice" - ], - "attributes": {}, - "supports": { - "html": false, - "reusable": false, - "spacing": { "padding": true, "margin": true } - }, - "editorStyle": "newspack-blocks-editor", - "style": "newspack-blocks-fast-checkout-nyp-input" -} diff --git a/src/blocks/fast-checkout-nyp-input/edit.tsx b/src/blocks/fast-checkout-nyp-input/edit.tsx deleted file mode 100644 index 1f017b78c..000000000 --- a/src/blocks/fast-checkout-nyp-input/edit.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { __ } from '@wordpress/i18n'; -import { useEffect, useState } from '@wordpress/element'; -import apiFetch from '@wordpress/api-fetch'; -import { useBlockProps } from '@wordpress/block-editor'; -import { Spinner } from '@wordpress/components'; - -import type { StoreApiProduct } from '../fast-checkout/types'; - -interface EditProps { - context: { - 'newspack-blocks/fastCheckoutProductId'?: string | number; - 'newspack-blocks/fastCheckoutNypPrice'?: string; - }; -} - -type NypState = { status: 'loading' } | { status: 'not-nyp' } | { status: 'ready'; min?: string; max?: string; suggested?: string }; - -export default function Edit( { context }: EditProps ) { - const productId = parseInt( String( context?.[ 'newspack-blocks/fastCheckoutProductId' ] ?? 0 ), 10 ); - const overridePrice = String( context?.[ 'newspack-blocks/fastCheckoutNypPrice' ] || '' ); - const blockProps = useBlockProps(); - const [ nyp, setNyp ] = useState< NypState >( { status: 'loading' } ); - - useEffect( () => { - if ( ! productId ) { - setNyp( { status: 'loading' } ); - return; - } - setNyp( { status: 'loading' } ); - apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ productId }` } ) - .then( product => { - const ext = product?.extensions?.name_your_price; - if ( ! ext?.is_nyp ) { - setNyp( { status: 'not-nyp' } ); - return; - } - setNyp( { - status: 'ready', - min: ext.minimum_price, - max: ext.maximum_price, - suggested: ext.suggested_price, - } ); - } ) - .catch( () => setNyp( { status: 'not-nyp' } ) ); - }, [ productId ] ); - - if ( ! productId ) { - return ( -
- { __( 'Pick a Name Your Price product on the parent Fast Checkout block.', 'newspack-blocks' ) } -
- ); - } - - if ( nyp.status === 'loading' ) { - return ( -
- -
- ); - } - - if ( nyp.status === 'not-nyp' ) { - return ( -
- { __( 'This product is not configured for Name Your Price.', 'newspack-blocks' ) } -
- ); - } - - const inputId = 'fc-nyp-edit-input'; - - return ( -
- - -

- { nyp.min && nyp.max - ? `${ nyp.min } – ${ nyp.max } · suggested ${ nyp.suggested }` - : __( 'Reader can set any amount.', 'newspack-blocks' ) } -

-
- ); -} diff --git a/src/blocks/fast-checkout-nyp-input/editor.scss b/src/blocks/fast-checkout-nyp-input/editor.scss deleted file mode 100644 index 846b5e812..000000000 --- a/src/blocks/fast-checkout-nyp-input/editor.scss +++ /dev/null @@ -1,4 +0,0 @@ -.wp-block-newspack-blocks-fast-checkout-nyp-input { - pointer-events: none; - opacity: 0.85; -} diff --git a/src/blocks/fast-checkout-nyp-input/editor.ts b/src/blocks/fast-checkout-nyp-input/editor.ts deleted file mode 100644 index f06b2dd69..000000000 --- a/src/blocks/fast-checkout-nyp-input/editor.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Fast Checkout NYP Input — editor bundle entry. - */ - -import { registerBlockType } from '@wordpress/blocks'; -import { name, settings } from './index'; -import './editor.scss'; - -registerBlockType( name, settings ); diff --git a/src/blocks/fast-checkout-nyp-input/index.tsx b/src/blocks/fast-checkout-nyp-input/index.tsx deleted file mode 100644 index 2e0a30cba..000000000 --- a/src/blocks/fast-checkout-nyp-input/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { __ } from '@wordpress/i18n'; -import metadata from './block.json'; -import edit from './edit'; -import './view.scss'; - -export const name: string = metadata.name; -export const title: string = __( 'Fast Checkout — Name Your Price', 'newspack-blocks' ); - -function save() { - return null; -} - -export const settings = { - ...metadata, - title, - edit, - save, -}; diff --git a/src/blocks/fast-checkout-nyp-input/view.php b/src/blocks/fast-checkout-nyp-input/view.php deleted file mode 100644 index cf4e2a1d0..000000000 --- a/src/blocks/fast-checkout-nyp-input/view.php +++ /dev/null @@ -1,158 +0,0 @@ - __NAMESPACE__ . '\\render_block', - ] - ); -} -add_action( 'init', __NAMESPACE__ . '\\register_block' ); - -/** - * Enqueue the frontend view bundle when this block is present on the page. - */ -function enqueue_assets() { - if ( is_admin() || ! is_singular() ) { - return; - } - $post = get_post(); - if ( ! $post || ! has_block( BLOCK_NAME, $post ) ) { - return; - } - \Newspack_Blocks::enqueue_view_assets( BLOCK_SLUG ); -} -add_action( 'wp_enqueue_scripts', __NAMESPACE__ . '\\enqueue_assets' ); - -/** - * Render the NYP input SSR shell. - * - * @param array $attrs Block attributes. - * @param string $content Inner content (unused). - * @param object $block Block instance with context. - * @return string Rendered HTML. - */ -function render_block( $attrs, $content, $block ) { - if ( ! function_exists( 'wc_get_product' ) ) { - return ''; - } - if ( ! class_exists( '\WC_Name_Your_Price_Helpers' ) ) { - return ''; - } - - $product_id = (int) ( $block->context['newspack-blocks/fastCheckoutProductId'] ?? 0 ); - if ( ! $product_id ) { - return ''; - } - $product = wc_get_product( $product_id ); - if ( ! $product ) { - return ''; - } - if ( ! \WC_Name_Your_Price_Helpers::is_nyp( $product_id ) ) { - return ''; - } - - $min = (float) \WC_Name_Your_Price_Helpers::get_minimum_price( $product_id ); - $max = (float) \WC_Name_Your_Price_Helpers::get_maximum_price( $product_id ); - $suggested = (float) \WC_Name_Your_Price_Helpers::get_suggested_price( $product_id ); - - $attr_price = $block->context['newspack-blocks/fastCheckoutNypPrice'] ?? ''; - // phpcs:ignore WordPress.Security.NonceVerification.Recommended - $qp_price = ''; - // phpcs:ignore WordPress.Security.NonceVerification.Recommended - if ( isset( $_GET[ Fast_Checkout::QP_PRICE ] ) ) { - // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - $qp_price = sanitize_text_field( wp_unslash( $_GET[ Fast_Checkout::QP_PRICE ] ) ); - } - - $current = $suggested; - if ( $attr_price && is_numeric( $attr_price ) ) { - $current = (float) $attr_price; - } - if ( $qp_price && is_numeric( $qp_price ) ) { - $current = (float) $qp_price; - } - if ( $max > 0 ) { - $current = min( $current, $max ); - } - if ( $min > 0 ) { - $current = max( $current, $min ); - } - - $wrapper_attributes = get_block_wrapper_attributes( - [ - 'data-product-id' => (string) $product_id, - 'data-source-post' => (string) get_the_ID(), - 'data-min' => (string) $min, - 'data-max' => (string) $max, - 'data-suggested' => (string) $suggested, - ] - ); - - $min_price_html = $min ? wc_price( $min ) : ''; - $max_price_html = $max ? wc_price( $max ) : ''; - $suggested_html = $suggested ? wc_price( $suggested ) : ''; - - $range_label = ''; - if ( $min && $max ) { - $range_label = sprintf( - /* translators: 1: min price, 2: max price, 3: suggested price */ - __( '%1$s – %2$s · suggested %3$s', 'newspack-blocks' ), - $min_price_html, - $max_price_html, - $suggested_html - ); - } - - $input_id = 'fc-nyp-input-' . $product_id; - - ob_start(); - ?> -
> - - - min="" - - - max="" - - value="" - /> - -

- -

- - -
- ( () => host.querySelector< HTMLInputElement >( 'input[type="number"]' ), [ host ] ); - const noticeNode = useMemo< HTMLElement | null >( - () => host.querySelector( '.wp-block-newspack-blocks-fast-checkout-nyp-input__notice' ), - [ host ] - ); - const [ inFlight, setInFlight ] = useState( false ); - const [ error, setError ] = useState< string >( '' ); - const lastApplied = useRef< number >( parseFloat( input?.value || '0' ) || 0 ); - const timer = useRef< ReturnType< typeof setTimeout > | null >( null ); - const inFlightRef = useRef< boolean >( false ); - - useEffect( () => { - if ( noticeNode ) { - if ( error ) { - noticeNode.textContent = error; - noticeNode.removeAttribute( 'hidden' ); - } else { - noticeNode.textContent = ''; - noticeNode.setAttribute( 'hidden', '' ); - } - } - }, [ error, noticeNode ] ); - - useEffect( () => { - host.dataset.status = inFlight ? 'busy' : 'idle'; - // Toggle the swapping flag on the parent Fast Checkout block so a CSS - // overlay can mask the WC Checkout block during cart updates and hide - // the brief "Your cart is currently empty" flash. - const fastCheckout = host.closest< HTMLElement >( '.wp-block-newspack-blocks-fast-checkout' ); - if ( fastCheckout ) { - if ( inFlight ) { - fastCheckout.dataset.fcSwapping = 'true'; - } else { - delete fastCheckout.dataset.fcSwapping; - } - } - }, [ host, inFlight ] ); - - useEffect( () => { - if ( ! input ) { - return; - } - const onBlur = () => { - if ( timer.current ) { - clearTimeout( timer.current ); - } - timer.current = setTimeout( apply, DEBOUNCE_MS ); - }; - - const apply = async () => { - if ( inFlightRef.current ) { - return; - } - const raw = parseFloat( input.value ); - if ( ! isFinite( raw ) || raw <= 0 ) { - setError( __( 'Enter a valid amount.', 'newspack-blocks' ) ); - input.value = String( lastApplied.current ); - return; - } - let clamped = raw; - if ( max > 0 ) { - clamped = Math.min( clamped, max ); - } - if ( min > 0 ) { - clamped = Math.max( clamped, min ); - } - if ( clamped !== raw ) { - input.value = String( clamped ); - setError( - sprintf( - /* translators: %s is the price actually applied. */ - __( 'Adjusted to %s to meet limits.', 'newspack-blocks' ), - String( clamped ) - ) - ); - } else { - setError( '' ); - } - if ( clamped === lastApplied.current ) { - return; - } - - inFlightRef.current = true; - setInFlight( true ); - try { - await applyNypPrice( productId, clamped, parseInt( host.dataset.sourcePost || '0', 10 ) ); - lastApplied.current = clamped; - updateUrlParam( 'fc_price', String( clamped ) ); - } catch ( ex: unknown ) { - setError( ( ex as Error )?.message || __( 'Could not update price.', 'newspack-blocks' ) ); - input.value = String( lastApplied.current ); - } finally { - inFlightRef.current = false; - setInFlight( false ); - } - }; - - input.addEventListener( 'blur', onBlur ); - return () => { - input.removeEventListener( 'blur', onBlur ); - if ( timer.current ) { - clearTimeout( timer.current ); - } - }; - }, [ input, min, max, productId ] ); - - useEffect( () => { - const onPopstate = () => { - const params = new URLSearchParams( window.location.search ); - const next = parseFloat( params.get( 'fc_price' ) || '' ); - if ( isFinite( next ) && next > 0 && input && next !== lastApplied.current ) { - input.value = String( next ); - lastApplied.current = next; - } - }; - window.addEventListener( 'popstate', onPopstate ); - return () => window.removeEventListener( 'popstate', onPopstate ); - }, [ input ] ); - - return null; -} - -async function applyNypPrice( productId: number, price: number, sourcePost: number ) { - const cartActions = dispatch( STORE ); - const cartSelectors = select( STORE ); - const items = cartSelectors.getCartData()?.items || []; - const existing = items.find( ( item: { id?: number; key?: string } ) => item.id === productId ) as { key?: string } | undefined; - - if ( existing?.key ) { - await ( cartActions as { removeItemFromCart: ( key: string ) => Promise< unknown > } ).removeItemFromCart( existing.key ); - } - const cartItemData: Record< string, unknown > = { nyp: price }; - if ( sourcePost ) { - cartItemData._newspack_fast_checkout_source_post = sourcePost; - } - await ( - cartActions as { - addItemToCart: ( id: number, qty: number, variation?: unknown[], cartItemData?: Record< string, unknown > ) => Promise< unknown >; - } - ).addItemToCart( productId, 1, [], cartItemData ); -} - -function updateUrlParam( key: string, value: string ) { - const url = new URL( window.location.href ); - url.searchParams.set( key, value ); - window.history.replaceState( {}, '', url.toString() ); -} - -function init() { - document.querySelectorAll< HTMLElement >( '.wp-block-newspack-blocks-fast-checkout-nyp-input' ).forEach( host => { - const productId = parseInt( host.dataset.productId || '0', 10 ); - const min = parseFloat( host.dataset.min || '0' ) || 0; - const max = parseFloat( host.dataset.max || '0' ) || 0; - if ( ! productId ) { - return; - } - const sentinel = document.createElement( 'span' ); - sentinel.style.display = 'none'; - host.appendChild( sentinel ); - const root = createRoot( sentinel ); - root.render( ); - } ); -} - -if ( document.readyState === 'loading' ) { - document.addEventListener( 'DOMContentLoaded', init ); -} else { - init(); -} diff --git a/src/blocks/fast-checkout-variation-selector/block.json b/src/blocks/fast-checkout-variation-selector/block.json deleted file mode 100644 index e715e6fcc..000000000 --- a/src/blocks/fast-checkout-variation-selector/block.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 3, - "name": "newspack-blocks/fast-checkout-variation-selector", - "title": "Fast Checkout — Variation Selector", - "category": "newspack", - "description": "Lets readers pick a variation for the parent Fast Checkout product.", - "keywords": [ "woocommerce", "checkout", "variation", "fast" ], - "textdomain": "newspack-blocks", - "parent": [ "newspack-blocks/fast-checkout" ], - "usesContext": [ - "newspack-blocks/fastCheckoutProductId", - "newspack-blocks/fastCheckoutVariationId", - "newspack-blocks/fastCheckoutGroupedChild", - "newspack-blocks/fastCheckoutNypPrice" - ], - "attributes": {}, - "supports": { - "html": false, - "reusable": false, - "spacing": { "padding": true, "margin": true } - }, - "editorStyle": "newspack-blocks-editor", - "style": "newspack-blocks-fast-checkout-variation-selector" -} diff --git a/src/blocks/fast-checkout-variation-selector/edit.tsx b/src/blocks/fast-checkout-variation-selector/edit.tsx deleted file mode 100644 index aa2575c54..000000000 --- a/src/blocks/fast-checkout-variation-selector/edit.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { __ } from '@wordpress/i18n'; -import { useEffect, useState } from '@wordpress/element'; -import apiFetch from '@wordpress/api-fetch'; -import { useBlockProps } from '@wordpress/block-editor'; -import { Spinner } from '@wordpress/components'; - -import type { StoreApiProduct, StoreApiVariation } from '../fast-checkout/types'; - -interface AttributeGroup { - name: string; - options: string[]; -} - -interface EditProps { - context: { - 'newspack-blocks/fastCheckoutProductId'?: string | number; - }; -} - -export default function Edit( { context }: EditProps ) { - const productId = parseInt( String( context?.[ 'newspack-blocks/fastCheckoutProductId' ] ?? 0 ), 10 ); - const blockProps = useBlockProps( { 'data-editor-hint': __( 'Reader-facing variation selector', 'newspack-blocks' ) } ); - const [ groups, setGroups ] = useState< AttributeGroup[] | null >( null ); - - useEffect( () => { - if ( ! productId ) { - setGroups( null ); - return; - } - apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ productId }` } ) - .then( product => { - if ( ! product?.variations?.length ) { - setGroups( [] ); - return; - } - return apiFetch< StoreApiVariation[] >( { - path: `/wc/v2/products/${ productId }/variations?per_page=100`, - } ).then( variations => { - const map = new Map< string, Set< string > >(); - variations.forEach( v => { - v.attributes.forEach( a => { - if ( ! map.has( a.name ) ) { - map.set( a.name, new Set() ); - } - map.get( a.name )!.add( a.option ); - } ); - } ); - const next: AttributeGroup[] = []; - map.forEach( ( opts, name ) => { - next.push( { name, options: Array.from( opts ) } ); - } ); - setGroups( next ); - } ); - } ) - .catch( () => setGroups( [] ) ); - }, [ productId ] ); - - if ( ! productId ) { - return ( -
- { __( 'Pick a product on the parent Fast Checkout block.', 'newspack-blocks' ) } -
- ); - } - - if ( null === groups ) { - return ( -
- -
- ); - } - - if ( ! groups.length ) { - return ( -
- { __( 'No variations found for this product.', 'newspack-blocks' ) } -
- ); - } - - return ( -
- { groups.map( group => ( -
- { group.name } - { group.options.map( option => { - const inputId = `preview_${ group.name }_${ option }`; - return ( - - ); - } ) } -
- ) ) } -
- ); -} diff --git a/src/blocks/fast-checkout-variation-selector/editor.scss b/src/blocks/fast-checkout-variation-selector/editor.scss deleted file mode 100644 index 8787f30b6..000000000 --- a/src/blocks/fast-checkout-variation-selector/editor.scss +++ /dev/null @@ -1,14 +0,0 @@ -.wp-block-newspack-blocks-fast-checkout-variation-selector { - pointer-events: none; - opacity: 0.85; - - &::before { - content: attr( data-editor-hint ); - display: block; - font-size: 0.75em; - text-transform: uppercase; - letter-spacing: 0.05em; - opacity: 0.6; - margin-bottom: 0.5em; - } -} diff --git a/src/blocks/fast-checkout-variation-selector/editor.ts b/src/blocks/fast-checkout-variation-selector/editor.ts deleted file mode 100644 index 62b93733a..000000000 --- a/src/blocks/fast-checkout-variation-selector/editor.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Fast Checkout Variation Selector — editor bundle entry. - */ - -import { registerBlockType } from '@wordpress/blocks'; -import { name, settings } from './index'; -import './editor.scss'; - -registerBlockType( name, settings ); diff --git a/src/blocks/fast-checkout-variation-selector/index.tsx b/src/blocks/fast-checkout-variation-selector/index.tsx deleted file mode 100644 index ad2ee7f38..000000000 --- a/src/blocks/fast-checkout-variation-selector/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { __ } from '@wordpress/i18n'; -import metadata from './block.json'; -import edit from './edit'; -import './view.scss'; - -export const name: string = metadata.name; -export const title: string = __( 'Fast Checkout — Variation Selector', 'newspack-blocks' ); - -function save() { - return null; -} - -export const settings = { - ...metadata, - title, - edit, - save, -}; diff --git a/src/blocks/fast-checkout-variation-selector/resolve.test.js b/src/blocks/fast-checkout-variation-selector/resolve.test.js deleted file mode 100644 index 5740f68a7..000000000 --- a/src/blocks/fast-checkout-variation-selector/resolve.test.js +++ /dev/null @@ -1,25 +0,0 @@ -import { resolveVariationId } from './resolve'; - -describe( 'resolveVariationId', () => { - const variations = [ - { id: 1, attributes: { attribute_color: 'red', attribute_size: 's' }, is_in_stock: true }, - { id: 2, attributes: { attribute_color: 'red', attribute_size: 'm' }, is_in_stock: true }, - { id: 3, attributes: { attribute_color: 'blue', attribute_size: 's' }, is_in_stock: true }, - { id: 4, attributes: { attribute_color: 'blue', attribute_size: 'm' }, is_in_stock: true }, - ]; - - it( 'returns null when not all attributes are picked', () => { - const selection = { attribute_color: 'red' }; - expect( resolveVariationId( variations, selection ) ).toBeNull(); - } ); - - it( 'returns matching variation id when all attributes picked', () => { - const selection = { attribute_color: 'blue', attribute_size: 'm' }; - expect( resolveVariationId( variations, selection ) ).toBe( 4 ); - } ); - - it( 'returns null when selection has no matching variation', () => { - const selection = { attribute_color: 'green', attribute_size: 'l' }; - expect( resolveVariationId( variations, selection ) ).toBeNull(); - } ); -} ); diff --git a/src/blocks/fast-checkout-variation-selector/resolve.ts b/src/blocks/fast-checkout-variation-selector/resolve.ts deleted file mode 100644 index 03a364b57..000000000 --- a/src/blocks/fast-checkout-variation-selector/resolve.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Pure helpers for variation resolution. - */ - -export interface VariationData { - id: number; - attributes: Record< string, string >; - is_in_stock: boolean; -} - -export function attributesMatch( variation: VariationData, selection: Record< string, string > ): boolean { - return Object.entries( variation.attributes ).every( ( [ key, value ] ) => selection[ key ] === value ); -} - -export function resolveVariationId( variations: VariationData[], selection: Record< string, string > ): number | null { - const required = new Set< string >(); - variations.forEach( v => Object.keys( v.attributes ).forEach( k => required.add( k ) ) ); - for ( const key of required ) { - if ( ! selection[ key ] ) { - return null; - } - } - const match = variations.find( v => attributesMatch( v, selection ) ); - return match ? match.id : null; -} diff --git a/src/blocks/fast-checkout-variation-selector/view.php b/src/blocks/fast-checkout-variation-selector/view.php deleted file mode 100644 index 137cd3fa0..000000000 --- a/src/blocks/fast-checkout-variation-selector/view.php +++ /dev/null @@ -1,169 +0,0 @@ - __NAMESPACE__ . '\\render_block', - ] - ); -} -add_action( 'init', __NAMESPACE__ . '\\register_block' ); - -/** - * Enqueue the frontend view bundle when this block is present on the page. - */ -function enqueue_assets() { - if ( is_admin() || ! is_singular() ) { - return; - } - $post = get_post(); - if ( ! $post || ! has_block( BLOCK_NAME, $post ) ) { - return; - } - \Newspack_Blocks::enqueue_view_assets( BLOCK_SLUG ); -} -add_action( 'wp_enqueue_scripts', __NAMESPACE__ . '\\enqueue_assets' ); - -/** - * Render the variation selector SSR shell. - * - * @param array $attrs Block attributes (currently none defined). - * @param string $content Inner content (unused). - * @param WP_Block $block Block instance with context. - * @return string Rendered HTML. - */ -function render_block( $attrs, $content, $block ) { - if ( ! function_exists( 'wc_get_product' ) ) { - return ''; - } - - $product_id = $block->context['newspack-blocks/fastCheckoutProductId'] ?? 0; - $product_id = (int) $product_id; - if ( ! $product_id ) { - return ''; - } - - $product = wc_get_product( $product_id ); - if ( ! $product || ! $product->is_type( 'variable' ) ) { - return ''; - } - - $variations = $product->get_available_variations(); - $current_variation = (int) ( $block->context['newspack-blocks/fastCheckoutVariationId'] ?? 0 ); - if ( count( $variations ) < 2 ) { - return ''; - } - // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - if ( isset( $_GET[ Fast_Checkout::QP_VARIATION ] ) ) { - // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - $qp_raw = wp_unslash( $_GET[ Fast_Checkout::QP_VARIATION ] ); - $qp = (int) filter_var( $qp_raw, FILTER_SANITIZE_NUMBER_INT ); - if ( $qp > 0 ) { - $current_variation = $qp; - } - } - - $attributes = $product->get_variation_attributes(); - if ( empty( $attributes ) ) { - return ''; - } - - $current_attrs = []; - if ( $current_variation && function_exists( 'wc_get_product_variation_attributes' ) ) { - $current_attrs = wc_get_product_variation_attributes( $current_variation ); - } - - $wrapper_attributes = get_block_wrapper_attributes( - [ - 'data-product-id' => (string) $product_id, - 'data-source-post' => (string) get_the_ID(), - 'data-current-variation' => (string) $current_variation, - 'data-variations' => wp_json_encode( - array_map( - function ( $v ) { - return [ - 'id' => $v['variation_id'], - 'attributes' => $v['attributes'], - 'is_in_stock' => $v['is_in_stock'], - ]; - }, - $variations - ) - ), - ] - ); - - // Map each (attribute_name, option_value) to is-available across in-stock variations. - $option_availability = []; - foreach ( $variations as $v ) { - if ( empty( $v['is_in_stock'] ) ) { - continue; - } - foreach ( $v['attributes'] as $key => $value ) { - // $key is e.g. "attribute_color"; strip the prefix to match $attribute_name iteration below. - $attr = preg_replace( '/^attribute_/', '', $key ); - $value = sanitize_title( $value ); - $option_availability[ $attr ][ $value ] = true; - } - } - - ob_start(); - ?> -
> - $options ) : ?> - -
- - - - - -
- - -
- elements, listens to radio changes, resolves - * to a variation ID once all attributes are picked, and swaps the cart - * line via the WC Store API cart store. - */ - -import { createRoot, useEffect, useMemo, useRef, useState } from '@wordpress/element'; -import { dispatch, select } from '@wordpress/data'; -import { __ } from '@wordpress/i18n'; -import { resolveVariationId } from './resolve'; -import type { VariationData } from './resolve'; -import './view.scss'; - -const STORE = 'wc/store/cart' as const; - -interface RootProps { - host: HTMLFormElement; - productId: number; - variations: VariationData[]; - currentVariationId: number; -} - -function readCurrentSelections( host: HTMLFormElement ): Record< string, string > { - const result: Record< string, string > = {}; - host.querySelectorAll< HTMLInputElement >( 'input[type="radio"]:checked' ).forEach( input => { - result[ input.name ] = input.value; - } ); - return result; -} - -function VariationSelector( { host, productId, variations, currentVariationId }: RootProps ) { - const [ pendingId, setPendingId ] = useState< number >( currentVariationId ); - const [ inFlight, setInFlight ] = useState( false ); - const [ error, setError ] = useState< string >( '' ); - // Track in-flight via ref so concurrent change events early-return without racing on state. - const inFlightRef = useRef< boolean >( false ); - // Remember which radios were SSR-disabled (out of stock) so we don't re-enable them later. - const ssrDisabled = useRef< Set< HTMLInputElement > | null >( null ); - - const noticeNode = useMemo< HTMLElement | null >( - () => host.querySelector( '.wp-block-newspack-blocks-fast-checkout-variation-selector__notice' ), - [ host ] - ); - - // Capture SSR-disabled radios on mount so disabled-state toggling preserves them. - useEffect( () => { - if ( ssrDisabled.current === null ) { - ssrDisabled.current = new Set(); - host.querySelectorAll< HTMLInputElement >( 'input[type="radio"][disabled]' ).forEach( input => { - ssrDisabled.current!.add( input ); - } ); - } - }, [ host ] ); - - useEffect( () => { - if ( noticeNode ) { - if ( error ) { - noticeNode.textContent = error; - noticeNode.removeAttribute( 'hidden' ); - } else { - noticeNode.textContent = ''; - noticeNode.setAttribute( 'hidden', '' ); - } - } - }, [ error, noticeNode ] ); - - useEffect( () => { - const onChange = async () => { - if ( inFlightRef.current ) { - return; - } - setError( '' ); - const selection = readCurrentSelections( host ); - const resolvedId = resolveVariationId( variations, selection ); - if ( ! resolvedId ) { - return; - } - // Verify the resolved variation is in stock before attempting cart swap. - const resolved = variations.find( v => v.id === resolvedId ); - if ( resolved && ! resolved.is_in_stock ) { - setError( __( 'That combination is out of stock.', 'newspack-blocks' ) ); - revertSelection( host, pendingId, variations ); - return; - } - if ( resolvedId === pendingId ) { - return; - } - inFlightRef.current = true; - setInFlight( true ); - try { - await swapCartItem( pendingId, resolvedId, parseInt( host.dataset.sourcePost || '0', 10 ) ); - setPendingId( resolvedId ); - updateUrlParam( 'fc_variation', String( resolvedId ) ); - } catch ( e: unknown ) { - setError( ( e as Error )?.message || __( 'Could not update selection.', 'newspack-blocks' ) ); - revertSelection( host, pendingId, variations ); - } finally { - inFlightRef.current = false; - setInFlight( false ); - } - }; - host.addEventListener( 'change', onChange ); - return () => host.removeEventListener( 'change', onChange ); - }, [ host, productId, variations, pendingId ] ); - - useEffect( () => { - const onPopstate = () => { - const params = new URLSearchParams( window.location.search ); - const next = parseInt( params.get( 'fc_variation' ) || '', 10 ); - if ( next && next !== pendingId ) { - const variation = variations.find( v => v.id === next ); - if ( variation ) { - setPendingId( next ); - applySelectionToDom( host, variation ); - } - } - }; - window.addEventListener( 'popstate', onPopstate ); - return () => window.removeEventListener( 'popstate', onPopstate ); - }, [ host, variations, pendingId ] ); - - useEffect( () => { - host.dataset.status = inFlight ? 'busy' : 'idle'; - // Toggle the swapping flag on the parent Fast Checkout block so a CSS - // overlay can mask the WC Checkout block during cart updates and hide - // the brief "Your cart is currently empty" flash. - const fastCheckout = host.closest< HTMLElement >( '.wp-block-newspack-blocks-fast-checkout' ); - if ( fastCheckout ) { - if ( inFlight ) { - fastCheckout.dataset.fcSwapping = 'true'; - } else { - delete fastCheckout.dataset.fcSwapping; - } - } - // Disable all non-SSR-disabled radios while in-flight; restore the original disabled state when idle. - host.querySelectorAll< HTMLInputElement >( 'input[type="radio"]' ).forEach( input => { - if ( ssrDisabled.current?.has( input ) ) { - input.disabled = true; - return; - } - input.disabled = inFlight; - } ); - }, [ host, inFlight ] ); - - return null; -} - -function applySelectionToDom( host: HTMLFormElement, variation: VariationData ) { - Object.entries( variation.attributes ).forEach( ( [ key, value ] ) => { - const radio = host.querySelector< HTMLInputElement >( `input[type="radio"][name="${ key }"][value="${ value }"]` ); - if ( radio ) { - radio.checked = true; - } - } ); -} - -function revertSelection( host: HTMLFormElement, currentId: number, variations: VariationData[] ) { - const variation = variations.find( v => v.id === currentId ); - if ( variation ) { - applySelectionToDom( host, variation ); - } -} - -async function swapCartItem( oldVariationId: number, newVariationId: number, sourcePost: number ) { - const cartActions = dispatch( STORE ); - const cartSelectors = select( STORE ); - const items = cartSelectors.getCartData()?.items || []; - // For variation cart items, item.id is the variation_id. - const existing = items.find( ( item: { id?: number; key?: string } ) => item.id === oldVariationId ); - - if ( existing ) { - await ( cartActions as { removeItemFromCart: ( key: string ) => Promise< unknown > } ).removeItemFromCart( - ( existing as { key: string } ).key - ); - } - - // The Store API accepts a variation ID directly as the cart item id; no need to spell out attributes. - const cartItemData: Record< string, unknown > = {}; - if ( sourcePost ) { - cartItemData._newspack_fast_checkout_source_post = sourcePost; - } - await ( - cartActions as { - addItemToCart: ( id: number, qty: number, variation?: unknown[], cartItemData?: Record< string, unknown > ) => Promise< unknown >; - } - ).addItemToCart( newVariationId, 1, [], cartItemData ); -} - -function updateUrlParam( key: string, value: string ) { - const url = new URL( window.location.href ); - url.searchParams.set( key, value ); - window.history.replaceState( {}, '', url.toString() ); -} - -function init() { - document.querySelectorAll< HTMLFormElement >( '.wp-block-newspack-blocks-fast-checkout-variation-selector' ).forEach( host => { - const productId = parseInt( host.dataset.productId || '0', 10 ); - let variations: VariationData[] = []; - try { - variations = JSON.parse( host.dataset.variations || '[]' ); - } catch { - variations = []; - } - if ( ! productId || ! variations.length ) { - return; - } - const currentVariationId = parseInt( host.dataset.currentVariation || '0', 10 ); - const sentinel = document.createElement( 'span' ); - sentinel.style.display = 'none'; - host.appendChild( sentinel ); - const root = createRoot( sentinel ); - root.render( - - ); - } ); -} - -if ( document.readyState === 'loading' ) { - document.addEventListener( 'DOMContentLoaded', init ); -} else { - init(); -} diff --git a/src/blocks/fast-checkout/edit.tsx b/src/blocks/fast-checkout/edit.tsx index f190de71b..f68c77810 100644 --- a/src/blocks/fast-checkout/edit.tsx +++ b/src/blocks/fast-checkout/edit.tsx @@ -6,7 +6,6 @@ import { __ } from '@wordpress/i18n'; import { useEffect, useRef, useState } from '@wordpress/element'; import apiFetch from '@wordpress/api-fetch'; import { debounce } from 'lodash'; -import { createBlock } from '@wordpress/blocks'; import { InnerBlocks, InspectorControls, useBlockProps } from '@wordpress/block-editor'; import { useSelect, useDispatch } from '@wordpress/data'; import { PanelBody, BaseControl, TextControl, Button, Spinner, FormTokenField, SelectControl, Placeholder, Notice } from '@wordpress/components'; @@ -246,7 +245,7 @@ export default function Edit( { attributes, setAttributes, clientId }: EditProps const { product, variation, is_variable: isVariable, afterSuccessURL } = attributes; const [ groupedWarning, setGroupedWarning ] = useState< string >( '' ); const blockProps = useBlockProps(); - const { updateBlockAttributes, insertBlocks, removeBlocks } = useDispatch( 'core/block-editor' ); + const { updateBlockAttributes, removeBlocks } = useDispatch( 'core/block-editor' ); // On first insert, find the checkout-actions-block and disable "Return to Cart". const allDescendants = useSelect( @@ -319,7 +318,9 @@ export default function Edit( { attributes, setAttributes, clientId }: EditProps n: !! attributes.is_nyp, } ); - // Auto-insert or auto-clean selector inner blocks when the product type changes. + // On product type transitions: clean up the donate-selector inner block + // when the product is no longer grouped, and reset stale type-specific + // attributes so they don't carry over into the new product context. useEffect( () => { const prev = previousFlags.current; const next = { @@ -328,50 +329,13 @@ export default function Edit( { attributes, setAttributes, clientId }: EditProps n: !! attributes.is_nyp, }; - const transitions: { type: 'v' | 'g' | 'n'; on: boolean }[] = []; - if ( prev.v !== next.v ) { - transitions.push( { type: 'v', on: next.v } ); - } - if ( prev.g !== next.g ) { - transitions.push( { type: 'g', on: next.g } ); - } - if ( prev.n !== next.n ) { - transitions.push( { type: 'n', on: next.n } ); - } - - if ( ! transitions.length ) { - previousFlags.current = next; + if ( prev.v === next.v && prev.g === next.g && prev.n === next.n ) { return; } - const slugFor: Record< 'v' | 'g' | 'n', string > = { - v: 'newspack-blocks/fast-checkout-variation-selector', - g: 'newspack-blocks/fast-checkout-grouped-selector', - n: 'newspack-blocks/fast-checkout-nyp-input', - }; - - const findClientIds = ( name: string ): string[] => innerBlocks.filter( ( b: Block ) => b.name === name ).map( ( b: Block ) => b.clientId ); - - const checkoutIndex = innerBlocks.findIndex( ( b: Block ) => b.name === 'woocommerce/checkout' ); - const insertIndex = checkoutIndex >= 0 ? checkoutIndex : innerBlocks.length; - - transitions.forEach( ( { type, on } ) => { - const slug = slugFor[ type ]; - if ( on ) { - if ( findClientIds( slug ).length === 0 ) { - insertBlocks( createBlock( slug ), insertIndex, clientId, false ); - } - } else { - const ids = findClientIds( slug ); - if ( ids.length ) { - removeBlocks( ids, false ); - } - } - } ); - previousFlags.current = next; - // Donate-selector is manually inserted; auto-clean it on grouped → not-grouped transition. + // Donate-selector is manually inserted; auto-clean on grouped → not-grouped. if ( prev.g && ! next.g ) { const donateIds = innerBlocks .filter( ( b: Block ) => b.name === 'newspack-blocks/fast-checkout-donate-selector' ) @@ -391,7 +355,7 @@ export default function Edit( { attributes, setAttributes, clientId }: EditProps if ( prev.n && ! next.n && attributes.nyp_price ) { setAttributes( { nyp_price: '' } ); } - }, [ attributes.is_variable, attributes.is_grouped, attributes.is_nyp, innerBlocks, insertBlocks, removeBlocks, clientId ] ); + }, [ attributes.is_variable, attributes.is_grouped, attributes.is_nyp, innerBlocks, removeBlocks ] ); if ( ! product ) { return ( diff --git a/tests/test-fast-checkout-selectors.php b/tests/test-fast-checkout-selectors.php deleted file mode 100644 index 8aa0235ae..000000000 --- a/tests/test-fast-checkout-selectors.php +++ /dev/null @@ -1,264 +0,0 @@ -markTestSkipped( 'WooCommerce is not available.' ); - } - } - - /** - * Create a variable product with two attributes (color, size). - * - * @return array { 'parent': WC_Product_Variable, 'variations': WC_Product_Variation[] } - */ - private function create_variable_product() { - $parent = new \WC_Product_Variable(); - $parent->set_name( 'Test Variable Product' ); - $parent->set_status( 'publish' ); - - $attribute_color = new \WC_Product_Attribute(); - $attribute_color->set_name( 'Color' ); - $attribute_color->set_options( [ 'Red', 'Blue' ] ); - $attribute_color->set_visible( true ); - $attribute_color->set_variation( true ); - - $attribute_size = new \WC_Product_Attribute(); - $attribute_size->set_name( 'Size' ); - $attribute_size->set_options( [ 'S', 'M' ] ); - $attribute_size->set_visible( true ); - $attribute_size->set_variation( true ); - - $parent->set_attributes( [ $attribute_color, $attribute_size ] ); - $parent->save(); - - $variations = []; - foreach ( [ - [ 'Red', 'S' ], - [ 'Red', 'M' ], - [ 'Blue', 'S' ], - [ 'Blue', 'M' ], - ] as [ $color, $size ] ) { - $v = new \WC_Product_Variation(); - $v->set_parent_id( $parent->get_id() ); - $v->set_attributes( [ 'color' => $color, 'size' => $size ] ); - $v->set_regular_price( '10.00' ); - $v->set_status( 'publish' ); - $v->save(); - $variations[] = $v; - } - - // Reload parent so it picks up the variations. - $parent = wc_get_product( $parent->get_id() ); - return [ 'parent' => $parent, 'variations' => $variations ]; - } - - /** - * Test that the variation-selector renders one fieldset per attribute. - */ - public function test_variation_selector_renders_attribute_fieldsets() { - $this->skip_without_wc(); - $fixture = $this->create_variable_product(); - - $block_html = sprintf( - ' -
- -
- ', - $fixture['parent']->get_id() - ); - - $rendered = do_blocks( $block_html ); - - $this->assertStringContainsString( 'assertStringContainsString( 'Color', $rendered ); - $this->assertStringContainsString( 'Size', $rendered ); - $this->assertStringContainsString( 'value="red"', $rendered ); - $this->assertStringContainsString( 'value="blue"', $rendered ); - $this->assertStringContainsString( 'value="s"', $rendered ); - $this->assertStringContainsString( 'value="m"', $rendered ); - } - - /** - * Test that the variation-selector pre-checks the editor's chosen variation. - */ - public function test_variation_selector_pre_checks_editor_variation() { - $this->skip_without_wc(); - $fixture = $this->create_variable_product(); - $blue_m = $fixture['variations'][3]; // [ 'Blue', 'M' ] - - $block_html = sprintf( - ' -
- -
- ', - $fixture['parent']->get_id(), - $blue_m->get_id() - ); - - $rendered = do_blocks( $block_html ); - - // Both 'blue' and 'm' radios should be checked. - $this->assertMatchesRegularExpression( '/value="blue"[^>]*checked/', $rendered ); - $this->assertMatchesRegularExpression( '/value="m"[^>]*checked/', $rendered ); - - // Negative assertions: the unchecked radios should not have `checked`. - $this->assertDoesNotMatchRegularExpression( '/value="red"[^>]*checked/', $rendered ); - $this->assertDoesNotMatchRegularExpression( '/value="s"[^>]*checked/', $rendered ); - } - - /** - * Create a grouped product with two children. - * - * @return array { 'parent': WC_Product_Grouped, 'children': WC_Product_Simple[] } - */ - private function create_grouped_product_fixture() { - $first = new \WC_Product_Simple(); - $first->set_name( 'Annual' ); - $first->set_regular_price( '50.00' ); - $first->set_status( 'publish' ); - $first->save(); - - $second = new \WC_Product_Simple(); - $second->set_name( 'Monthly' ); - $second->set_regular_price( '5.00' ); - $second->set_status( 'publish' ); - $second->save(); - - $parent = new \WC_Product_Grouped(); - $parent->set_name( 'Membership' ); - $parent->set_status( 'publish' ); - $parent->set_children( [ $first->get_id(), $second->get_id() ] ); - $parent->save(); - - return [ 'parent' => wc_get_product( $parent->get_id() ), 'children' => [ $first, $second ] ]; - } - - /** - * Test that the grouped-selector renders one radio per child. - */ - public function test_grouped_selector_renders_children() { - $this->skip_without_wc(); - $fixture = $this->create_grouped_product_fixture(); - - $block_html = sprintf( - ' -
- -
- ', - $fixture['parent']->get_id() - ); - - $rendered = do_blocks( $block_html ); - - $this->assertStringContainsString( 'Annual', $rendered ); - $this->assertStringContainsString( 'Monthly', $rendered ); - $this->assertSame( 2, preg_match_all( '/]+type="radio"/', $rendered ) ); - } - - /** - * Test that the grouped-selector pre-checks the editor's chosen child. - */ - public function test_grouped_selector_pre_checks_editor_child() { - $this->skip_without_wc(); - $fixture = $this->create_grouped_product_fixture(); - $monthly = $fixture['children'][1]; - - $block_html = sprintf( - ' -
- -
- ', - $fixture['parent']->get_id(), - $monthly->get_id() - ); - - $rendered = do_blocks( $block_html ); - - $this->assertMatchesRegularExpression( - '/value="' . $monthly->get_id() . '"[^>]*checked/', - $rendered - ); - } - - /** - * Test that the grouped-selector pre-checks the first child when none set. - */ - public function test_grouped_selector_falls_back_to_first_child() { - $this->skip_without_wc(); - $fixture = $this->create_grouped_product_fixture(); - $annual = $fixture['children'][0]; - - $block_html = sprintf( - ' -
- -
- ', - $fixture['parent']->get_id() - ); - - $rendered = do_blocks( $block_html ); - - $this->assertMatchesRegularExpression( - '/value="' . $annual->get_id() . '"[^>]*checked/', - $rendered - ); - } - - /** - * Test that the NYP input renders with min/max/suggested attributes when WC NYP is active. - */ - public function test_nyp_input_renders_constraints() { - $this->skip_without_wc(); - if ( ! class_exists( '\WC_Name_Your_Price_Helpers' ) ) { - $this->markTestSkipped( 'WC Name Your Price not available.' ); - } - - $product = new \WC_Product_Simple(); - $product->set_name( 'Donate' ); - $product->set_status( 'publish' ); - $product->save(); - update_post_meta( $product->get_id(), '_nyp', 'yes' ); - update_post_meta( $product->get_id(), '_min_price', '5' ); - update_post_meta( $product->get_id(), '_max_price', '500' ); - update_post_meta( $product->get_id(), '_suggested_price', '20' ); - - $block_html = sprintf( - ' -
- -
- ', - $product->get_id() - ); - - $rendered = do_blocks( $block_html ); - - $this->assertStringContainsString( 'data-min="5"', $rendered ); - $this->assertStringContainsString( 'data-max="500"', $rendered ); - $this->assertStringContainsString( 'data-suggested="20"', $rendered ); - $this->assertStringContainsString( 'min="5"', $rendered ); - $this->assertStringContainsString( 'max="500"', $rendered ); - $this->assertStringContainsString( 'value="20"', $rendered ); - } -}