From 306dc70b7b5448e146ef81b7fc483659e8f2c957 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 16 Apr 2026 11:36:47 -0300 Subject: [PATCH 01/18] feat(fast-checkout): scaffold Fast_Checkout class with block parser helpers --- includes/class-fast-checkout.php | 206 +++++++++++++++++++++++++++++++ newspack-blocks.php | 1 + tests/test-fast-checkout.php | 113 +++++++++++++++++ 3 files changed, 320 insertions(+) create mode 100644 includes/class-fast-checkout.php create mode 100644 tests/test-fast-checkout.php diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php new file mode 100644 index 000000000..353e1a1c9 --- /dev/null +++ b/includes/class-fast-checkout.php @@ -0,0 +1,206 @@ +ID ] ) ) { + return self::$post_product_cache[ $post->ID ]; + } + $blocks = parse_blocks( $post->post_content ); + $result = self::find_fast_checkout_block_product( $blocks ); + self::$post_product_cache[ $post->ID ] = $result; + return $result; + } + + /** + * Recursively search blocks for the first Fast Checkout block. + * + * @param array $blocks Parsed blocks. + * @return int|null Product ID or null. + */ + private static function find_fast_checkout_block_product( $blocks ) { + foreach ( $blocks as $block ) { + if ( self::BLOCK_NAME === $block['blockName'] ) { + return self::resolve_product_id_from_attrs( $block['attrs'] ?? [] ); + } + if ( ! empty( $block['innerBlocks'] ) ) { + $found = self::find_fast_checkout_block_product( $block['innerBlocks'] ); + if ( null !== $found ) { + return $found; + } + } + } + return null; + } + + /** + * Register the block bindings source. + */ + public static function register_bindings_source() { + // Stub. + } + + /** + * Mark the current page as non-cacheable when a Fast Checkout block is present. + */ + public static function mark_page_noncacheable() { + // Stub. + } + + /** + * Replace the WooCommerce cart contents with the block's product. + */ + public static function maybe_replace_cart() { + // Stub. + } + + /** + * Filter the rendered output of the Fast Checkout block. + * + * @param string $content Rendered block content. + * @param array $block Block data including attrs. + * @return string Filtered content. + */ + public static function filter_render( $content, $block ) { + return $content; + } + + /** + * Override the WooCommerce return URL for orders placed via Fast Checkout. + * + * @param string $url Default return URL. + * @param \WC_Order $order The order. + * @return string Possibly overridden URL. + */ + public static function maybe_override_return_url( $url, $order ) { + return $url; + } + + /** + * Attach source post meta to order line items. + * + * @param \WC_Order_Item_Product $item The line item. + * @param string $cart_item_key Cart item key. + * @param array $values Cart item values. + * @param \WC_Order $order The order. + */ + public static function attach_line_item_meta( $item, $cart_item_key, $values, $order ) { + // Stub. + } + + /** + * Add product context keys to core block metadata. + * + * @param array $metadata Block type metadata. + * @return array Filtered metadata. + */ + public static function add_context_to_core_blocks( $metadata ) { + return $metadata; + } +} + +Fast_Checkout::init(); diff --git a/newspack-blocks.php b/newspack-blocks.php index 6c7b83e56..baa04a5bd 100755 --- a/newspack-blocks.php +++ b/newspack-blocks.php @@ -24,6 +24,7 @@ require_once NEWSPACK_BLOCKS__PLUGIN_DIR . 'includes/modal-checkout/class-checkout-data.php'; require_once NEWSPACK_BLOCKS__PLUGIN_DIR . 'includes/modal-checkout/class-change-payment-gateway.php'; require_once NEWSPACK_BLOCKS__PLUGIN_DIR . 'includes/class-modal-checkout.php'; +require_once NEWSPACK_BLOCKS__PLUGIN_DIR . 'includes/class-fast-checkout.php'; require_once NEWSPACK_BLOCKS__PLUGIN_DIR . 'includes/plugins/class-the-events-calendar.php'; diff --git a/tests/test-fast-checkout.php b/tests/test-fast-checkout.php new file mode 100644 index 000000000..f19829a80 --- /dev/null +++ b/tests/test-fast-checkout.php @@ -0,0 +1,113 @@ + '42' ] ); + $this->assertSame( 42, $result ); + } + + /** + * Test that a variable product with variation resolves to the variation ID. + */ + public function test_resolve_variable_prefers_variation() { + $result = Fast_Checkout::resolve_product_id_from_attrs( + [ + 'product' => '42', + 'variation' => '99', + 'is_variable' => true, + ] + ); + $this->assertSame( 99, $result ); + } + + /** + * Test that a variable product without variation falls back to product ID. + */ + public function test_resolve_variable_without_variation_falls_back() { + $result = Fast_Checkout::resolve_product_id_from_attrs( + [ + 'product' => '42', + 'is_variable' => true, + ] + ); + $this->assertSame( 42, $result ); + } + + /** + * Test that missing attributes return null. + */ + public function test_resolve_missing_returns_null() { + $result = Fast_Checkout::resolve_product_id_from_attrs( [] ); + $this->assertNull( $result ); + } + + /** + * Test extracting product ID from a top-level Fast Checkout block. + */ + public function test_get_block_product_id_top_level() { + $post_id = self::factory()->post->create( + [ + 'post_content' => '', + ] + ); + $post = get_post( $post_id ); + $result = Fast_Checkout::get_block_product_id( $post ); + $this->assertSame( 55, $result ); + } + + /** + * Test extracting product ID from a Fast Checkout block nested in columns. + */ + public function test_get_block_product_id_nested_in_columns() { + $content = '
'; + $post_id = self::factory()->post->create( [ 'post_content' => $content ] ); + $post = get_post( $post_id ); + $result = Fast_Checkout::get_block_product_id( $post ); + $this->assertSame( 77, $result ); + } + + /** + * Test that a post without a Fast Checkout block returns null. + */ + public function test_get_block_product_id_absent() { + $post_id = self::factory()->post->create( + [ + 'post_content' => '

Hello

', + ] + ); + $post = get_post( $post_id ); + $result = Fast_Checkout::get_block_product_id( $post ); + $this->assertNull( $result ); + } + + /** + * Test that null post returns null. + */ + public function test_get_block_product_id_handles_null_post() { + $result = Fast_Checkout::get_block_product_id( null ); + $this->assertNull( $result ); + } +} From 14616508f31b6d9d9c4eb91a70049c188ee2ac9e Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 16 Apr 2026 11:37:51 -0300 Subject: [PATCH 02/18] feat(fast-checkout): idempotent cart replacement on wp hook --- includes/class-fast-checkout.php | 58 ++++++++++++++- tests/test-fast-checkout.php | 119 +++++++++++++++++++++++++++++++ 2 files changed, 176 insertions(+), 1 deletion(-) diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php index 353e1a1c9..74880431f 100644 --- a/includes/class-fast-checkout.php +++ b/includes/class-fast-checkout.php @@ -155,7 +155,63 @@ public static function mark_page_noncacheable() { * Replace the WooCommerce cart contents with the block's product. */ public static function maybe_replace_cart() { - // Stub. + if ( is_admin() && ! wp_doing_ajax() ) { + return; + } + if ( ! function_exists( 'WC' ) || ! is_singular() ) { + return; + } + $post = get_post(); + if ( ! $post || ! has_block( self::BLOCK_NAME, $post ) ) { + return; + } + $product_id = self::get_block_product_id( $post ); + if ( ! $product_id ) { + return; + } + $product = wc_get_product( $product_id ); + if ( ! $product || ! $product->is_purchasable() ) { + return; + } + $cart = WC()->cart; + if ( ! $cart ) { + return; + } + + // Idempotency: if the cart already has exactly the right item, do nothing. + $cart_contents = $cart->get_cart(); + if ( 1 === count( $cart_contents ) ) { + $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 && 1 === (int) $item['quantity'] ) { + return; + } + } + + // Build cart item data. + $cart_item_data = [ + self::CART_ITEM_SOURCE_KEY => $post->ID, + ]; + + /** + * Filter the cart item data added by Fast Checkout. + * + * @param array $cart_item_data Cart item data. + * @param int $product_id Resolved product or variation ID. + * @param \WP_Post $post The source post. + */ + $cart_item_data = apply_filters( 'newspack_blocks_fast_checkout_cart_item_data', $cart_item_data, $product_id, $post ); + + $cart->empty_cart(); + + if ( $product->is_type( 'variation' ) ) { + $parent_id = $product->get_parent_id(); + $cart->add_to_cart( $parent_id, 1, $product_id, [], $cart_item_data ); + } else { + $cart->add_to_cart( $product_id, 1, 0, [], $cart_item_data ); + } } /** diff --git a/tests/test-fast-checkout.php b/tests/test-fast-checkout.php index f19829a80..40b69631d 100644 --- a/tests/test-fast-checkout.php +++ b/tests/test-fast-checkout.php @@ -110,4 +110,123 @@ public function test_get_block_product_id_handles_null_post() { $result = Fast_Checkout::get_block_product_id( null ); $this->assertNull( $result ); } + + // ---- Cart replacement tests (WC-dependent) ---- + + /** + * Skip the current test if WooCommerce is not available. + */ + private function skip_without_wc() { + if ( ! function_exists( 'WC' ) || ! class_exists( 'WC_Product_Simple' ) ) { + $this->markTestSkipped( 'WooCommerce is not available.' ); + } + } + + /** + * Create a post containing a Fast Checkout block for the given product ID. + * + * @param int $product_id Product ID. + * @param array $extra_attrs Additional block attributes. + * @return int Post ID. + */ + private function make_fast_checkout_post( $product_id, $extra_attrs = [] ) { + $attrs = array_merge( [ 'product' => (string) $product_id ], $extra_attrs ); + $json = wp_json_encode( $attrs ); + $content = sprintf( '', $json ); + return self::factory()->post->create( [ 'post_content' => $content ] ); + } + + /** + * Create a simple WC product. + * + * @return \WC_Product_Simple + */ + private function create_simple_product() { + $product = new \WC_Product_Simple(); + $product->set_name( 'Test Product' ); + $product->set_regular_price( '10.00' ); + $product->set_status( 'publish' ); + $product->save(); + return $product; + } + + /** + * Test that maybe_replace_cart does nothing when no block is present. + */ + public function test_maybe_replace_cart_noop_without_block() { + $this->skip_without_wc(); + + $post_id = self::factory()->post->create( + [ 'post_content' => '

No block here

' ] + ); + $this->go_to( get_permalink( $post_id ) ); + Fast_Checkout::maybe_replace_cart(); + $this->assertCount( 0, WC()->cart->get_cart() ); + } + + /** + * Test that maybe_replace_cart adds the product to the cart. + */ + public function test_maybe_replace_cart_adds_product() { + $this->skip_without_wc(); + + $product = $this->create_simple_product(); + $post_id = $this->make_fast_checkout_post( $product->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( $product->get_id(), (int) $item['product_id'] ); + $this->assertSame( $post_id, $item[ Fast_Checkout::CART_ITEM_SOURCE_KEY ] ); + } + + /** + * Test that calling maybe_replace_cart twice is idempotent. + */ + public function test_maybe_replace_cart_is_idempotent() { + $this->skip_without_wc(); + + $product = $this->create_simple_product(); + $post_id = $this->make_fast_checkout_post( $product->get_id() ); + $this->go_to( get_permalink( $post_id ) ); + + Fast_Checkout::maybe_replace_cart(); + $keys_first = array_keys( WC()->cart->get_cart() ); + + Fast_Checkout::reset_cache(); + Fast_Checkout::maybe_replace_cart(); + $keys_second = array_keys( WC()->cart->get_cart() ); + + $this->assertSame( $keys_first, $keys_second ); + } + + /** + * Test that a mismatched product in the cart is replaced. + */ + public function test_maybe_replace_cart_replaces_mismatched() { + $this->skip_without_wc(); + + $product_a = $this->create_simple_product(); + $product_b = $this->create_simple_product(); + + // Add product A to cart first. + WC()->cart->add_to_cart( $product_a->get_id() ); + $this->assertCount( 1, WC()->cart->get_cart() ); + + // Post references product B. + $post_id = $this->make_fast_checkout_post( $product_b->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( $product_b->get_id(), (int) $item['product_id'] ); + } } From 0d5a358d6af05030d2ef33a1644d1e7ea4b2c01e Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 16 Apr 2026 11:40:54 -0300 Subject: [PATCH 03/18] feat(fast-checkout): register PHP block bindings source for product fields --- includes/class-fast-checkout.php | 59 +++++++++++++++++++++++++++++- tests/test-fast-checkout.php | 62 ++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php index 74880431f..518e5e5e6 100644 --- a/includes/class-fast-checkout.php +++ b/includes/class-fast-checkout.php @@ -141,7 +141,64 @@ private static function find_fast_checkout_block_product( $blocks ) { * Register the block bindings source. */ public static function register_bindings_source() { - // Stub. + if ( ! function_exists( 'register_block_bindings_source' ) ) { + return; + } + register_block_bindings_source( + self::BINDINGS_SOURCE, + [ + 'label' => __( 'Fast Checkout Product', 'newspack-blocks' ), + 'get_value_callback' => [ __CLASS__, 'bindings_get_value' ], + 'uses_context' => [ self::CONTEXT_PRODUCT_KEY, self::CONTEXT_VARIATION_KEY ], + ] + ); + } + + /** + * Resolve a product field value for block bindings. + * + * @param array $source_args Source arguments including 'field'. + * @param object $block The block instance with context. + * @param string $attribute_name The bound attribute name. + * @return string Resolved value or empty string. + */ + public static function bindings_get_value( $source_args, $block, $attribute_name ) { + $field = $source_args['field'] ?? ''; + $product_id = $block->context[ self::CONTEXT_PRODUCT_KEY ] ?? 0; + $variation_id = $block->context[ self::CONTEXT_VARIATION_KEY ] ?? 0; + + // Variation takes precedence. + $resolved_id = $variation_id ? (int) $variation_id : (int) $product_id; + if ( ! $resolved_id ) { + return ''; + } + + if ( ! function_exists( 'wc_get_product' ) ) { + return ''; + } + + $product = wc_get_product( $resolved_id ); + if ( ! $product ) { + return ''; + } + + switch ( $field ) { + case 'title': + return $product->get_name(); + case 'short_description': + return $product->get_short_description(); + case 'price': + return wc_price( $product->get_price() ); + case 'price_raw': + return (string) $product->get_price(); + case 'image_url': + $image_url = wp_get_attachment_image_url( $product->get_image_id(), 'large' ); + return $image_url ? $image_url : ''; + case 'url': + return get_permalink( $product->get_id() ); + default: + return ''; + } } /** diff --git a/tests/test-fast-checkout.php b/tests/test-fast-checkout.php index 40b69631d..d6ba58107 100644 --- a/tests/test-fast-checkout.php +++ b/tests/test-fast-checkout.php @@ -204,6 +204,68 @@ public function test_maybe_replace_cart_is_idempotent() { $this->assertSame( $keys_first, $keys_second ); } + // ---- Bindings source tests ---- + + /** + * Test that the bindings source is registered. + */ + public function test_bindings_source_is_registered() { + if ( ! class_exists( 'WP_Block_Bindings_Registry' ) ) { + $this->markTestSkipped( 'WP_Block_Bindings_Registry not available.' ); + } + $registry = WP_Block_Bindings_Registry::get_instance(); + $source = $registry->get_registered( Fast_Checkout::BINDINGS_SOURCE ); + $this->assertNotNull( $source, 'Bindings source should be registered.' ); + } + + /** + * Test that the title field resolves to the product name. + */ + public function test_bindings_title_resolves() { + $this->skip_without_wc(); + + $product = $this->create_simple_product(); + $block = (object) [ + 'context' => [ + Fast_Checkout::CONTEXT_PRODUCT_KEY => $product->get_id(), + Fast_Checkout::CONTEXT_VARIATION_KEY => 0, + ], + ]; + $result = Fast_Checkout::bindings_get_value( [ 'field' => 'title' ], $block, 'content' ); + $this->assertSame( $product->get_name(), $result ); + } + + /** + * Test that a missing product returns an empty string. + */ + public function test_bindings_missing_product_returns_empty_string() { + $block = (object) [ + 'context' => [ + Fast_Checkout::CONTEXT_PRODUCT_KEY => 0, + Fast_Checkout::CONTEXT_VARIATION_KEY => 0, + ], + ]; + $result = Fast_Checkout::bindings_get_value( [ 'field' => 'title' ], $block, 'content' ); + $this->assertSame( '', $result ); + } + + /** + * Test that an unknown field returns an empty string. + */ + public function test_bindings_unknown_field_returns_empty_string() { + $this->skip_without_wc(); + + $product = $this->create_simple_product(); + $block = (object) [ + 'context' => [ + Fast_Checkout::CONTEXT_PRODUCT_KEY => $product->get_id(), + Fast_Checkout::CONTEXT_VARIATION_KEY => 0, + ], + ]; + $result = Fast_Checkout::bindings_get_value( [ 'field' => 'nonexistent' ], $block, 'content' ); + $this->assertSame( '', $result ); + } + /** * Test that a mismatched product in the cart is replaced. */ From dc83f2c7d703f9974a216ba7bb6a934c98443eb2 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 16 Apr 2026 11:41:32 -0300 Subject: [PATCH 04/18] feat(fast-checkout): render unavailable state when product is missing or not purchasable --- includes/class-fast-checkout.php | 14 ++++++++++++- tests/test-fast-checkout.php | 36 ++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php index 518e5e5e6..277d86aa8 100644 --- a/includes/class-fast-checkout.php +++ b/includes/class-fast-checkout.php @@ -279,7 +279,19 @@ public static function maybe_replace_cart() { * @return string Filtered content. */ public static function filter_render( $content, $block ) { - return $content; + $attrs = $block['attrs'] ?? []; + $product_id = self::resolve_product_id_from_attrs( $attrs ); + + if ( $product_id && function_exists( 'wc_get_product' ) ) { + $product = wc_get_product( $product_id ); + if ( $product && $product->is_purchasable() ) { + return $content; + } + } + + return '
' + . esc_html__( 'This product is no longer available.', 'newspack-blocks' ) + . '
'; } /** diff --git a/tests/test-fast-checkout.php b/tests/test-fast-checkout.php index d6ba58107..f318ca31b 100644 --- a/tests/test-fast-checkout.php +++ b/tests/test-fast-checkout.php @@ -111,6 +111,42 @@ public function test_get_block_product_id_handles_null_post() { $this->assertNull( $result ); } + // ---- Render filter tests ---- + + /** + * Test that filter_render passes through when product is valid. + */ + public function test_filter_render_passes_through_when_product_valid() { + $this->skip_without_wc(); + + $product = $this->create_simple_product(); + $content = '
Buy now
'; + $block = [ 'attrs' => [ 'product' => (string) $product->get_id() ] ]; + $filtered = Fast_Checkout::filter_render( $content, $block ); + $this->assertSame( $content, $filtered ); + } + + /** + * Test that filter_render shows unavailable notice for missing product. + */ + public function test_filter_render_replaces_when_product_missing() { + $content = '
Buy now
'; + $block = [ 'attrs' => [ 'product' => '999999' ] ]; + $filtered = Fast_Checkout::filter_render( $content, $block ); + $this->assertStringContainsString( 'unavailable', $filtered ); + $this->assertStringNotContainsString( 'Buy now', $filtered ); + } + + /** + * Test that filter_render shows unavailable notice when no product ID. + */ + public function test_filter_render_replaces_when_no_product_id() { + $content = '
Buy now
'; + $block = [ 'attrs' => [] ]; + $filtered = Fast_Checkout::filter_render( $content, $block ); + $this->assertStringContainsString( 'unavailable', $filtered ); + } + // ---- Cart replacement tests (WC-dependent) ---- /** From 1a96e30286adb1dac201f748efd3c33956a4bdce Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 16 Apr 2026 11:43:08 -0300 Subject: [PATCH 05/18] feat(fast-checkout): post-purchase redirect via cart item meta and afterSuccessURL --- includes/class-fast-checkout.php | 60 +++++++++++++++++++++- tests/test-fast-checkout.php | 85 ++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php index 277d86aa8..56b5b8cc5 100644 --- a/includes/class-fast-checkout.php +++ b/includes/class-fast-checkout.php @@ -302,9 +302,62 @@ public static function filter_render( $content, $block ) { * @return string Possibly overridden URL. */ public static function maybe_override_return_url( $url, $order ) { + if ( ! is_object( $order ) || ! method_exists( $order, 'get_items' ) ) { + return $url; + } + foreach ( $order->get_items() as $item ) { + $source_post_id = $item->get_meta( self::CART_ITEM_SOURCE_KEY ); + if ( ! $source_post_id ) { + continue; + } + $custom_url = self::get_after_success_url( (int) $source_post_id ); + if ( $custom_url ) { + return $custom_url; + } + } return $url; } + /** + * Get the afterSuccessURL from a Fast Checkout block in a post. + * + * @param int $post_id Post ID. + * @return string Custom URL or empty string. + */ + private static function get_after_success_url( $post_id ) { + $post = get_post( $post_id ); + if ( ! $post ) { + return ''; + } + $blocks = parse_blocks( $post->post_content ); + $block = self::find_fast_checkout_block( $blocks ); + if ( ! $block ) { + return ''; + } + return $block['attrs']['afterSuccessURL'] ?? ''; + } + + /** + * Recursively find the first Fast Checkout block. + * + * @param array $blocks Parsed blocks. + * @return array|null Block array or null. + */ + private static function find_fast_checkout_block( $blocks ) { + foreach ( $blocks as $block ) { + if ( self::BLOCK_NAME === $block['blockName'] ) { + return $block; + } + if ( ! empty( $block['innerBlocks'] ) ) { + $found = self::find_fast_checkout_block( $block['innerBlocks'] ); + if ( $found ) { + return $found; + } + } + } + return null; + } + /** * Attach source post meta to order line items. * @@ -314,7 +367,12 @@ public static function maybe_override_return_url( $url, $order ) { * @param \WC_Order $order The order. */ public static function attach_line_item_meta( $item, $cart_item_key, $values, $order ) { - // Stub. + if ( ! isset( $values[ self::CART_ITEM_SOURCE_KEY ] ) ) { + return; + } + if ( is_object( $item ) && method_exists( $item, 'add_meta_data' ) ) { + $item->add_meta_data( self::CART_ITEM_SOURCE_KEY, (int) $values[ self::CART_ITEM_SOURCE_KEY ], true ); + } } /** diff --git a/tests/test-fast-checkout.php b/tests/test-fast-checkout.php index f318ca31b..274075a86 100644 --- a/tests/test-fast-checkout.php +++ b/tests/test-fast-checkout.php @@ -302,6 +302,91 @@ public function test_bindings_unknown_field_returns_empty_string() { $this->assertSame( '', $result ); } + // ---- Post-purchase redirect tests (WC-dependent) ---- + + /** + * Test that attach_line_item_meta copies the source post ID. + */ + public function test_attach_line_item_meta_copies_source_post() { + $this->skip_without_wc(); + + $item = new \WC_Order_Item_Product(); + $values = [ Fast_Checkout::CART_ITEM_SOURCE_KEY => 123 ]; + Fast_Checkout::attach_line_item_meta( $item, 'key', $values, null ); + $this->assertSame( 123, $item->get_meta( Fast_Checkout::CART_ITEM_SOURCE_KEY ) ); + } + + /** + * Test that attach_line_item_meta is a no-op without source key. + */ + public function test_attach_line_item_meta_noop_without_source() { + $this->skip_without_wc(); + + $item = new \WC_Order_Item_Product(); + Fast_Checkout::attach_line_item_meta( $item, 'key', [], null ); + $this->assertSame( '', $item->get_meta( Fast_Checkout::CART_ITEM_SOURCE_KEY ) ); + } + + /** + * Test that return URL is overridden when order has source post with custom URL. + */ + public function test_return_url_override_uses_custom_url() { + $this->skip_without_wc(); + + $product = $this->create_simple_product(); + $post_id = $this->make_fast_checkout_post( + $product->get_id(), + [ 'afterSuccessURL' => 'https://example.com/thank-you' ] + ); + + $order = wc_create_order(); + $item = new \WC_Order_Item_Product(); + $item->set_product( $product ); + $item->add_meta_data( Fast_Checkout::CART_ITEM_SOURCE_KEY, $post_id, true ); + $order->add_item( $item ); + $order->save(); + + $result = Fast_Checkout::maybe_override_return_url( 'https://default.com', $order ); + $this->assertSame( 'https://example.com/thank-you', $result ); + } + + /** + * Test that return URL passes through when block has no afterSuccessURL. + */ + public function test_return_url_override_passes_through_when_no_custom_url() { + $this->skip_without_wc(); + + $product = $this->create_simple_product(); + $post_id = $this->make_fast_checkout_post( $product->get_id() ); + + $order = wc_create_order(); + $item = new \WC_Order_Item_Product(); + $item->set_product( $product ); + $item->add_meta_data( Fast_Checkout::CART_ITEM_SOURCE_KEY, $post_id, true ); + $order->add_item( $item ); + $order->save(); + + $result = Fast_Checkout::maybe_override_return_url( 'https://default.com', $order ); + $this->assertSame( 'https://default.com', $result ); + } + + /** + * Test that return URL passes through for unrelated orders. + */ + public function test_return_url_override_ignores_unrelated_orders() { + $this->skip_without_wc(); + + $product = $this->create_simple_product(); + $order = wc_create_order(); + $item = new \WC_Order_Item_Product(); + $item->set_product( $product ); + $order->add_item( $item ); + $order->save(); + + $result = Fast_Checkout::maybe_override_return_url( 'https://default.com', $order ); + $this->assertSame( 'https://default.com', $result ); + } + /** * Test that a mismatched product in the cart is replaced. */ From 13448bba40afd50f1c283941b7f9d6e3d9ae4ffd Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 16 Apr 2026 11:43:33 -0300 Subject: [PATCH 06/18] feat(fast-checkout): mark landing pages non-cacheable --- includes/class-fast-checkout.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php index 56b5b8cc5..e964b41a3 100644 --- a/includes/class-fast-checkout.php +++ b/includes/class-fast-checkout.php @@ -205,7 +205,17 @@ public static function bindings_get_value( $source_args, $block, $attribute_name * Mark the current page as non-cacheable when a Fast Checkout block is present. */ public static function mark_page_noncacheable() { - // Stub. + if ( is_admin() || ! is_singular() ) { + return; + } + $post = get_post(); + if ( ! $post || ! has_block( self::BLOCK_NAME, $post ) ) { + return; + } + if ( ! defined( 'DONOTCACHEPAGE' ) ) { + define( 'DONOTCACHEPAGE', true ); + } + nocache_headers(); } /** From 96d579aa2dd110db7415a56551320e7bce0739a6 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 16 Apr 2026 11:49:37 -0300 Subject: [PATCH 07/18] feat(fast-checkout): expose product context to core heading/image/paragraph blocks --- includes/class-fast-checkout.php | 14 ++++++++++++ tests/test-fast-checkout.php | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php index e964b41a3..ecf1ca842 100644 --- a/includes/class-fast-checkout.php +++ b/includes/class-fast-checkout.php @@ -392,6 +392,20 @@ public static function attach_line_item_meta( $item, $cart_item_key, $values, $o * @return array Filtered metadata. */ public static function add_context_to_core_blocks( $metadata ) { + if ( ! is_array( $metadata ) || empty( $metadata['name'] ) ) { + return $metadata; + } + if ( ! in_array( $metadata['name'], self::CORE_CONTEXT_BLOCKS, true ) ) { + return $metadata; + } + $existing = isset( $metadata['usesContext'] ) && is_array( $metadata['usesContext'] ) + ? $metadata['usesContext'] + : []; + $metadata['usesContext'] = array_values( + array_unique( + array_merge( $existing, [ self::CONTEXT_PRODUCT_KEY, self::CONTEXT_VARIATION_KEY ] ) + ) + ); return $metadata; } } diff --git a/tests/test-fast-checkout.php b/tests/test-fast-checkout.php index 274075a86..dc76a1b22 100644 --- a/tests/test-fast-checkout.php +++ b/tests/test-fast-checkout.php @@ -111,6 +111,45 @@ public function test_get_block_product_id_handles_null_post() { $this->assertNull( $result ); } + // ---- Core block context opt-in tests ---- + + /** + * Test that core/heading gets both context keys, preserving existing ones. + */ + public function test_add_context_to_core_blocks_extends_heading() { + $metadata = [ + 'name' => 'core/heading', + 'usesContext' => [ 'postId' ], + ]; + $result = Fast_Checkout::add_context_to_core_blocks( $metadata ); + $this->assertContains( 'postId', $result['usesContext'] ); + $this->assertContains( Fast_Checkout::CONTEXT_PRODUCT_KEY, $result['usesContext'] ); + $this->assertContains( Fast_Checkout::CONTEXT_VARIATION_KEY, $result['usesContext'] ); + } + + /** + * Test that unrelated blocks are not modified. + */ + public function test_add_context_to_core_blocks_skips_unrelated() { + $metadata = [ + 'name' => 'core/button', + 'usesContext' => [ 'postId' ], + ]; + $result = Fast_Checkout::add_context_to_core_blocks( $metadata ); + $this->assertSame( [ 'postId' ], $result['usesContext'] ); + } + + /** + * Test that blocks without usesContext get context keys added. + */ + public function test_add_context_to_core_blocks_handles_missing_uses_context() { + $metadata = [ 'name' => 'core/image' ]; + $result = Fast_Checkout::add_context_to_core_blocks( $metadata ); + $this->assertContains( Fast_Checkout::CONTEXT_PRODUCT_KEY, $result['usesContext'] ); + $this->assertContains( Fast_Checkout::CONTEXT_VARIATION_KEY, $result['usesContext'] ); + $this->assertCount( 2, $result['usesContext'] ); + } + // ---- Render filter tests ---- /** From a329f2f4eeb4290f93fc1fdb199591dff59a6897 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 16 Apr 2026 11:50:57 -0300 Subject: [PATCH 08/18] feat(fast-checkout): scaffold block.json and view.php --- src/blocks/fast-checkout/block.json | 44 +++++++++++++++++++++++++++++ src/blocks/fast-checkout/view.php | 31 ++++++++++++++++++++ src/blocks/fast-checkout/view.scss | 10 +++++++ 3 files changed, 85 insertions(+) create mode 100644 src/blocks/fast-checkout/block.json create mode 100644 src/blocks/fast-checkout/view.php create mode 100644 src/blocks/fast-checkout/view.scss diff --git a/src/blocks/fast-checkout/block.json b/src/blocks/fast-checkout/block.json new file mode 100644 index 000000000..18c347fc3 --- /dev/null +++ b/src/blocks/fast-checkout/block.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "newspack-blocks/fast-checkout", + "title": "Fast Checkout", + "category": "newspack", + "description": "Single-product checkout landing page wrapping the WooCommerce Checkout block.", + "keywords": [ "woocommerce", "checkout", "product", "landing", "fast" ], + "textdomain": "newspack-blocks", + "attributes": { + "product": { "type": "string" }, + "variation": { "type": "string" }, + "is_variable": { "type": "boolean", "default": false }, + "afterSuccessURL": { "type": "string" } + }, + "providesContext": { + "newspack-blocks/fastCheckoutProductId": "product", + "newspack-blocks/fastCheckoutVariationId": "variation" + }, + "supports": { + "anchor": true, + "align": [ "wide", "full" ], + "html": false, + "color": { + "gradients": true, + "__experimentalDefaultControls": { "background": true, "text": true } + }, + "typography": { + "fontSize": true, + "lineHeight": true + }, + "spacing": { + "padding": true, + "margin": true, + "blockGap": true + }, + "layout": { + "allowSwitching": false, + "default": { "type": "constrained" } + } + }, + "editorStyle": "newspack-blocks-editor", + "style": "newspack-blocks-fast-checkout" +} diff --git a/src/blocks/fast-checkout/view.php b/src/blocks/fast-checkout/view.php new file mode 100644 index 000000000..7b09b6db2 --- /dev/null +++ b/src/blocks/fast-checkout/view.php @@ -0,0 +1,31 @@ + Date: Thu, 16 Apr 2026 11:53:55 -0300 Subject: [PATCH 09/18] feat(fast-checkout): editor components, bindings source, and block list entry --- block-list.json | 1 + src/blocks/fast-checkout/bindings-source.js | 103 ++++++++++++ src/blocks/fast-checkout/edit.js | 169 ++++++++++++++++++++ src/blocks/fast-checkout/editor.js | 11 ++ src/blocks/fast-checkout/editor.scss | 3 + src/blocks/fast-checkout/index.js | 28 ++++ src/blocks/fast-checkout/template.js | 58 +++++++ src/blocks/fast-checkout/use-context.js | 24 +++ 8 files changed, 397 insertions(+) create mode 100644 src/blocks/fast-checkout/bindings-source.js create mode 100644 src/blocks/fast-checkout/edit.js create mode 100644 src/blocks/fast-checkout/editor.js create mode 100644 src/blocks/fast-checkout/editor.scss create mode 100644 src/blocks/fast-checkout/index.js create mode 100644 src/blocks/fast-checkout/template.js create mode 100644 src/blocks/fast-checkout/use-context.js diff --git a/block-list.json b/block-list.json index 9541a4d47..39f9015bc 100644 --- a/block-list.json +++ b/block-list.json @@ -5,6 +5,7 @@ "carousel", "checkout-button", "donate", + "fast-checkout", "homepage-articles", "video-playlist", "iframe" diff --git a/src/blocks/fast-checkout/bindings-source.js b/src/blocks/fast-checkout/bindings-source.js new file mode 100644 index 000000000..71134afc8 --- /dev/null +++ b/src/blocks/fast-checkout/bindings-source.js @@ -0,0 +1,103 @@ +/** + * Fast Checkout block bindings source — editor side. + * + * Registers a bindings source that resolves fields (title, image_url, etc.) + * from the product whose ID is provided by Fast Checkout block context. + */ + +import { registerBlockBindingsSource } from '@wordpress/blocks'; +import { __ } from '@wordpress/i18n'; +import apiFetch from '@wordpress/api-fetch'; + +const SOURCE_NAME = 'newspack-blocks/fast-checkout-product'; +const PRODUCT_CONTEXT_KEY = 'newspack-blocks/fastCheckoutProductId'; +const VARIATION_CONTEXT_KEY = 'newspack-blocks/fastCheckoutVariationId'; + +const productCache = new Map(); +const pendingFetches = new Map(); + +/** + * Fetch a product from the WooCommerce Store API and cache it. + * + * @param {number|string} productId + * @return {Promise} Cached or fetched product record. + */ +export function fetchProduct( productId ) { + const id = parseInt( productId, 10 ); + if ( ! id ) { + return Promise.resolve( null ); + } + if ( productCache.has( id ) ) { + return Promise.resolve( productCache.get( id ) ); + } + if ( pendingFetches.has( id ) ) { + return pendingFetches.get( id ); + } + const promise = apiFetch( { path: `/wc/store/v1/products/${ id }` } ) + .then( product => { + productCache.set( id, product ); + pendingFetches.delete( id ); + return product; + } ) + .catch( () => { + productCache.set( id, null ); + pendingFetches.delete( id ); + return null; + } ); + pendingFetches.set( id, promise ); + return promise; +} + +/** + * Pull a single field from a cached Store API product record. + * + * @param {Object} product Store API product record. + * @param {string} field Field name. + * @return {string} Resolved field value. + */ +function readField( product, field ) { + if ( ! product ) { + return ''; + } + switch ( field ) { + case 'title': + return product.name || ''; + case 'short_description': + return product.short_description || ''; + case 'price': + return product.price_html || ''; + case 'price_raw': + return product.prices?.price || ''; + case 'image_url': + return product.images?.[ 0 ]?.src || ''; + case 'url': + return product.permalink || ''; + default: + return ''; + } +} + +registerBlockBindingsSource( { + name: SOURCE_NAME, + label: __( 'Fast Checkout Product', 'newspack-blocks' ), + usesContext: [ PRODUCT_CONTEXT_KEY, VARIATION_CONTEXT_KEY ], + getValues( { bindings, context } ) { + const variationId = parseInt( context?.[ VARIATION_CONTEXT_KEY ], 10 ) || 0; + const productId = parseInt( context?.[ PRODUCT_CONTEXT_KEY ], 10 ) || 0; + const resolvedId = variationId || productId; + const product = resolvedId ? productCache.get( resolvedId ) : null; + + // Trigger a fetch if we have an ID but no cache entry yet. + // The next render cycle picks up the cached value once resolved. + if ( resolvedId && ! productCache.has( resolvedId ) ) { + fetchProduct( resolvedId ); + } + + const result = {}; + for ( const [ attr, binding ] of Object.entries( bindings ) ) { + const field = binding?.args?.field; + result[ attr ] = field ? readField( product, field ) : ''; + } + return result; + }, +} ); diff --git a/src/blocks/fast-checkout/edit.js b/src/blocks/fast-checkout/edit.js new file mode 100644 index 000000000..77f9b4e1c --- /dev/null +++ b/src/blocks/fast-checkout/edit.js @@ -0,0 +1,169 @@ +/** + * Fast Checkout block — editor component. + */ + +import { __ } from '@wordpress/i18n'; +import { useEffect, useState } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; +import { debounce } from 'lodash'; +import { InnerBlocks, InspectorControls, useBlockProps } from '@wordpress/block-editor'; +import { PanelBody, BaseControl, TextControl, Button, Spinner, FormTokenField, SelectControl } from '@wordpress/components'; + +import { DEFAULT_TEMPLATE } from './template'; +import { fetchProduct } from './bindings-source'; + +function ProductPicker( { productId, onChange } ) { + const [ suggestions, setSuggestions ] = useState( {} ); + const [ inFlight, setInFlight ] = useState( false ); + const [ selectedName, setSelectedName ] = useState( '' ); + const [ isChanging, setIsChanging ] = useState( ! productId ); + + useEffect( () => { + if ( ! productId ) { + setSelectedName( '' ); + return; + } + fetchProduct( productId ).then( product => { + setSelectedName( product?.name || '' ); + } ); + }, [ productId ] ); + + const fetchSuggestions = debounce( search => { + if ( search.length < 3 ) { + return; + } + setInFlight( true ); + apiFetch( { + path: `/wc/store/v1/products?search=${ encodeURIComponent( search ) }&per_page=10`, + } ) + .then( products => { + const next = {}; + products.forEach( p => { + next[ p.id ] = p.name; + } ); + setSuggestions( next ); + } ) + .finally( () => setInFlight( false ) ); + }, 200 ); + + if ( productId && ! isChanging ) { + return ( + +
{ selectedName || }
+ +
+ ); + } + + return ( + + { + const tokenName = tokens[ 0 ]; + const id = Object.keys( suggestions ).find( key => suggestions[ key ] === tokenName ); + if ( id ) { + onChange( String( id ) ); + setIsChanging( false ); + } + } } + /> + { inFlight && } + + ); +} + +function VariationPicker( { productId, variationId, onChange } ) { + const [ variations, setVariations ] = useState( [] ); + + useEffect( () => { + if ( ! productId ) { + setVariations( [] ); + return; + } + apiFetch( { path: `/wc/v2/products/${ productId }/variations?per_page=100` } ) + .then( res => { + setVariations( + res.map( v => ( { + id: v.id, + label: v.attributes.map( a => `${ a.name }: ${ a.option }` ).join( ', ' ), + } ) ) + ); + } ) + .catch( () => setVariations( [] ) ); + }, [ productId ] ); + + if ( ! variations.length ) { + return null; + } + + return ( + ( { label: v.label, value: String( v.id ) } ) ), + ] } + onChange={ onChange } + /> + ); +} + +export default function Edit( { attributes, setAttributes } ) { + const { product, variation, is_variable: isVariable, afterSuccessURL } = attributes; + const blockProps = useBlockProps(); + + // Detect variable product when product changes. + useEffect( () => { + if ( ! product ) { + return; + } + apiFetch( { path: `/wc/store/v1/products/${ product }` } ) + .then( p => { + setAttributes( { is_variable: !! p?.variations?.length } ); + } ) + .catch( () => setAttributes( { is_variable: false } ) ); + }, [ product ] ); + + return ( + <> + + + + setAttributes( { + product: newId, + variation: '', + is_variable: false, + } ) + } + /> + { product && isVariable && ( + setAttributes( { variation: newVariation } ) } + /> + ) } + setAttributes( { afterSuccessURL: value } ) } + /> + + +
+ +
+ + ); +} diff --git a/src/blocks/fast-checkout/editor.js b/src/blocks/fast-checkout/editor.js new file mode 100644 index 000000000..f469185e0 --- /dev/null +++ b/src/blocks/fast-checkout/editor.js @@ -0,0 +1,11 @@ +/** + * Fast Checkout block — editor bundle entry. + */ + +import { registerBlockType } from '@wordpress/blocks'; +import { name, settings } from './index'; +import './bindings-source'; +import './use-context'; +import './editor.scss'; + +registerBlockType( name, settings ); diff --git a/src/blocks/fast-checkout/editor.scss b/src/blocks/fast-checkout/editor.scss new file mode 100644 index 000000000..6ecc7c200 --- /dev/null +++ b/src/blocks/fast-checkout/editor.scss @@ -0,0 +1,3 @@ +/** + * Fast Checkout block — editor styles. + */ diff --git a/src/blocks/fast-checkout/index.js b/src/blocks/fast-checkout/index.js new file mode 100644 index 000000000..8ecd90d24 --- /dev/null +++ b/src/blocks/fast-checkout/index.js @@ -0,0 +1,28 @@ +/** + * Fast Checkout block — settings export. + */ + +import { __ } from '@wordpress/i18n'; +import { InnerBlocks, useBlockProps } from '@wordpress/block-editor'; +import metadata from './block.json'; +import edit from './edit'; +import './view.scss'; + +export const name = metadata.name; +export const title = __( 'Fast Checkout', 'newspack-blocks' ); + +function save() { + const blockProps = useBlockProps.save(); + return ( +
+ +
+ ); +} + +export const settings = { + ...metadata, + title, + edit, + save, +}; diff --git a/src/blocks/fast-checkout/template.js b/src/blocks/fast-checkout/template.js new file mode 100644 index 000000000..d4c567824 --- /dev/null +++ b/src/blocks/fast-checkout/template.js @@ -0,0 +1,58 @@ +/** + * Default inner-block template for the Fast Checkout block. + * + * The Woo Checkout block carries its own lock attribute so it cannot be removed + * or reordered, while the rest of the template stays editable. + */ + +const BINDING_SOURCE = 'newspack-blocks/fast-checkout-product'; + +const bind = field => ( { source: BINDING_SOURCE, args: { field } } ); + +export const DEFAULT_TEMPLATE = [ + [ + 'core/columns', + {}, + [ + [ + 'core/column', + { width: '50%' }, + [ + [ + 'core/image', + { + metadata: { + bindings: { + url: bind( 'image_url' ), + alt: bind( 'title' ), + }, + }, + }, + ], + [ + 'core/heading', + { + level: 2, + metadata: { + bindings: { + content: bind( 'title' ), + }, + }, + }, + ], + [ + 'core/paragraph', + { + metadata: { + bindings: { + content: bind( 'short_description' ), + }, + }, + }, + ], + ], + ], + [ 'core/column', { width: '50%' }, [ [ 'woocommerce/checkout', { lock: { remove: true, move: true } } ] ] ], + ], + ], +]; diff --git a/src/blocks/fast-checkout/use-context.js b/src/blocks/fast-checkout/use-context.js new file mode 100644 index 000000000..bebf0d414 --- /dev/null +++ b/src/blocks/fast-checkout/use-context.js @@ -0,0 +1,24 @@ +/** + * Extend core/heading, core/image, and core/paragraph to consume the + * Fast Checkout product context in the editor. + */ + +import { addFilter } from '@wordpress/hooks'; + +const PRODUCT_CONTEXT_KEY = 'newspack-blocks/fastCheckoutProductId'; +const VARIATION_CONTEXT_KEY = 'newspack-blocks/fastCheckoutVariationId'; +const TARGETS = [ 'core/heading', 'core/image', 'core/paragraph' ]; + +addFilter( 'blocks.registerBlockType', 'newspack-blocks/fast-checkout/use-context', ( settings, name ) => { + if ( ! TARGETS.includes( name ) ) { + return settings; + } + const existing = Array.isArray( settings.usesContext ) ? settings.usesContext : []; + if ( existing.includes( PRODUCT_CONTEXT_KEY ) ) { + return settings; + } + return { + ...settings, + usesContext: [ ...existing, PRODUCT_CONTEXT_KEY, VARIATION_CONTEXT_KEY ], + }; +} ); From 121062fba44fe047e876782ff15d8491b1a29541 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 16 Apr 2026 11:56:03 -0300 Subject: [PATCH 10/18] feat(fast-checkout): add view.js entry for frontend asset compilation --- src/blocks/fast-checkout/view.js | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/blocks/fast-checkout/view.js diff --git a/src/blocks/fast-checkout/view.js b/src/blocks/fast-checkout/view.js new file mode 100644 index 000000000..741c50d6a --- /dev/null +++ b/src/blocks/fast-checkout/view.js @@ -0,0 +1,4 @@ +/** + * Internal dependencies + */ +import './view.scss'; From 3fece57c3d61690b909c6a4251ceb3463aa42035 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 16 Apr 2026 12:17:38 -0300 Subject: [PATCH 11/18] refactor(fast-checkout): use flat template layout instead of columns --- src/blocks/fast-checkout/template.js | 74 ++++++++++++---------------- 1 file changed, 31 insertions(+), 43 deletions(-) diff --git a/src/blocks/fast-checkout/template.js b/src/blocks/fast-checkout/template.js index d4c567824..e6a80e196 100644 --- a/src/blocks/fast-checkout/template.js +++ b/src/blocks/fast-checkout/template.js @@ -11,48 +11,36 @@ const bind = field => ( { source: BINDING_SOURCE, args: { field } } ); export const DEFAULT_TEMPLATE = [ [ - 'core/columns', - {}, - [ - [ - 'core/column', - { width: '50%' }, - [ - [ - 'core/image', - { - metadata: { - bindings: { - url: bind( 'image_url' ), - alt: bind( 'title' ), - }, - }, - }, - ], - [ - 'core/heading', - { - level: 2, - metadata: { - bindings: { - content: bind( 'title' ), - }, - }, - }, - ], - [ - 'core/paragraph', - { - metadata: { - bindings: { - content: bind( 'short_description' ), - }, - }, - }, - ], - ], - ], - [ 'core/column', { width: '50%' }, [ [ 'woocommerce/checkout', { lock: { remove: true, move: true } } ] ] ], - ], + 'core/image', + { + metadata: { + bindings: { + url: bind( 'image_url' ), + alt: bind( 'title' ), + }, + }, + }, ], + [ + 'core/heading', + { + level: 2, + metadata: { + bindings: { + content: bind( 'title' ), + }, + }, + }, + ], + [ + 'core/paragraph', + { + metadata: { + bindings: { + content: bind( 'short_description' ), + }, + }, + }, + ], + [ 'woocommerce/checkout', { lock: { remove: true, move: true } } ], ]; From 535a02fd8b91efa6632a01ddd038926ecbdee8c3 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 16 Apr 2026 12:36:35 -0300 Subject: [PATCH 12/18] fix(fast-checkout): flag page as checkout so payment gateways initialize --- includes/class-fast-checkout.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php index ecf1ca842..e4e70f9e2 100644 --- a/includes/class-fast-checkout.php +++ b/includes/class-fast-checkout.php @@ -62,6 +62,7 @@ public static function init() { add_filter( 'woocommerce_get_return_url', [ __CLASS__, 'maybe_override_return_url' ], 10, 2 ); add_action( 'woocommerce_checkout_create_order_line_item', [ __CLASS__, 'attach_line_item_meta' ], 10, 4 ); add_filter( 'block_type_metadata', [ __CLASS__, 'add_context_to_core_blocks' ] ); + add_filter( 'woocommerce_is_checkout', [ __CLASS__, 'maybe_flag_as_checkout' ] ); } /** @@ -218,6 +219,25 @@ public static function mark_page_noncacheable() { nocache_headers(); } + /** + * Flag the current page as a WooCommerce checkout page when it contains + * a Fast Checkout block. This ensures payment gateway scripts (Stripe, etc.) + * enqueue their initialization data. + * + * @param bool $is_checkout Current checkout flag. + * @return bool + */ + public static function maybe_flag_as_checkout( $is_checkout ) { + if ( $is_checkout || is_admin() || ! is_singular() ) { + return $is_checkout; + } + $post = get_post(); + if ( $post && has_block( self::BLOCK_NAME, $post ) ) { + return true; + } + return $is_checkout; + } + /** * Replace the WooCommerce cart contents with the block's product. */ From 69523de80027f99469562dd6b966a5232a64854e Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 16 Apr 2026 12:57:49 -0300 Subject: [PATCH 13/18] fix(fast-checkout): disable return-to-cart link by default in editor and frontend --- includes/class-fast-checkout.php | 23 +++++++++++++++++++++++ src/blocks/fast-checkout/edit.js | 19 ++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php index e4e70f9e2..d00c55a27 100644 --- a/includes/class-fast-checkout.php +++ b/includes/class-fast-checkout.php @@ -63,6 +63,7 @@ public static function init() { add_action( 'woocommerce_checkout_create_order_line_item', [ __CLASS__, 'attach_line_item_meta' ], 10, 4 ); 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' ] ); } /** @@ -238,6 +239,28 @@ public static function maybe_flag_as_checkout( $is_checkout ) { return $is_checkout; } + /** + * Hide the "Return to Cart" link in the checkout actions block when + * rendering inside a Fast Checkout page. + * + * @param array $parsed_block Parsed block data. + * @return array + */ + public static function filter_checkout_actions_block( $parsed_block ) { + if ( 'woocommerce/checkout-actions-block' !== ( $parsed_block['blockName'] ?? '' ) ) { + return $parsed_block; + } + if ( is_admin() || ! is_singular() ) { + return $parsed_block; + } + $post = get_post(); + if ( ! $post || ! has_block( self::BLOCK_NAME, $post ) ) { + return $parsed_block; + } + $parsed_block['attrs']['showReturnToCart'] = false; + return $parsed_block; + } + /** * Replace the WooCommerce cart contents with the block's product. */ diff --git a/src/blocks/fast-checkout/edit.js b/src/blocks/fast-checkout/edit.js index 77f9b4e1c..07e4dca93 100644 --- a/src/blocks/fast-checkout/edit.js +++ b/src/blocks/fast-checkout/edit.js @@ -7,6 +7,7 @@ import { useEffect, useState } from '@wordpress/element'; import apiFetch from '@wordpress/api-fetch'; import { debounce } from 'lodash'; import { InnerBlocks, InspectorControls, useBlockProps } from '@wordpress/block-editor'; +import { useSelect, useDispatch } from '@wordpress/data'; import { PanelBody, BaseControl, TextControl, Button, Spinner, FormTokenField, SelectControl } from '@wordpress/components'; import { DEFAULT_TEMPLATE } from './template'; @@ -116,9 +117,25 @@ function VariationPicker( { productId, variationId, onChange } ) { ); } -export default function Edit( { attributes, setAttributes } ) { +export default function Edit( { attributes, setAttributes, clientId } ) { const { product, variation, is_variable: isVariable, afterSuccessURL } = attributes; const blockProps = useBlockProps(); + const { updateBlockAttributes } = useDispatch( 'core/block-editor' ); + + // On first insert, find the checkout-actions-block and disable "Return to Cart". + const allDescendants = useSelect( + select => { + const getBlocks = select( 'core/block-editor' ).getBlocks; + const walk = blocks => blocks.flatMap( b => [ b, ...walk( b.innerBlocks ) ] ); + return walk( getBlocks( clientId ) ); + }, + [ clientId ] + ); + useEffect( () => { + allDescendants + .filter( b => b.name === 'woocommerce/checkout-actions-block' && b.attributes.showReturnToCart ) + .forEach( b => updateBlockAttributes( b.clientId, { showReturnToCart: false } ) ); + }, [ allDescendants.length ] ); // Detect variable product when product changes. useEffect( () => { From f68350caf0a4e651c3eab75a430b2b39f7b993d5 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 16 Apr 2026 13:02:39 -0300 Subject: [PATCH 14/18] feat(fast-checkout): show placeholder with product picker before product is selected --- src/blocks/fast-checkout/edit.js | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/blocks/fast-checkout/edit.js b/src/blocks/fast-checkout/edit.js index 07e4dca93..bf48aca67 100644 --- a/src/blocks/fast-checkout/edit.js +++ b/src/blocks/fast-checkout/edit.js @@ -8,7 +8,7 @@ import apiFetch from '@wordpress/api-fetch'; import { debounce } from 'lodash'; import { InnerBlocks, InspectorControls, useBlockProps } from '@wordpress/block-editor'; import { useSelect, useDispatch } from '@wordpress/data'; -import { PanelBody, BaseControl, TextControl, Button, Spinner, FormTokenField, SelectControl } from '@wordpress/components'; +import { PanelBody, BaseControl, TextControl, Button, Spinner, FormTokenField, SelectControl, Placeholder } from '@wordpress/components'; import { DEFAULT_TEMPLATE } from './template'; import { fetchProduct } from './bindings-source'; @@ -149,6 +149,28 @@ export default function Edit( { attributes, setAttributes, clientId } ) { .catch( () => setAttributes( { is_variable: false } ) ); }, [ product ] ); + if ( ! product ) { + return ( +
+ + + setAttributes( { + product: newId, + variation: '', + is_variable: false, + } ) + } + /> + +
+ ); + } + return ( <> @@ -163,7 +185,7 @@ export default function Edit( { attributes, setAttributes, clientId } ) { } ) } /> - { product && isVariable && ( + { isVariable && ( Date: Thu, 16 Apr 2026 13:10:49 -0300 Subject: [PATCH 15/18] feat(fast-checkout): support URL query params for email, qty, coupon, variation, price, redirect --- includes/class-fast-checkout.php | 117 ++++++++++++++++++++++++++++--- 1 file changed, 109 insertions(+), 8 deletions(-) diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php index d00c55a27..9221fa4b3 100644 --- a/includes/class-fast-checkout.php +++ b/includes/class-fast-checkout.php @@ -44,6 +44,16 @@ final class Fast_Checkout { */ const CORE_CONTEXT_BLOCKS = [ 'core/heading', 'core/image', 'core/paragraph' ]; + /** + * 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'; + /** * Cache of post ID → product ID lookups. * @@ -261,8 +271,52 @@ public static function filter_checkout_actions_block( $parsed_block ) { return $parsed_block; } + /** + * Read and sanitize the supported Fast Checkout query parameters. + * + * @return array Associative array of query parameter values (only non-empty ones). + */ + 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; + } + + $qty = filter_input( INPUT_GET, self::QP_QTY, FILTER_SANITIZE_NUMBER_INT ); + if ( $qty && (int) $qty > 0 ) { + $params['qty'] = (int) $qty; + } + + $coupon = filter_input( INPUT_GET, self::QP_COUPON, FILTER_SANITIZE_SPECIAL_CHARS ); + 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; + } + + $price = filter_input( INPUT_GET, self::QP_PRICE, FILTER_SANITIZE_SPECIAL_CHARS ); + if ( $price && is_numeric( $price ) && (float) $price > 0 ) { + $params['price'] = (float) $price; + } + + $success = filter_input( INPUT_GET, self::QP_SUCCESS, FILTER_SANITIZE_URL ); + if ( $success ) { + $params['success'] = $success; + } + + return $params; + } + /** * Replace the WooCommerce cart contents with the block's product. + * + * Supports URL query parameters to override variation, quantity, price, + * coupon, billing email, and post-purchase redirect URL. */ public static function maybe_replace_cart() { if ( is_admin() && ! wp_doing_ajax() ) { @@ -275,7 +329,16 @@ public static function maybe_replace_cart() { if ( ! $post || ! has_block( self::BLOCK_NAME, $post ) ) { return; } - $product_id = self::get_block_product_id( $post ); + + $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']; + } + if ( ! $product_id ) { return; } @@ -290,12 +353,12 @@ 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 ) ) { + 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 && 1 === (int) $item['quantity'] ) { + if ( $matches_id && (int) $quantity === (int) $item['quantity'] ) { return; } } @@ -305,22 +368,51 @@ public static function maybe_replace_cart() { self::CART_ITEM_SOURCE_KEY => $post->ID, ]; + // Store fc_success override in cart item data so it carries to the order. + if ( ! empty( $qp['success'] ) ) { + $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']; + $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; + $cart_item_data['nyp'] = (float) \WC_Name_Your_Price_Helpers::standardize_number( $price ); + } + } + /** * Filter the cart item data added by Fast Checkout. * * @param array $cart_item_data Cart item data. * @param int $product_id Resolved product or variation ID. * @param \WP_Post $post The source post. + * @param array $qp Sanitized query parameters. */ - $cart_item_data = apply_filters( 'newspack_blocks_fast_checkout_cart_item_data', $cart_item_data, $product_id, $post ); + $cart_item_data = apply_filters( 'newspack_blocks_fast_checkout_cart_item_data', $cart_item_data, $product_id, $post, $qp ); $cart->empty_cart(); if ( $product->is_type( 'variation' ) ) { $parent_id = $product->get_parent_id(); - $cart->add_to_cart( $parent_id, 1, $product_id, [], $cart_item_data ); + $cart->add_to_cart( $parent_id, $quantity, $product_id, [], $cart_item_data ); } else { - $cart->add_to_cart( $product_id, 1, 0, [], $cart_item_data ); + $cart->add_to_cart( $product_id, $quantity, 0, [], $cart_item_data ); + } + + // Apply coupon. + if ( ! empty( $qp['coupon'] ) ) { + $cart->apply_coupon( $qp['coupon'] ); + } + + // Pre-fill billing email. + if ( ! empty( $qp['email'] ) ) { + WC()->customer->set_billing_email( $qp['email'] ); + WC()->customer->save(); } } @@ -363,6 +455,11 @@ public static function maybe_override_return_url( $url, $order ) { if ( ! $source_post_id ) { continue; } + // fc_success query param takes precedence over block attribute. + $fc_success = $item->get_meta( '_fc_success_url' ); + if ( $fc_success ) { + return $fc_success; + } $custom_url = self::get_after_success_url( (int) $source_post_id ); if ( $custom_url ) { return $custom_url; @@ -423,8 +520,12 @@ public static function attach_line_item_meta( $item, $cart_item_key, $values, $o if ( ! isset( $values[ self::CART_ITEM_SOURCE_KEY ] ) ) { return; } - if ( is_object( $item ) && method_exists( $item, 'add_meta_data' ) ) { - $item->add_meta_data( self::CART_ITEM_SOURCE_KEY, (int) $values[ self::CART_ITEM_SOURCE_KEY ], true ); + if ( ! is_object( $item ) || ! method_exists( $item, 'add_meta_data' ) ) { + return; + } + $item->add_meta_data( self::CART_ITEM_SOURCE_KEY, (int) $values[ self::CART_ITEM_SOURCE_KEY ], true ); + if ( ! empty( $values['_fc_success_url'] ) ) { + $item->add_meta_data( '_fc_success_url', esc_url_raw( $values['_fc_success_url'] ), true ); } } From 46a5f3c0cf677deb66818cad8cda28e65c1b684c Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 16 Apr 2026 13:50:48 -0300 Subject: [PATCH 16/18] refactor(fast-checkout): convert editor sources to TypeScript --- ...{bindings-source.js => bindings-source.ts} | 35 ++++++++------- .../fast-checkout/{edit.js => edit.tsx} | 44 +++++++++++++------ .../fast-checkout/{editor.js => editor.ts} | 0 .../fast-checkout/{index.js => index.tsx} | 4 +- .../{template.js => template.ts} | 8 +++- src/blocks/fast-checkout/types.ts | 42 ++++++++++++++++++ .../{use-context.js => use-context.ts} | 7 ++- webpack.config.js | 21 ++++++++- 8 files changed, 124 insertions(+), 37 deletions(-) rename src/blocks/fast-checkout/{bindings-source.js => bindings-source.ts} (65%) rename src/blocks/fast-checkout/{edit.js => edit.tsx} (79%) rename src/blocks/fast-checkout/{editor.js => editor.ts} (100%) rename src/blocks/fast-checkout/{index.js => index.tsx} (79%) rename src/blocks/fast-checkout/{template.js => template.ts} (73%) create mode 100644 src/blocks/fast-checkout/types.ts rename src/blocks/fast-checkout/{use-context.js => use-context.ts} (83%) diff --git a/src/blocks/fast-checkout/bindings-source.js b/src/blocks/fast-checkout/bindings-source.ts similarity index 65% rename from src/blocks/fast-checkout/bindings-source.js rename to src/blocks/fast-checkout/bindings-source.ts index 71134afc8..0d22a5440 100644 --- a/src/blocks/fast-checkout/bindings-source.js +++ b/src/blocks/fast-checkout/bindings-source.ts @@ -8,32 +8,33 @@ import { registerBlockBindingsSource } from '@wordpress/blocks'; import { __ } from '@wordpress/i18n'; import apiFetch from '@wordpress/api-fetch'; +import type { StoreApiProduct, ProductField, Binding, BindingsContext } from './types'; const SOURCE_NAME = 'newspack-blocks/fast-checkout-product'; const PRODUCT_CONTEXT_KEY = 'newspack-blocks/fastCheckoutProductId'; const VARIATION_CONTEXT_KEY = 'newspack-blocks/fastCheckoutVariationId'; -const productCache = new Map(); -const pendingFetches = new Map(); +const productCache = new Map< number, StoreApiProduct | null >(); +const pendingFetches = new Map< number, Promise< StoreApiProduct | null > >(); /** * Fetch a product from the WooCommerce Store API and cache it. * - * @param {number|string} productId - * @return {Promise} Cached or fetched product record. + * @param productId Product or variation ID. + * @return Cached or fetched product record. */ -export function fetchProduct( productId ) { - const id = parseInt( productId, 10 ); +export function fetchProduct( productId: number | string ): Promise< StoreApiProduct | null > { + const id = parseInt( String( productId ), 10 ); if ( ! id ) { return Promise.resolve( null ); } if ( productCache.has( id ) ) { - return Promise.resolve( productCache.get( id ) ); + return Promise.resolve( productCache.get( id ) ?? null ); } if ( pendingFetches.has( id ) ) { - return pendingFetches.get( id ); + return pendingFetches.get( id )!; } - const promise = apiFetch( { path: `/wc/store/v1/products/${ id }` } ) + const promise = apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ id }` } ) .then( product => { productCache.set( id, product ); pendingFetches.delete( id ); @@ -51,11 +52,11 @@ export function fetchProduct( productId ) { /** * Pull a single field from a cached Store API product record. * - * @param {Object} product Store API product record. - * @param {string} field Field name. - * @return {string} Resolved field value. + * @param product Store API product record. + * @param field Field name. + * @return Resolved field value. */ -function readField( product, field ) { +function readField( product: StoreApiProduct | null | undefined, field: ProductField ): string { if ( ! product ) { return ''; } @@ -81,9 +82,9 @@ registerBlockBindingsSource( { name: SOURCE_NAME, label: __( 'Fast Checkout Product', 'newspack-blocks' ), usesContext: [ PRODUCT_CONTEXT_KEY, VARIATION_CONTEXT_KEY ], - getValues( { bindings, context } ) { - const variationId = parseInt( context?.[ VARIATION_CONTEXT_KEY ], 10 ) || 0; - const productId = parseInt( context?.[ PRODUCT_CONTEXT_KEY ], 10 ) || 0; + getValues( { bindings, context }: { bindings: Record< string, Binding >; context: BindingsContext } ) { + const variationId = parseInt( String( context?.[ VARIATION_CONTEXT_KEY ] ?? 0 ), 10 ) || 0; + const productId = parseInt( String( context?.[ PRODUCT_CONTEXT_KEY ] ?? 0 ), 10 ) || 0; const resolvedId = variationId || productId; const product = resolvedId ? productCache.get( resolvedId ) : null; @@ -93,7 +94,7 @@ registerBlockBindingsSource( { fetchProduct( resolvedId ); } - const result = {}; + const result: Record< string, string > = {}; for ( const [ attr, binding ] of Object.entries( bindings ) ) { const field = binding?.args?.field; result[ attr ] = field ? readField( product, field ) : ''; diff --git a/src/blocks/fast-checkout/edit.js b/src/blocks/fast-checkout/edit.tsx similarity index 79% rename from src/blocks/fast-checkout/edit.js rename to src/blocks/fast-checkout/edit.tsx index bf48aca67..9bed70c65 100644 --- a/src/blocks/fast-checkout/edit.js +++ b/src/blocks/fast-checkout/edit.tsx @@ -12,9 +12,15 @@ import { PanelBody, BaseControl, TextControl, Button, Spinner, FormTokenField, S import { DEFAULT_TEMPLATE } from './template'; import { fetchProduct } from './bindings-source'; +import type { FastCheckoutAttributes, StoreApiProduct, StoreApiVariation, Variation } from './types'; -function ProductPicker( { productId, onChange } ) { - const [ suggestions, setSuggestions ] = useState( {} ); +interface ProductPickerProps { + productId?: string; + onChange: ( id: string ) => void; +} + +function ProductPicker( { productId, onChange }: ProductPickerProps ) { + const [ suggestions, setSuggestions ] = useState< Record< string, string > >( {} ); const [ inFlight, setInFlight ] = useState( false ); const [ selectedName, setSelectedName ] = useState( '' ); const [ isChanging, setIsChanging ] = useState( ! productId ); @@ -29,16 +35,16 @@ function ProductPicker( { productId, onChange } ) { } ); }, [ productId ] ); - const fetchSuggestions = debounce( search => { + const fetchSuggestions = debounce( ( search: string ) => { if ( search.length < 3 ) { return; } setInFlight( true ); - apiFetch( { + apiFetch< StoreApiProduct[] >( { path: `/wc/store/v1/products?search=${ encodeURIComponent( search ) }&per_page=10`, } ) .then( products => { - const next = {}; + const next: Record< string, string > = {}; products.forEach( p => { next[ p.id ] = p.name; } ); @@ -66,7 +72,7 @@ function ProductPicker( { productId, onChange } ) { placeholder={ __( 'Type to search for a product…', 'newspack-blocks' ) } suggestions={ Object.values( suggestions ) } onInputChange={ fetchSuggestions } - onChange={ tokens => { + onChange={ ( tokens: string[] ) => { const tokenName = tokens[ 0 ]; const id = Object.keys( suggestions ).find( key => suggestions[ key ] === tokenName ); if ( id ) { @@ -80,15 +86,21 @@ function ProductPicker( { productId, onChange } ) { ); } -function VariationPicker( { productId, variationId, onChange } ) { - const [ variations, setVariations ] = useState( [] ); +interface VariationPickerProps { + productId: string; + variationId?: string; + onChange: ( variationId: string ) => void; +} + +function VariationPicker( { productId, variationId, onChange }: VariationPickerProps ) { + const [ variations, setVariations ] = useState< Variation[] >( [] ); useEffect( () => { if ( ! productId ) { setVariations( [] ); return; } - apiFetch( { path: `/wc/v2/products/${ productId }/variations?per_page=100` } ) + apiFetch< StoreApiVariation[] >( { path: `/wc/v2/products/${ productId }/variations?per_page=100` } ) .then( res => { setVariations( res.map( v => ( { @@ -117,7 +129,13 @@ function VariationPicker( { productId, variationId, onChange } ) { ); } -export default function Edit( { attributes, setAttributes, clientId } ) { +interface EditProps { + attributes: FastCheckoutAttributes; + setAttributes: ( attrs: Partial< FastCheckoutAttributes > ) => void; + clientId: string; +} + +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' ); @@ -125,8 +143,8 @@ export default function Edit( { attributes, setAttributes, clientId } ) { // On first insert, find the checkout-actions-block and disable "Return to Cart". const allDescendants = useSelect( select => { - const getBlocks = select( 'core/block-editor' ).getBlocks; - const walk = blocks => blocks.flatMap( b => [ b, ...walk( b.innerBlocks ) ] ); + const getBlocks = ( select( 'core/block-editor' ) as { getBlocks: ( id?: string ) => Block[] } ).getBlocks; + const walk = ( blocks: Block[] ): Block[] => blocks.flatMap( b => [ b, ...walk( b.innerBlocks ) ] ); return walk( getBlocks( clientId ) ); }, [ clientId ] @@ -142,7 +160,7 @@ export default function Edit( { attributes, setAttributes, clientId } ) { if ( ! product ) { return; } - apiFetch( { path: `/wc/store/v1/products/${ product }` } ) + apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ product }` } ) .then( p => { setAttributes( { is_variable: !! p?.variations?.length } ); } ) diff --git a/src/blocks/fast-checkout/editor.js b/src/blocks/fast-checkout/editor.ts similarity index 100% rename from src/blocks/fast-checkout/editor.js rename to src/blocks/fast-checkout/editor.ts diff --git a/src/blocks/fast-checkout/index.js b/src/blocks/fast-checkout/index.tsx similarity index 79% rename from src/blocks/fast-checkout/index.js rename to src/blocks/fast-checkout/index.tsx index 8ecd90d24..48550e594 100644 --- a/src/blocks/fast-checkout/index.js +++ b/src/blocks/fast-checkout/index.tsx @@ -8,8 +8,8 @@ import metadata from './block.json'; import edit from './edit'; import './view.scss'; -export const name = metadata.name; -export const title = __( 'Fast Checkout', 'newspack-blocks' ); +export const name: string = metadata.name; +export const title: string = __( 'Fast Checkout', 'newspack-blocks' ); function save() { const blockProps = useBlockProps.save(); diff --git a/src/blocks/fast-checkout/template.js b/src/blocks/fast-checkout/template.ts similarity index 73% rename from src/blocks/fast-checkout/template.js rename to src/blocks/fast-checkout/template.ts index e6a80e196..1bd5b3463 100644 --- a/src/blocks/fast-checkout/template.js +++ b/src/blocks/fast-checkout/template.ts @@ -5,11 +5,15 @@ * or reordered, while the rest of the template stays editable. */ +import type { ProductField } from './types'; + const BINDING_SOURCE = 'newspack-blocks/fast-checkout-product'; -const bind = field => ( { source: BINDING_SOURCE, args: { field } } ); +const bind = ( field: ProductField ) => ( { source: BINDING_SOURCE, args: { field } } ); + +type TemplateBlock = [ string, Record< string, unknown >, ...TemplateBlock[][] ]; -export const DEFAULT_TEMPLATE = [ +export const DEFAULT_TEMPLATE: TemplateBlock[] = [ [ 'core/image', { diff --git a/src/blocks/fast-checkout/types.ts b/src/blocks/fast-checkout/types.ts new file mode 100644 index 000000000..dfddf89d4 --- /dev/null +++ b/src/blocks/fast-checkout/types.ts @@ -0,0 +1,42 @@ +export type ProductField = 'title' | 'short_description' | 'price' | 'price_raw' | 'image_url' | 'url'; + +export interface StoreApiProduct { + id: number; + name: string; + short_description: string; + price_html: string; + prices?: { price?: string }; + images?: { src: string }[]; + permalink: string; + variations?: number[]; +} + +export interface StoreApiVariation { + id: number; + attributes: { name: string; option: string }[]; +} + +export interface Variation { + id: number; + label: string; +} + +export interface FastCheckoutAttributes { + product?: string; + variation?: string; + is_variable: boolean; + afterSuccessURL?: string; +} + +export interface BindingArgs { + field: ProductField; +} + +export interface Binding { + args?: BindingArgs; +} + +export interface BindingsContext { + 'newspack-blocks/fastCheckoutProductId'?: string | number; + 'newspack-blocks/fastCheckoutVariationId'?: string | number; +} diff --git a/src/blocks/fast-checkout/use-context.js b/src/blocks/fast-checkout/use-context.ts similarity index 83% rename from src/blocks/fast-checkout/use-context.js rename to src/blocks/fast-checkout/use-context.ts index bebf0d414..6e32faf19 100644 --- a/src/blocks/fast-checkout/use-context.js +++ b/src/blocks/fast-checkout/use-context.ts @@ -9,7 +9,12 @@ const PRODUCT_CONTEXT_KEY = 'newspack-blocks/fastCheckoutProductId'; const VARIATION_CONTEXT_KEY = 'newspack-blocks/fastCheckoutVariationId'; const TARGETS = [ 'core/heading', 'core/image', 'core/paragraph' ]; -addFilter( 'blocks.registerBlockType', 'newspack-blocks/fast-checkout/use-context', ( settings, name ) => { +interface BlockSettings { + usesContext?: string[]; + [ key: string ]: unknown; +} + +addFilter( 'blocks.registerBlockType', 'newspack-blocks/fast-checkout/use-context', ( settings: BlockSettings, name: string ) => { if ( ! TARGETS.includes( name ) ) { return settings; } diff --git a/webpack.config.js b/webpack.config.js index 12ced78bb..6a70d284e 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -20,14 +20,31 @@ const blockList = JSON.parse( fs.readFileSync( blockListFile ) ); const editorSetup = path.join( __dirname, 'src', 'setup', 'editor' ); function blockScripts( type, inputDir, blocks ) { - return blocks.map( block => path.join( inputDir, 'blocks', block, `${ type }.js` ) ).filter( fs.existsSync ); + return blocks + .map( block => { + const jsPath = path.join( inputDir, 'blocks', block, `${ type }.js` ); + if ( fs.existsSync( jsPath ) ) { + return jsPath; + } + const tsPath = path.join( inputDir, 'blocks', block, `${ type }.ts` ); + if ( fs.existsSync( tsPath ) ) { + return tsPath; + } + return null; + } ) + .filter( Boolean ); +} + +function hasEditorEntry( block ) { + const base = path.join( __dirname, 'src', 'blocks', block, 'editor' ); + return [ '.js', '.ts', '.tsx' ].some( ext => fs.existsSync( base + ext ) ); } const blocksDir = path.join( __dirname, 'src', 'blocks' ); const blocks = fs .readdirSync( blocksDir ) .filter( block => isDevelopment || blockList.production.includes( block ) ) - .filter( block => fs.existsSync( path.join( __dirname, 'src', 'blocks', block, 'editor.js' ) ) ); + .filter( hasEditorEntry ); // Helps split up each block into its own folder view script const viewBlocksScripts = blocks.reduce( ( viewBlocks, block ) => { From 3642c31354da98236d26a773055440b7eba59cf8 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 23:41:49 -0300 Subject: [PATCH 17/18] fix(fast-checkout): default NYP cart price to suggested when fc_price absent --- includes/class-fast-checkout.php | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php index 9221fa4b3..d6643e912 100644 --- a/includes/class-fast-checkout.php +++ b/includes/class-fast-checkout.php @@ -373,14 +373,20 @@ 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: fc_price > suggested > minimum. + if ( class_exists( '\WC_Name_Your_Price_Helpers' ) && \WC_Name_Your_Price_Helpers::is_nyp( $product_id ) ) { + $price = ! empty( $qp['price'] ) ? (float) $qp['price'] : null; + if ( null === $price ) { + $price = (float) \WC_Name_Your_Price_Helpers::get_suggested_price( $product_id ); + } + if ( ! $price ) { + $price = (float) \WC_Name_Your_Price_Helpers::get_minimum_price( $product_id ); + } + if ( $price > 0 ) { $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 ); } } From 361d4f4a3a290b6ecd33e80f62db805122df99f0 Mon Sep 17 00:00:00 2001 From: Miguel Peixe Date: Thu, 30 Apr 2026 23:47:55 -0300 Subject: [PATCH 18/18] fix(fast-checkout): re-populate cart when stale NYP item lacks nyp value --- includes/class-fast-checkout.php | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php index d6643e912..9645f55cb 100644 --- a/includes/class-fast-checkout.php +++ b/includes/class-fast-checkout.php @@ -354,11 +354,19 @@ 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' ) ) + $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'] ) { + $matches_qty = (int) $quantity === (int) $item['quantity']; + + // NYP products require a valid nyp value to checkout. If the cart + // item was added before the suggested-price fallback landed, it + // will be missing — fail idempotency so we re-populate. + $is_nyp = class_exists( '\WC_Name_Your_Price_Helpers' ) && \WC_Name_Your_Price_Helpers::is_nyp( $product_id ); + $has_valid_nyp = ! $is_nyp || ( isset( $item['nyp'] ) && is_numeric( $item['nyp'] ) && (float) $item['nyp'] > 0 ); + + if ( $matches_id && $matches_qty && $has_valid_nyp ) { return; } } @@ -403,6 +411,13 @@ public static function maybe_replace_cart() { $cart->empty_cart(); + // Clear stale validation notices from the prior cart state — e.g. WC NYP's + // `check_cart_items` may have added an error notice on `wp_loaded` (before + // this action) for an item that's about to be replaced. + if ( function_exists( 'wc_clear_notices' ) ) { + wc_clear_notices( 'error' ); + } + if ( $product->is_type( 'variation' ) ) { $parent_id = $product->get_parent_id(); $cart->add_to_cart( $parent_id, $quantity, $product_id, [], $cart_item_data );