| @@ -15,8 +15,10 @@ | ||
| 15 | 15 | exit; |
| 16 | 16 | } |
| 17 | 17 | |
| 18 | 18 | use Disco\App\Disco; |
| 19 | +use Disco\App\Intents\CategoryBogo\CategoryBogo; | |
| 20 | +use Disco\App\Intents\CategoryBogo\CategoryBogoCart; | |
| 19 | 21 | use Disco\App\Utility\Helper; |
| 20 | 22 | |
| 21 | 23 | if ( ! function_exists( 'disco_cart_apply_free_items' ) ) { |
| 22 | 24 | /** |
| @@ -41,17 +43,42 @@ | ||
| 41 | 43 | |
| 42 | 44 | $is_processing = true; |
| 43 | 45 | |
| 44 | 46 | try { |
| 47 | + /** | |
| 48 | + * Category BOGO owns its own free-item lifecycle: buy reservation, | |
| 49 | + * entitlement, reward selection and paid/free reconciliation all live | |
| 50 | + * in CategoryBogo. When such a campaign is active it fully manages | |
| 51 | + * every cart line of its reward categories. | |
| 52 | + */ | |
| 53 | + $category_bogo = new CategoryBogo(); | |
| 54 | + $category_active = $category_bogo->apply_free_items_to_cart( $cart ); | |
| 55 | + | |
| 56 | + /** | |
| 57 | + * Nothing but category campaigns are active, so every free item in the | |
| 58 | + * cart is already reconciled. Skipping the pass below avoids recomputing | |
| 59 | + * a discount set that would only be discarded. | |
| 60 | + */ | |
| 61 | + if ( $category_active && ! $category_bogo->has_non_category_free_bogo_campaigns() ) { | |
| 62 | + return; | |
| 63 | + } | |
| 64 | + | |
| 45 | 65 | // Calculate discounts - free products are automatically excluded in IntentHelper |
| 46 | 66 | $disco = new Disco(); |
| 47 | 67 | $discounts = $disco->get_cart_items_discount_for_bogo( $cart ); |
| 48 | 68 | $discounts = is_array( $discounts ) ? $discounts : array(); |
| 49 | 69 | |
| 50 | - $free_ids = $discounts['get_ids'] ?? array(); | |
| 51 | - $free_qty = $discounts['get_qty'] ?? 0; | |
| 52 | - $bogo_type = $discounts['bogo_type'] ?? 'products'; | |
| 70 | + $free_ids = $discounts['get_ids'] ?? array(); | |
| 71 | + $free_qty = $discounts['get_qty'] ?? 0; | |
| 72 | + $free_qty_map = $discounts['get_qty_map'] ?? array(); | |
| 73 | + $bogo_type = $discounts['bogo_type'] ?? 'products'; | |
| 74 | + $free_selection = $discounts['free_item_selection'] ?? 'cart_order'; | |
| 53 | 75 | |
| 76 | + // The category engine already applied the category rules. | |
| 77 | + if ( $category_active && 'categories' === $bogo_type ) { | |
| 78 | + return; | |
| 79 | + } | |
| 80 | + | |
| 54 | 81 | // Only process if we have valid discount data |
| 55 | 82 | if ( ! empty( $free_qty ) && ! empty( $free_ids ) ) { |
| 56 | 83 | // Remove invalid free items from cart |
| 57 | 84 | disco_remove_invalid_free_items( $cart, $free_ids, $bogo_type ); |
| @@ -57,11 +84,11 @@ | ||
| 57 | 84 | disco_remove_invalid_free_items( $cart, $free_ids, $bogo_type ); |
| 58 | 85 | |
| 59 | 86 | // Add or update free items |
| 60 | 87 | if ( $bogo_type === 'categories' ) { |
| 61 | - disco_apply_category_based_free_items( $cart, $free_ids, $free_qty ); | |
| 88 | + disco_apply_category_based_free_items( $cart, $free_ids, $free_qty, $free_selection ); | |
| 62 | 89 | } else { |
| 63 | - disco_apply_product_based_free_items( $cart, $free_ids, $free_qty ); | |
| 90 | + disco_apply_product_based_free_items( $cart, $free_ids, $free_qty_map, $free_qty ); | |
| 64 | 91 | } |
| 65 | 92 | } else { |
| 66 | 93 | // No valid BOGO, remove all free items |
| 67 | 94 | disco_remove_all_free_items( $cart ); |
| @@ -83,8 +110,13 @@ | ||
| 83 | 110 | function disco_remove_all_free_items( $cart ) { |
| 84 | 111 | $items_to_remove = array(); |
| 85 | 112 | |
| 86 | 113 | foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) { |
| 114 | + // Lines owned by the category engine are managed there. | |
| 115 | + if ( ! empty( $cart_item[ CategoryBogoCart::FREE_ITEM_FLAG ] ) ) { | |
| 116 | + continue; | |
| 117 | + } | |
| 118 | + | |
| 87 | 119 | if ( ! empty( $cart_item['is_free_product'] ) ) { |
| 88 | 120 | $items_to_remove[] = $cart_item_key; |
| 89 | 121 | } |
| 90 | 122 | } |
| @@ -106,9 +138,9 @@ | ||
| 106 | 138 | function disco_remove_invalid_free_items( $cart, $free_ids, $bogo_type ) { |
| 107 | 139 | $items_to_remove = array(); |
| 108 | 140 | |
| 109 | 141 | foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) { |
| 110 | - if ( empty( $cart_item['is_free_product'] ) ) { | |
| 142 | + if ( empty( $cart_item['is_free_product'] ) || ! empty( $cart_item[ CategoryBogoCart::FREE_ITEM_FLAG ] ) ) { | |
| 111 | 143 | continue; |
| 112 | 144 | } |
| 113 | 145 | |
| 114 | 146 | $product_id = $cart_item['product_id']; |
| @@ -140,71 +172,91 @@ | ||
| 140 | 172 | if ( ! function_exists( 'disco_apply_category_based_free_items' ) ) { |
| 141 | 173 | /** |
| 142 | 174 | * Apply free items based on category rules. |
| 143 | 175 | * |
| 144 | - * @param \WC_Cart $cart Cart Object. | |
| 145 | - * @param array $free_ids Category IDs for free products. | |
| 146 | - * @param int $free_qty Quantity of free items allowed. | |
| 176 | + * @param \WC_Cart $cart Cart Object. | |
| 177 | + * @param array $free_ids Category IDs for free products. | |
| 178 | + * @param int $free_qty Quantity of free items allowed. | |
| 179 | + * @param string $free_selection Which category item to reward: cart_order | lowest | highest. | |
| 147 | 180 | */ |
| 148 | - function disco_apply_category_based_free_items( $cart, $free_ids, $free_qty ) { | |
| 149 | - // First, enforce quantity limits on existing free items and count them | |
| 150 | - $existing_free_count = 0; | |
| 181 | + function disco_apply_category_based_free_items( $cart, $free_ids, $free_qty, $free_selection = 'cart_order' ) { | |
| 182 | + $free_qty = max( 0, (int) $free_qty ); | |
| 151 | 183 | |
| 152 | - foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) { | |
| 153 | - if ( ! empty( $cart_item['is_free_product'] ) ) { | |
| 154 | - $product_id = $cart_item['product_id']; | |
| 184 | + // Rank category members by the selection strategy, then flag whole lines | |
| 185 | + // free IN PLACE (the bought units become free) up to $free_qty. Only the | |
| 186 | + // is_free_product flag is toggled — cart quantities are never mutated here | |
| 187 | + // (that fires nested recalculations during calculate_totals and corrupts | |
| 188 | + // the cart). The reward count treats a product's paid + free quantity as | |
| 189 | + // its buy quantity, so flagging a line free never drops it from the count. | |
| 190 | + $ordered = disco_order_free_candidates( $cart, $free_ids, $free_selection ); | |
| 155 | 191 | |
| 156 | - if ( Helper::is_in_category( $product_id, $free_ids ) ) { | |
| 157 | - $item_qty = $cart_item['quantity']; | |
| 192 | + // Choose whole lines that fit within the free quantity. | |
| 193 | + $target = array(); | |
| 194 | + $count = 0; | |
| 158 | 195 | |
| 159 | - // Check if this item's quantity exceeds remaining allowed free qty | |
| 160 | - $remaining_allowed = $free_qty - $existing_free_count; | |
| 196 | + foreach ( $ordered as $cart_item_key ) { | |
| 197 | + if ( $count >= $free_qty ) { | |
| 198 | + break; | |
| 199 | + } | |
| 161 | 200 | |
| 162 | - if ( $item_qty > $remaining_allowed && $remaining_allowed > 0 ) { | |
| 163 | - // Reduce quantity to allowed amount | |
| 164 | - $cart->set_quantity( $cart_item_key, $remaining_allowed ); | |
| 165 | - $existing_free_count += $remaining_allowed; | |
| 166 | - } elseif ( $remaining_allowed <= 0 ) { | |
| 167 | - // No more free items allowed, remove free flag or remove item | |
| 168 | - $cart->remove_cart_item( $cart_item_key ); | |
| 169 | - } else { | |
| 170 | - $existing_free_count += $item_qty; | |
| 171 | - } | |
| 172 | - } | |
| 201 | + $qty = (int) $cart->cart_contents[ $cart_item_key ]['quantity']; | |
| 202 | + | |
| 203 | + if ( $qty <= ( $free_qty - $count ) ) { | |
| 204 | + $target[ $cart_item_key ] = true; | |
| 205 | + $count += $qty; | |
| 173 | 206 | } |
| 174 | 207 | } |
| 175 | 208 | |
| 176 | - // Calculate how many more items we can mark as free | |
| 177 | - $remaining_free_qty = $free_qty - $existing_free_count; | |
| 209 | + // Reconcile: flag targets free, clear the flag on any other category line. | |
| 210 | + foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) { | |
| 211 | + if ( ! Helper::is_in_category( $cart_item['product_id'], $free_ids ) ) { | |
| 212 | + continue; | |
| 213 | + } | |
| 178 | 214 | |
| 179 | - if ( $remaining_free_qty <= 0 ) { | |
| 180 | - return; // Already have enough free items | |
| 215 | + $should_be_free = isset( $target[ $cart_item_key ] ); | |
| 216 | + $currently_free = ! empty( $cart_item['is_free_product'] ); | |
| 217 | + | |
| 218 | + if ( $should_be_free && ! $currently_free ) { | |
| 219 | + $cart->cart_contents[ $cart_item_key ]['is_free_product'] = true; | |
| 220 | + } elseif ( ! $should_be_free && $currently_free ) { | |
| 221 | + unset( $cart->cart_contents[ $cart_item_key ]['is_free_product'] ); | |
| 222 | + } | |
| 181 | 223 | } |
| 224 | + } | |
| 225 | +} | |
| 182 | 226 | |
| 183 | - $free_count = 0; | |
| 227 | +if ( ! function_exists( 'disco_order_free_candidates' ) ) { | |
| 228 | + /** | |
| 229 | + * Return the reward-category cart keys ordered by the selection strategy. | |
| 230 | + * | |
| 231 | + * Includes every in-category cart item (even ones currently flagged free, so | |
| 232 | + * the reward can be re-picked). `cart_order` keeps cart order; `lowest` / | |
| 233 | + * `highest` sort by price (via CalcFactory::get_price, so a previously-freed | |
| 234 | + * item still ranks by its real price). Ties keep cart order (stable on PHP 8+). | |
| 235 | + * | |
| 236 | + * @param \WC_Cart $cart Cart Object. | |
| 237 | + * @param array $free_ids Category IDs for free products. | |
| 238 | + * @param string $free_selection cart_order | lowest | highest. | |
| 239 | + * @return array List of cart item keys. | |
| 240 | + */ | |
| 241 | + function disco_order_free_candidates( $cart, $free_ids, $free_selection ) { | |
| 242 | + $candidates = array(); | |
| 184 | 243 | |
| 185 | 244 | foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) { |
| 186 | - if ( $free_count >= $remaining_free_qty ) { | |
| 187 | - break; | |
| 188 | - } | |
| 189 | - | |
| 190 | - // Skip items already marked as free | |
| 191 | - if ( ! empty( $cart_item['is_free_product'] ) ) { | |
| 245 | + if ( ! Helper::is_in_category( $cart_item['product_id'], $free_ids ) ) { | |
| 192 | 246 | continue; |
| 193 | 247 | } |
| 194 | 248 | |
| 195 | - $product_id = $cart_item['product_id']; | |
| 249 | + $candidates[ $cart_item_key ] = (float) \Disco\App\Calc\CalcFactory::get_price( $cart_item ); | |
| 250 | + } | |
| 196 | 251 | |
| 197 | - if ( Helper::is_in_category( $product_id, $free_ids ) ) { | |
| 198 | - $qty_to_mark = $remaining_free_qty - $free_count; | |
| 252 | + if ( 'lowest' === $free_selection ) { | |
| 253 | + asort( $candidates ); | |
| 254 | + } elseif ( 'highest' === $free_selection ) { | |
| 255 | + arsort( $candidates ); | |
| 256 | + } | |
| 199 | 257 | |
| 200 | - // Only mark item free if the full quantity fits | |
| 201 | - if ( $cart_item['quantity'] <= $qty_to_mark ) { | |
| 202 | - $cart->cart_contents[ $cart_item_key ]['is_free_product'] = true; | |
| 203 | - $free_count += $cart_item['quantity']; | |
| 204 | - } | |
| 205 | - } | |
| 206 | - } | |
| 258 | + return array_keys( $candidates ); | |
| 207 | 259 | } |
| 208 | 260 | } |
| 209 | 261 | |
| 210 | 262 | if ( ! function_exists( 'disco_apply_product_based_free_items' ) ) { |
| @@ -210,14 +262,32 @@ | ||
| 210 | 262 | if ( ! function_exists( 'disco_apply_product_based_free_items' ) ) { |
| 211 | 263 | /** |
| 212 | 264 | * Apply free items based on product rules. |
| 213 | 265 | * |
| 214 | - * @param \WC_Cart $cart Cart Object. | |
| 215 | - * @param array $free_ids Product IDs for free products. | |
| 216 | - * @param int $free_qty Quantity of free items. | |
| 266 | + * Every product id in $free_ids gets its own free line, so multi-product | |
| 267 | + * BOGO (e.g. BuyXGetX across several cart products) rewards them all — not | |
| 268 | + * just the last one. The free quantity is per id via $qty_map, falling back | |
| 269 | + * to $fallback_qty. | |
| 270 | + * | |
| 271 | + * @param \WC_Cart $cart Cart Object. | |
| 272 | + * @param array $free_ids Product IDs for free products. | |
| 273 | + * @param array|int $qty_map Map of product id => free quantity (or a plain int for all). | |
| 274 | + * @param int $fallback_qty Quantity to use when a id is absent from the map. | |
| 217 | 275 | */ |
| 218 | - function disco_apply_product_based_free_items( $cart, $free_ids, $free_qty ) { | |
| 276 | + function disco_apply_product_based_free_items( $cart, $free_ids, $qty_map, $fallback_qty = 1 ) { | |
| 277 | + // Allow a plain integer for backward compatibility. | |
| 278 | + if ( ! is_array( $qty_map ) ) { | |
| 279 | + $fallback_qty = (int) $qty_map; | |
| 280 | + $qty_map = array(); | |
| 281 | + } | |
| 282 | + | |
| 219 | 283 | foreach ( $free_ids as $product_id ) { |
| 284 | + $free_qty = isset( $qty_map[ (int) $product_id ] ) ? (int) $qty_map[ (int) $product_id ] : (int) $fallback_qty; | |
| 285 | + | |
| 286 | + if ( $free_qty <= 0 ) { | |
| 287 | + continue; | |
| 288 | + } | |
| 289 | + | |
| 220 | 290 | $found = false; |
| 221 | 291 | |
| 222 | 292 | foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) { |
| 223 | 293 | if ( |
| @@ -224,9 +294,9 @@ | ||
| 224 | 294 | intval( $cart_item['product_id'] ) === intval( $product_id ) |
| 225 | 295 | && ! empty( $cart_item['is_free_product'] ) |
| 226 | 296 | ) { |
| 227 | 297 | $current_qty = $cart_item['quantity']; |
| 228 | - if ( $current_qty !== $free_qty ) { | |
| 298 | + if ( (int) $current_qty !== $free_qty ) { | |
| 229 | 299 | $cart->set_quantity( $cart_item_key, $free_qty ); |
| 230 | 300 | } |
| 231 | 301 | $found = true; |
| 232 | 302 | break; |
| @@ -250,25 +320,140 @@ | ||
| 250 | 320 | if ( is_admin() && ! defined( 'DOING_AJAX' ) ) { |
| 251 | 321 | return; |
| 252 | 322 | } |
| 253 | 323 | |
| 324 | + // Drop last pass's ratios: every partly-free line re-registers below. | |
| 325 | + disco_category_bogo_paid_ratio_registry( false ); | |
| 326 | + | |
| 254 | 327 | foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) { |
| 255 | - if ( empty( $cart_item['is_free_product'] ) ) { | |
| 328 | + if ( ! empty( $cart_item['is_free_product'] ) ) { | |
| 329 | + $cart_item['data']->set_price( 0 ); | |
| 330 | + $cart_item['data']->set_regular_price( 0 ); | |
| 331 | + $cart->cart_contents[ $cart_item_key ]['free_product_note'] = __( 'This item is added as free!', 'disco' ); | |
| 332 | + | |
| 256 | 333 | continue; |
| 257 | 334 | } |
| 258 | 335 | |
| 259 | - $cart_item['data']->set_price( 0 ); | |
| 260 | - $cart_item['data']->set_regular_price( 0 ); | |
| 261 | - $cart->cart_contents[ $cart_item_key ]['free_product_note'] = __( 'This item is added as free!', 'disco' ); | |
| 336 | + disco_apply_category_bogo_partial_free_price( $cart, $cart_item_key, $cart_item ); | |
| 262 | 337 | } |
| 263 | 338 | } |
| 264 | 339 | add_action( 'woocommerce_before_calculate_totals', 'disco_set_free_product_price', 10 ); |
| 265 | 340 | } |
| 266 | 341 | |
| 342 | +if ( ! function_exists( 'disco_apply_category_bogo_partial_free_price' ) ) { | |
| 343 | + /** | |
| 344 | + * Charge only the paid units of a line that is partly free. | |
| 345 | + * | |
| 346 | + * Category BOGO never adds or removes cart lines, so a line the customer | |
| 347 | + * added can hold both free and paid units. The line keeps its quantity and | |
| 348 | + * its per-unit price is scaled down so the line total covers the paid units | |
| 349 | + * only: qty 3 with 1 free at 20 is charged 40. | |
| 350 | + * | |
| 351 | + * The scale factor is registered against this line's own product object and | |
| 352 | + * applied by {@see disco_apply_category_bogo_paid_ratio} on top of whatever price | |
| 353 | + * Disco's product filters resolve, so it never fights those filters and never | |
| 354 | + * compounds across recalculations. | |
| 355 | + * | |
| 356 | + * @param \WC_Cart $cart Cart Object. | |
| 357 | + * @param string $cart_item_key Cart item key. | |
| 358 | + * @param array $cart_item Cart item. | |
| 359 | + */ | |
| 360 | + function disco_apply_category_bogo_partial_free_price( $cart, $cart_item_key, $cart_item ) { | |
| 361 | + $free_qty = isset( $cart_item[ CategoryBogoCart::FREE_QUANTITY_META ] ) | |
| 362 | + ? (int) $cart_item[ CategoryBogoCart::FREE_QUANTITY_META ] | |
| 363 | + : 0; | |
| 364 | + $line_qty = (int) $cart_item['quantity']; | |
| 365 | + | |
| 366 | + if ( $free_qty <= 0 || $line_qty <= 0 || $free_qty >= $line_qty ) { | |
| 367 | + return; | |
| 368 | + } | |
| 369 | + | |
| 370 | + if ( empty( $cart_item['data'] ) || ! $cart_item['data'] instanceof WC_Product ) { | |
| 371 | + return; | |
| 372 | + } | |
| 373 | + | |
| 374 | + $paid_qty = $line_qty - $free_qty; | |
| 375 | + | |
| 376 | + disco_category_bogo_paid_ratio_registry( $cart_item['data'], $paid_qty / $line_qty ); | |
| 377 | + | |
| 378 | + $cart->cart_contents[ $cart_item_key ]['free_product_note'] = sprintf( | |
| 379 | + /* translators: %d: number of units given for free. */ | |
| 380 | + _n( '%d item is added as free!', '%d items are added as free!', $free_qty, 'disco' ), | |
| 381 | + $free_qty | |
| 382 | + ); | |
| 383 | + } | |
| 384 | +} | |
| 385 | + | |
| 386 | +if ( ! function_exists( 'disco_category_bogo_paid_ratio_registry' ) ) { | |
| 387 | + /** | |
| 388 | + * Registry of paid-unit ratios, keyed by cart line product object. | |
| 389 | + * | |
| 390 | + * Each cart line owns its own product instance, so the object identity is | |
| 391 | + * what distinguishes "this line of 3 with 1 free" from another line of the | |
| 392 | + * same product. Called with a product to register a ratio, with no arguments | |
| 393 | + * to read the registry, and with null to reset it before a fresh pass. | |
| 394 | + * | |
| 395 | + * @param \WC_Product|null $product Line product object to register. | |
| 396 | + * @param float $ratio Paid units / line quantity. | |
| 397 | + * @return array Registered ratios keyed by object id. | |
| 398 | + */ | |
| 399 | + function disco_category_bogo_paid_ratio_registry( $product = null, $ratio = 1.0 ) { | |
| 400 | + static $ratios = array(); | |
| 401 | + | |
| 402 | + if ( $product instanceof WC_Product ) { | |
| 403 | + $ratios[ spl_object_id( $product ) ] = (float) $ratio; | |
| 404 | + } elseif ( false === $product ) { | |
| 405 | + $ratios = array(); | |
| 406 | + } | |
| 407 | + | |
| 408 | + return $ratios; | |
| 409 | + } | |
| 410 | +} | |
| 411 | + | |
| 412 | +if ( ! function_exists( 'disco_apply_category_bogo_paid_ratio' ) ) { | |
| 413 | + /** | |
| 414 | + * Scale a partly-free cart line's price down to its paid units. | |
| 415 | + * | |
| 416 | + * Runs after Disco's own product price filters, so the free units are | |
| 417 | + * removed from whatever price the other campaigns resolved. Those filters | |
| 418 | + * sit at PHP_INT_MAX, so this one shares that priority and relies on | |
| 419 | + * registration order: bogo.php is required after product.php, and WordPress | |
| 420 | + * runs same-priority callbacks in the order they were added. Registering | |
| 421 | + * lower would put this first, and disco_discounted_price would then re-read | |
| 422 | + * the base price and discard the scaling. | |
| 423 | + * | |
| 424 | + * @param float|string $price Product price. | |
| 425 | + * @param \WC_Product $product Product object. | |
| 426 | + * @return float|string | |
| 427 | + */ | |
| 428 | + function disco_apply_category_bogo_paid_ratio( $price, $product ) { | |
| 429 | + if ( ! is_numeric( $price ) || ! is_object( $product ) ) { | |
| 430 | + return $price; | |
| 431 | + } | |
| 432 | + | |
| 433 | + $ratios = disco_category_bogo_paid_ratio_registry(); | |
| 434 | + $object_id = spl_object_id( $product ); | |
| 435 | + | |
| 436 | + if ( ! isset( $ratios[ $object_id ] ) ) { | |
| 437 | + return $price; | |
| 438 | + } | |
| 439 | + | |
| 440 | + return (float) $price * $ratios[ $object_id ]; | |
| 441 | + } | |
| 442 | + | |
| 443 | + add_filter( 'woocommerce_product_get_price', 'disco_apply_category_bogo_paid_ratio', PHP_INT_MAX, 2 ); | |
| 444 | + add_filter( 'woocommerce_product_variation_get_price', 'disco_apply_category_bogo_paid_ratio', PHP_INT_MAX, 2 ); | |
| 445 | +} | |
| 446 | + | |
| 267 | 447 | if ( ! function_exists( 'disco_free_product_label' ) ) { |
| 268 | 448 | /** |
| 269 | 449 | * Display free product label in cart. |
| 270 | 450 | * |
| 451 | + * The note is wrapped in `.disco-free-item-note` so it stands out from the | |
| 452 | + * other item meta rows, which are plain grey text. WooCommerce renders item | |
| 453 | + * data through `wp_kses_post()`, so the span survives; the note itself is | |
| 454 | + * escaped here because it is interpolated into markup. | |
| 455 | + * | |
| 271 | 456 | * @param array $item_data Item data. |
| 272 | 457 | * @param array $cart_item Cart item. |
| 273 | 458 | * @return array |
| 274 | 459 | */ |
| @@ -275,9 +460,12 @@ | ||
| 275 | 460 | function disco_free_product_label( $item_data, $cart_item ) { |
| 276 | 461 | if ( ! empty( $cart_item['free_product_note'] ) ) { |
| 277 | 462 | $item_data[] = array( |
| 278 | 463 | 'key' => __( 'Note', 'disco' ), |
| 279 | - 'value' => $cart_item['free_product_note'], | |
| 464 | + 'value' => sprintf( | |
| 465 | + '<span class="disco-free-item-note">%s</span>', | |
| 466 | + esc_html( $cart_item['free_product_note'] ) | |
| 467 | + ), | |
| 280 | 468 | ); |
| 281 | 469 | } |
| 282 | 470 | return $item_data; |
| 283 | 471 | } |