HEX
Server: Apache/2.4.68 (Debian)
System: Linux as-cs-widget-demo-us-central1 6.1.0-44-cloud-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.164-1 (2026-03-09) x86_64
User: root (0)
PHP: 8.2.32
Disabled: NONE
Upload Files
File: /var/www/html/wp-content/plugins/wp-recipe-maker/includes/public/class-wprm-instacart.php
<?php
/**
 * Handle integration with Instacart.
 *
 * @link       https://bootstrapped.ventures
 * @since      9.8.0
 *
 * @package    WP_Recipe_Maker
 * @subpackage WP_Recipe_Maker/includes/public
 */

/**
 * Handle integration with Instacart.
 *
 * @since      9.8.0
 * @package    WP_Recipe_Maker
 * @subpackage WP_Recipe_Maker/includes/public
 * @author     Brecht Vandersmissen <brecht@bootstrapped.ventures>
 */
class WPRM_Instacart {

	/**
	 * Register actions and filters.
	 *
	 * @since	9.8.0
	 */
	public static function init() {
		add_filter( 'wprm_recipe_ingredients_shortcode', array( __CLASS__, 'instacart_after_ingredients' ), 9 );
	}

	/**
	 * Add Instacart button after the ingredients.
	 *
	 * @since	9.8.0
	 * @param	mixed $output Current ingredients output.
	 */
	public static function instacart_after_ingredients( $output ) {
		if ( WPRM_Settings::get( 'integration_instacart_agree' ) && WPRM_Settings::get( 'integration_instacart' ) ) {
			$output = $output . do_shortcode( '[wprm-spacer][wprm-recipe-shop-instacart]' );
		}

		return $output;
	}

	/**
	 * Get the Instacart link for a recipe.
	 *
	 * @since	9.8.0
	 * @param	array $data Recipe data.
	 * @return	string|WP_Error Instacart URL or error on validation failure.
	 */
	public static function get_link_for_recipe( $data ) {
		$recipe_id = intval( $data['recipeId'] );

		// Validate recipeId: post must exist, be a published recipe.
		$post = get_post( $recipe_id );
		if ( ! $post ) {
			return new WP_Error( 'invalid_recipe', __( 'The specified recipe does not exist.', 'wp-recipe-maker' ), array( 'status' => 404 ) );
		}
		if ( WPRM_POST_TYPE !== get_post_type( $recipe_id ) ) {
			return new WP_Error( 'invalid_recipe', __( 'The specified recipe does not exist.', 'wp-recipe-maker' ), array( 'status' => 404 ) );
		}
		if ( 'publish' !== get_post_status( $recipe_id ) ) {
			return new WP_Error( 'invalid_recipe', __( 'The specified recipe does not exist.', 'wp-recipe-maker' ), array( 'status' => 404 ) );
		}

		$servings_system_combination = sanitize_key( $data['servingsSystemCombination'] );

		// Validate servingsSystemCombination: format must be servings-system, system 1 or 2, servings integer in range.
		$parts = explode( '-', $servings_system_combination, 2 );
		if ( 2 !== count( $parts ) ) {
			return new WP_Error( 'invalid_combination', __( 'Invalid servings and system combination.', 'wp-recipe-maker' ), array( 'status' => 400 ) );
		}
		$system = isset( $parts[1] ) ? $parts[1] : '';
		if ( ! in_array( $system, array( '1', '2' ), true ) ) {
			return new WP_Error( 'invalid_combination', __( 'Invalid servings and system combination.', 'wp-recipe-maker' ), array( 'status' => 400 ) );
		}
		$servings_raw = isset( $parts[0] ) ? $parts[0] : '';
		$requested_servings = WPRM_Recipe_Parser::parse_quantity( $servings_raw );
		if ( ! is_numeric( $requested_servings ) || $requested_servings != (int) $requested_servings || 1 > $requested_servings ) {
			return new WP_Error( 'invalid_combination', __( 'Invalid servings and system combination.', 'wp-recipe-maker' ), array( 'status' => 400 ) );
		}
		$requested_servings = (int) $requested_servings;
		$original_parsed = WPRM_Recipe_Parser::parse_quantity( get_post_meta( $recipe_id, 'wprm_servings', true ) );
		$original_parsed = is_numeric( $original_parsed ) && 0 < $original_parsed ? (int) $original_parsed : 1;
		$max_servings = max( 100, $original_parsed * 4 );
		$within_cache_range = ( $requested_servings <= $max_servings );

		// Look for existing combination first (only in-range combinations are cached).
		$existing_combinations = get_post_meta( $recipe_id, 'wprm_instacart_combinations', true );
		$existing_combinations = $existing_combinations ? maybe_unserialize( $existing_combinations ) : array();

		foreach ( $existing_combinations as $combination => $result ) {
			if ( $combination === $servings_system_combination ) {
				// Use cached result if it's less than a month old.
				if ( strtotime( '-1 month' ) < $result['timestamp'] ) {
					$link = self::get_link_from_response( $result['response'] );

					// Make sure the link is valid.
					if ( $link ) {
						return $link;
					}
				}
			}
		}
		
		// No cached result, get a new one through the Instacart API.
		$api_data = array(
			'title' => sanitize_text_field( $data['title'] ),
			'image_url' => esc_url( $data['image_url'] ),
			'ingredients' => array(),
		);

		foreach ( $data['ingredients'] as $ingredient ) {
			$name = trim( strip_tags( html_entity_decode( self::do_shortcode_safe( $ingredient['name'] ) ) ) );
			$quantity = WPRM_Recipe_Parser::parse_quantity( $ingredient['quantity'] );

			// Default to 1 if no quantity.
			if ( ! $quantity ) {
				$quantity = 1;
			}

			if ( $name && $quantity ) {
				$unit = trim( strip_tags( html_entity_decode( self::do_shortcode_safe( $ingredient['unit'] ) ) ) );
				$item = array(
					'name' => $name,
					'measurements' => array(
						'quantity' => $quantity,
						'unit' => $unit ? $unit : 'each',
					),
				);

				$api_data['ingredients'][] = self::filter_recipe_ingredient_payload( $item, $ingredient, $data, $recipe_id );
			}
		}

		$api_data = self::filter_recipe_payload( $api_data, $data, $recipe_id );

		// Call Instacart API.
		$instacart_response = self::call_instacart_api( 'recipe', $api_data );

		// Store result for future use only when within the logical range (out-of-range requests still get the link but are not cached).
		if ( $within_cache_range ) {
			$existing_combinations[ $servings_system_combination ] = array(
				'response' => $instacart_response,
				'timestamp' => time(),
			);
			update_post_meta( $recipe_id, 'wprm_instacart_combinations', $existing_combinations );
		}

		// Return Instacart URL.
		return self::get_link_from_response( $instacart_response );
	}

	/**
	 * Get the Instacart link for a shopping list.
	 *
	 * @since	9.8.0
	 * @param	array $data Shopping list data.
	 */
	public static function get_link_for_list( $data ) {
		// Check for existing link first.
		if ( isset( $data['meta'] ) && isset( $data['meta']['instacart'] ) ) {
			$response = $data['meta']['instacart']['response'];
			$timestamp = $data['meta']['instacart']['timestamp'];

			// Use cached result if it's less than a month old.
			if ( strtotime( '-1 month' ) < $timestamp ) {
				$link = self::get_link_from_response( $response );

				if ( $link ) {
					return $link;
				}
			}
		}

		// No cached result, get a new one through the Instacart API.
		$title = isset( $data['collection'] ) && isset( $data['collection']['name'] ) && $data['collection']['name'] ? $data['collection']['name'] : 'Shopping List';

		$api_data = array(
			'title' => sanitize_text_field( $title ),
			'line_items' => array(),
		);

		foreach ( $data['groups'] as $group ) {
			foreach ( $group['ingredients'] as $ingredient ) {
				$name = trim( strip_tags( html_entity_decode( self::do_shortcode_safe( $ingredient['name'] ) ) ) );

				// Exclude checked ingredients.
				if ( isset( $ingredient['checked'] ) && $ingredient['checked'] ) {
					continue;
				}

				foreach ( $ingredient['variations'] as $variation ) {
					$variation_amount = isset( $variation['amount'] ) ? $variation['amount'] : '';
					$variation_unit = isset( $variation['unit'] ) ? $variation['unit'] : '';

					$display = isset( $variation['display'] ) ? $variation['display'] : '';
					$display = trim( $display );

					if ( $display ) {
						$parsed = WPRM_Recipe_Parser::parse_ingredient( $display, true );
						$variation_amount = $parsed['amount'];

						// All the rest is the unit.
						$variation_unit = trim( $parsed['unit'] . $parsed['name'] . $parsed['notes'] );
					}

					$quantity = WPRM_Recipe_Parser::parse_quantity( $variation_amount );

					// Default to 1 if no quantity.
					if ( ! $quantity ) {
						$quantity = 1;
					}

					if ( $name && $quantity ) {
						$unit = trim( strip_tags( html_entity_decode( self::do_shortcode_safe( $variation_unit ) ) ) );
						$line_item = array(
							'name' => $name,
							'quantity' => $quantity,
							'unit' => $unit ? $unit : 'each',
						);

						$api_data['line_items'][] = self::filter_shopping_list_item_payload( $line_item, $variation, $ingredient, $group, $data );
					}
				}
			}
		}

		$api_data = self::filter_shopping_list_payload( $api_data, $data );

		// Call Instacart API and get link.
		$instacart_response = self::call_instacart_api( 'shopping_list', $api_data );
		$link = self::get_link_from_response( $instacart_response );

		// Found link, store in meta for future use.
		if ( $link ) {
			$meta = array(
				'instacart' => array(
					'response' => $instacart_response,
					'timestamp' => time(),
				),
			);
			WPRMPRC_Shopping_List::save_meta( $data['uid'], $meta );
		}

		return $link;
	}

	/**
	 * Call the Instacart API.
	 *
	 * @since	9.8.0
	 * @param	string $type Type of call.
	 * @param	array $data Data to send.
	 */
	public static function call_instacart_api( $type, $data ) {
		$data['link_type'] = $type;
		$data['landing_page_configuration'] = array(
			'partner_linkback_url' => WPRM_Compatibility::get_home_url(),
			'enable_pantry_items' => true,
		);

		return WPRM_Proxy::call( 'instacart', $data, array(
			'X-Instacart-Type' => $type,
		) );
	}

	/**
	 * Get the correct link to use from the API response.
	 *
	 * @since	9.8.0
	 * @param	mixed $response Instcart API response.
	 */
	public static function get_link_from_response( $response ) {
		$link = false;
		$response = is_object( $response ) ? (array) $response : $response;

		if ( $response && isset( $response['products_link_url'] ) ) {
			$link = $response['products_link_url'];

			$affiliate_id = WPRM_Settings::get( 'integration_instacart_affiliate_id' );
			if ( $affiliate_id ) {
				$link = add_query_arg( array(
					'utm_campaign' => 'instacart-idp',
					'utm_medium' => 'affiliate',
					'utm_source' => 'instacart_idp',
					'utm_term' => 'partnertype-mediapartner',
					'utm_content' => 'campaignid-20313_partnerid-' . $affiliate_id,
				), $link );
			}
		}

		return $link;
	}

	/**
	 * Filter a single Instacart recipe ingredient payload item.
	 *
	 * Add per-item Instacart filters through code, for example:
	 * array(
	 * 	'filters' => array(
	 * 		'brand_filters' => array(
	 * 			array(
	 * 				'type' => 'include',
	 * 				'brand' => 'Instacart',
	 * 			),
	 * 		),
	 * 		'health_filters' => array(
	 * 			array(
	 * 				'type' => 'include',
	 * 				'health_attribute' => 'GLUTEN_FREE',
	 * 			),
	 * 		),
	 * 	),
	 * )
	 *
	 * brand_filters values are case-sensitive. health_filters should use
	 * Instacart's documented health attribute constants.
	 *
	 * @since	10.1.1
	 * @param	array $item Instacart ingredient payload item.
	 * @param	array $ingredient Original recipe ingredient data.
	 * @param	array $data Original recipe request data.
	 * @param	int   $recipe_id Recipe post ID.
	 * @return	array
	 */
	public static function filter_recipe_ingredient_payload( $item, $ingredient, $data, $recipe_id ) {
		return self::apply_array_filter( 'wprm_instacart_recipe_ingredient', $item, $ingredient, $data, $recipe_id );
	}

	/**
	 * Filter the Instacart recipe payload before sending it to the proxy.
	 *
	 * Use this hook to add or adjust filters across multiple ingredients.
	 *
	 * @since	10.1.1
	 * @param	array $api_data Instacart recipe payload.
	 * @param	array $data Original recipe request data.
	 * @param	int   $recipe_id Recipe post ID.
	 * @return	array
	 */
	public static function filter_recipe_payload( $api_data, $data, $recipe_id ) {
		return self::apply_array_filter( 'wprm_instacart_recipe_payload', $api_data, $data, $recipe_id );
	}

	/**
	 * Filter a single Instacart shopping list line item payload item.
	 *
	 * Add per-item Instacart filters through code, for example:
	 * array(
	 * 	'filters' => array(
	 * 		'brand_filters' => array(
	 * 			array(
	 * 				'type' => 'exclude',
	 * 				'brand' => 'Example Brand',
	 * 			),
	 * 		),
	 * 		'health_filters' => array(
	 * 			array(
	 * 				'type' => 'include',
	 * 				'health_attribute' => 'VEGAN',
	 * 			),
	 * 		),
	 * 	),
	 * )
	 *
	 * brand_filters values are case-sensitive. health_filters should use
	 * Instacart's documented health attribute constants.
	 *
	 * @since	10.1.1
	 * @param	array $line_item Instacart shopping list line item payload item.
	 * @param	array $variation Original shopping list variation data.
	 * @param	array $ingredient Original shopping list ingredient data.
	 * @param	array $group Original shopping list group data.
	 * @param	array $data Original shopping list data.
	 * @return	array
	 */
	public static function filter_shopping_list_item_payload( $line_item, $variation, $ingredient, $group, $data ) {
		return self::apply_array_filter( 'wprm_instacart_shopping_list_item', $line_item, $variation, $ingredient, $group, $data );
	}

	/**
	 * Filter the Instacart shopping list payload before sending it to the proxy.
	 *
	 * Use this hook to add or adjust filters across multiple line items.
	 *
	 * @since	10.1.1
	 * @param	array $api_data Instacart shopping list payload.
	 * @param	array $data Original shopping list data.
	 * @return	array
	 */
	public static function filter_shopping_list_payload( $api_data, $data ) {
		return self::apply_array_filter( 'wprm_instacart_shopping_list_payload', $api_data, $data );
	}

	/**
	 * Apply a filter and only accept array results.
	 *
	 * @since	10.1.1
	 * @return	array
	 */
	public static function apply_array_filter() {
		$args = func_get_args();
		$hook = array_shift( $args );
		$default = isset( $args[0] ) && is_array( $args[0] ) ? $args[0] : array();
		$filtered = apply_filters_ref_array( $hook, $args );

		return is_array( $filtered ) ? $filtered : $default;
	}

	/**
	 * Execute safe shortcodes only.
	 *
	 * @since	10.1.0
	 */
	public static function do_shortcode_safe( $content ) {
		// No more executing of shortcodes for security reasons, only strip.
		global $shortcode_tags;

		if ( empty( $shortcode_tags ) || ! is_array( $shortcode_tags ) ) {
			return $content;
		}

		$pattern = get_shortcode_regex();

		return preg_replace_callback( "/$pattern/s", function( $matches ) {
			// $matches[5] is the content between opening/closing tags
			return isset( $matches[5] ) ? $matches[5] : '';
		}, $content );
	}
}

WPRM_Instacart::init();