session = $session; // No-JS fallback: ?add-to-quote={product_id}&quantity=N, or the variations form submitted with // add-to-quote. Priority 19 runs before WooCommerce's add-to-cart handler on the same form. add_action( 'wp_loaded', array( $this, 'handle_query_string_add' ), 19 ); // Logging out destroys the session; drop the counter with it. add_action( 'clear_auth_cookie', array( $this, 'clear_count_cookie' ) ); } /** * Remove the count cookie. */ public function clear_count_cookie() { if ( ! headers_sent() ) { wc_setcookie( self::COUNT_COOKIE, '', time() - YEAR_IN_SECONDS, false, false ); } } /** * All items in the list. * * @return array */ public function get_items() { return (array) $this->session->get( self::SESSION_KEY, array() ); } /** * Number of items. * * @return int */ public function count() { return count( $this->get_items() ); } /** * Whether the list is empty. * * @return bool */ public function is_empty() { return 0 === $this->count(); } /** * Item key for a product/variation pair. * * @param int $product_id Product id. * @param int $variation_id Variation id (0 for simple products). * @return string */ public function item_key( $product_id, $variation_id = 0 ) { return md5( $product_id . ( $variation_id ? '-' . $variation_id : '' ) ); } /** * Whether the product is already in the list. * * @param int $product_id Product id. * @param int $variation_id Variation id. * @return bool */ public function exists( $product_id, $variation_id = 0 ) { return array_key_exists( $this->item_key( $product_id, $variation_id ), $this->get_items() ); } /** * Product (or variation) object for a stored list item. * * @param array $item Item with product_id and variation_id. * @return \WC_Product|false|null */ public static function item_product( array $item ) { return wc_get_product( ! empty( $item['variation_id'] ) ? $item['variation_id'] : $item['product_id'] ); } /** * Customer-facing message for an add_item() result. * * @param string $result added|exists|invalid. * @return string */ public static function result_message( $result ) { switch ( $result ) { case 'added': return __( 'Product added to your quote list.', 'boring-quote' ); case 'exists': return __( 'Product is already in your quote list.', 'boring-quote' ); default: return __( 'This product cannot be added to the quote list. If it has options, choose them first.', 'boring-quote' ); } } /** * Add an item. * * @param int $product_id Product id. * @param int $quantity Quantity. * @param int $variation_id Variation id. * @param array $attributes Chosen variation attributes (attribute_* => value). * @return string 'added'|'exists'|'invalid' */ public function add_item( $product_id, $quantity = 1, $variation_id = 0, $attributes = array() ) { $product_id = absint( $product_id ); $variation_id = absint( $variation_id ); $quantity = max( 1, absint( $quantity ) ); $product = wc_get_product( $variation_id ? $variation_id : $product_id ); /** * Filter whether the item may be added (visibility, roles in Pro, etc.). * * @param bool $valid Valid flag. * @param int $product_id Product id. * @param \WC_Product|false $product Product object. */ $valid = apply_filters( 'bbquote_item_is_valid', self::is_quotable( $product ) && self::matches_parent( $product, $product_id ), $product_id, $product ); if ( ! $valid ) { return 'invalid'; } if ( $this->exists( $product_id, $variation_id ) ) { return 'exists'; } $items = $this->get_items(); $items[ $this->item_key( $product_id, $variation_id ) ] = array( 'product_id' => $product_id, 'variation_id' => $variation_id, 'quantity' => $quantity, 'attributes' => array_filter( (array) $attributes, 'is_string' ), ); $this->save( $items ); do_action( 'bbquote_item_added', $product_id, $variation_id, $quantity ); return 'added'; } /** * Whether a product may be quoted: published (and, for variations, enabled with a published parent). * Deliberately NOT is_visible(): that hides out-of-stock products when the store hides them from * the catalog, which is exactly when customers ask for a quote. * * @param \WC_Product|false|null $product Product. * @return bool */ public static function is_quotable( $product ) { if ( ! $product instanceof \WC_Product || 'publish' !== $product->get_status() ) { return false; } if ( $product->is_type( 'variation' ) ) { $parent = wc_get_product( $product->get_parent_id() ); return $parent instanceof \WC_Product && 'publish' === $parent->get_status(); } return true; } /** * Whether the resolved product fits the requested parent id: a variation must belong to it, * and a variable product cannot be added without a chosen variation. * * @param \WC_Product|false|null $product Product resolved from variation_id or product_id. * @param int $product_id Requested product id. * @return bool */ public static function matches_parent( $product, $product_id ) { if ( ! $product instanceof \WC_Product ) { return false; } if ( $product->is_type( 'variation' ) ) { return (int) $product->get_parent_id() === (int) $product_id; } return ! $product->is_type( 'variable' ) && (int) $product->get_id() === (int) $product_id; } /** * Update item quantity. * * @param string $key Item key. * @param int $quantity New quantity; 0 removes the item. * @return bool */ public function update_quantity( $key, $quantity ) { $items = $this->get_items(); if ( ! isset( $items[ $key ] ) ) { return false; } $quantity = absint( $quantity ); if ( 0 === $quantity ) { return $this->remove_item( $key ); } $items[ $key ]['quantity'] = $quantity; $this->save( $items ); return true; } /** * Remove an item. * * @param string $key Item key. * @return bool */ public function remove_item( $key ) { $items = $this->get_items(); if ( ! isset( $items[ $key ] ) ) { return false; } unset( $items[ $key ] ); $this->save( $items ); return true; } /** * Empty the list. */ public function clear() { $this->save( array() ); } /** * Store items and refresh the count cookie. * `bbquote_count` holds only a number, so cached pages can show the counter without * varying HTML per visitor (read by frontend.js). * * @param array $items Items. */ protected function save( array $items ) { $this->session->set( self::SESSION_KEY, $items ); if ( ! headers_sent() ) { $count = count( $items ); wc_setcookie( self::COUNT_COOKIE, (string) $count, $count ? $this->session->get_expiration() : time() - YEAR_IN_SECONDS, is_ssl(), false ); } } /** * No-JS fallback for adding by query string. */ public function handle_query_string_add() { if ( empty( $_REQUEST['add-to-quote'] ) ) { return; } $nonce = isset( $_REQUEST['bbquote_add_nonce'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['bbquote_add_nonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, 'bbquote_add_to_quote' ) ) { if ( function_exists( 'wc_add_notice' ) ) { wc_add_notice( __( 'Your session has expired. Please try again.', 'boring-quote' ), 'error' ); } if ( ! wp_doing_ajax() && ! headers_sent() ) { wp_safe_redirect( remove_query_arg( array( 'add-to-quote', 'variation_id', 'quantity', 'bbquote_add_nonce' ) ) ); exit; } return; } if ( ! is_numeric( $_REQUEST['add-to-quote'] ) ) { return; } $product_id = absint( $_REQUEST['add-to-quote'] ); $variation_id = isset( $_REQUEST['variation_id'] ) ? absint( $_REQUEST['variation_id'] ) : 0; $quantity = isset( $_REQUEST['quantity'] ) ? absint( $_REQUEST['quantity'] ) : 1; // Without JS the variations form sends only the chosen attributes, not the variation id. $attributes = array(); foreach ( wp_unslash( $_REQUEST ) as $name => $value ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized below. if ( is_string( $name ) && 0 === strpos( $name, 'attribute_' ) && is_scalar( $value ) ) { $attributes[ sanitize_title( $name ) ] = sanitize_text_field( (string) $value ); } } $product = wc_get_product( $product_id ); if ( ! $variation_id && $attributes && $product && $product->is_type( 'variable' ) ) { $variation_id = (int) \WC_Data_Store::load( 'product' )->find_matching_product_variation( $product, $attributes ); } $result = $this->add_item( $product_id, $quantity, $variation_id, $attributes ); if ( function_exists( 'wc_add_notice' ) ) { $types = array( 'added' => 'success', 'exists' => 'notice', ); wc_add_notice( self::result_message( $result ), isset( $types[ $result ] ) ? $types[ $result ] : 'error' ); } // Drop the query args so a reload does not add the product again. if ( ! wp_doing_ajax() && ! headers_sent() ) { wp_safe_redirect( remove_query_arg( array( 'add-to-quote', 'variation_id', 'quantity', 'bbquote_add_nonce' ) ) ); exit; } } }