diff --git a/block-list.json b/block-list.json
index 39f9015bc..001b4392a 100644
--- a/block-list.json
+++ b/block-list.json
@@ -6,6 +6,7 @@
"checkout-button",
"donate",
"fast-checkout",
+ "fast-checkout-donate-selector",
"homepage-articles",
"video-playlist",
"iframe"
diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php
index 9645f55cb..66a0cf517 100644
--- a/includes/class-fast-checkout.php
+++ b/includes/class-fast-checkout.php
@@ -47,12 +47,13 @@ final class Fast_Checkout {
/**
* Query parameter names.
*/
- const QP_EMAIL = 'fc_email';
- const QP_QTY = 'fc_qty';
- const QP_COUPON = 'fc_coupon';
- const QP_VARIATION = 'fc_variation';
- const QP_PRICE = 'fc_price';
- const QP_SUCCESS = 'fc_success';
+ const QP_EMAIL = 'fc_email';
+ const QP_QTY = 'fc_qty';
+ const QP_COUPON = 'fc_coupon';
+ const QP_VARIATION = 'fc_variation';
+ const QP_GROUPED_CHILD = 'fc_grouped_child';
+ const QP_PRICE = 'fc_price';
+ const QP_SUCCESS = 'fc_success';
/**
* Cache of post ID → product ID lookups.
@@ -61,6 +62,13 @@ final class Fast_Checkout {
*/
private static $post_product_cache = [];
+ /**
+ * Cache of post ID → parsed block array.
+ *
+ * @var array
+ */
+ private static $post_blocks_cache = [];
+
/**
* Initialize hooks.
*/
@@ -74,6 +82,8 @@ public static function init() {
add_filter( 'block_type_metadata', [ __CLASS__, 'add_context_to_core_blocks' ] );
add_filter( 'woocommerce_is_checkout', [ __CLASS__, 'maybe_flag_as_checkout' ] );
add_filter( 'render_block_data', [ __CLASS__, 'filter_checkout_actions_block' ] );
+ add_filter( 'woocommerce_store_api_add_to_cart_data', [ __CLASS__, 'store_api_nyp_bridge_handler' ], 10, 2 );
+ add_filter( 'wc_nyp_show_edit_link_in_cart', [ __CLASS__, 'suppress_nyp_edit_link' ], 10, 2 );
}
/**
@@ -81,25 +91,45 @@ public static function init() {
*/
public static function reset_cache() {
self::$post_product_cache = [];
+ self::$post_blocks_cache = [];
+ }
+
+ /**
+ * Parse the post's blocks once, caching the result per post ID.
+ *
+ * @param \WP_Post $post Post object.
+ * @return array Parsed block tree.
+ */
+ private static function get_parsed_blocks( $post ) {
+ if ( ! isset( self::$post_blocks_cache[ $post->ID ] ) ) {
+ self::$post_blocks_cache[ $post->ID ] = parse_blocks( $post->post_content );
+ }
+ return self::$post_blocks_cache[ $post->ID ];
}
/**
* Resolve the effective product ID from block attributes.
*
- * Returns the variation ID when the product is variable and a variation is set,
+ * Returns the grouped child ID when the product is grouped and a child is set,
+ * the variation ID when the product is variable and a variation is set,
* otherwise the product ID. Returns null when no product attribute is present.
*
* @param array $attrs Block attributes.
- * @return int|null Product or variation ID, or null.
+ * @return int|null Product, variation, or grouped child ID, or null.
*/
public static function resolve_product_id_from_attrs( $attrs ) {
if ( empty( $attrs['product'] ) ) {
return null;
}
- $product_id = (int) $attrs['product'];
- $is_variable = ! empty( $attrs['is_variable'] );
- $variation = ! empty( $attrs['variation'] ) ? (int) $attrs['variation'] : 0;
+ $product_id = (int) $attrs['product'];
+ $is_variable = ! empty( $attrs['is_variable'] );
+ $is_grouped = ! empty( $attrs['is_grouped'] );
+ $variation = ! empty( $attrs['variation'] ) ? (int) $attrs['variation'] : 0;
+ $grouped_child = ! empty( $attrs['grouped_child'] ) ? (int) $attrs['grouped_child'] : 0;
+ if ( $is_grouped && $grouped_child ) {
+ return $grouped_child;
+ }
if ( $is_variable && $variation ) {
return $variation;
}
@@ -122,7 +152,7 @@ public static function get_block_product_id( $post ) {
if ( isset( self::$post_product_cache[ $post->ID ] ) ) {
return self::$post_product_cache[ $post->ID ];
}
- $blocks = parse_blocks( $post->post_content );
+ $blocks = self::get_parsed_blocks( $post );
$result = self::find_fast_checkout_block_product( $blocks );
self::$post_product_cache[ $post->ID ] = $result;
return $result;
@@ -149,6 +179,77 @@ private static function find_fast_checkout_block_product( $blocks ) {
return null;
}
+ /**
+ * Get the parsed attributes of the first Fast Checkout block in the post.
+ *
+ * @param \WP_Post $post Post object.
+ * @return array Block attributes.
+ */
+ private static function get_block_attributes( $post ) {
+ $blocks = self::get_parsed_blocks( $post );
+ $block = self::find_fast_checkout_block( $blocks );
+ return $block['attrs'] ?? [];
+ }
+
+ /**
+ * Resolve the effective product ID for cart replacement, applying query
+ * param overrides and runtime first-child fallback for grouped products.
+ *
+ * @param array $attrs Block attributes.
+ * @param array $qp Sanitized query params.
+ * @return int|null Resolved product/variation ID, or null.
+ */
+ private static function resolve_effective_product_id( $attrs, $qp ) {
+ if ( empty( $attrs['product'] ) ) {
+ return null;
+ }
+ $is_grouped = ! empty( $attrs['is_grouped'] );
+ $is_variable = ! empty( $attrs['is_variable'] );
+
+ // Grouped: query param override → attribute → first child (runtime).
+ if ( $is_grouped ) {
+ $parent = (int) $attrs['product'];
+ $valid_children = [];
+ if ( function_exists( 'wc_get_product' ) ) {
+ $parent_product = wc_get_product( $parent );
+ if ( $parent_product && method_exists( $parent_product, 'get_children' ) ) {
+ $valid_children = array_map( 'intval', $parent_product->get_children() );
+ }
+ }
+ if ( ! empty( $qp['grouped_child'] ) && in_array( (int) $qp['grouped_child'], $valid_children, true ) ) {
+ return (int) $qp['grouped_child'];
+ }
+ if ( ! empty( $attrs['grouped_child'] ) && in_array( (int) $attrs['grouped_child'], $valid_children, true ) ) {
+ return (int) $attrs['grouped_child'];
+ }
+ if ( ! empty( $valid_children ) ) {
+ return (int) $valid_children[0];
+ }
+ return $parent;
+ }
+
+ // Variable: query param > attribute > first variation (runtime).
+ if ( $is_variable ) {
+ if ( ! empty( $qp['variation'] ) ) {
+ return (int) $qp['variation'];
+ }
+ if ( ! empty( $attrs['variation'] ) ) {
+ return (int) $attrs['variation'];
+ }
+ if ( function_exists( 'wc_get_product' ) ) {
+ $parent = wc_get_product( (int) $attrs['product'] );
+ if ( $parent && method_exists( $parent, 'get_children' ) ) {
+ $children = array_map( 'intval', $parent->get_children() );
+ if ( ! empty( $children ) ) {
+ return $children[0];
+ }
+ }
+ }
+ }
+
+ return (int) $attrs['product'];
+ }
+
/**
* Register the block bindings source.
*/
@@ -279,34 +380,70 @@ public static function filter_checkout_actions_block( $parsed_block ) {
private static function get_query_params() {
$params = [];
- $email = filter_input( INPUT_GET, self::QP_EMAIL, FILTER_SANITIZE_EMAIL );
- if ( $email && is_email( $email ) ) {
- $params['email'] = $email;
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( isset( $_GET[ self::QP_EMAIL ] ) ) {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $email = sanitize_email( wp_unslash( $_GET[ self::QP_EMAIL ] ) );
+ if ( $email && is_email( $email ) ) {
+ $params['email'] = $email;
+ }
}
- $qty = filter_input( INPUT_GET, self::QP_QTY, FILTER_SANITIZE_NUMBER_INT );
- if ( $qty && (int) $qty > 0 ) {
- $params['qty'] = (int) $qty;
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( isset( $_GET[ self::QP_QTY ] ) ) {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ $qty_raw = wp_unslash( $_GET[ self::QP_QTY ] );
+ $qty = (int) filter_var( $qty_raw, FILTER_SANITIZE_NUMBER_INT );
+ if ( $qty > 0 ) {
+ $params['qty'] = $qty;
+ }
}
- $coupon = filter_input( INPUT_GET, self::QP_COUPON, FILTER_SANITIZE_SPECIAL_CHARS );
- if ( $coupon ) {
- $params['coupon'] = $coupon;
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( isset( $_GET[ self::QP_COUPON ] ) ) {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $coupon = sanitize_text_field( wp_unslash( $_GET[ self::QP_COUPON ] ) );
+ if ( $coupon ) {
+ $params['coupon'] = $coupon;
+ }
}
- $variation = filter_input( INPUT_GET, self::QP_VARIATION, FILTER_SANITIZE_NUMBER_INT );
- if ( $variation && (int) $variation > 0 ) {
- $params['variation'] = (int) $variation;
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( isset( $_GET[ self::QP_VARIATION ] ) ) {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ $variation_raw = wp_unslash( $_GET[ self::QP_VARIATION ] );
+ $variation = (int) filter_var( $variation_raw, FILTER_SANITIZE_NUMBER_INT );
+ if ( $variation > 0 ) {
+ $params['variation'] = $variation;
+ }
}
- $price = filter_input( INPUT_GET, self::QP_PRICE, FILTER_SANITIZE_SPECIAL_CHARS );
- if ( $price && is_numeric( $price ) && (float) $price > 0 ) {
- $params['price'] = (float) $price;
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( isset( $_GET[ self::QP_GROUPED_CHILD ] ) ) {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ $grouped_child_raw = wp_unslash( $_GET[ self::QP_GROUPED_CHILD ] );
+ $grouped_child = (int) filter_var( $grouped_child_raw, FILTER_SANITIZE_NUMBER_INT );
+ if ( $grouped_child > 0 ) {
+ $params['grouped_child'] = $grouped_child;
+ }
}
- $success = filter_input( INPUT_GET, self::QP_SUCCESS, FILTER_SANITIZE_URL );
- if ( $success ) {
- $params['success'] = $success;
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( isset( $_GET[ self::QP_PRICE ] ) ) {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $price_raw = sanitize_text_field( wp_unslash( $_GET[ self::QP_PRICE ] ) );
+ if ( is_numeric( $price_raw ) && (float) $price_raw > 0 ) {
+ $params['price'] = (float) $price_raw;
+ }
+ }
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( isset( $_GET[ self::QP_SUCCESS ] ) ) {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $success = esc_url_raw( wp_unslash( $_GET[ self::QP_SUCCESS ] ) );
+ if ( $success ) {
+ $params['success'] = $success;
+ }
}
return $params;
@@ -330,14 +467,10 @@ public static function maybe_replace_cart() {
return;
}
- $qp = self::get_query_params();
- $product_id = self::get_block_product_id( $post );
- $quantity = $qp['qty'] ?? 1;
-
- // fc_variation overrides the block attribute.
- if ( ! empty( $qp['variation'] ) ) {
- $product_id = $qp['variation'];
- }
+ $qp = self::get_query_params();
+ $attrs = self::get_block_attributes( $post );
+ $product_id = self::resolve_effective_product_id( $attrs, $qp );
+ $quantity = $qp['qty'] ?? 1;
if ( ! $product_id ) {
return;
@@ -355,9 +488,10 @@ public static function maybe_replace_cart() {
$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;
+ $cart_pid = $product->is_type( 'variation' )
+ ? (int) $item['variation_id']
+ : (int) $item['product_id'];
+ $matches_id = $cart_pid === $product_id;
$matches_qty = (int) $quantity === (int) $item['quantity'];
// NYP products require a valid nyp value to checkout. If the cart
@@ -381,9 +515,12 @@ public static function maybe_replace_cart() {
$cart_item_data['_fc_success_url'] = $qp['success'];
}
- // Handle Name Your Price: fc_price > suggested > minimum.
+ // Handle Name Your Price: fc_price > nyp_price attr > 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 && ! empty( $attrs['nyp_price'] ) && is_numeric( $attrs['nyp_price'] ) ) {
+ $price = (float) $attrs['nyp_price'];
+ }
if ( null === $price ) {
$price = (float) \WC_Name_Your_Price_Helpers::get_suggested_price( $product_id );
}
@@ -446,7 +583,8 @@ public static function maybe_replace_cart() {
*/
public static function filter_render( $content, $block ) {
$attrs = $block['attrs'] ?? [];
- $product_id = self::resolve_product_id_from_attrs( $attrs );
+ $qp = self::get_query_params();
+ $product_id = self::resolve_effective_product_id( $attrs, $qp );
if ( $product_id && function_exists( 'wc_get_product' ) ) {
$product = wc_get_product( $product_id );
@@ -500,7 +638,7 @@ private static function get_after_success_url( $post_id ) {
if ( ! $post ) {
return '';
}
- $blocks = parse_blocks( $post->post_content );
+ $blocks = self::get_parsed_blocks( $post );
$block = self::find_fast_checkout_block( $blocks );
if ( ! $block ) {
return '';
@@ -550,6 +688,111 @@ public static function attach_line_item_meta( $item, $cart_item_key, $values, $o
}
}
+ /**
+ * Filter handler that adapts the WC Store API add-to-cart payload to inject
+ * NYP cart_item_data when the reader-set price is present in the request.
+ *
+ * @param array $request_data WC's normalized cart_item_data array.
+ * @param \WP_REST_Request $request Underlying REST request.
+ * @return array
+ */
+ public static function store_api_nyp_bridge_handler( $request_data, $request ) {
+ $product_id = isset( $request_data['product_id'] ) ? (int) $request_data['product_id'] : 0;
+ if ( ! $product_id && method_exists( $request, 'get_param' ) ) {
+ $product_id = (int) $request->get_param( 'id' );
+ }
+ $cart_item_data = $request_data['cart_item_data'] ?? [];
+ $cart_item_data = self::propagate_source_post( $cart_item_data, $request );
+ $cart_item_data = self::store_api_nyp_bridge( $cart_item_data, $product_id, $request );
+ $request_data['cart_item_data'] = $cart_item_data;
+ return $request_data;
+ }
+
+ /**
+ * Propagate the Fast Checkout source-post marker from the Store API
+ * request into cart_item_data, so cart items added via selector swaps
+ * remain identifiable as Fast Checkout items (drives edit-link
+ * suppression, post-purchase redirect, and order line item meta).
+ *
+ * @param array $cart_item_data Existing cart item data.
+ * @param \WP_REST_Request $request REST request.
+ * @return array
+ */
+ public static function propagate_source_post( $cart_item_data, $request ) {
+ if ( isset( $cart_item_data[ self::CART_ITEM_SOURCE_KEY ] ) ) {
+ return $cart_item_data;
+ }
+ $body = method_exists( $request, 'get_body_params' ) ? $request->get_body_params() : [];
+ $raw = $body[ self::CART_ITEM_SOURCE_KEY ] ?? null;
+ if ( null === $raw && method_exists( $request, 'get_json_params' ) ) {
+ $json = $request->get_json_params();
+ $raw = is_array( $json ) ? ( $json[ self::CART_ITEM_SOURCE_KEY ] ?? null ) : null;
+ }
+ if ( $raw && is_numeric( $raw ) && (int) $raw > 0 ) {
+ $cart_item_data[ self::CART_ITEM_SOURCE_KEY ] = (int) $raw;
+ }
+ return $cart_item_data;
+ }
+
+ /**
+ * Pure helper: inject the NYP value into cart_item_data when the request
+ * body carries one and the target product is NYP-eligible. Clamps to min/max.
+ *
+ * @param array $cart_item_data Existing cart item data.
+ * @param int $product_id Target product.
+ * @param \WP_REST_Request $request REST request.
+ * @return array
+ */
+ public static function store_api_nyp_bridge( $cart_item_data, $product_id, $request ) {
+ if ( ! class_exists( '\WC_Name_Your_Price_Helpers' ) ) {
+ return $cart_item_data;
+ }
+ if ( ! \WC_Name_Your_Price_Helpers::is_nyp( $product_id ) ) {
+ return $cart_item_data;
+ }
+ $body = method_exists( $request, 'get_body_params' ) ? $request->get_body_params() : [];
+ $raw = $body['nyp'] ?? null;
+ if ( null === $raw && method_exists( $request, 'get_json_params' ) ) {
+ $json = $request->get_json_params();
+ $raw = is_array( $json ) ? ( $json['nyp'] ?? null ) : null;
+ }
+ // No explicit price provided — fall back to the product's suggested
+ // price, then minimum. Lets selector swaps (which don't carry an nyp
+ // value) succeed for grouped/variable products with NYP children.
+ if ( null === $raw || ! is_numeric( $raw ) ) {
+ $raw = \WC_Name_Your_Price_Helpers::get_suggested_price( $product_id );
+ if ( ! is_numeric( $raw ) || (float) $raw <= 0 ) {
+ $raw = \WC_Name_Your_Price_Helpers::get_minimum_price( $product_id );
+ }
+ if ( ! is_numeric( $raw ) || (float) $raw <= 0 ) {
+ return $cart_item_data;
+ }
+ }
+ $price = (float) $raw;
+ $min_price = \WC_Name_Your_Price_Helpers::get_minimum_price( $product_id );
+ $max_price = \WC_Name_Your_Price_Helpers::get_maximum_price( $product_id );
+ $price = ! empty( $max_price ) ? min( $price, (float) $max_price ) : $price;
+ $price = ! empty( $min_price ) ? max( $price, (float) $min_price ) : $price;
+ $cart_item_data['nyp'] = (float) \WC_Name_Your_Price_Helpers::standardize_number( $price );
+ return $cart_item_data;
+ }
+
+ /**
+ * Suppress WC Name Your Price's "Edit price" link in the checkout cart
+ * summary for cart items added via Fast Checkout — the reader edits the
+ * amount through our selector blocks instead.
+ *
+ * @param bool $show Whether the link should render.
+ * @param array $cart_item The cart item.
+ * @return bool
+ */
+ public static function suppress_nyp_edit_link( $show, $cart_item ) {
+ if ( is_array( $cart_item ) && isset( $cart_item[ self::CART_ITEM_SOURCE_KEY ] ) ) {
+ return false;
+ }
+ return $show;
+ }
+
/**
* Add product context keys to core block metadata.
*
diff --git a/src/blocks/fast-checkout-donate-selector/block.json b/src/blocks/fast-checkout-donate-selector/block.json
new file mode 100644
index 000000000..8a7394248
--- /dev/null
+++ b/src/blocks/fast-checkout-donate-selector/block.json
@@ -0,0 +1,25 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "newspack-blocks/fast-checkout-donate-selector",
+ "title": "Fast Checkout — Donate Selector",
+ "category": "newspack",
+ "description": "Frequency radios with a shared Name Your Price input for a grouped donate product.",
+ "keywords": [ "woocommerce", "checkout", "donate", "subscription", "fast" ],
+ "textdomain": "newspack-blocks",
+ "parent": [ "newspack-blocks/fast-checkout" ],
+ "usesContext": [
+ "newspack-blocks/fastCheckoutProductId",
+ "newspack-blocks/fastCheckoutVariationId",
+ "newspack-blocks/fastCheckoutGroupedChild",
+ "newspack-blocks/fastCheckoutNypPrice"
+ ],
+ "attributes": {},
+ "supports": {
+ "html": false,
+ "reusable": false,
+ "spacing": { "padding": true, "margin": true }
+ },
+ "editorStyle": "newspack-blocks-editor",
+ "style": "newspack-blocks-fast-checkout-donate-selector"
+}
diff --git a/src/blocks/fast-checkout-donate-selector/edit.tsx b/src/blocks/fast-checkout-donate-selector/edit.tsx
new file mode 100644
index 000000000..f1413f672
--- /dev/null
+++ b/src/blocks/fast-checkout-donate-selector/edit.tsx
@@ -0,0 +1,132 @@
+import { __ } from '@wordpress/i18n';
+import { useEffect, useState } from '@wordpress/element';
+import apiFetch from '@wordpress/api-fetch';
+import { useBlockProps } from '@wordpress/block-editor';
+import { Spinner } from '@wordpress/components';
+
+import type { StoreApiProduct } from '../fast-checkout/types';
+
+interface ChildPreview {
+ id: number;
+ name: string;
+}
+
+/**
+ * Best-effort frequency derivation from a (prefix-stripped) child name.
+ * Mirrors the canonical period-meta sort the SSR uses, but operates on
+ * naming conventions because Store API doesn't expose subscription period.
+ *
+ * @param name Child product name (with parent prefix already stripped).
+ * @return Rank (for sorting) and frequency-derived label, or the raw name
+ * for unrecognized frequencies (which sort last).
+ */
+function frequencyFromName( name: string ): { rank: number; label: string } {
+ const lower = name.toLowerCase();
+ if ( lower.includes( 'one-time' ) || lower.includes( 'one time' ) || lower.includes( 'once' ) ) {
+ return { rank: 0, label: __( 'One-time donation', 'newspack-blocks' ) };
+ }
+ if ( lower.includes( 'daily' ) || lower.includes( 'day' ) ) {
+ return { rank: 1, label: __( 'Daily donation', 'newspack-blocks' ) };
+ }
+ if ( lower.includes( 'weekly' ) || lower.includes( 'week' ) ) {
+ return { rank: 2, label: __( 'Weekly donation', 'newspack-blocks' ) };
+ }
+ if ( lower.includes( 'monthly' ) || lower.includes( 'month' ) ) {
+ return { rank: 3, label: __( 'Monthly donation', 'newspack-blocks' ) };
+ }
+ if ( lower.includes( 'yearly' ) || lower.includes( 'year' ) || lower.includes( 'annual' ) ) {
+ return { rank: 4, label: __( 'Yearly donation', 'newspack-blocks' ) };
+ }
+ return { rank: 99, label: name };
+}
+
+interface EditProps {
+ context: {
+ 'newspack-blocks/fastCheckoutProductId'?: string | number;
+ };
+}
+
+export default function Edit( { context }: EditProps ) {
+ const productId = parseInt( String( context?.[ 'newspack-blocks/fastCheckoutProductId' ] ?? 0 ), 10 );
+ const blockProps = useBlockProps();
+ const [ children, setChildren ] = useState< ChildPreview[] | null >( null );
+
+ useEffect( () => {
+ if ( ! productId ) {
+ setChildren( null );
+ return;
+ }
+ apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ productId }` } )
+ .then( product => {
+ const parentPrefix = `${ product?.name || '' }: `;
+ const ids = product?.grouped_products || [];
+ if ( ! ids.length ) {
+ setChildren( [] );
+ return;
+ }
+ return Promise.all(
+ ids.map( id => apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ id }` } ).catch( () => null ) )
+ ).then( fetched => {
+ const list = fetched
+ .filter( ( c ): c is StoreApiProduct => Boolean( c ) )
+ .map( c => {
+ const stripped = c.name.startsWith( parentPrefix ) ? c.name.slice( parentPrefix.length ) : c.name;
+ const { rank, label } = frequencyFromName( stripped );
+ return { id: c.id, name: label, rank };
+ } );
+ list.sort( ( a, b ) => a.rank - b.rank );
+ setChildren( list.map( ( { id, name } ) => ( { id, name } ) ) );
+ } );
+ } )
+ .catch( () => setChildren( [] ) );
+ }, [ productId ] );
+
+ if ( ! productId ) {
+ return (
+
+ { __( 'Pick a grouped Donate product on the parent Fast Checkout block.', 'newspack-blocks' ) }
+
+ );
+ }
+
+ if ( null === children ) {
+ return (
+
+
+
+ );
+ }
+
+ if ( ! children.length ) {
+ return (
+
+ { __( 'No grouped children found for this product.', 'newspack-blocks' ) }
+
+ );
+ }
+
+ const inputId = 'fc-donate-edit-amount';
+
+ return (
+
+
+ { children.map( child => {
+ const id = `preview-donate-${ child.id }`;
+ return (
+
+ );
+ } ) }
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/blocks/fast-checkout-donate-selector/editor.scss b/src/blocks/fast-checkout-donate-selector/editor.scss
new file mode 100644
index 000000000..07f35fd69
--- /dev/null
+++ b/src/blocks/fast-checkout-donate-selector/editor.scss
@@ -0,0 +1,10 @@
+.wp-block-newspack-blocks-fast-checkout-donate-selector {
+ pointer-events: none;
+ opacity: 0.85;
+
+ // view.scss sets a 1.25em font-size on the amount input that compounds
+ // against the editor canvas cascade; normalize for the preview.
+ input[ type="number" ] {
+ font-size: 14px;
+ }
+}
diff --git a/src/blocks/fast-checkout-donate-selector/editor.ts b/src/blocks/fast-checkout-donate-selector/editor.ts
new file mode 100644
index 000000000..e87af3966
--- /dev/null
+++ b/src/blocks/fast-checkout-donate-selector/editor.ts
@@ -0,0 +1,9 @@
+/**
+ * Fast Checkout Donate Selector — editor bundle entry.
+ */
+
+import { registerBlockType } from '@wordpress/blocks';
+import { name, settings } from './index';
+import './editor.scss';
+
+registerBlockType( name, settings );
diff --git a/src/blocks/fast-checkout-donate-selector/index.tsx b/src/blocks/fast-checkout-donate-selector/index.tsx
new file mode 100644
index 000000000..8f50109b3
--- /dev/null
+++ b/src/blocks/fast-checkout-donate-selector/index.tsx
@@ -0,0 +1,18 @@
+import { __ } from '@wordpress/i18n';
+import metadata from './block.json';
+import edit from './edit';
+import './view.scss';
+
+export const name: string = metadata.name;
+export const title: string = __( 'Fast Checkout — Donate Selector', 'newspack-blocks' );
+
+function save() {
+ return null;
+}
+
+export const settings = {
+ ...metadata,
+ title,
+ edit,
+ save,
+};
diff --git a/src/blocks/fast-checkout-donate-selector/view.php b/src/blocks/fast-checkout-donate-selector/view.php
new file mode 100644
index 000000000..9cb27041f
--- /dev/null
+++ b/src/blocks/fast-checkout-donate-selector/view.php
@@ -0,0 +1,314 @@
+ __NAMESPACE__ . '\\render_block',
+ ]
+ );
+}
+add_action( 'init', __NAMESPACE__ . '\\register_block' );
+
+/**
+ * Enqueue the frontend view bundle when this block is present on the page.
+ */
+function enqueue_assets() {
+ if ( is_admin() || ! is_singular() ) {
+ return;
+ }
+ $post = get_post();
+ if ( ! $post || ! has_block( BLOCK_NAME, $post ) ) {
+ return;
+ }
+ \Newspack_Blocks::enqueue_view_assets( BLOCK_SLUG );
+}
+add_action( 'wp_enqueue_scripts', __NAMESPACE__ . '\\enqueue_assets' );
+
+/**
+ * Get the WC Subscriptions period for a product, or empty string.
+ *
+ * @param int $product_id Product ID.
+ * @return string `month`, `year`, etc. — or empty string for one-time.
+ */
+function get_subscription_period( $product_id ) {
+ $period = get_post_meta( $product_id, '_subscription_period', true );
+ return is_string( $period ) ? $period : '';
+}
+
+/**
+ * Get the NYP min/max/suggested for a product (or empty array if not NYP).
+ *
+ * @param int $product_id Product ID.
+ * @return array{min:string,max:string,suggested:string}|null
+ */
+function get_nyp_config( $product_id ) {
+ if ( ! class_exists( '\WC_Name_Your_Price_Helpers' ) ) {
+ return null;
+ }
+ if ( ! \WC_Name_Your_Price_Helpers::is_nyp( $product_id ) ) {
+ return null;
+ }
+ return [
+ 'min' => (string) \WC_Name_Your_Price_Helpers::get_minimum_price( $product_id ),
+ 'max' => (string) \WC_Name_Your_Price_Helpers::get_maximum_price( $product_id ),
+ 'suggested' => (string) \WC_Name_Your_Price_Helpers::get_suggested_price( $product_id ),
+ ];
+}
+
+/**
+ * Map a WC Subscriptions period code to a "/ monthly", "/ yearly" suffix.
+ *
+ * @param string $period The period code.
+ * @return string Suffix or empty.
+ */
+function suffix_for_period( $period ) {
+ switch ( $period ) {
+ case 'day':
+ return ' / ' . __( 'daily', 'newspack-blocks' );
+ case 'week':
+ return ' / ' . __( 'weekly', 'newspack-blocks' );
+ case 'month':
+ return ' / ' . __( 'monthly', 'newspack-blocks' );
+ case 'year':
+ return ' / ' . __( 'yearly', 'newspack-blocks' );
+ default:
+ return '';
+ }
+}
+
+/**
+ * Map a WC Subscriptions period code to a frequency label
+ * ("One-time donation", "Monthly donation", etc.).
+ *
+ * @param string $period The period code.
+ * @return string Label.
+ */
+function label_for_period( $period ) {
+ switch ( $period ) {
+ case 'day':
+ return __( 'Daily donation', 'newspack-blocks' );
+ case 'week':
+ return __( 'Weekly donation', 'newspack-blocks' );
+ case 'month':
+ return __( 'Monthly donation', 'newspack-blocks' );
+ case 'year':
+ return __( 'Yearly donation', 'newspack-blocks' );
+ default:
+ return __( 'One-time donation', 'newspack-blocks' );
+ }
+}
+
+/**
+ * Render the donate selector SSR shell.
+ *
+ * @param array $attrs Block attributes.
+ * @param string $content Inner content (unused).
+ * @param object $block Block instance with context.
+ * @return string Rendered HTML.
+ */
+function render_block( $attrs, $content, $block ) {
+ if ( ! function_exists( 'wc_get_product' ) ) {
+ return '';
+ }
+
+ $product_id = (int) ( $block->context['newspack-blocks/fastCheckoutProductId'] ?? 0 );
+ if ( ! $product_id ) {
+ return '';
+ }
+ $product = wc_get_product( $product_id );
+ if ( ! $product || ! $product->is_type( 'grouped' ) ) {
+ return '';
+ }
+
+ $child_ids = array_map( 'intval', $product->get_children() );
+ if ( empty( $child_ids ) ) {
+ return '';
+ }
+
+ // Build per-child data and skip non-NYP / non-purchasable children.
+ $children = [];
+ foreach ( $child_ids as $child_id ) {
+ $child = wc_get_product( $child_id );
+ if ( ! $child || ! $child->is_purchasable() ) {
+ continue;
+ }
+ $nyp_config = get_nyp_config( $child_id );
+ if ( null === $nyp_config ) {
+ // Donate selector targets NYP children. Skip non-NYP.
+ continue;
+ }
+ $period = get_subscription_period( $child_id );
+ $children[] = [
+ 'id' => $child_id,
+ 'name' => label_for_period( $period ),
+ 'period' => $period,
+ 'min' => $nyp_config['min'],
+ 'max' => $nyp_config['max'],
+ 'suggested' => $nyp_config['suggested'],
+ ];
+ }
+
+ if ( empty( $children ) ) {
+ return '';
+ }
+
+ // Sort by frequency: one-time → daily → weekly → monthly → yearly.
+ $period_rank = [
+ '' => 0,
+ 'day' => 1,
+ 'week' => 2,
+ 'month' => 3,
+ 'year' => 4,
+ ];
+ usort(
+ $children,
+ function ( $a, $b ) use ( $period_rank ) {
+ return ( $period_rank[ $a['period'] ] ?? 99 ) - ( $period_rank[ $b['period'] ] ?? 99 );
+ }
+ );
+
+ // Resolve the currently-selected child: query param > context attribute > first.
+ $current_child = (int) ( $block->context['newspack-blocks/fastCheckoutGroupedChild'] ?? 0 );
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( isset( $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] ) ) {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ $qp_raw = wp_unslash( $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] );
+ $qp = (int) filter_var( $qp_raw, FILTER_SANITIZE_NUMBER_INT );
+ if ( $qp > 0 ) {
+ $current_child = $qp;
+ }
+ }
+ $valid_ids = array_column( $children, 'id' );
+ if ( ! $current_child || ! in_array( $current_child, $valid_ids, true ) ) {
+ $current_child = (int) $valid_ids[0];
+ }
+
+ $current = null;
+ foreach ( $children as $c ) {
+ if ( $c['id'] === $current_child ) {
+ $current = $c;
+ break;
+ }
+ }
+ if ( null === $current ) {
+ return '';
+ }
+
+ // Resolve the initial input value: fc_price > attribute > current child's suggested.
+ $initial_amount = $current['suggested'];
+ $attr_price = $block->context['newspack-blocks/fastCheckoutNypPrice'] ?? '';
+ if ( $attr_price && is_numeric( $attr_price ) ) {
+ $initial_amount = (string) $attr_price;
+ }
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( isset( $_GET[ Fast_Checkout::QP_PRICE ] ) ) {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
+ $qp_price = sanitize_text_field( wp_unslash( $_GET[ Fast_Checkout::QP_PRICE ] ) );
+ if ( $qp_price && is_numeric( $qp_price ) ) {
+ $initial_amount = $qp_price;
+ }
+ }
+ if ( $current['max'] && (float) $initial_amount > (float) $current['max'] ) {
+ $initial_amount = $current['max'];
+ }
+ if ( $current['min'] && (float) $initial_amount < (float) $current['min'] ) {
+ $initial_amount = $current['min'];
+ }
+
+ $wrapper_attributes = get_block_wrapper_attributes(
+ [
+ 'data-product-id' => (string) $product_id,
+ 'data-source-post' => (string) get_the_ID(),
+ 'data-current-child' => (string) $current_child,
+ 'data-children' => wp_json_encode( $children ),
+ ]
+ );
+
+ $input_id = 'fc-donate-amount-' . $product_id;
+ $suffix_text = suffix_for_period( $current['period'] );
+ $range_label = '';
+ if ( $current['min'] && $current['max'] ) {
+ $range_label = sprintf(
+ /* translators: 1: min price, 2: max price, 3: suggested price */
+ __( '%1$s – %2$s · suggested %3$s', 'newspack-blocks' ),
+ wc_price( $current['min'] ),
+ wc_price( $current['max'] ),
+ wc_price( $current['suggested'] )
+ );
+ }
+
+ ob_start();
+ ?>
+
+ ( currentChildId );
+ const [ inFlight, setInFlight ] = useState( false );
+ const inFlightRef = useRef< boolean >( false );
+ const [ error, setError ] = useState< string >( '' );
+
+ const input = useMemo< HTMLInputElement | null >( () => host.querySelector< HTMLInputElement >( 'input[type="number"]' ), [ host ] );
+ const suffixNode = useMemo< HTMLElement | null >(
+ () => host.querySelector( '.wp-block-newspack-blocks-fast-checkout-donate-selector__suffix' ),
+ [ host ]
+ );
+ const noticeNode = useMemo< HTMLElement | null >(
+ () => host.querySelector( '.wp-block-newspack-blocks-fast-checkout-donate-selector__notice' ),
+ [ host ]
+ );
+
+ const lastApplied = useRef< { childId: number; amount: number } >( {
+ childId: currentChildId,
+ amount: parseFloat( input?.value || '0' ) || 0,
+ } );
+ const blurTimer = useRef< ReturnType< typeof setTimeout > | null >( null );
+
+ const findChild = ( id: number ) => children.find( c => c.id === id );
+
+ useEffect( () => {
+ if ( noticeNode ) {
+ if ( error ) {
+ noticeNode.textContent = error;
+ noticeNode.removeAttribute( 'hidden' );
+ } else {
+ noticeNode.textContent = '';
+ noticeNode.setAttribute( 'hidden', '' );
+ }
+ }
+ }, [ error, noticeNode ] );
+
+ useEffect( () => {
+ host.dataset.status = inFlight ? 'busy' : 'idle';
+ const fastCheckout = host.closest< HTMLElement >( '.wp-block-newspack-blocks-fast-checkout' );
+ if ( fastCheckout ) {
+ if ( inFlight ) {
+ fastCheckout.dataset.fcSwapping = 'true';
+ } else {
+ delete fastCheckout.dataset.fcSwapping;
+ }
+ }
+ }, [ host, inFlight ] );
+
+ // Apply the cart change for the currently-selected child + amount.
+ const applySelection = async ( nextChildId: number, nextAmount: number ) => {
+ if ( inFlightRef.current ) {
+ return;
+ }
+ inFlightRef.current = true;
+ setInFlight( true );
+ try {
+ const cartActions = dispatch( STORE );
+ const cartSelectors = select( STORE );
+ const items = cartSelectors.getCartData()?.items || [];
+ // Remove ALL cart items matching any of this donate group's children
+ // (not just the one we think is there) so stale duplicates from
+ // earlier swaps or partial failures don't accumulate.
+ const childIds = new Set( children.map( c => c.id ) );
+ const toRemove: string[] = [];
+ items.forEach( ( item: { id?: number; key?: string } ) => {
+ if ( item.id && item.key && childIds.has( item.id ) ) {
+ toRemove.push( item.key );
+ }
+ } );
+ for ( const key of toRemove ) {
+ await ( cartActions as { removeItemFromCart: ( key: string ) => Promise< unknown > } ).removeItemFromCart( key );
+ }
+ const sourcePost = parseInt( host.dataset.sourcePost || '0', 10 );
+ const cartItemData: Record< string, unknown > = { nyp: nextAmount };
+ if ( sourcePost ) {
+ cartItemData._newspack_fast_checkout_source_post = sourcePost;
+ }
+ await (
+ cartActions as {
+ addItemToCart: ( id: number, qty: number, variation?: unknown[], cartItemData?: Record< string, unknown > ) => Promise< unknown >;
+ }
+ ).addItemToCart( nextChildId, 1, [], cartItemData );
+ lastApplied.current = { childId: nextChildId, amount: nextAmount };
+ updateUrlParam( 'fc_grouped_child', String( nextChildId ) );
+ updateUrlParam( 'fc_price', String( nextAmount ) );
+ setError( '' );
+ } catch ( ex: unknown ) {
+ setError( ( ex as Error )?.message || __( 'Could not update selection.', 'newspack-blocks' ) );
+ } finally {
+ inFlightRef.current = false;
+ setInFlight( false );
+ }
+ };
+
+ // Radio change → swap to the new child, preserving the current amount
+ // (clamped to the new child's min/max). Update suffix and input bounds.
+ useEffect( () => {
+ const onRadioChange = async ( e: Event ) => {
+ const target = e.target as HTMLInputElement;
+ if ( target?.type !== 'radio' || target.name !== 'fc_donate_child' ) {
+ return;
+ }
+ const nextId = parseInt( target.value, 10 );
+ if ( ! nextId || nextId === pendingId ) {
+ return;
+ }
+ const child = findChild( nextId );
+ if ( ! child ) {
+ return;
+ }
+ const minNum = parseFloat( child.min ) || 0;
+ const maxNum = parseFloat( child.max ) || 0;
+ const currentAmount = parseFloat( input?.value || '0' ) || 0;
+ let nextAmount = currentAmount > 0 ? currentAmount : parseFloat( child.suggested ) || 0;
+ if ( maxNum > 0 ) {
+ nextAmount = Math.min( nextAmount, maxNum );
+ }
+ if ( minNum > 0 ) {
+ nextAmount = Math.max( nextAmount, minNum );
+ }
+ if ( input ) {
+ input.value = String( nextAmount );
+ if ( minNum > 0 ) {
+ input.min = String( minNum );
+ } else {
+ input.removeAttribute( 'min' );
+ }
+ if ( maxNum > 0 ) {
+ input.max = String( maxNum );
+ } else {
+ input.removeAttribute( 'max' );
+ }
+ }
+ if ( suffixNode ) {
+ suffixNode.textContent = suffixForPeriod( child.period );
+ }
+ host.dataset.currentChild = String( nextId );
+ setPendingId( nextId );
+ await applySelection( nextId, nextAmount );
+ };
+ host.addEventListener( 'change', onRadioChange );
+ return () => host.removeEventListener( 'change', onRadioChange );
+ }, [ host, pendingId, input, suffixNode, children ] );
+
+ // Input blur (debounced) → apply NYP price for the currently-selected child.
+ useEffect( () => {
+ if ( ! input ) {
+ return;
+ }
+ const onBlur = () => {
+ if ( blurTimer.current ) {
+ clearTimeout( blurTimer.current );
+ }
+ blurTimer.current = setTimeout( apply, DEBOUNCE_MS );
+ };
+
+ const apply = async () => {
+ if ( inFlightRef.current ) {
+ return;
+ }
+ const child = findChild( pendingId );
+ if ( ! child ) {
+ return;
+ }
+ const raw = parseFloat( input.value );
+ if ( ! isFinite( raw ) || raw <= 0 ) {
+ setError( __( 'Enter a valid amount.', 'newspack-blocks' ) );
+ input.value = String( lastApplied.current.amount );
+ return;
+ }
+ let clamped = raw;
+ const maxNum = parseFloat( child.max ) || 0;
+ const minNum = parseFloat( child.min ) || 0;
+ if ( maxNum > 0 ) {
+ clamped = Math.min( clamped, maxNum );
+ }
+ if ( minNum > 0 ) {
+ clamped = Math.max( clamped, minNum );
+ }
+ if ( clamped !== raw ) {
+ input.value = String( clamped );
+ setError(
+ sprintf(
+ /* translators: %s is the price actually applied. */
+ __( 'Adjusted to %s to meet limits.', 'newspack-blocks' ),
+ String( clamped )
+ )
+ );
+ } else {
+ setError( '' );
+ }
+ if ( clamped === lastApplied.current.amount && pendingId === lastApplied.current.childId ) {
+ return;
+ }
+ await applySelection( pendingId, clamped );
+ };
+
+ input.addEventListener( 'blur', onBlur );
+ return () => {
+ input.removeEventListener( 'blur', onBlur );
+ if ( blurTimer.current ) {
+ clearTimeout( blurTimer.current );
+ }
+ };
+ }, [ input, pendingId, children ] );
+
+ // popstate: re-sync from URL params on back/forward navigation.
+ useEffect( () => {
+ const onPopstate = () => {
+ const params = new URLSearchParams( window.location.search );
+ const nextId = parseInt( params.get( 'fc_grouped_child' ) || '', 10 );
+ const nextPrice = parseFloat( params.get( 'fc_price' ) || '' );
+ if ( nextId && nextId !== pendingId ) {
+ const radio = host.querySelector< HTMLInputElement >( `input[type="radio"][value="${ nextId }"]` );
+ if ( radio ) {
+ radio.checked = true;
+ setPendingId( nextId );
+ const child = findChild( nextId );
+ if ( child && suffixNode ) {
+ suffixNode.textContent = suffixForPeriod( child.period );
+ }
+ }
+ }
+ if ( isFinite( nextPrice ) && nextPrice > 0 && input ) {
+ input.value = String( nextPrice );
+ }
+ };
+ window.addEventListener( 'popstate', onPopstate );
+ return () => window.removeEventListener( 'popstate', onPopstate );
+ }, [ host, pendingId, input, suffixNode ] );
+
+ return null;
+}
+
+function updateUrlParam( key: string, value: string ) {
+ const url = new URL( window.location.href );
+ url.searchParams.set( key, value );
+ window.history.replaceState( {}, '', url.toString() );
+}
+
+function init() {
+ document.querySelectorAll< HTMLFormElement >( '.wp-block-newspack-blocks-fast-checkout-donate-selector' ).forEach( host => {
+ const productId = parseInt( host.dataset.productId || '0', 10 );
+ const currentChildId = parseInt( host.dataset.currentChild || '0', 10 );
+ let children: ChildData[] = [];
+ try {
+ children = JSON.parse( host.dataset.children || '[]' );
+ } catch {
+ children = [];
+ }
+ if ( ! productId || ! children.length || ! currentChildId ) {
+ return;
+ }
+ const sentinel = document.createElement( 'span' );
+ sentinel.style.display = 'none';
+ host.appendChild( sentinel );
+ const root = createRoot( sentinel );
+ root.render( );
+ } );
+}
+
+if ( document.readyState === 'loading' ) {
+ document.addEventListener( 'DOMContentLoaded', init );
+} else {
+ init();
+}
diff --git a/src/blocks/fast-checkout/block.json b/src/blocks/fast-checkout/block.json
index 18c347fc3..0bbe1acf1 100644
--- a/src/blocks/fast-checkout/block.json
+++ b/src/blocks/fast-checkout/block.json
@@ -11,11 +11,17 @@
"product": { "type": "string" },
"variation": { "type": "string" },
"is_variable": { "type": "boolean", "default": false },
+ "is_grouped": { "type": "boolean", "default": false },
+ "is_nyp": { "type": "boolean", "default": false },
+ "grouped_child": { "type": "string" },
+ "nyp_price": { "type": "string" },
"afterSuccessURL": { "type": "string" }
},
"providesContext": {
"newspack-blocks/fastCheckoutProductId": "product",
- "newspack-blocks/fastCheckoutVariationId": "variation"
+ "newspack-blocks/fastCheckoutVariationId": "variation",
+ "newspack-blocks/fastCheckoutGroupedChild": "grouped_child",
+ "newspack-blocks/fastCheckoutNypPrice": "nyp_price"
},
"supports": {
"anchor": true,
diff --git a/src/blocks/fast-checkout/edit.tsx b/src/blocks/fast-checkout/edit.tsx
index 9bed70c65..f68c77810 100644
--- a/src/blocks/fast-checkout/edit.tsx
+++ b/src/blocks/fast-checkout/edit.tsx
@@ -3,12 +3,12 @@
*/
import { __ } from '@wordpress/i18n';
-import { useEffect, useState } from '@wordpress/element';
+import { useEffect, useRef, useState } from '@wordpress/element';
import apiFetch from '@wordpress/api-fetch';
import { debounce } from 'lodash';
import { InnerBlocks, InspectorControls, useBlockProps } from '@wordpress/block-editor';
import { useSelect, useDispatch } from '@wordpress/data';
-import { PanelBody, BaseControl, TextControl, Button, Spinner, FormTokenField, SelectControl, Placeholder } from '@wordpress/components';
+import { PanelBody, BaseControl, TextControl, Button, Spinner, FormTokenField, SelectControl, Placeholder, Notice } from '@wordpress/components';
import { DEFAULT_TEMPLATE } from './template';
import { fetchProduct } from './bindings-source';
@@ -129,6 +129,112 @@ function VariationPicker( { productId, variationId, onChange }: VariationPickerP
);
}
+interface GroupedChildPickerProps {
+ productId: string;
+ childId?: string;
+ onChange: ( id: string ) => void;
+}
+
+function GroupedChildPicker( { productId, childId, onChange }: GroupedChildPickerProps ) {
+ const [ children, setChildren ] = useState< { id: number; name: string }[] >( [] );
+
+ useEffect( () => {
+ if ( ! productId ) {
+ setChildren( [] );
+ return;
+ }
+ apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ productId }` } )
+ .then( product => {
+ const ids = product?.grouped_products || [];
+ if ( ! ids.length ) {
+ setChildren( [] );
+ return;
+ }
+ return Promise.all(
+ ids.map( id => apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ id }` } ).catch( () => null ) )
+ ).then( results => {
+ setChildren( results.filter( ( c ): c is StoreApiProduct => Boolean( c ) ).map( c => ( { id: c.id, name: c.name } ) ) );
+ } );
+ } )
+ .catch( () => setChildren( [] ) );
+ }, [ productId ] );
+
+ if ( ! children.length ) {
+ return null;
+ }
+
+ return (
+ ( { label: c.name, value: String( c.id ) } ) ),
+ ] }
+ onChange={ onChange }
+ />
+ );
+}
+
+interface NypDefaultPriceProps {
+ productId: string;
+ value?: string;
+ onChange: ( value: string ) => void;
+}
+
+function NypDefaultPrice( { productId, value, onChange }: NypDefaultPriceProps ) {
+ const [ help, setHelp ] = useState< string >( '' );
+
+ useEffect( () => {
+ if ( ! productId ) {
+ return;
+ }
+ apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ productId }` } )
+ .then( product => {
+ const nyp = product?.extensions?.name_your_price;
+ if ( ! nyp?.is_nyp ) {
+ setHelp( '' );
+ return;
+ }
+ const min = nyp.minimum_price;
+ const max = nyp.maximum_price;
+ const suggested = nyp.suggested_price;
+ const parts: string[] = [];
+ if ( min && max ) {
+ parts.push(
+ /* translators: 1: min price, 2: max price */
+ __( 'Allowed: %1$s – %2$s.', 'newspack-blocks' ).replace( '%1$s', min ).replace( '%2$s', max )
+ );
+ }
+ if ( suggested ) {
+ parts.push(
+ /* translators: %s: suggested price */
+ __( 'Suggested: %s.', 'newspack-blocks' ).replace( '%s', suggested )
+ );
+ }
+ setHelp( parts.join( ' ' ) || __( 'Reader can set any amount.', 'newspack-blocks' ) );
+ } )
+ .catch( () => setHelp( '' ) );
+ }, [ productId ] );
+
+ return (
+
+ );
+}
+
+interface Block {
+ name: string;
+ clientId: string;
+ attributes: Record< string, unknown >;
+ innerBlocks: Block[];
+}
+
interface EditProps {
attributes: FastCheckoutAttributes;
setAttributes: ( attrs: Partial< FastCheckoutAttributes > ) => void;
@@ -137,8 +243,9 @@ interface EditProps {
export default function Edit( { attributes, setAttributes, clientId }: EditProps ) {
const { product, variation, is_variable: isVariable, afterSuccessURL } = attributes;
+ const [ groupedWarning, setGroupedWarning ] = useState< string >( '' );
const blockProps = useBlockProps();
- const { updateBlockAttributes } = useDispatch( 'core/block-editor' );
+ const { updateBlockAttributes, removeBlocks } = useDispatch( 'core/block-editor' );
// On first insert, find the checkout-actions-block and disable "Return to Cart".
const allDescendants = useSelect(
@@ -149,24 +256,107 @@ export default function Edit( { attributes, setAttributes, clientId }: EditProps
},
[ clientId ]
);
+
+ // Direct children of this block — used for selector auto-insert/auto-clean.
+ const innerBlocks = useSelect(
+ select => ( select( 'core/block-editor' ) as { getBlocks: ( id: string ) => Block[] } ).getBlocks( clientId ),
+ [ clientId ]
+ );
+
useEffect( () => {
allDescendants
.filter( b => b.name === 'woocommerce/checkout-actions-block' && b.attributes.showReturnToCart )
.forEach( b => updateBlockAttributes( b.clientId, { showReturnToCart: false } ) );
}, [ allDescendants.length ] );
- // Detect variable product when product changes.
+ // Detect product type and set flags when product changes.
useEffect( () => {
if ( ! product ) {
return;
}
apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ product }` } )
.then( p => {
- setAttributes( { is_variable: !! p?.variations?.length } );
+ const flags = {
+ is_variable: !! p?.variations?.length,
+ is_grouped: p?.type === 'grouped' || !! p?.grouped_products?.length,
+ is_nyp: !! p?.extensions?.name_your_price?.is_nyp,
+ };
+ setAttributes( flags );
+
+ if ( flags.is_grouped && p?.grouped_products?.length ) {
+ Promise.all(
+ p.grouped_products.map( id => apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ id }` } ).catch( () => null ) )
+ ).then( children => {
+ const unsupported = children.filter(
+ ( c ): c is StoreApiProduct => !! c && ( ( c.variations?.length ?? 0 ) > 0 || !! c.extensions?.name_your_price?.is_nyp )
+ );
+ if ( unsupported.length ) {
+ setGroupedWarning(
+ __(
+ 'This grouped product contains variable or Name Your Price children. Fast Checkout treats children as simple products; those types are not supported.',
+ 'newspack-blocks'
+ )
+ );
+ } else {
+ setGroupedWarning( '' );
+ }
+ } );
+ } else {
+ setGroupedWarning( '' );
+ }
} )
- .catch( () => setAttributes( { is_variable: false } ) );
+ .catch( () => {
+ setAttributes( { is_variable: false, is_grouped: false, is_nyp: false } );
+ setGroupedWarning( '' );
+ } );
}, [ product ] );
+ // Track previous type flags so we only react to transitions, not every render.
+ const previousFlags = useRef< { v: boolean; g: boolean; n: boolean } >( {
+ v: !! attributes.is_variable,
+ g: !! attributes.is_grouped,
+ n: !! attributes.is_nyp,
+ } );
+
+ // On product type transitions: clean up the donate-selector inner block
+ // when the product is no longer grouped, and reset stale type-specific
+ // attributes so they don't carry over into the new product context.
+ useEffect( () => {
+ const prev = previousFlags.current;
+ const next = {
+ v: !! attributes.is_variable,
+ g: !! attributes.is_grouped,
+ n: !! attributes.is_nyp,
+ };
+
+ if ( prev.v === next.v && prev.g === next.g && prev.n === next.n ) {
+ return;
+ }
+
+ previousFlags.current = next;
+
+ // Donate-selector is manually inserted; auto-clean on grouped → not-grouped.
+ if ( prev.g && ! next.g ) {
+ const donateIds = innerBlocks
+ .filter( ( b: Block ) => b.name === 'newspack-blocks/fast-checkout-donate-selector' )
+ .map( ( b: Block ) => b.clientId );
+ if ( donateIds.length ) {
+ removeBlocks( donateIds, false );
+ }
+ }
+
+ // Reset child/price defaults when the product type changes.
+ if ( prev.v && ! next.v && attributes.variation ) {
+ setAttributes( { variation: '' } );
+ }
+ if ( prev.g && ! next.g && attributes.grouped_child ) {
+ setAttributes( { grouped_child: '' } );
+ }
+ if ( prev.n && ! next.n && attributes.nyp_price ) {
+ setAttributes( { nyp_price: '' } );
+ }
+ }, [ attributes.is_variable, attributes.is_grouped, attributes.is_nyp, innerBlocks, removeBlocks ] );
+
if ( ! product ) {
return (
@@ -180,7 +370,11 @@ export default function Edit( { attributes, setAttributes, clientId }: EditProps
setAttributes( {
product: newId,
variation: '',
+ grouped_child: '',
+ nyp_price: '',
is_variable: false,
+ is_grouped: false,
+ is_nyp: false,
} )
}
/>
@@ -199,7 +393,11 @@ export default function Edit( { attributes, setAttributes, clientId }: EditProps
setAttributes( {
product: newId,
variation: '',
+ grouped_child: '',
+ nyp_price: '',
is_variable: false,
+ is_grouped: false,
+ is_nyp: false,
} )
}
/>
@@ -210,6 +408,25 @@ export default function Edit( { attributes, setAttributes, clientId }: EditProps
onChange={ newVariation => setAttributes( { variation: newVariation } ) }
/>
) }
+ { attributes.is_grouped && (
+
setAttributes( { grouped_child: value } ) }
+ />
+ ) }
+ { attributes.is_nyp && (
+ setAttributes( { nyp_price: value } ) }
+ />
+ ) }
+ { groupedWarning && (
+
+ { groupedWarning }
+
+ ) }
getMethod( 'get_query_params' );
+ $method->setAccessible( true );
+ return $method->invoke( null );
+ }
+
/**
* Test that a simple product attribute resolves to the product ID.
*/
@@ -186,6 +197,30 @@ public function test_filter_render_replaces_when_no_product_id() {
$this->assertStringContainsString( 'unavailable', $filtered );
}
+ /**
+ * Test that filter_render passes through for a grouped product without
+ * an editor-set grouped_child, by resolving to the first purchasable child.
+ *
+ * Regression: previously called resolve_product_id_from_attrs (attrs-only),
+ * which returned the parent grouped product ID — and grouped parents are
+ * not purchasable, so the unavailable notice rendered instead of content.
+ */
+ public function test_filter_render_passes_through_for_grouped_without_child() {
+ $this->skip_without_wc();
+
+ $child = $this->create_simple_product();
+ $grouped = $this->create_grouped_product( [ $child->get_id() ] );
+ $content = 'Buy now
';
+ $block = [
+ 'attrs' => [
+ 'product' => (string) $grouped->get_id(),
+ 'is_grouped' => true,
+ ],
+ ];
+ $filtered = Fast_Checkout::filter_render( $content, $block );
+ $this->assertSame( $content, $filtered );
+ }
+
// ---- Cart replacement tests (WC-dependent) ----
/**
@@ -225,6 +260,21 @@ private function create_simple_product() {
return $product;
}
+ /**
+ * Create a grouped WC product with the given children.
+ *
+ * @param int[] $child_ids Existing child product IDs.
+ * @return \WC_Product_Grouped
+ */
+ private function create_grouped_product( $child_ids ) {
+ $product = new \WC_Product_Grouped();
+ $product->set_name( 'Test Grouped Product' );
+ $product->set_status( 'publish' );
+ $product->set_children( $child_ids );
+ $product->save();
+ return $product;
+ }
+
/**
* Test that maybe_replace_cart does nothing when no block is present.
*/
@@ -426,6 +476,66 @@ public function test_return_url_override_ignores_unrelated_orders() {
$this->assertSame( 'https://default.com', $result );
}
+ // ---- Grouped product resolution tests ----
+
+ /**
+ * Test that a grouped product with grouped_child resolves to the child ID.
+ */
+ public function test_resolve_grouped_prefers_child() {
+ $result = Fast_Checkout::resolve_product_id_from_attrs(
+ [
+ 'product' => '42',
+ 'grouped_child' => '88',
+ 'is_grouped' => true,
+ ]
+ );
+ $this->assertSame( 88, $result );
+ }
+
+ /**
+ * Test that a grouped product without grouped_child returns the parent ID.
+ * Server-side runtime resolution to the first child happens in maybe_replace_cart.
+ */
+ public function test_resolve_grouped_without_child_returns_parent() {
+ $result = Fast_Checkout::resolve_product_id_from_attrs(
+ [
+ 'product' => '42',
+ 'is_grouped' => true,
+ ]
+ );
+ // Without runtime WC lookup, the helper returns the parent ID.
+ // Runtime resolution to the first child happens in maybe_replace_cart.
+ $this->assertSame( 42, $result );
+ }
+
+ // ---- Query param tests ----
+
+ /**
+ * Test that fc_grouped_child query param is read into params.
+ */
+ public function test_get_query_params_reads_grouped_child() {
+ $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] = '123';
+
+ $params = $this->invoke_get_query_params();
+
+ $this->assertSame( 123, $params['grouped_child'] ?? null );
+
+ unset( $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] );
+ }
+
+ /**
+ * Test that an invalid (non-numeric) fc_grouped_child is dropped.
+ */
+ public function test_get_query_params_rejects_invalid_grouped_child() {
+ $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] = 'not-a-number';
+
+ $params = $this->invoke_get_query_params();
+
+ $this->assertArrayNotHasKey( 'grouped_child', $params );
+
+ unset( $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] );
+ }
+
/**
* Test that a mismatched product in the cart is replaced.
*/
@@ -451,4 +561,284 @@ public function test_maybe_replace_cart_replaces_mismatched() {
$item = reset( $cart_contents );
$this->assertSame( $product_b->get_id(), (int) $item['product_id'] );
}
+
+ // ---- Cart replacement: grouped product tests ----
+
+ /**
+ * Test that maybe_replace_cart adds grouped_child product to cart when set.
+ */
+ public function test_maybe_replace_cart_adds_grouped_child() {
+ $this->skip_without_wc();
+
+ $child = $this->create_simple_product();
+ $grouped = $this->create_grouped_product( [ $child->get_id() ] );
+ $post_id = $this->make_fast_checkout_post(
+ $grouped->get_id(),
+ [
+ 'is_grouped' => true,
+ 'grouped_child' => (string) $child->get_id(),
+ ]
+ );
+ $this->go_to( get_permalink( $post_id ) );
+
+ Fast_Checkout::maybe_replace_cart();
+
+ $cart_contents = WC()->cart->get_cart();
+ $this->assertCount( 1, $cart_contents );
+ $item = reset( $cart_contents );
+ $this->assertSame( $child->get_id(), (int) $item['product_id'] );
+ }
+
+ /**
+ * Test that maybe_replace_cart resolves to the first child when grouped_child is empty.
+ */
+ public function test_maybe_replace_cart_grouped_falls_back_to_first_child() {
+ $this->skip_without_wc();
+
+ $first = $this->create_simple_product();
+ $second = $this->create_simple_product();
+ $grouped = $this->create_grouped_product( [ $first->get_id(), $second->get_id() ] );
+ $post_id = $this->make_fast_checkout_post(
+ $grouped->get_id(),
+ [ 'is_grouped' => true ]
+ );
+ $this->go_to( get_permalink( $post_id ) );
+
+ Fast_Checkout::maybe_replace_cart();
+
+ $cart_contents = WC()->cart->get_cart();
+ $this->assertCount( 1, $cart_contents );
+ $item = reset( $cart_contents );
+ $this->assertSame( $first->get_id(), (int) $item['product_id'] );
+ }
+
+ /**
+ * Test that fc_grouped_child query param overrides grouped_child attribute.
+ */
+ public function test_maybe_replace_cart_grouped_query_param_override() {
+ $this->skip_without_wc();
+
+ $first = $this->create_simple_product();
+ $second = $this->create_simple_product();
+ $grouped = $this->create_grouped_product( [ $first->get_id(), $second->get_id() ] );
+ $post_id = $this->make_fast_checkout_post(
+ $grouped->get_id(),
+ [
+ 'is_grouped' => true,
+ 'grouped_child' => (string) $first->get_id(),
+ ]
+ );
+
+ $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] = (string) $second->get_id();
+ $this->go_to( get_permalink( $post_id ) );
+
+ Fast_Checkout::maybe_replace_cart();
+
+ $cart_contents = WC()->cart->get_cart();
+ $this->assertCount( 1, $cart_contents );
+ $item = reset( $cart_contents );
+ $this->assertSame( $second->get_id(), (int) $item['product_id'] );
+
+ unset( $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] );
+ }
+
+ /**
+ * Test that fc_grouped_child referencing a non-child is rejected (falls back).
+ */
+ public function test_maybe_replace_cart_grouped_query_param_rejects_foreign_id() {
+ $this->skip_without_wc();
+
+ $first = $this->create_simple_product();
+ $foreign = $this->create_simple_product();
+ $grouped = $this->create_grouped_product( [ $first->get_id() ] );
+ $post_id = $this->make_fast_checkout_post(
+ $grouped->get_id(),
+ [ 'is_grouped' => true ]
+ );
+
+ $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] = (string) $foreign->get_id();
+ $this->go_to( get_permalink( $post_id ) );
+
+ Fast_Checkout::maybe_replace_cart();
+
+ $cart_contents = WC()->cart->get_cart();
+ $this->assertCount( 1, $cart_contents );
+ $item = reset( $cart_contents );
+ $this->assertSame( $first->get_id(), (int) $item['product_id'] );
+
+ unset( $_GET[ Fast_Checkout::QP_GROUPED_CHILD ] );
+ }
+
+ /**
+ * Test that maybe_replace_cart applies nyp_price attribute when fc_price is absent.
+ *
+ * Skips when WC Name Your Price plugin isn't active.
+ */
+ public function test_maybe_replace_cart_applies_nyp_attribute() {
+ $this->skip_without_wc();
+ if ( ! class_exists( '\WC_Name_Your_Price_Helpers' ) ) {
+ $this->markTestSkipped( 'WC Name Your Price not available.' );
+ }
+
+ $product = $this->create_simple_product();
+ update_post_meta( $product->get_id(), '_nyp', 'yes' );
+ update_post_meta( $product->get_id(), '_min_price', '5' );
+ update_post_meta( $product->get_id(), '_max_price', '50' );
+ update_post_meta( $product->get_id(), '_suggested_price', '15' );
+
+ $post_id = $this->make_fast_checkout_post(
+ $product->get_id(),
+ [
+ 'is_nyp' => true,
+ 'nyp_price' => '20.00',
+ ]
+ );
+ $this->go_to( get_permalink( $post_id ) );
+
+ Fast_Checkout::maybe_replace_cart();
+
+ $cart_contents = WC()->cart->get_cart();
+ $this->assertCount( 1, $cart_contents );
+ $item = reset( $cart_contents );
+ $this->assertSame( 20.0, (float) $item['nyp'] );
+ }
+
+ /**
+ * Test that the Store API NYP bridge filter applies nyp from request body.
+ */
+ public function test_store_api_nyp_bridge_applies_request_value() {
+ $this->skip_without_wc();
+ if ( ! class_exists( '\WC_Name_Your_Price_Helpers' ) ) {
+ $this->markTestSkipped( 'WC Name Your Price not available.' );
+ }
+
+ $product = $this->create_simple_product();
+ update_post_meta( $product->get_id(), '_nyp', 'yes' );
+ update_post_meta( $product->get_id(), '_min_price', '5' );
+ update_post_meta( $product->get_id(), '_max_price', '50' );
+ update_post_meta( $product->get_id(), '_suggested_price', '15' );
+
+ // Simulate Store API add_to_cart payload with cart_item_data.nyp.
+ $request = new \WP_REST_Request( 'POST', '/wc/store/v1/cart/add-item' );
+ $request->set_body_params(
+ [
+ 'id' => $product->get_id(),
+ 'quantity' => 1,
+ 'nyp' => 22.5,
+ ]
+ );
+
+ $cart_item_data = [];
+ $cart_item_data = Fast_Checkout::store_api_nyp_bridge(
+ $cart_item_data,
+ $product->get_id(),
+ $request
+ );
+
+ $this->assertSame( 22.5, $cart_item_data['nyp'] ?? null );
+ }
+
+ /**
+ * Test that the bridge falls back to the suggested price when the request
+ * has no nyp value — covers the case of grouped-selector swaps for NYP
+ * children, where addItemToCart is called without an explicit price.
+ */
+ public function test_store_api_nyp_bridge_falls_back_to_suggested() {
+ $this->skip_without_wc();
+ if ( ! class_exists( '\WC_Name_Your_Price_Helpers' ) ) {
+ $this->markTestSkipped( 'WC Name Your Price not available.' );
+ }
+
+ $product = $this->create_simple_product();
+ update_post_meta( $product->get_id(), '_nyp', 'yes' );
+ update_post_meta( $product->get_id(), '_min_price', '5' );
+ update_post_meta( $product->get_id(), '_max_price', '50' );
+ update_post_meta( $product->get_id(), '_suggested_price', '15' );
+
+ $request = new \WP_REST_Request( 'POST', '/wc/store/v1/cart/add-item' );
+ $request->set_body_params(
+ [
+ 'id' => $product->get_id(),
+ 'quantity' => 1,
+ ]
+ );
+
+ $cart_item_data = Fast_Checkout::store_api_nyp_bridge(
+ [],
+ $product->get_id(),
+ $request
+ );
+
+ $this->assertSame( 15.0, (float) ( $cart_item_data['nyp'] ?? 0 ) );
+ }
+
+ /**
+ * Test that fc_price query param overrides nyp_price attribute when both set.
+ *
+ * Skips when WC Name Your Price plugin isn't active.
+ */
+ public function test_maybe_replace_cart_nyp_query_param_overrides_attribute() {
+ $this->skip_without_wc();
+ if ( ! class_exists( '\WC_Name_Your_Price_Helpers' ) ) {
+ $this->markTestSkipped( 'WC Name Your Price not available.' );
+ }
+
+ $product = $this->create_simple_product();
+ update_post_meta( $product->get_id(), '_nyp', 'yes' );
+ update_post_meta( $product->get_id(), '_min_price', '5' );
+ update_post_meta( $product->get_id(), '_max_price', '50' );
+ update_post_meta( $product->get_id(), '_suggested_price', '15' );
+
+ $post_id = $this->make_fast_checkout_post(
+ $product->get_id(),
+ [
+ 'is_nyp' => true,
+ 'nyp_price' => '20.00',
+ ]
+ );
+
+ $_GET[ Fast_Checkout::QP_PRICE ] = '35.00';
+ $this->go_to( get_permalink( $post_id ) );
+
+ Fast_Checkout::maybe_replace_cart();
+
+ $cart_contents = WC()->cart->get_cart();
+ $this->assertCount( 1, $cart_contents );
+ $item = reset( $cart_contents );
+ $this->assertSame( 35.0, (float) $item['nyp'] );
+
+ unset( $_GET[ Fast_Checkout::QP_PRICE ] );
+ }
+
+ /**
+ * Test that maybe_replace_cart falls back to the product's suggested price
+ * when neither fc_price nor nyp_price attribute is set.
+ *
+ * Skips when WC Name Your Price plugin isn't active.
+ */
+ public function test_maybe_replace_cart_falls_back_to_suggested_nyp() {
+ $this->skip_without_wc();
+ if ( ! class_exists( '\WC_Name_Your_Price_Helpers' ) ) {
+ $this->markTestSkipped( 'WC Name Your Price not available.' );
+ }
+
+ $product = $this->create_simple_product();
+ update_post_meta( $product->get_id(), '_nyp', 'yes' );
+ update_post_meta( $product->get_id(), '_min_price', '5' );
+ update_post_meta( $product->get_id(), '_max_price', '50' );
+ update_post_meta( $product->get_id(), '_suggested_price', '15' );
+
+ $post_id = $this->make_fast_checkout_post(
+ $product->get_id(),
+ [ 'is_nyp' => true ]
+ );
+ $this->go_to( get_permalink( $post_id ) );
+
+ Fast_Checkout::maybe_replace_cart();
+
+ $cart_contents = WC()->cart->get_cart();
+ $this->assertCount( 1, $cart_contents );
+ $item = reset( $cart_contents );
+ $this->assertSame( 15.0, (float) $item['nyp'] );
+ }
}
diff --git a/webpack.config.js b/webpack.config.js
index 6a70d284e..d36f1f377 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -46,17 +46,18 @@ const blocks = fs
.filter( block => isDevelopment || blockList.production.includes( block ) )
.filter( hasEditorEntry );
-// Helps split up each block into its own folder view script
+// Helps split up each block into its own folder view script.
const viewBlocksScripts = blocks.reduce( ( viewBlocks, block ) => {
const pathToBlock = [ __dirname, 'src', 'blocks', block ];
- let viewScriptPath = path.join( ...pathToBlock, 'view.js' );
- let fileExists = fs.existsSync( viewScriptPath );
- if ( ! fileExists ) {
- // Try TS.
- viewScriptPath = path.join( ...pathToBlock, 'view.ts' );
- fileExists = fs.existsSync( viewScriptPath );
+ let viewScriptPath;
+ for ( const ext of [ 'view.js', 'view.ts', 'view.tsx' ] ) {
+ const candidate = path.join( ...pathToBlock, ext );
+ if ( fs.existsSync( candidate ) ) {
+ viewScriptPath = candidate;
+ break;
+ }
}
- if ( fileExists ) {
+ if ( viewScriptPath ) {
viewBlocks[ block + '/view' ] = viewScriptPath;
}
return viewBlocks;