diff --git a/block-list.json b/block-list.json index 9541a4d47..39f9015bc 100644 --- a/block-list.json +++ b/block-list.json @@ -5,6 +5,7 @@ "carousel", "checkout-button", "donate", + "fast-checkout", "homepage-articles", "video-playlist", "iframe" diff --git a/includes/class-fast-checkout.php b/includes/class-fast-checkout.php new file mode 100644 index 000000000..9645f55cb --- /dev/null +++ b/includes/class-fast-checkout.php @@ -0,0 +1,578 @@ +ID ] ) ) { + return self::$post_product_cache[ $post->ID ]; + } + $blocks = parse_blocks( $post->post_content ); + $result = self::find_fast_checkout_block_product( $blocks ); + self::$post_product_cache[ $post->ID ] = $result; + return $result; + } + + /** + * Recursively search blocks for the first Fast Checkout block. + * + * @param array $blocks Parsed blocks. + * @return int|null Product ID or null. + */ + private static function find_fast_checkout_block_product( $blocks ) { + foreach ( $blocks as $block ) { + if ( self::BLOCK_NAME === $block['blockName'] ) { + return self::resolve_product_id_from_attrs( $block['attrs'] ?? [] ); + } + if ( ! empty( $block['innerBlocks'] ) ) { + $found = self::find_fast_checkout_block_product( $block['innerBlocks'] ); + if ( null !== $found ) { + return $found; + } + } + } + return null; + } + + /** + * Register the block bindings source. + */ + public static function register_bindings_source() { + if ( ! function_exists( 'register_block_bindings_source' ) ) { + return; + } + register_block_bindings_source( + self::BINDINGS_SOURCE, + [ + 'label' => __( 'Fast Checkout Product', 'newspack-blocks' ), + 'get_value_callback' => [ __CLASS__, 'bindings_get_value' ], + 'uses_context' => [ self::CONTEXT_PRODUCT_KEY, self::CONTEXT_VARIATION_KEY ], + ] + ); + } + + /** + * Resolve a product field value for block bindings. + * + * @param array $source_args Source arguments including 'field'. + * @param object $block The block instance with context. + * @param string $attribute_name The bound attribute name. + * @return string Resolved value or empty string. + */ + public static function bindings_get_value( $source_args, $block, $attribute_name ) { + $field = $source_args['field'] ?? ''; + $product_id = $block->context[ self::CONTEXT_PRODUCT_KEY ] ?? 0; + $variation_id = $block->context[ self::CONTEXT_VARIATION_KEY ] ?? 0; + + // Variation takes precedence. + $resolved_id = $variation_id ? (int) $variation_id : (int) $product_id; + if ( ! $resolved_id ) { + return ''; + } + + if ( ! function_exists( 'wc_get_product' ) ) { + return ''; + } + + $product = wc_get_product( $resolved_id ); + if ( ! $product ) { + return ''; + } + + switch ( $field ) { + case 'title': + return $product->get_name(); + case 'short_description': + return $product->get_short_description(); + case 'price': + return wc_price( $product->get_price() ); + case 'price_raw': + return (string) $product->get_price(); + case 'image_url': + $image_url = wp_get_attachment_image_url( $product->get_image_id(), 'large' ); + return $image_url ? $image_url : ''; + case 'url': + return get_permalink( $product->get_id() ); + default: + return ''; + } + } + + /** + * Mark the current page as non-cacheable when a Fast Checkout block is present. + */ + public static function mark_page_noncacheable() { + if ( is_admin() || ! is_singular() ) { + return; + } + $post = get_post(); + if ( ! $post || ! has_block( self::BLOCK_NAME, $post ) ) { + return; + } + if ( ! defined( 'DONOTCACHEPAGE' ) ) { + define( 'DONOTCACHEPAGE', true ); + } + nocache_headers(); + } + + /** + * Flag the current page as a WooCommerce checkout page when it contains + * a Fast Checkout block. This ensures payment gateway scripts (Stripe, etc.) + * enqueue their initialization data. + * + * @param bool $is_checkout Current checkout flag. + * @return bool + */ + public static function maybe_flag_as_checkout( $is_checkout ) { + if ( $is_checkout || is_admin() || ! is_singular() ) { + return $is_checkout; + } + $post = get_post(); + if ( $post && has_block( self::BLOCK_NAME, $post ) ) { + return true; + } + return $is_checkout; + } + + /** + * Hide the "Return to Cart" link in the checkout actions block when + * rendering inside a Fast Checkout page. + * + * @param array $parsed_block Parsed block data. + * @return array + */ + public static function filter_checkout_actions_block( $parsed_block ) { + if ( 'woocommerce/checkout-actions-block' !== ( $parsed_block['blockName'] ?? '' ) ) { + return $parsed_block; + } + if ( is_admin() || ! is_singular() ) { + return $parsed_block; + } + $post = get_post(); + if ( ! $post || ! has_block( self::BLOCK_NAME, $post ) ) { + return $parsed_block; + } + $parsed_block['attrs']['showReturnToCart'] = false; + return $parsed_block; + } + + /** + * Read and sanitize the supported Fast Checkout query parameters. + * + * @return array Associative array of query parameter values (only non-empty ones). + */ + private static function get_query_params() { + $params = []; + + $email = filter_input( INPUT_GET, self::QP_EMAIL, FILTER_SANITIZE_EMAIL ); + if ( $email && is_email( $email ) ) { + $params['email'] = $email; + } + + $qty = filter_input( INPUT_GET, self::QP_QTY, FILTER_SANITIZE_NUMBER_INT ); + if ( $qty && (int) $qty > 0 ) { + $params['qty'] = (int) $qty; + } + + $coupon = filter_input( INPUT_GET, self::QP_COUPON, FILTER_SANITIZE_SPECIAL_CHARS ); + if ( $coupon ) { + $params['coupon'] = $coupon; + } + + $variation = filter_input( INPUT_GET, self::QP_VARIATION, FILTER_SANITIZE_NUMBER_INT ); + if ( $variation && (int) $variation > 0 ) { + $params['variation'] = (int) $variation; + } + + $price = filter_input( INPUT_GET, self::QP_PRICE, FILTER_SANITIZE_SPECIAL_CHARS ); + if ( $price && is_numeric( $price ) && (float) $price > 0 ) { + $params['price'] = (float) $price; + } + + $success = filter_input( INPUT_GET, self::QP_SUCCESS, FILTER_SANITIZE_URL ); + if ( $success ) { + $params['success'] = $success; + } + + return $params; + } + + /** + * Replace the WooCommerce cart contents with the block's product. + * + * Supports URL query parameters to override variation, quantity, price, + * coupon, billing email, and post-purchase redirect URL. + */ + public static function maybe_replace_cart() { + if ( is_admin() && ! wp_doing_ajax() ) { + return; + } + if ( ! function_exists( 'WC' ) || ! is_singular() ) { + return; + } + $post = get_post(); + if ( ! $post || ! has_block( self::BLOCK_NAME, $post ) ) { + return; + } + + $qp = self::get_query_params(); + $product_id = self::get_block_product_id( $post ); + $quantity = $qp['qty'] ?? 1; + + // fc_variation overrides the block attribute. + if ( ! empty( $qp['variation'] ) ) { + $product_id = $qp['variation']; + } + + if ( ! $product_id ) { + return; + } + $product = wc_get_product( $product_id ); + if ( ! $product || ! $product->is_purchasable() ) { + return; + } + $cart = WC()->cart; + if ( ! $cart ) { + return; + } + + // Idempotency: if the cart already has exactly the right item, do nothing. + $cart_contents = $cart->get_cart(); + if ( 1 === count( $cart_contents ) && empty( $qp ) ) { + $item = reset( $cart_contents ); + $matches_id = ( $product->is_type( 'variation' ) ) + ? (int) $item['variation_id'] === $product_id + : (int) $item['product_id'] === $product_id; + $matches_qty = (int) $quantity === (int) $item['quantity']; + + // NYP products require a valid nyp value to checkout. If the cart + // item was added before the suggested-price fallback landed, it + // will be missing — fail idempotency so we re-populate. + $is_nyp = class_exists( '\WC_Name_Your_Price_Helpers' ) && \WC_Name_Your_Price_Helpers::is_nyp( $product_id ); + $has_valid_nyp = ! $is_nyp || ( isset( $item['nyp'] ) && is_numeric( $item['nyp'] ) && (float) $item['nyp'] > 0 ); + + if ( $matches_id && $matches_qty && $has_valid_nyp ) { + return; + } + } + + // Build cart item data. + $cart_item_data = [ + self::CART_ITEM_SOURCE_KEY => $post->ID, + ]; + + // Store fc_success override in cart item data so it carries to the order. + if ( ! empty( $qp['success'] ) ) { + $cart_item_data['_fc_success_url'] = $qp['success']; + } + + // Handle Name Your Price: fc_price > suggested > minimum. + if ( class_exists( '\WC_Name_Your_Price_Helpers' ) && \WC_Name_Your_Price_Helpers::is_nyp( $product_id ) ) { + $price = ! empty( $qp['price'] ) ? (float) $qp['price'] : null; + if ( null === $price ) { + $price = (float) \WC_Name_Your_Price_Helpers::get_suggested_price( $product_id ); + } + if ( ! $price ) { + $price = (float) \WC_Name_Your_Price_Helpers::get_minimum_price( $product_id ); + } + if ( $price > 0 ) { + $min_price = \WC_Name_Your_Price_Helpers::get_minimum_price( $product_id ); + $max_price = \WC_Name_Your_Price_Helpers::get_maximum_price( $product_id ); + $price = ! empty( $max_price ) ? min( $price, (float) $max_price ) : $price; + $price = ! empty( $min_price ) ? max( $price, (float) $min_price ) : $price; + $cart_item_data['nyp'] = (float) \WC_Name_Your_Price_Helpers::standardize_number( $price ); + } + } + + /** + * Filter the cart item data added by Fast Checkout. + * + * @param array $cart_item_data Cart item data. + * @param int $product_id Resolved product or variation ID. + * @param \WP_Post $post The source post. + * @param array $qp Sanitized query parameters. + */ + $cart_item_data = apply_filters( 'newspack_blocks_fast_checkout_cart_item_data', $cart_item_data, $product_id, $post, $qp ); + + $cart->empty_cart(); + + // Clear stale validation notices from the prior cart state — e.g. WC NYP's + // `check_cart_items` may have added an error notice on `wp_loaded` (before + // this action) for an item that's about to be replaced. + if ( function_exists( 'wc_clear_notices' ) ) { + wc_clear_notices( 'error' ); + } + + if ( $product->is_type( 'variation' ) ) { + $parent_id = $product->get_parent_id(); + $cart->add_to_cart( $parent_id, $quantity, $product_id, [], $cart_item_data ); + } else { + $cart->add_to_cart( $product_id, $quantity, 0, [], $cart_item_data ); + } + + // Apply coupon. + if ( ! empty( $qp['coupon'] ) ) { + $cart->apply_coupon( $qp['coupon'] ); + } + + // Pre-fill billing email. + if ( ! empty( $qp['email'] ) ) { + WC()->customer->set_billing_email( $qp['email'] ); + WC()->customer->save(); + } + } + + /** + * Filter the rendered output of the Fast Checkout block. + * + * @param string $content Rendered block content. + * @param array $block Block data including attrs. + * @return string Filtered content. + */ + public static function filter_render( $content, $block ) { + $attrs = $block['attrs'] ?? []; + $product_id = self::resolve_product_id_from_attrs( $attrs ); + + if ( $product_id && function_exists( 'wc_get_product' ) ) { + $product = wc_get_product( $product_id ); + if ( $product && $product->is_purchasable() ) { + return $content; + } + } + + return '
' + . esc_html__( 'This product is no longer available.', 'newspack-blocks' ) + . '
'; + } + + /** + * Override the WooCommerce return URL for orders placed via Fast Checkout. + * + * @param string $url Default return URL. + * @param \WC_Order $order The order. + * @return string Possibly overridden URL. + */ + public static function maybe_override_return_url( $url, $order ) { + if ( ! is_object( $order ) || ! method_exists( $order, 'get_items' ) ) { + return $url; + } + foreach ( $order->get_items() as $item ) { + $source_post_id = $item->get_meta( self::CART_ITEM_SOURCE_KEY ); + if ( ! $source_post_id ) { + continue; + } + // fc_success query param takes precedence over block attribute. + $fc_success = $item->get_meta( '_fc_success_url' ); + if ( $fc_success ) { + return $fc_success; + } + $custom_url = self::get_after_success_url( (int) $source_post_id ); + if ( $custom_url ) { + return $custom_url; + } + } + return $url; + } + + /** + * Get the afterSuccessURL from a Fast Checkout block in a post. + * + * @param int $post_id Post ID. + * @return string Custom URL or empty string. + */ + private static function get_after_success_url( $post_id ) { + $post = get_post( $post_id ); + if ( ! $post ) { + return ''; + } + $blocks = parse_blocks( $post->post_content ); + $block = self::find_fast_checkout_block( $blocks ); + if ( ! $block ) { + return ''; + } + return $block['attrs']['afterSuccessURL'] ?? ''; + } + + /** + * Recursively find the first Fast Checkout block. + * + * @param array $blocks Parsed blocks. + * @return array|null Block array or null. + */ + private static function find_fast_checkout_block( $blocks ) { + foreach ( $blocks as $block ) { + if ( self::BLOCK_NAME === $block['blockName'] ) { + return $block; + } + if ( ! empty( $block['innerBlocks'] ) ) { + $found = self::find_fast_checkout_block( $block['innerBlocks'] ); + if ( $found ) { + return $found; + } + } + } + return null; + } + + /** + * Attach source post meta to order line items. + * + * @param \WC_Order_Item_Product $item The line item. + * @param string $cart_item_key Cart item key. + * @param array $values Cart item values. + * @param \WC_Order $order The order. + */ + public static function attach_line_item_meta( $item, $cart_item_key, $values, $order ) { + if ( ! isset( $values[ self::CART_ITEM_SOURCE_KEY ] ) ) { + return; + } + if ( ! is_object( $item ) || ! method_exists( $item, 'add_meta_data' ) ) { + return; + } + $item->add_meta_data( self::CART_ITEM_SOURCE_KEY, (int) $values[ self::CART_ITEM_SOURCE_KEY ], true ); + if ( ! empty( $values['_fc_success_url'] ) ) { + $item->add_meta_data( '_fc_success_url', esc_url_raw( $values['_fc_success_url'] ), true ); + } + } + + /** + * Add product context keys to core block metadata. + * + * @param array $metadata Block type metadata. + * @return array Filtered metadata. + */ + public static function add_context_to_core_blocks( $metadata ) { + if ( ! is_array( $metadata ) || empty( $metadata['name'] ) ) { + return $metadata; + } + if ( ! in_array( $metadata['name'], self::CORE_CONTEXT_BLOCKS, true ) ) { + return $metadata; + } + $existing = isset( $metadata['usesContext'] ) && is_array( $metadata['usesContext'] ) + ? $metadata['usesContext'] + : []; + $metadata['usesContext'] = array_values( + array_unique( + array_merge( $existing, [ self::CONTEXT_PRODUCT_KEY, self::CONTEXT_VARIATION_KEY ] ) + ) + ); + return $metadata; + } +} + +Fast_Checkout::init(); diff --git a/newspack-blocks.php b/newspack-blocks.php index 6c7b83e56..baa04a5bd 100755 --- a/newspack-blocks.php +++ b/newspack-blocks.php @@ -24,6 +24,7 @@ require_once NEWSPACK_BLOCKS__PLUGIN_DIR . 'includes/modal-checkout/class-checkout-data.php'; require_once NEWSPACK_BLOCKS__PLUGIN_DIR . 'includes/modal-checkout/class-change-payment-gateway.php'; require_once NEWSPACK_BLOCKS__PLUGIN_DIR . 'includes/class-modal-checkout.php'; +require_once NEWSPACK_BLOCKS__PLUGIN_DIR . 'includes/class-fast-checkout.php'; require_once NEWSPACK_BLOCKS__PLUGIN_DIR . 'includes/plugins/class-the-events-calendar.php'; diff --git a/src/blocks/fast-checkout/bindings-source.ts b/src/blocks/fast-checkout/bindings-source.ts new file mode 100644 index 000000000..0d22a5440 --- /dev/null +++ b/src/blocks/fast-checkout/bindings-source.ts @@ -0,0 +1,104 @@ +/** + * Fast Checkout block bindings source — editor side. + * + * Registers a bindings source that resolves fields (title, image_url, etc.) + * from the product whose ID is provided by Fast Checkout block context. + */ + +import { registerBlockBindingsSource } from '@wordpress/blocks'; +import { __ } from '@wordpress/i18n'; +import apiFetch from '@wordpress/api-fetch'; +import type { StoreApiProduct, ProductField, Binding, BindingsContext } from './types'; + +const SOURCE_NAME = 'newspack-blocks/fast-checkout-product'; +const PRODUCT_CONTEXT_KEY = 'newspack-blocks/fastCheckoutProductId'; +const VARIATION_CONTEXT_KEY = 'newspack-blocks/fastCheckoutVariationId'; + +const productCache = new Map< number, StoreApiProduct | null >(); +const pendingFetches = new Map< number, Promise< StoreApiProduct | null > >(); + +/** + * Fetch a product from the WooCommerce Store API and cache it. + * + * @param productId Product or variation ID. + * @return Cached or fetched product record. + */ +export function fetchProduct( productId: number | string ): Promise< StoreApiProduct | null > { + const id = parseInt( String( productId ), 10 ); + if ( ! id ) { + return Promise.resolve( null ); + } + if ( productCache.has( id ) ) { + return Promise.resolve( productCache.get( id ) ?? null ); + } + if ( pendingFetches.has( id ) ) { + return pendingFetches.get( id )!; + } + const promise = apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ id }` } ) + .then( product => { + productCache.set( id, product ); + pendingFetches.delete( id ); + return product; + } ) + .catch( () => { + productCache.set( id, null ); + pendingFetches.delete( id ); + return null; + } ); + pendingFetches.set( id, promise ); + return promise; +} + +/** + * Pull a single field from a cached Store API product record. + * + * @param product Store API product record. + * @param field Field name. + * @return Resolved field value. + */ +function readField( product: StoreApiProduct | null | undefined, field: ProductField ): string { + if ( ! product ) { + return ''; + } + switch ( field ) { + case 'title': + return product.name || ''; + case 'short_description': + return product.short_description || ''; + case 'price': + return product.price_html || ''; + case 'price_raw': + return product.prices?.price || ''; + case 'image_url': + return product.images?.[ 0 ]?.src || ''; + case 'url': + return product.permalink || ''; + default: + return ''; + } +} + +registerBlockBindingsSource( { + name: SOURCE_NAME, + label: __( 'Fast Checkout Product', 'newspack-blocks' ), + usesContext: [ PRODUCT_CONTEXT_KEY, VARIATION_CONTEXT_KEY ], + getValues( { bindings, context }: { bindings: Record< string, Binding >; context: BindingsContext } ) { + const variationId = parseInt( String( context?.[ VARIATION_CONTEXT_KEY ] ?? 0 ), 10 ) || 0; + const productId = parseInt( String( context?.[ PRODUCT_CONTEXT_KEY ] ?? 0 ), 10 ) || 0; + const resolvedId = variationId || productId; + const product = resolvedId ? productCache.get( resolvedId ) : null; + + // Trigger a fetch if we have an ID but no cache entry yet. + // The next render cycle picks up the cached value once resolved. + if ( resolvedId && ! productCache.has( resolvedId ) ) { + fetchProduct( resolvedId ); + } + + const result: Record< string, string > = {}; + for ( const [ attr, binding ] of Object.entries( bindings ) ) { + const field = binding?.args?.field; + result[ attr ] = field ? readField( product, field ) : ''; + } + return result; + }, +} ); diff --git a/src/blocks/fast-checkout/block.json b/src/blocks/fast-checkout/block.json new file mode 100644 index 000000000..18c347fc3 --- /dev/null +++ b/src/blocks/fast-checkout/block.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "newspack-blocks/fast-checkout", + "title": "Fast Checkout", + "category": "newspack", + "description": "Single-product checkout landing page wrapping the WooCommerce Checkout block.", + "keywords": [ "woocommerce", "checkout", "product", "landing", "fast" ], + "textdomain": "newspack-blocks", + "attributes": { + "product": { "type": "string" }, + "variation": { "type": "string" }, + "is_variable": { "type": "boolean", "default": false }, + "afterSuccessURL": { "type": "string" } + }, + "providesContext": { + "newspack-blocks/fastCheckoutProductId": "product", + "newspack-blocks/fastCheckoutVariationId": "variation" + }, + "supports": { + "anchor": true, + "align": [ "wide", "full" ], + "html": false, + "color": { + "gradients": true, + "__experimentalDefaultControls": { "background": true, "text": true } + }, + "typography": { + "fontSize": true, + "lineHeight": true + }, + "spacing": { + "padding": true, + "margin": true, + "blockGap": true + }, + "layout": { + "allowSwitching": false, + "default": { "type": "constrained" } + } + }, + "editorStyle": "newspack-blocks-editor", + "style": "newspack-blocks-fast-checkout" +} diff --git a/src/blocks/fast-checkout/edit.tsx b/src/blocks/fast-checkout/edit.tsx new file mode 100644 index 000000000..9bed70c65 --- /dev/null +++ b/src/blocks/fast-checkout/edit.tsx @@ -0,0 +1,226 @@ +/** + * Fast Checkout block — editor component. + */ + +import { __ } from '@wordpress/i18n'; +import { useEffect, useState } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; +import { debounce } from 'lodash'; +import { InnerBlocks, InspectorControls, useBlockProps } from '@wordpress/block-editor'; +import { useSelect, useDispatch } from '@wordpress/data'; +import { PanelBody, BaseControl, TextControl, Button, Spinner, FormTokenField, SelectControl, Placeholder } from '@wordpress/components'; + +import { DEFAULT_TEMPLATE } from './template'; +import { fetchProduct } from './bindings-source'; +import type { FastCheckoutAttributes, StoreApiProduct, StoreApiVariation, Variation } from './types'; + +interface ProductPickerProps { + productId?: string; + onChange: ( id: string ) => void; +} + +function ProductPicker( { productId, onChange }: ProductPickerProps ) { + const [ suggestions, setSuggestions ] = useState< Record< string, string > >( {} ); + const [ inFlight, setInFlight ] = useState( false ); + const [ selectedName, setSelectedName ] = useState( '' ); + const [ isChanging, setIsChanging ] = useState( ! productId ); + + useEffect( () => { + if ( ! productId ) { + setSelectedName( '' ); + return; + } + fetchProduct( productId ).then( product => { + setSelectedName( product?.name || '' ); + } ); + }, [ productId ] ); + + const fetchSuggestions = debounce( ( search: string ) => { + if ( search.length < 3 ) { + return; + } + setInFlight( true ); + apiFetch< StoreApiProduct[] >( { + path: `/wc/store/v1/products?search=${ encodeURIComponent( search ) }&per_page=10`, + } ) + .then( products => { + const next: Record< string, string > = {}; + products.forEach( p => { + next[ p.id ] = p.name; + } ); + setSuggestions( next ); + } ) + .finally( () => setInFlight( false ) ); + }, 200 ); + + if ( productId && ! isChanging ) { + return ( + +
{ selectedName || }
+ +
+ ); + } + + return ( + + { + const tokenName = tokens[ 0 ]; + const id = Object.keys( suggestions ).find( key => suggestions[ key ] === tokenName ); + if ( id ) { + onChange( String( id ) ); + setIsChanging( false ); + } + } } + /> + { inFlight && } + + ); +} + +interface VariationPickerProps { + productId: string; + variationId?: string; + onChange: ( variationId: string ) => void; +} + +function VariationPicker( { productId, variationId, onChange }: VariationPickerProps ) { + const [ variations, setVariations ] = useState< Variation[] >( [] ); + + useEffect( () => { + if ( ! productId ) { + setVariations( [] ); + return; + } + apiFetch< StoreApiVariation[] >( { path: `/wc/v2/products/${ productId }/variations?per_page=100` } ) + .then( res => { + setVariations( + res.map( v => ( { + id: v.id, + label: v.attributes.map( a => `${ a.name }: ${ a.option }` ).join( ', ' ), + } ) ) + ); + } ) + .catch( () => setVariations( [] ) ); + }, [ productId ] ); + + if ( ! variations.length ) { + return null; + } + + return ( + ( { label: v.label, value: String( v.id ) } ) ), + ] } + onChange={ onChange } + /> + ); +} + +interface EditProps { + attributes: FastCheckoutAttributes; + setAttributes: ( attrs: Partial< FastCheckoutAttributes > ) => void; + clientId: string; +} + +export default function Edit( { attributes, setAttributes, clientId }: EditProps ) { + const { product, variation, is_variable: isVariable, afterSuccessURL } = attributes; + const blockProps = useBlockProps(); + const { updateBlockAttributes } = useDispatch( 'core/block-editor' ); + + // On first insert, find the checkout-actions-block and disable "Return to Cart". + const allDescendants = useSelect( + select => { + const getBlocks = ( select( 'core/block-editor' ) as { getBlocks: ( id?: string ) => Block[] } ).getBlocks; + const walk = ( blocks: Block[] ): Block[] => blocks.flatMap( b => [ b, ...walk( b.innerBlocks ) ] ); + return walk( getBlocks( clientId ) ); + }, + [ clientId ] + ); + useEffect( () => { + allDescendants + .filter( b => b.name === 'woocommerce/checkout-actions-block' && b.attributes.showReturnToCart ) + .forEach( b => updateBlockAttributes( b.clientId, { showReturnToCart: false } ) ); + }, [ allDescendants.length ] ); + + // Detect variable product when product changes. + useEffect( () => { + if ( ! product ) { + return; + } + apiFetch< StoreApiProduct >( { path: `/wc/store/v1/products/${ product }` } ) + .then( p => { + setAttributes( { is_variable: !! p?.variations?.length } ); + } ) + .catch( () => setAttributes( { is_variable: false } ) ); + }, [ product ] ); + + if ( ! product ) { + return ( +
+ + + setAttributes( { + product: newId, + variation: '', + is_variable: false, + } ) + } + /> + +
+ ); + } + + return ( + <> + + + + setAttributes( { + product: newId, + variation: '', + is_variable: false, + } ) + } + /> + { isVariable && ( + setAttributes( { variation: newVariation } ) } + /> + ) } + setAttributes( { afterSuccessURL: value } ) } + /> + + +
+ +
+ + ); +} diff --git a/src/blocks/fast-checkout/editor.scss b/src/blocks/fast-checkout/editor.scss new file mode 100644 index 000000000..6ecc7c200 --- /dev/null +++ b/src/blocks/fast-checkout/editor.scss @@ -0,0 +1,3 @@ +/** + * Fast Checkout block — editor styles. + */ diff --git a/src/blocks/fast-checkout/editor.ts b/src/blocks/fast-checkout/editor.ts new file mode 100644 index 000000000..f469185e0 --- /dev/null +++ b/src/blocks/fast-checkout/editor.ts @@ -0,0 +1,11 @@ +/** + * Fast Checkout block — editor bundle entry. + */ + +import { registerBlockType } from '@wordpress/blocks'; +import { name, settings } from './index'; +import './bindings-source'; +import './use-context'; +import './editor.scss'; + +registerBlockType( name, settings ); diff --git a/src/blocks/fast-checkout/index.tsx b/src/blocks/fast-checkout/index.tsx new file mode 100644 index 000000000..48550e594 --- /dev/null +++ b/src/blocks/fast-checkout/index.tsx @@ -0,0 +1,28 @@ +/** + * Fast Checkout block — settings export. + */ + +import { __ } from '@wordpress/i18n'; +import { InnerBlocks, useBlockProps } from '@wordpress/block-editor'; +import metadata from './block.json'; +import edit from './edit'; +import './view.scss'; + +export const name: string = metadata.name; +export const title: string = __( 'Fast Checkout', 'newspack-blocks' ); + +function save() { + const blockProps = useBlockProps.save(); + return ( +
+ +
+ ); +} + +export const settings = { + ...metadata, + title, + edit, + save, +}; diff --git a/src/blocks/fast-checkout/template.ts b/src/blocks/fast-checkout/template.ts new file mode 100644 index 000000000..1bd5b3463 --- /dev/null +++ b/src/blocks/fast-checkout/template.ts @@ -0,0 +1,50 @@ +/** + * Default inner-block template for the Fast Checkout block. + * + * The Woo Checkout block carries its own lock attribute so it cannot be removed + * or reordered, while the rest of the template stays editable. + */ + +import type { ProductField } from './types'; + +const BINDING_SOURCE = 'newspack-blocks/fast-checkout-product'; + +const bind = ( field: ProductField ) => ( { source: BINDING_SOURCE, args: { field } } ); + +type TemplateBlock = [ string, Record< string, unknown >, ...TemplateBlock[][] ]; + +export const DEFAULT_TEMPLATE: TemplateBlock[] = [ + [ + 'core/image', + { + metadata: { + bindings: { + url: bind( 'image_url' ), + alt: bind( 'title' ), + }, + }, + }, + ], + [ + 'core/heading', + { + level: 2, + metadata: { + bindings: { + content: bind( 'title' ), + }, + }, + }, + ], + [ + 'core/paragraph', + { + metadata: { + bindings: { + content: bind( 'short_description' ), + }, + }, + }, + ], + [ 'woocommerce/checkout', { lock: { remove: true, move: true } } ], +]; diff --git a/src/blocks/fast-checkout/types.ts b/src/blocks/fast-checkout/types.ts new file mode 100644 index 000000000..dfddf89d4 --- /dev/null +++ b/src/blocks/fast-checkout/types.ts @@ -0,0 +1,42 @@ +export type ProductField = 'title' | 'short_description' | 'price' | 'price_raw' | 'image_url' | 'url'; + +export interface StoreApiProduct { + id: number; + name: string; + short_description: string; + price_html: string; + prices?: { price?: string }; + images?: { src: string }[]; + permalink: string; + variations?: number[]; +} + +export interface StoreApiVariation { + id: number; + attributes: { name: string; option: string }[]; +} + +export interface Variation { + id: number; + label: string; +} + +export interface FastCheckoutAttributes { + product?: string; + variation?: string; + is_variable: boolean; + afterSuccessURL?: string; +} + +export interface BindingArgs { + field: ProductField; +} + +export interface Binding { + args?: BindingArgs; +} + +export interface BindingsContext { + 'newspack-blocks/fastCheckoutProductId'?: string | number; + 'newspack-blocks/fastCheckoutVariationId'?: string | number; +} diff --git a/src/blocks/fast-checkout/use-context.ts b/src/blocks/fast-checkout/use-context.ts new file mode 100644 index 000000000..6e32faf19 --- /dev/null +++ b/src/blocks/fast-checkout/use-context.ts @@ -0,0 +1,29 @@ +/** + * Extend core/heading, core/image, and core/paragraph to consume the + * Fast Checkout product context in the editor. + */ + +import { addFilter } from '@wordpress/hooks'; + +const PRODUCT_CONTEXT_KEY = 'newspack-blocks/fastCheckoutProductId'; +const VARIATION_CONTEXT_KEY = 'newspack-blocks/fastCheckoutVariationId'; +const TARGETS = [ 'core/heading', 'core/image', 'core/paragraph' ]; + +interface BlockSettings { + usesContext?: string[]; + [ key: string ]: unknown; +} + +addFilter( 'blocks.registerBlockType', 'newspack-blocks/fast-checkout/use-context', ( settings: BlockSettings, name: string ) => { + if ( ! TARGETS.includes( name ) ) { + return settings; + } + const existing = Array.isArray( settings.usesContext ) ? settings.usesContext : []; + if ( existing.includes( PRODUCT_CONTEXT_KEY ) ) { + return settings; + } + return { + ...settings, + usesContext: [ ...existing, PRODUCT_CONTEXT_KEY, VARIATION_CONTEXT_KEY ], + }; +} ); diff --git a/src/blocks/fast-checkout/view.js b/src/blocks/fast-checkout/view.js new file mode 100644 index 000000000..741c50d6a --- /dev/null +++ b/src/blocks/fast-checkout/view.js @@ -0,0 +1,4 @@ +/** + * Internal dependencies + */ +import './view.scss'; diff --git a/src/blocks/fast-checkout/view.php b/src/blocks/fast-checkout/view.php new file mode 100644 index 000000000..7b09b6db2 --- /dev/null +++ b/src/blocks/fast-checkout/view.php @@ -0,0 +1,31 @@ + '42' ] ); + $this->assertSame( 42, $result ); + } + + /** + * Test that a variable product with variation resolves to the variation ID. + */ + public function test_resolve_variable_prefers_variation() { + $result = Fast_Checkout::resolve_product_id_from_attrs( + [ + 'product' => '42', + 'variation' => '99', + 'is_variable' => true, + ] + ); + $this->assertSame( 99, $result ); + } + + /** + * Test that a variable product without variation falls back to product ID. + */ + public function test_resolve_variable_without_variation_falls_back() { + $result = Fast_Checkout::resolve_product_id_from_attrs( + [ + 'product' => '42', + 'is_variable' => true, + ] + ); + $this->assertSame( 42, $result ); + } + + /** + * Test that missing attributes return null. + */ + public function test_resolve_missing_returns_null() { + $result = Fast_Checkout::resolve_product_id_from_attrs( [] ); + $this->assertNull( $result ); + } + + /** + * Test extracting product ID from a top-level Fast Checkout block. + */ + public function test_get_block_product_id_top_level() { + $post_id = self::factory()->post->create( + [ + 'post_content' => '', + ] + ); + $post = get_post( $post_id ); + $result = Fast_Checkout::get_block_product_id( $post ); + $this->assertSame( 55, $result ); + } + + /** + * Test extracting product ID from a Fast Checkout block nested in columns. + */ + public function test_get_block_product_id_nested_in_columns() { + $content = '
'; + $post_id = self::factory()->post->create( [ 'post_content' => $content ] ); + $post = get_post( $post_id ); + $result = Fast_Checkout::get_block_product_id( $post ); + $this->assertSame( 77, $result ); + } + + /** + * Test that a post without a Fast Checkout block returns null. + */ + public function test_get_block_product_id_absent() { + $post_id = self::factory()->post->create( + [ + 'post_content' => '

Hello

', + ] + ); + $post = get_post( $post_id ); + $result = Fast_Checkout::get_block_product_id( $post ); + $this->assertNull( $result ); + } + + /** + * Test that null post returns null. + */ + public function test_get_block_product_id_handles_null_post() { + $result = Fast_Checkout::get_block_product_id( null ); + $this->assertNull( $result ); + } + + // ---- Core block context opt-in tests ---- + + /** + * Test that core/heading gets both context keys, preserving existing ones. + */ + public function test_add_context_to_core_blocks_extends_heading() { + $metadata = [ + 'name' => 'core/heading', + 'usesContext' => [ 'postId' ], + ]; + $result = Fast_Checkout::add_context_to_core_blocks( $metadata ); + $this->assertContains( 'postId', $result['usesContext'] ); + $this->assertContains( Fast_Checkout::CONTEXT_PRODUCT_KEY, $result['usesContext'] ); + $this->assertContains( Fast_Checkout::CONTEXT_VARIATION_KEY, $result['usesContext'] ); + } + + /** + * Test that unrelated blocks are not modified. + */ + public function test_add_context_to_core_blocks_skips_unrelated() { + $metadata = [ + 'name' => 'core/button', + 'usesContext' => [ 'postId' ], + ]; + $result = Fast_Checkout::add_context_to_core_blocks( $metadata ); + $this->assertSame( [ 'postId' ], $result['usesContext'] ); + } + + /** + * Test that blocks without usesContext get context keys added. + */ + public function test_add_context_to_core_blocks_handles_missing_uses_context() { + $metadata = [ 'name' => 'core/image' ]; + $result = Fast_Checkout::add_context_to_core_blocks( $metadata ); + $this->assertContains( Fast_Checkout::CONTEXT_PRODUCT_KEY, $result['usesContext'] ); + $this->assertContains( Fast_Checkout::CONTEXT_VARIATION_KEY, $result['usesContext'] ); + $this->assertCount( 2, $result['usesContext'] ); + } + + // ---- Render filter tests ---- + + /** + * Test that filter_render passes through when product is valid. + */ + public function test_filter_render_passes_through_when_product_valid() { + $this->skip_without_wc(); + + $product = $this->create_simple_product(); + $content = '
Buy now
'; + $block = [ 'attrs' => [ 'product' => (string) $product->get_id() ] ]; + $filtered = Fast_Checkout::filter_render( $content, $block ); + $this->assertSame( $content, $filtered ); + } + + /** + * Test that filter_render shows unavailable notice for missing product. + */ + public function test_filter_render_replaces_when_product_missing() { + $content = '
Buy now
'; + $block = [ 'attrs' => [ 'product' => '999999' ] ]; + $filtered = Fast_Checkout::filter_render( $content, $block ); + $this->assertStringContainsString( 'unavailable', $filtered ); + $this->assertStringNotContainsString( 'Buy now', $filtered ); + } + + /** + * Test that filter_render shows unavailable notice when no product ID. + */ + public function test_filter_render_replaces_when_no_product_id() { + $content = '
Buy now
'; + $block = [ 'attrs' => [] ]; + $filtered = Fast_Checkout::filter_render( $content, $block ); + $this->assertStringContainsString( 'unavailable', $filtered ); + } + + // ---- Cart replacement tests (WC-dependent) ---- + + /** + * Skip the current test if WooCommerce is not available. + */ + private function skip_without_wc() { + if ( ! function_exists( 'WC' ) || ! class_exists( 'WC_Product_Simple' ) ) { + $this->markTestSkipped( 'WooCommerce is not available.' ); + } + } + + /** + * Create a post containing a Fast Checkout block for the given product ID. + * + * @param int $product_id Product ID. + * @param array $extra_attrs Additional block attributes. + * @return int Post ID. + */ + private function make_fast_checkout_post( $product_id, $extra_attrs = [] ) { + $attrs = array_merge( [ 'product' => (string) $product_id ], $extra_attrs ); + $json = wp_json_encode( $attrs ); + $content = sprintf( '', $json ); + return self::factory()->post->create( [ 'post_content' => $content ] ); + } + + /** + * Create a simple WC product. + * + * @return \WC_Product_Simple + */ + private function create_simple_product() { + $product = new \WC_Product_Simple(); + $product->set_name( 'Test Product' ); + $product->set_regular_price( '10.00' ); + $product->set_status( 'publish' ); + $product->save(); + return $product; + } + + /** + * Test that maybe_replace_cart does nothing when no block is present. + */ + public function test_maybe_replace_cart_noop_without_block() { + $this->skip_without_wc(); + + $post_id = self::factory()->post->create( + [ 'post_content' => '

No block here

' ] + ); + $this->go_to( get_permalink( $post_id ) ); + Fast_Checkout::maybe_replace_cart(); + $this->assertCount( 0, WC()->cart->get_cart() ); + } + + /** + * Test that maybe_replace_cart adds the product to the cart. + */ + public function test_maybe_replace_cart_adds_product() { + $this->skip_without_wc(); + + $product = $this->create_simple_product(); + $post_id = $this->make_fast_checkout_post( $product->get_id() ); + $this->go_to( get_permalink( $post_id ) ); + + Fast_Checkout::maybe_replace_cart(); + + $cart_contents = WC()->cart->get_cart(); + $this->assertCount( 1, $cart_contents ); + + $item = reset( $cart_contents ); + $this->assertSame( $product->get_id(), (int) $item['product_id'] ); + $this->assertSame( $post_id, $item[ Fast_Checkout::CART_ITEM_SOURCE_KEY ] ); + } + + /** + * Test that calling maybe_replace_cart twice is idempotent. + */ + public function test_maybe_replace_cart_is_idempotent() { + $this->skip_without_wc(); + + $product = $this->create_simple_product(); + $post_id = $this->make_fast_checkout_post( $product->get_id() ); + $this->go_to( get_permalink( $post_id ) ); + + Fast_Checkout::maybe_replace_cart(); + $keys_first = array_keys( WC()->cart->get_cart() ); + + Fast_Checkout::reset_cache(); + Fast_Checkout::maybe_replace_cart(); + $keys_second = array_keys( WC()->cart->get_cart() ); + + $this->assertSame( $keys_first, $keys_second ); + } + + // ---- Bindings source tests ---- + + /** + * Test that the bindings source is registered. + */ + public function test_bindings_source_is_registered() { + if ( ! class_exists( 'WP_Block_Bindings_Registry' ) ) { + $this->markTestSkipped( 'WP_Block_Bindings_Registry not available.' ); + } + $registry = WP_Block_Bindings_Registry::get_instance(); + $source = $registry->get_registered( Fast_Checkout::BINDINGS_SOURCE ); + $this->assertNotNull( $source, 'Bindings source should be registered.' ); + } + + /** + * Test that the title field resolves to the product name. + */ + public function test_bindings_title_resolves() { + $this->skip_without_wc(); + + $product = $this->create_simple_product(); + $block = (object) [ + 'context' => [ + Fast_Checkout::CONTEXT_PRODUCT_KEY => $product->get_id(), + Fast_Checkout::CONTEXT_VARIATION_KEY => 0, + ], + ]; + $result = Fast_Checkout::bindings_get_value( [ 'field' => 'title' ], $block, 'content' ); + $this->assertSame( $product->get_name(), $result ); + } + + /** + * Test that a missing product returns an empty string. + */ + public function test_bindings_missing_product_returns_empty_string() { + $block = (object) [ + 'context' => [ + Fast_Checkout::CONTEXT_PRODUCT_KEY => 0, + Fast_Checkout::CONTEXT_VARIATION_KEY => 0, + ], + ]; + $result = Fast_Checkout::bindings_get_value( [ 'field' => 'title' ], $block, 'content' ); + $this->assertSame( '', $result ); + } + + /** + * Test that an unknown field returns an empty string. + */ + public function test_bindings_unknown_field_returns_empty_string() { + $this->skip_without_wc(); + + $product = $this->create_simple_product(); + $block = (object) [ + 'context' => [ + Fast_Checkout::CONTEXT_PRODUCT_KEY => $product->get_id(), + Fast_Checkout::CONTEXT_VARIATION_KEY => 0, + ], + ]; + $result = Fast_Checkout::bindings_get_value( [ 'field' => 'nonexistent' ], $block, 'content' ); + $this->assertSame( '', $result ); + } + + // ---- Post-purchase redirect tests (WC-dependent) ---- + + /** + * Test that attach_line_item_meta copies the source post ID. + */ + public function test_attach_line_item_meta_copies_source_post() { + $this->skip_without_wc(); + + $item = new \WC_Order_Item_Product(); + $values = [ Fast_Checkout::CART_ITEM_SOURCE_KEY => 123 ]; + Fast_Checkout::attach_line_item_meta( $item, 'key', $values, null ); + $this->assertSame( 123, $item->get_meta( Fast_Checkout::CART_ITEM_SOURCE_KEY ) ); + } + + /** + * Test that attach_line_item_meta is a no-op without source key. + */ + public function test_attach_line_item_meta_noop_without_source() { + $this->skip_without_wc(); + + $item = new \WC_Order_Item_Product(); + Fast_Checkout::attach_line_item_meta( $item, 'key', [], null ); + $this->assertSame( '', $item->get_meta( Fast_Checkout::CART_ITEM_SOURCE_KEY ) ); + } + + /** + * Test that return URL is overridden when order has source post with custom URL. + */ + public function test_return_url_override_uses_custom_url() { + $this->skip_without_wc(); + + $product = $this->create_simple_product(); + $post_id = $this->make_fast_checkout_post( + $product->get_id(), + [ 'afterSuccessURL' => 'https://example.com/thank-you' ] + ); + + $order = wc_create_order(); + $item = new \WC_Order_Item_Product(); + $item->set_product( $product ); + $item->add_meta_data( Fast_Checkout::CART_ITEM_SOURCE_KEY, $post_id, true ); + $order->add_item( $item ); + $order->save(); + + $result = Fast_Checkout::maybe_override_return_url( 'https://default.com', $order ); + $this->assertSame( 'https://example.com/thank-you', $result ); + } + + /** + * Test that return URL passes through when block has no afterSuccessURL. + */ + public function test_return_url_override_passes_through_when_no_custom_url() { + $this->skip_without_wc(); + + $product = $this->create_simple_product(); + $post_id = $this->make_fast_checkout_post( $product->get_id() ); + + $order = wc_create_order(); + $item = new \WC_Order_Item_Product(); + $item->set_product( $product ); + $item->add_meta_data( Fast_Checkout::CART_ITEM_SOURCE_KEY, $post_id, true ); + $order->add_item( $item ); + $order->save(); + + $result = Fast_Checkout::maybe_override_return_url( 'https://default.com', $order ); + $this->assertSame( 'https://default.com', $result ); + } + + /** + * Test that return URL passes through for unrelated orders. + */ + public function test_return_url_override_ignores_unrelated_orders() { + $this->skip_without_wc(); + + $product = $this->create_simple_product(); + $order = wc_create_order(); + $item = new \WC_Order_Item_Product(); + $item->set_product( $product ); + $order->add_item( $item ); + $order->save(); + + $result = Fast_Checkout::maybe_override_return_url( 'https://default.com', $order ); + $this->assertSame( 'https://default.com', $result ); + } + + /** + * Test that a mismatched product in the cart is replaced. + */ + public function test_maybe_replace_cart_replaces_mismatched() { + $this->skip_without_wc(); + + $product_a = $this->create_simple_product(); + $product_b = $this->create_simple_product(); + + // Add product A to cart first. + WC()->cart->add_to_cart( $product_a->get_id() ); + $this->assertCount( 1, WC()->cart->get_cart() ); + + // Post references product B. + $post_id = $this->make_fast_checkout_post( $product_b->get_id() ); + $this->go_to( get_permalink( $post_id ) ); + + Fast_Checkout::maybe_replace_cart(); + + $cart_contents = WC()->cart->get_cart(); + $this->assertCount( 1, $cart_contents ); + + $item = reset( $cart_contents ); + $this->assertSame( $product_b->get_id(), (int) $item['product_id'] ); + } +} diff --git a/webpack.config.js b/webpack.config.js index 12ced78bb..6a70d284e 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -20,14 +20,31 @@ const blockList = JSON.parse( fs.readFileSync( blockListFile ) ); const editorSetup = path.join( __dirname, 'src', 'setup', 'editor' ); function blockScripts( type, inputDir, blocks ) { - return blocks.map( block => path.join( inputDir, 'blocks', block, `${ type }.js` ) ).filter( fs.existsSync ); + return blocks + .map( block => { + const jsPath = path.join( inputDir, 'blocks', block, `${ type }.js` ); + if ( fs.existsSync( jsPath ) ) { + return jsPath; + } + const tsPath = path.join( inputDir, 'blocks', block, `${ type }.ts` ); + if ( fs.existsSync( tsPath ) ) { + return tsPath; + } + return null; + } ) + .filter( Boolean ); +} + +function hasEditorEntry( block ) { + const base = path.join( __dirname, 'src', 'blocks', block, 'editor' ); + return [ '.js', '.ts', '.tsx' ].some( ext => fs.existsSync( base + ext ) ); } const blocksDir = path.join( __dirname, 'src', 'blocks' ); const blocks = fs .readdirSync( blocksDir ) .filter( block => isDevelopment || blockList.production.includes( block ) ) - .filter( block => fs.existsSync( path.join( __dirname, 'src', 'blocks', block, 'editor.js' ) ) ); + .filter( hasEditorEntry ); // Helps split up each block into its own folder view script const viewBlocksScripts = blocks.reduce( ( viewBlocks, block ) => {