get_meta( '_tryv_sample_enabled', true ) ) { return false; } if ( tryv_product_is_pack( $product ) ) { // QC fixes B2 + M7: a pack is only sampleable when the pack // itself is purchasable and in stock AND it has at least one // valid component at order time. An empty pack, or a pack // whose components are all unpublished / out of stock / // not purchasable / themselves packs, is not sampleable, so // no button renders and no sample add validates. A component // that is temporarily out of stock stays stored on the pack // and becomes valid again when it is back in stock, because // validity is checked live here, not by deleting it on save. return $product->is_purchasable() && $product->is_in_stock() && ! empty( tryv_pack_valid_component_ids( $product ) ); } return $product->is_purchasable() && $product->is_in_stock(); } /** * Is this product a swatch pack? * * @param WC_Product $product Product object. * @return bool */ function tryv_product_is_pack( $product ) { if ( ! $product instanceof WC_Product ) { return false; } return 'yes' === $product->get_meta( '_tryv_is_pack', true ); } /** * Component product IDs for a pack, excluding the pack itself. * * @param WC_Product $product Product object. * @return int[] */ function tryv_pack_product_ids( $product ) { if ( ! $product instanceof WC_Product ) { return array(); } $raw = $product->get_meta( '_tryv_pack_products', true ); if ( ! is_array( $raw ) ) { return array(); } $ids = array(); foreach ( $raw as $id ) { $id = absint( $id ); if ( $id > 0 && $id !== $product->get_id() ) { $ids[] = $id; } } return array_values( array_unique( $ids ) ); } /** * Valid component IDs for a pack, checked live at order time (M7): * the component must exist, be published, be purchasable, be in * stock, and must not itself be a pack (nested packs are rejected). * A component's own "Allow samples" flag is NOT required: pack * sampling is governed by the pack's flag, so a swatch material can * be sampled as part of a pack without being offered as a single * sample. Stored components that fail this check are hidden from * ordering but are not deleted, so they return when eligible again. * Use this list for all runtime decisions; tryv_pack_product_ids() * returns the stored list for display/editing. * * @param WC_Product $product Pack product object. * @return int[] */ function tryv_pack_valid_component_ids( $product ) { if ( ! $product instanceof WC_Product ) { return array(); } $ids = array(); foreach ( tryv_pack_product_ids( $product ) as $id ) { $component = wc_get_product( $id ); if ( ! $component ) { continue; } if ( 'publish' !== $component->get_status() ) { continue; } if ( tryv_product_is_pack( $component ) ) { continue; } if ( ! $component->is_purchasable() || ! $component->is_in_stock() ) { continue; } $ids[] = absint( $id ); } return array_values( array_unique( $ids ) ); } /** * Is one product a valid pack component at save time? Save-time * filtering is deliberately narrower than the live check above: * only self-reference, nested packs, and non-existent products are * dropped on save. Publish/stock/purchasability are NOT dropped on * save, because those change over time and a temporarily * out-of-stock component should not be permanently deleted from * the pack. The live check handles those at order time. * * @param int $component_id Candidate component ID. * @param int $pack_id Pack product ID. * @return bool */ function tryv_is_saveable_pack_component( $component_id, $pack_id ) { $component_id = absint( $component_id ); if ( $component_id <= 0 || $component_id === absint( $pack_id ) ) { return false; } $component = wc_get_product( $component_id ); if ( ! $component ) { return false; } if ( tryv_product_is_pack( $component ) ) { return false; } return true; } /** * Sample price for a product or variation. Blank falls back to the * product's regular price; a stored 0 is a genuinely free sample price. * For a variation, the full fallback chain lives in * tryv_sample_price_for() in cart.php: variation sample, variation * regular, parent sample, parent regular. This helper on its own * only does sample-then-regular for the object given to it. * * @param WC_Product $product Product or variation object. * @return float */ function tryv_get_sample_price( $product ) { $raw = $product->get_meta( '_tryv_sample_price', true ); if ( '' === $raw || null === $raw ) { return (float) $product->get_regular_price(); } return max( 0, (float) $raw ); } /** * Resolve a per-product numeric limit, falling back to the global default. * * @param WC_Product $product Product object. * @param string $meta_key Meta key for the per-product value. * @param string $setting_key Global setting key. * @return int 0 means no limit, except for the free allowance where 0 means no free samples. */ function tryv_resolve_limit( $product, $meta_key, $setting_key ) { $raw = $product->get_meta( $meta_key, true ); if ( '' === $raw || null === $raw ) { $settings = tryv_get_settings(); return absint( $settings[ $setting_key ] ); } return absint( $raw ); } /** * The sampled identity for a cart/order line: variation ID when a * variation was chosen, otherwise the product ID. * * @param int $product_id Product ID. * @param int $variation_id Variation ID, 0 for simple products. * @return int */ function tryv_sampled_id( $product_id, $variation_id ) { return $variation_id > 0 ? absint( $variation_id ) : absint( $product_id ); } /** * Render the sample fields on the simple-product edit screen. */ function tryv_render_product_fields() { global $post; if ( ! $post ) { return; } $product = wc_get_product( $post->ID ); if ( ! $product ) { return; } echo '
'; } /** * Product options for the pack multi-select: published simple and * variable products, excluding the pack itself. * * @param int $exclude_id Product ID to exclude. * @return array id => title */ function tryv_product_options_for_pack( $exclude_id ) { $options = array(); $products = wc_get_products( array( 'limit' => -1, 'status' => 'publish', 'type' => array( 'simple', 'variable' ), ) ); foreach ( $products as $p ) { if ( $p->get_id() === absint( $exclude_id ) ) { continue; } $options[ $p->get_id() ] = $p->get_name() . ' (#' . $p->get_id() . ')'; } return $options; } /** * Save the product fields. Runs inside WooCommerce's product-save flow, * which already verifies its own nonce; the capability check here is * an extra guard, and the save is abandoned if it fails. * * @param WC_Product $product Product object being saved. */ function tryv_save_product_fields( $product ) { if ( ! current_user_can( 'edit_post', $product->get_id() ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification -- WooCommerce verifies the product-save nonce. $posted = isset( $_POST['_tryv_sample_enabled'] ) ? sanitize_text_field( wp_unslash( $_POST['_tryv_sample_enabled'] ) ) : 'no'; $product->update_meta_data( '_tryv_sample_enabled', 'yes' === $posted ? 'yes' : 'no' ); $product->update_meta_data( '_tryv_sample_price', tryv_posted_decimal( '_tryv_sample_price' ) ); $product->update_meta_data( '_tryv_sample_free', tryv_posted_int_or_blank( '_tryv_sample_free' ) ); $product->update_meta_data( '_tryv_sample_max_order', tryv_posted_int_or_blank( '_tryv_sample_max_order' ) ); $product->update_meta_data( '_tryv_sample_max_customer', tryv_posted_int_or_blank( '_tryv_sample_max_customer' ) ); // phpcs:ignore WordPress.Security.NonceVerification -- WooCommerce verifies the product-save nonce. $is_pack = isset( $_POST['_tryv_is_pack'] ) && 'yes' === sanitize_text_field( wp_unslash( $_POST['_tryv_is_pack'] ) ); $product->update_meta_data( '_tryv_is_pack', $is_pack ? 'yes' : 'no' ); $pack_ids = array(); // phpcs:ignore WordPress.Security.NonceVerification -- WooCommerce verifies the product-save nonce. if ( isset( $_POST['_tryv_pack_products'] ) ) { // QC fix M6, backward compatible: the fixed field name posts // an array, but an old form (or a single selection) posts a // string. Treat a string as a one-element list instead of // dropping it, so a one-component pack can never be lost. $posted_pack = wp_unslash( $_POST['_tryv_pack_products'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput if ( ! is_array( $posted_pack ) ) { $posted_pack = array( $posted_pack ); } foreach ( $posted_pack as $id ) { // QC fix M7: nested packs are rejected on save, as is // the pack itself and any non-existent product. Stock / // publish status is NOT filtered here (see // tryv_is_saveable_pack_component): that is checked live // at order time, so a temporarily out-of-stock // component is not permanently deleted from the pack. if ( tryv_is_saveable_pack_component( $id, $product->get_id() ) ) { $pack_ids[] = absint( $id ); } } } $product->update_meta_data( '_tryv_pack_products', array_values( array_unique( $pack_ids ) ) ); } /** * Read a posted decimal field, returning '' when blank. * * @param string $key Field name. * @return string */ function tryv_posted_decimal( $key ) { // phpcs:ignore WordPress.Security.NonceVerification -- Caller verifies the product-save nonce. if ( ! isset( $_POST[ $key ] ) || '' === $_POST[ $key ] ) { return ''; } $raw = sanitize_text_field( wp_unslash( $_POST[ $key ] ) ); if ( ! is_numeric( $raw ) ) { return ''; } return (string) max( 0, (float) $raw ); } /** * Read a posted integer field, returning '' when blank. * * @param string $key Field name. * @return string */ function tryv_posted_int_or_blank( $key ) { // phpcs:ignore WordPress.Security.NonceVerification -- Caller verifies the product-save nonce. if ( ! isset( $_POST[ $key ] ) || '' === $_POST[ $key ] ) { return ''; } return (string) max( 0, absint( $_POST[ $key ] ) ); } /** * Render per-variation sample fields. Only the price is per-variation: * whether sampling is allowed is set on the parent product, so a * variation is never silently disabled by an unticked box. * * @param int $loop Variation index in the form. * @param array $variation_data Variation data. * @param WP_Post $variation Variation post object. */ function tryv_render_variation_fields( $loop, $variation_data, $variation ) { $product = wc_get_product( $variation->ID ); if ( ! $product ) { return; } woocommerce_wp_text_input( array( 'id' => '_tryv_sample_price[' . $loop . ']', 'label' => __( 'Sample price', 'tryvertising' ), 'description' => __( 'Leave blank to use, in order: this variation\'s regular price, then the parent product\'s sample price, then the parent regular price.', 'tryvertising' ), 'desc_tip' => true, 'type' => 'number', 'custom_attributes' => array( 'min' => '0', 'step' => '0.01' ), 'value' => $product->get_meta( '_tryv_sample_price', true ), 'wrapper_class' => 'form-row form-row-full', ) ); } /** * Save per-variation sample fields (price only). * * @param int $variation_id Variation ID. * @param int $loop Variation index in the form. */ function tryv_save_variation_fields( $variation_id, $loop ) { if ( ! current_user_can( 'edit_post', $variation_id ) ) { return; } $product = wc_get_product( $variation_id ); if ( ! $product ) { return; } // phpcs:ignore WordPress.Security.NonceVerification -- WooCommerce verifies the product-save nonce. $price_raw = isset( $_POST['_tryv_sample_price'][ $loop ] ) ? sanitize_text_field( wp_unslash( $_POST['_tryv_sample_price'][ $loop ] ) ) : ''; $product->update_meta_data( '_tryv_sample_price', ( '' === $price_raw || ! is_numeric( $price_raw ) ) ? '' : (string) max( 0, (float) $price_raw ) ); $product->save(); }