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(); + ?> +
> +
+ + + + +
+ +
+ +
+ + min="" + + + max="" + + value="" + /> + +
+ +

+ +

+ + +
+
+ ( currentChildId ); + const [ inFlight, setInFlight ] = useState( false ); + const inFlightRef = useRef< boolean >( false ); + const [ error, setError ] = useState< string >( '' ); + + const input = useMemo< HTMLInputElement | null >( () => host.querySelector< HTMLInputElement >( 'input[type="number"]' ), [ host ] ); + const suffixNode = useMemo< HTMLElement | null >( + () => host.querySelector( '.wp-block-newspack-blocks-fast-checkout-donate-selector__suffix' ), + [ host ] + ); + const noticeNode = useMemo< HTMLElement | null >( + () => host.querySelector( '.wp-block-newspack-blocks-fast-checkout-donate-selector__notice' ), + [ host ] + ); + + const lastApplied = useRef< { childId: number; amount: number } >( { + childId: currentChildId, + amount: parseFloat( input?.value || '0' ) || 0, + } ); + const blurTimer = useRef< ReturnType< typeof setTimeout > | null >( null ); + + const findChild = ( id: number ) => children.find( c => c.id === id ); + + useEffect( () => { + if ( noticeNode ) { + if ( error ) { + noticeNode.textContent = error; + noticeNode.removeAttribute( 'hidden' ); + } else { + noticeNode.textContent = ''; + noticeNode.setAttribute( 'hidden', '' ); + } + } + }, [ error, noticeNode ] ); + + useEffect( () => { + host.dataset.status = inFlight ? 'busy' : 'idle'; + const fastCheckout = host.closest< HTMLElement >( '.wp-block-newspack-blocks-fast-checkout' ); + if ( fastCheckout ) { + if ( inFlight ) { + fastCheckout.dataset.fcSwapping = 'true'; + } else { + delete fastCheckout.dataset.fcSwapping; + } + } + }, [ host, inFlight ] ); + + // Apply the cart change for the currently-selected child + amount. + const applySelection = async ( nextChildId: number, nextAmount: number ) => { + if ( inFlightRef.current ) { + return; + } + inFlightRef.current = true; + setInFlight( true ); + try { + const cartActions = dispatch( STORE ); + const cartSelectors = select( STORE ); + const items = cartSelectors.getCartData()?.items || []; + // 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;