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/includes/class-fast-checkout.php b/includes/class-fast-checkout.php
new file mode 100644
index 000000000..9645f55cb
--- /dev/null
+++ b/includes/class-fast-checkout.php
@@ -0,0 +1,578 @@
+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() {
+ 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 '';
+ }
+ }
+
+ /**
+ * Mark the current page as non-cacheable when a Fast Checkout block is present.
+ */
+ public static function mark_page_noncacheable() {
+ 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();
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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() ) {
+ return;
+ }
+ if ( ! function_exists( 'WC' ) || ! is_singular() ) {
+ return;
+ }
+ $post = get_post();
+ if ( ! $post || ! has_block( self::BLOCK_NAME, $post ) ) {
+ 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'];
+ }
+
+ 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 ) && empty( $qp ) ) {
+ $item = reset( $cart_contents );
+ $matches_id = ( $product->is_type( 'variation' ) )
+ ? (int) $item['variation_id'] === $product_id
+ : (int) $item['product_id'] === $product_id;
+ $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;
+ }
+ }
+
+ // Build cart item data.
+ $cart_item_data = [
+ 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: 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, (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 );
+ }
+ }
+
+ /**
+ * 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, $qp );
+
+ $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 );
+ } else {
+ $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();
+ }
+ }
+
+ /**
+ * 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 ) {
+ $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' )
+ . '
';
+ }
+
+ /**
+ * 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 ) {
+ 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;
+ }
+ // 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;
+ }
+ }
+ 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.
+ *
+ * @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 ) {
+ if ( ! isset( $values[ self::CART_ITEM_SOURCE_KEY ] ) ) {
+ return;
+ }
+ 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 );
+ }
+ }
+
+ /**
+ * 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 ) {
+ 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;
+ }
+}
+
+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/src/blocks/fast-checkout/bindings-source.ts b/src/blocks/fast-checkout/bindings-source.ts
new file mode 100644
index 000000000..0d22a5440
--- /dev/null
+++ b/src/blocks/fast-checkout/bindings-source.ts
@@ -0,0 +1,104 @@
+/**
+ * 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';
+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< number, StoreApiProduct | null >();
+const pendingFetches = new Map< number, Promise< StoreApiProduct | null > >();
+
+/**
+ * Fetch a product from the WooCommerce Store API and cache it.
+ *
+ * @param productId Product or variation ID.
+ * @return Cached or fetched product record.
+ */
+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 ) ?? null );
+ }
+ if ( pendingFetches.has( id ) ) {
+ return pendingFetches.get( id )!;
+ }
+ const promise = apiFetch< StoreApiProduct >( { 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 product Store API product record.
+ * @param field Field name.
+ * @return Resolved field value.
+ */
+function readField( product: StoreApiProduct | null | undefined, field: ProductField ): string {
+ 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 }: { 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;
+
+ // 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: Record< string, string > = {};
+ 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/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/edit.tsx b/src/blocks/fast-checkout/edit.tsx
new file mode 100644
index 000000000..9bed70c65
--- /dev/null
+++ b/src/blocks/fast-checkout/edit.tsx
@@ -0,0 +1,226 @@
+/**
+ * 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 { useSelect, useDispatch } from '@wordpress/data';
+import { PanelBody, BaseControl, TextControl, Button, Spinner, FormTokenField, SelectControl, Placeholder } from '@wordpress/components';
+
+import { DEFAULT_TEMPLATE } from './template';
+import { fetchProduct } from './bindings-source';
+import type { FastCheckoutAttributes, StoreApiProduct, StoreApiVariation, Variation } from './types';
+
+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 );
+
+ useEffect( () => {
+ if ( ! productId ) {
+ setSelectedName( '' );
+ return;
+ }
+ fetchProduct( productId ).then( product => {
+ setSelectedName( product?.name || '' );
+ } );
+ }, [ productId ] );
+
+ const fetchSuggestions = debounce( ( search: string ) => {
+ if ( search.length < 3 ) {
+ return;
+ }
+ setInFlight( true );
+ apiFetch< StoreApiProduct[] >( {
+ path: `/wc/store/v1/products?search=${ encodeURIComponent( search ) }&per_page=10`,
+ } )
+ .then( products => {
+ const next: Record< string, string > = {};
+ 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 && }
+
+ );
+}
+
+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< StoreApiVariation[] >( { 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 }
+ />
+ );
+}
+
+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' );
+
+ // On first insert, find the checkout-actions-block and disable "Return to Cart".
+ const allDescendants = useSelect(
+ select => {
+ 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 ]
+ );
+ 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( () => {
+ if ( ! product ) {
+ return;
+ }
+ apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ product }` } )
+ .then( p => {
+ setAttributes( { is_variable: !! p?.variations?.length } );
+ } )
+ .catch( () => setAttributes( { is_variable: false } ) );
+ }, [ product ] );
+
+ if ( ! product ) {
+ return (
+
+
+
+ setAttributes( {
+ product: newId,
+ variation: '',
+ is_variable: false,
+ } )
+ }
+ />
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+ setAttributes( {
+ product: newId,
+ variation: '',
+ is_variable: false,
+ } )
+ }
+ />
+ { isVariable && (
+ setAttributes( { variation: newVariation } ) }
+ />
+ ) }
+ setAttributes( { afterSuccessURL: value } ) }
+ />
+
+
+
+
+
+ >
+ );
+}
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/editor.ts b/src/blocks/fast-checkout/editor.ts
new file mode 100644
index 000000000..f469185e0
--- /dev/null
+++ b/src/blocks/fast-checkout/editor.ts
@@ -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/index.tsx b/src/blocks/fast-checkout/index.tsx
new file mode 100644
index 000000000..48550e594
--- /dev/null
+++ b/src/blocks/fast-checkout/index.tsx
@@ -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: string = metadata.name;
+export const title: string = __( '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.ts b/src/blocks/fast-checkout/template.ts
new file mode 100644
index 000000000..1bd5b3463
--- /dev/null
+++ b/src/blocks/fast-checkout/template.ts
@@ -0,0 +1,50 @@
+/**
+ * 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.
+ */
+
+import type { ProductField } from './types';
+
+const BINDING_SOURCE = 'newspack-blocks/fast-checkout-product';
+
+const bind = ( field: ProductField ) => ( { source: BINDING_SOURCE, args: { field } } );
+
+type TemplateBlock = [ string, Record< string, unknown >, ...TemplateBlock[][] ];
+
+export const DEFAULT_TEMPLATE: TemplateBlock[] = [
+ [
+ '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 } } ],
+];
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.ts b/src/blocks/fast-checkout/use-context.ts
new file mode 100644
index 000000000..6e32faf19
--- /dev/null
+++ b/src/blocks/fast-checkout/use-context.ts
@@ -0,0 +1,29 @@
+/**
+ * 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' ];
+
+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;
+ }
+ 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 ],
+ };
+} );
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';
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 @@
+ '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 );
+ }
+
+ // ---- 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 ----
+
+ /**
+ * 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) ----
+
+ /**
+ * 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 );
+ }
+
+ // ---- 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 );
+ }
+
+ // ---- 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.
+ */
+ 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'] );
+ }
+}
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 ) => {