PluginProbe
Product Labels, Quick View, Buy Now, Pre-Orders, Frequently Bought Together & More for WooCommerce – Merchant / 2.3.2
Product Labels, Quick View, Buy Now, Pre-Orders, Frequently Bought Together & More for WooCommerce – Merchant v2.3.2
2.3.2 2.3.1 2.3.0 2.2.8 2.2.7 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.11.0 1.11.1 1.11.2 1.6 1.7 1.8 1.8.1 1.8.2 1.8.3 1.9.0 1.9.1 1.9.10 1.9.11 All 60 releases
merchant / inc / modules / pre-orders / classes / class-pre-orders-rules.php

class-pre-orders-rules.php in Product Labels, Quick View, Buy Now, Pre-Orders, Frequently Bought Together & More for WooCommerce – Merchant 2.3.2, at inc/modules/pre-orders/classes/class-pre-orders-rules.php

581 lines 20.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Pre-Orders Rules Repository.
4 *
5 * @package Merchant
6 */
7
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit; // Exit if accessed directly
10 }
11
12 /**
13 * Class Merchant_Pre_Orders_Rules_Repository
14 *
15 * Provides a centralized interface for resolving and managing pre-order eligibility.
16 *
17 * This repository is responsible for the complex orchestration of rule matching,
18 * ensuring that product-specific overrides are properly prioritized over global
19 * campaign settings. It handles all aspects of data retrieval, normalization,
20 * and structural validation for pre-order configurations.
21 */
22 class Merchant_Pre_Orders_Rules_Repository {
23
24 /**
25 * The module ID.
26 *
27 * @var string
28 */
29 const MODULE_ID = 'pre-orders';
30
31 /**
32 * The date time format.
33 *
34 * @var string
35 */
36 const DATE_TIME_FORMAT = 'm-d-Y h:i A';
37
38 /**
39 * Resolves the final applicable pre-order rule for a given product.
40 *
41 * The resolution logic starts by checking if pre-orders are explicitly enabled/disabled
42 * at the product level. If it's set to 'product' mode, it attempts to build a rule
43 * from product specific meta. If no valid product rule is found, it falls back to
44 * scanning the global campaign rules for a match.
45 *
46 * @param int $product_id The product ID to check.
47 *
48 * @return array The compiled rule settings or an empty array if pre-orders do not apply.
49 */
50 public static function get_rule_for_product( int $product_id ) {
51 // 1. Check Product-Specific Settings
52 // We check meta directly to determine mode.
53 $pre_order_mode = get_post_meta( $product_id, '_merchant_pre_order_mode', true );
54
55 if ( 'disabled' === $pre_order_mode ) {
56 return array();
57 }
58
59 if ( 'product' === $pre_order_mode ) {
60 $product_rule = self::get_product_scope_rule( $product_id );
61
62 /**
63 * Filter to get the available product rule.
64 *
65 * @param array $product_rule The product rule.
66 * @param int $product_id The product ID.
67 *
68 * @return array The available product rule.
69 *
70 * @since 2.2.4
71 */
72 return apply_filters( 'merchant_pre_order_available_rule', $product_rule, $product_id );
73 }
74
75 // 2. Global Rules Fallback
76 return self::find_matching_global_rule( $product_id );
77 }
78
79 /**
80 * Iterates through available global pre-order campaigns to find the first criteria match.
81 *
82 * Each global rule is validated against:
83 * 1. Campaign active status.
84 * 2. Time availability (start and end times).
85 * 3. User targeting conditions (roles/users).
86 * 4. Product exclusions (categories/tags/brands/specific IDs).
87 * 5. Scope matching (all products vs specific categories/tags/brands).
88 *
89 * @param int $product_id The product ID to match against global rules.
90 *
91 * @return array The first matching rule data or an empty array.
92 */
93 private static function find_matching_global_rule( int $product_id ) {
94 $rules = self::get_global_rules();
95 $current_time = merchant_get_current_timestamp();
96 $available_rule = array();
97
98 foreach ( $rules as $rule ) {
99 if ( isset( $rule['campaign_status'] ) && 'inactive' === $rule['campaign_status'] ) {
100 continue;
101 }
102
103 if ( ! self::is_valid_rule( $rule ) ) {
104 continue;
105 }
106
107 $rule = self::prepare_rule( $rule );
108
109 // Time Validation
110 if ( ! empty( $rule['pre_order_start'] ) && $rule['pre_order_start'] > $current_time ) {
111 continue;
112 }
113 if ( ! empty( $rule['pre_order_end'] ) && $rule['pre_order_end'] < $current_time ) {
114 continue;
115 }
116
117 // User Condition Check
118 if ( ! merchant_is_user_condition_passed( $rule ) ) {
119 continue;
120 }
121
122 // Exclusion Check
123 if ( self::is_product_excluded( $product_id, $rule ) ) {
124 continue;
125 }
126
127 // Scope Matching
128 if ( self::rule_matches_product( $rule, $product_id ) ) {
129 $available_rule = $rule;
130 break; // First match wins
131 }
132 }
133
134 /**
135 * Filter to get the available product rule.
136 *
137 * @param array $product_rule The product rule.
138 * @param int $product_id The product ID.
139 *
140 * @return array The available product rule.
141 *
142 * @since 2.2.4
143 */
144 return apply_filters( 'merchant_pre_order_available_rule', $available_rule, $product_id );
145 }
146
147 /**
148 * Determines if a global rule's scope includes the specified product.
149 *
150 * This method evaluates the rule's primary trigger (all products, specific products,
151 * or taxonomy-based rules like categories, tags, or brands).
152 *
153 * @param array $rule The rule configuration to evaluate.
154 * @param int $product_id The product ID to check against the rule scope.
155 *
156 * @return bool True if the product falls within the rule's scope, false otherwise.
157 */
158 private static function rule_matches_product( array $rule, int $product_id ) {
159 $trigger = $rule['trigger_on'] ?? 'product';
160
161 if ( 'all' === $trigger ) {
162 return true;
163 }
164
165 if ( 'product' === $trigger ) {
166 return in_array( $product_id, $rule['product_ids'] ?? array(), true );
167 }
168
169 // Taxonomy Triggers
170 $taxonomy_map = array(
171 'category' => 'product_cat',
172 'tags' => 'product_tag',
173 'brands' => 'product_brand',
174 );
175
176 if ( isset( $taxonomy_map[ $trigger ] ) ) {
177 $taxonomy = $taxonomy_map[ $trigger ];
178 $needed_slugs = array();
179 if ( 'category' === $trigger ) {
180 $needed_slugs = merchant_maybe_expand_categories( $rule['category_slugs'] ?? array(), $rule );
181 } elseif ( 'tags' === $trigger ) {
182 $needed_slugs = $rule['tag_slugs'] ?? array();
183 } elseif ( 'brands' === $trigger ) {
184 $needed_slugs = $rule['brand_slugs'] ?? array();
185 }
186
187 $terms = get_the_terms( $product_id, $taxonomy );
188 if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) {
189 foreach ( $terms as $term ) {
190 if ( in_array( $term->slug, $needed_slugs, true ) ) {
191 return true;
192 }
193 }
194 }
195 }
196
197 return false;
198 }
199
200 /**
201 * Constructs a pre-order rule specifically from a single product's metadata.
202 *
203 * This aggregates style, date, discount, and user targeting settings directly
204 * from the product edit screen, ensuring it matches the same structure as
205 * global rules for unified processing later.
206 *
207 * @param int $product_id The ID of the product being processed.
208 *
209 * @return array Compiled product-level pre-order rule or empty if invalid.
210 */
211 private static function get_product_scope_rule( int $product_id ) {
212 $rule = array_merge(
213 self::get_product_style_settings( $product_id ),
214 self::get_product_date_settings( $product_id )
215 );
216
217 // Analytics fallback
218 $rule['flexible_id'] = 'product-' . $product_id;
219
220 $rule = self::convert_rule_dates_to_timestamps( $rule );
221
222 if ( ! self::is_pre_order_time_valid( $rule ) ) {
223 return array();
224 }
225
226 $placement = get_post_meta( $product_id, '_merchant_pre_order_placement', true );
227 $rule['placement'] = $placement ? $placement : 'before';
228
229 // Create shipping_timestamp logic
230 $rule['shipping_timestamp'] = '';
231 if ( ! empty( $rule['shipping_date'] ) ) {
232 $rule['shipping_timestamp'] = is_numeric( $rule['shipping_date'] )
233 ? (int) $rule['shipping_date']
234 : merchant_date_string_to_timestamp( $rule['shipping_date'] );
235 }
236
237 // Merges
238 $rule = array_merge( $rule, self::get_product_discount_settings( $product_id ) );
239 $rule = array_merge( $rule, self::get_product_user_conditions( $product_id ) );
240
241 if ( ! merchant_is_user_condition_passed( $rule ) ) {
242 return array();
243 }
244
245 // Validation: Ensure we have a shipping date before considering this a valid pre-order
246 if ( empty( $rule['shipping_date'] ) ) {
247 return array();
248 }
249
250 return $rule;
251 }
252
253 /**
254 * Retrieves the visual configuration for the pre-order interface from product meta.
255 *
256 * Captures button labels, cart notes, and color themes (foreground, backdrop, frames).
257 *
258 * @param int $product_id The product ID.
259 *
260 * @return array Visual setting keys and their corresponding values.
261 */
262 private static function get_product_style_settings( int $product_id ) {
263 $button_text = get_post_meta( $product_id, '_merchant_pre_order_button_text', true );
264 $cart_label_text = get_post_meta( $product_id, '_merchant_pre_order_cart_label_text', true );
265 $additional_text = get_post_meta( $product_id, '_merchant_pre_order_additional_text', true );
266 $text_color = get_post_meta( $product_id, '_merchant_pre_order_text_color', true );
267 $text_hover_color = get_post_meta( $product_id, '_merchant_pre_order_text_hover_color', true );
268 $border_color = get_post_meta( $product_id, '_merchant_pre_order_border_color', true );
269 $border_hover_color = get_post_meta( $product_id, '_merchant_pre_order_border_hover_color', true );
270 $border_width = get_post_meta( $product_id, '_merchant_pre_order_border_width', true );
271 $border_radius = get_post_meta( $product_id, '_merchant_pre_order_border_radius', true );
272 $background_color = get_post_meta( $product_id, '_merchant_pre_order_background_color', true );
273 $background_hover_color = get_post_meta( $product_id, '_merchant_pre_order_background_hover_color', true );
274
275 return array(
276 'button_text' => ! empty( $button_text ) ? $button_text : __( 'Pre-Order Now', 'merchant' ),
277 'cart_label_text' => ! empty( $cart_label_text ) ? $cart_label_text : __( 'Ships on', 'merchant' ),
278 'additional_text' => $additional_text,
279 'text-color' => ! empty( $text_color ) ? $text_color : '#FFF',
280 'text-hover-color' => ! empty( $text_hover_color ) ? $text_hover_color : '#FFF',
281 'border-color' => ! empty( $border_color ) ? $border_color : '#212121',
282 'border-hover-color' => ! empty( $border_hover_color ) ? $border_hover_color : '#414141',
283 'border-width' => isset( $border_width ) && $border_width !== '' ? $border_width : 0,
284 'border-radius' => isset( $border_radius ) && $border_radius !== '' ? $border_radius : 0,
285 'background-color' => ! empty( $background_color ) ? $background_color : '#212121',
286 'background-hover-color' => ! empty( $background_hover_color ) ? $background_hover_color : '#414141',
287 );
288 }
289
290 /**
291 * Retrieves the temporal constraints for a pre-order from product meta.
292 *
293 * @param int $product_id The product ID.
294 *
295 * @return array Array containing shipping_date, pre_order_start, and pre_order_end.
296 */
297 private static function get_product_date_settings( int $product_id ) {
298 return array(
299 'shipping_date' => get_post_meta( $product_id, '_merchant_pre_order_shipping_date', true ),
300 'pre_order_start' => get_post_meta( $product_id, '_merchant_pre_order_start', true ),
301 'pre_order_end' => get_post_meta( $product_id, '_merchant_pre_order_end', true ),
302 );
303 }
304
305 /**
306 * Retrieves the financial incentive settings for pre-ordering a product.
307 *
308 * Checks if a discount is enabled and captures the type (percentage/fixed) and amount.
309 *
310 * @param int $product_id The product ID.
311 *
312 * @return array Discount toggle status and details.
313 */
314 private static function get_product_discount_settings( int $product_id ) {
315 $discount_toggle = get_post_meta( $product_id, '_merchant_pre_order_discount_toggle', true );
316 $is_enabled = ( '1' === $discount_toggle || 1 === $discount_toggle || true === $discount_toggle ); // Robust check
317
318 if ( ! $is_enabled ) {
319 return array( 'discount_toggle' => false );
320 }
321
322 $discount_type = get_post_meta( $product_id, '_merchant_pre_order_discount_type', true );
323 $discount_amount = get_post_meta( $product_id, '_merchant_pre_order_discount_amount', true );
324
325 return array(
326 'discount_toggle' => true,
327 'discount_type' => $discount_type ? $discount_type : 'percentage',
328 'discount_amount' => $discount_amount ? $discount_amount : '0',
329 );
330 }
331
332 /**
333 * Retrieves the user inheritance and visibility rules for a product.
334 *
335 * This includes both the positive targeting (which roles/users can see the pre-order)
336 * and the negative targeting (exclusion rules for specific roles/users).
337 *
338 * @param int $product_id The product ID.
339 *
340 * @return array {
341 * @type string $user_condition The targeting strategy ('all', 'roles', 'users').
342 * @type array $user_condition_roles List of authorized roles (if applicable).
343 * @type array $user_condition_users List of authorized user IDs (if applicable).
344 * @type array $exclude_roles List of denied roles (if applicable).
345 * @type array $exclude_users List of denied user IDs (if applicable).
346 * }
347 */
348 private static function get_product_user_conditions( int $product_id ) {
349 $targeting_strategy = get_post_meta( $product_id, '_merchant_pre_order_user_condition', true );
350 $active_strategy = ! empty( $targeting_strategy ) ? $targeting_strategy : 'all';
351
352 $conditions = array( 'user_condition' => $active_strategy );
353
354 if ( 'roles' === $active_strategy ) {
355 $authorized_roles = get_post_meta( $product_id, '_merchant_pre_order_user_condition_roles', true );
356 $conditions['user_condition_roles'] = ! empty( $authorized_roles ) ? $authorized_roles : array();
357 } elseif ( 'users' === $active_strategy ) {
358 $authorized_users = get_post_meta( $product_id, '_merchant_pre_order_user_condition_users', true );
359 $conditions['user_condition_users'] = ! empty( $authorized_users ) ? $authorized_users : array();
360 }
361
362 $exclusion_toggle = get_post_meta( $product_id, '_merchant_pre_order_user_exclusion_enabled', true );
363 if ( '1' === $exclusion_toggle || 1 === $exclusion_toggle ) {
364 $restricted_roles = get_post_meta( $product_id, '_merchant_pre_order_exclude_roles', true );
365 $restricted_users = get_post_meta( $product_id, '_merchant_pre_order_exclude_users', true );
366 $conditions['user_exclusion_enabled'] = true;
367 $conditions['exclude_roles'] = ! empty( $restricted_roles ) ? $restricted_roles : array();
368 $conditions['exclude_users'] = ! empty( $restricted_users ) ? $restricted_users : array();
369 }
370
371 return $conditions;
372 }
373
374 /**
375 * Normalizes human-readable date strings into Unix timestamps for comparison logic.
376 *
377 * Processes shipping, start, and end dates within a rule array.
378 *
379 * @param array $rule The rule array containing raw date strings.
380 *
381 * @return array The rule array with normalized integer timestamps.
382 */
383 private static function convert_rule_dates_to_timestamps( array $rule ) {
384 $fields = array( 'shipping_date', 'pre_order_start', 'pre_order_end' );
385 foreach ( $fields as $field ) {
386 if ( ! empty( $rule[ $field ] ) && ! is_numeric( $rule[ $field ] ) ) {
387 $rule[ $field ] = merchant_date_string_to_timestamp( $rule[ $field ] );
388 }
389 }
390
391 return $rule;
392 }
393
394 /**
395 * Ensures the current site time falls within the rule's active window.
396 *
397 * @param array $rule The rule to validate, containing pre_order_start and pre_order_end timestamps.
398 *
399 * @return bool True if currently active, false if not yet started or already expired.
400 */
401 private static function is_pre_order_time_valid( array $rule ) {
402 $current_time = merchant_get_current_timestamp();
403 if ( ! empty( $rule['pre_order_start'] ) && $rule['pre_order_start'] > $current_time ) {
404 return false;
405 }
406 if ( ! empty( $rule['pre_order_end'] ) && $rule['pre_order_end'] < $current_time ) {
407 return false;
408 }
409
410 return true;
411 }
412
413 /**
414 * Fetches the master list of global pre-order rules from the options framework.
415 *
416 * @return array Collection of global campaign rules.
417 */
418 public static function get_global_rules() {
419 return Merchant_Admin_Options::get( self::MODULE_ID, 'rules', array() );
420 }
421
422 /**
423 * Performs a structural integrity check on a rule to ensure all required fields are present.
424 *
425 * Validates triggers, discount formatting, and essential interface fields like button text.
426 *
427 * @param array $rule The rule data to validate.
428 *
429 * @return bool True if valid for processing, false otherwise.
430 */
431 private static function is_valid_rule( array $rule ) {
432 if ( ! isset( $rule['trigger_on'] ) ) {
433 return false;
434 }
435 if ( 'product' === $rule['trigger_on'] && empty( $rule['product_ids'] ) ) {
436 return false;
437 }
438 if ( 'category' === $rule['trigger_on'] && empty( $rule['category_slugs'] ) ) {
439 return false;
440 }
441 if ( 'tags' === $rule['trigger_on'] && empty( $rule['tag_slugs'] ) ) {
442 return false;
443 }
444 if ( 'brands' === $rule['trigger_on'] && empty( $rule['brand_slugs'] ) ) {
445 return false;
446 }
447
448 // Discount validation
449 if ( ! empty( $rule['discount_toggle'] ) ) {
450 if ( ! isset( $rule['discount_type'], $rule['discount_amount'] ) ) {
451 return false;
452 }
453 }
454
455 // Shipping / Interface
456 if ( empty( $rule['shipping_date'] ) ) {
457 return false;
458 }
459 if ( empty( $rule['button_text'] ) ) {
460 return false;
461 }
462 if ( empty( $rule['placement'] ) ) {
463 return false;
464 }
465
466 return true;
467 }
468
469 /**
470 * Sanitizes and normalizes global rule data before it's used in matching logic.
471 *
472 * Handles parsing of comma-separated IDs and converting date strings to common timestamps.
473 *
474 * @param array $rule The raw rule data from settings.
475 *
476 * @return array The prepared rule data.
477 */
478 private static function prepare_rule( array $rule ) {
479 if ( 'product' === $rule['trigger_on'] && is_string( $rule['product_ids'] ) ) {
480 $rule['product_ids'] = merchant_parse_product_ids( $rule['product_ids'] );
481 }
482
483 // Dates
484 if ( ! empty( $rule['pre_order_start'] ) ) {
485 $rule['pre_order_start'] = merchant_convert_date_to_timestamp( $rule['pre_order_start'], self::DATE_TIME_FORMAT );
486 }
487 if ( ! empty( $rule['pre_order_end'] ) ) {
488 $rule['pre_order_end'] = merchant_convert_date_to_timestamp( $rule['pre_order_end'], self::DATE_TIME_FORMAT );
489 }
490
491 $rule['shipping_timestamp'] = merchant_convert_date_to_timestamp( $rule['shipping_date'], self::DATE_TIME_FORMAT );
492
493 return $rule;
494 }
495
496 /**
497 * Checks if a specific product (or its parent if it's a variation) is explicitly excluded from a rule.
498 *
499 * Evaluates exclusions based on:
500 * 1. Specific Product IDs.
501 * 2. Product Categories.
502 * 3. Product Tags.
503 * 4. Product Brands.
504 *
505 * @param int $product_id The product ID to check.
506 * @param array $rule The rule containing exclusion settings.
507 *
508 * @return bool True if the product is excluded, false if it can proceed.
509 */
510 public static function is_product_excluded( int $product_id, array $rule ) {
511 $trigger = $rule['trigger_on'] ?? 'product';
512
513 // If variation, checks parent
514 $product = wc_get_product( $product_id );
515 $_product_id = $product && $product->is_type( 'variation' ) ? $product->get_parent_id() : $product_id;
516
517 // 1. Exclude Products
518 if ( ! empty( $rule['exclude_products_toggle'] ) ) {
519 $excluded_ids = merchant_parse_product_ids( $rule['excluded_products'] ?? array() );
520 if ( in_array( (int) $product_id, $excluded_ids, true ) || in_array( (int) $_product_id, $excluded_ids, true ) ) {
521 return true;
522 }
523 }
524
525 // 2. Exclude Categories
526 if ( ! empty( $rule['exclude_categories_toggle'] ) && 'category' !== $trigger ) {
527 $slugs = merchant_maybe_expand_categories( $rule['excluded_categories'] ?? array(), $rule );
528 if ( ! empty( $slugs ) && has_term( $slugs, 'product_cat', $_product_id ) ) {
529 return true;
530 }
531 }
532
533 // 3. Exclude Tags
534 if ( ! empty( $rule['exclude_tags_toggle'] ) && 'tags' !== $trigger ) {
535 $slugs = $rule['excluded_tags'] ?? array();
536 if ( ! empty( $slugs ) && has_term( $slugs, 'product_tag', $_product_id ) ) {
537 return true;
538 }
539 }
540
541 // 4. Exclude Brands
542 if ( ! empty( $rule['exclude_brands_toggle'] ) && 'brands' !== $trigger ) {
543 $slugs = $rule['excluded_brands'] ?? array();
544 if ( ! empty( $slugs ) && has_term( $slugs, 'product_brand', $_product_id ) ) {
545 return true;
546 }
547 }
548
549 return false;
550 }
551
552 /**
553 * Extracts and formats the specialized sale data from a pre-order rule.
554 *
555 * This is typically used to inject discount information into the checkout or cart logic.
556 *
557 * @param array $rule The full rule data.
558 *
559 * @return array|false Formatted sale details or false if no discount is applied.
560 */
561 public static function get_rule_sale( array $rule ) {
562 $sale = false;
563 if ( isset( $rule['discount_toggle'] ) && $rule['discount_toggle'] ) {
564 $sale = array(
565 'discount_type' => $rule['discount_type'],
566 'discount_amount' => $rule['discount_amount'],
567 );
568 }
569
570 /**
571 * Filter the pre order sale.
572 *
573 * @param array $sale The pre order sale.
574 * @param array $rule The pre order rule.
575 *
576 * @since 1.9.9
577 */
578 return apply_filters( 'merchant_pre_order_rule_sale', $sale, $rule );
579 }
580 }
581