| @@ -14,8 +14,85 @@ | ||
| 14 | 14 | |
| 15 | 15 | public static $installed_plugins; |
| 16 | 16 | |
| 17 | 17 | /** |
| 18 | + * Canvas layout used when nothing has been stored yet. | |
| 19 | + * | |
| 20 | + * Installs that predate this default are pinned to `horizontal` once, by | |
| 21 | + * Wpfnl_Activator::maybe_pin_legacy_builder_mode(), so flipping this value | |
| 22 | + * only affects sites that never ran on the old default. | |
| 23 | + * | |
| 24 | + * @since 3.12.13 | |
| 25 | + */ | |
| 26 | + const DEFAULT_FUNNEL_BUILDER_MODE = 'vertical'; | |
| 27 | + | |
| 28 | + /** | |
| 29 | + * User meta key holding a single admin's preferred canvas layout. | |
| 30 | + * | |
| 31 | + * Overrides the site-wide default for that user only, so switching views on | |
| 32 | + * the canvas survives a reload without changing anything for their teammates. | |
| 33 | + * | |
| 34 | + * @since 3.12.13 | |
| 35 | + */ | |
| 36 | + const CANVAS_VIEW_MODE_META_KEY = '_wpfnl_canvas_view_mode'; | |
| 37 | + | |
| 38 | + /** | |
| 39 | + * Session key holding the ids of the orders this session actually placed. | |
| 40 | + * | |
| 41 | + * Ownership has to be recorded per order. A marker that is not bound to a | |
| 42 | + * specific order authorises every order id a caller cares to submit. | |
| 43 | + * | |
| 44 | + * @since 3.12.14 | |
| 45 | + */ | |
| 46 | + const OWNED_ORDERS_SESSION_KEY = 'wpfnl_owned_orders'; | |
| 47 | + | |
| 48 | + /** | |
| 49 | + * Upper bound on the owned-order list carried in a single session. | |
| 50 | + * | |
| 51 | + * @since 3.12.14 | |
| 52 | + */ | |
| 53 | + const MAX_OWNED_ORDERS = 50; | |
| 54 | + | |
| 55 | + /** | |
| 56 | + * Action Scheduler hook that sends the store revenue report email. | |
| 57 | + * | |
| 58 | + * @since 3.14.0 | |
| 59 | + */ | |
| 60 | + const REVENUE_REPORT_HOOK = 'wpfunnels_send_revenue_report_email'; | |
| 61 | + | |
| 62 | + /** | |
| 63 | + * Post meta keys that must not travel between sites on funnel export/import. | |
| 64 | + * | |
| 65 | + * These hold per-site render caches or bookkeeping that is only meaningful on the site | |
| 66 | + * that produced them. Carrying them into a new site makes the imported step inherit the | |
| 67 | + * source site's cached state: | |
| 68 | + * | |
| 69 | + * - `_elementor_css` records that a `post-{ID}.css` file was already generated. Elementor | |
| 70 | + * regenerates only when the recorded status is empty (`Base::enqueue()`), and `Post_CSS` | |
| 71 | + * does not override `is_update_required()`, so a stale record makes it enqueue a CSS file | |
| 72 | + * that was never written here — the step renders with no per-post styles at all. | |
| 73 | + * - `_elementor_element_cache` holds pre-rendered element HTML from the source site. | |
| 74 | + * - `_elementor_page_assets` holds the cached widget style handles to enqueue. | |
| 75 | + * - `_edit_lock` / `_edit_last` / `_wp_old_slug` / `_eael_post_view_count` reference users, | |
| 76 | + * locks, and slugs that do not exist on the destination. | |
| 77 | + * | |
| 78 | + * Elementor's own `files_manager->clear_cache()` clears the first three, but only when the | |
| 79 | + * builder post-import pass runs. Excluding them on both sides keeps an import correct even | |
| 80 | + * when Elementor is absent or `allow_url_fopen` is disabled. | |
| 81 | + * | |
| 82 | + * @since 2.5.15 | |
| 83 | + */ | |
| 84 | + const VOLATILE_TRANSFER_META_KEYS = [ | |
| 85 | + '_elementor_css', | |
| 86 | + '_elementor_element_cache', | |
| 87 | + '_elementor_page_assets', | |
| 88 | + '_edit_lock', | |
| 89 | + '_edit_last', | |
| 90 | + '_wp_old_slug', | |
| 91 | + '_eael_post_view_count', | |
| 92 | + ]; | |
| 93 | + | |
| 94 | + /** | |
| 18 | 95 | * Get all the steps of the funnel |
| 19 | 96 | * |
| 20 | 97 | * @param $funnel_id |
| 21 | 98 | * |
| @@ -160,26 +237,89 @@ | ||
| 160 | 237 | * |
| 161 | 238 | * @return bool|int |
| 162 | 239 | */ |
| 163 | 240 | public static function get_checkout_id_from_post( $post = null ) { |
| 164 | - if ( is_array( $post ) && ! empty( $post['_wpfunnels_checkout_id'] ) ) { | |
| 165 | - $checkout_id = filter_var( wp_unslash( $post['_wpfunnels_checkout_id'] ), FILTER_SANITIZE_NUMBER_INT ); | |
| 166 | - return $checkout_id ? intval( $checkout_id ) : false; | |
| 241 | + if ( null === $post && ! empty( $_POST ) ) { | |
| 242 | + $post = $_POST; | |
| 167 | 243 | } |
| 168 | 244 | |
| 169 | - if ( is_array( $post ) && isset( $post['post_data'] ) ) { | |
| 170 | - $post_data = array(); | |
| 171 | - wp_parse_str( wp_unslash( $post['post_data'] ), $post_data ); | |
| 172 | - if ( ! empty( $post_data['_wpfunnels_checkout_id'] ) ) { | |
| 173 | - $checkout_id = filter_var( $post_data['_wpfunnels_checkout_id'], FILTER_SANITIZE_NUMBER_INT ); | |
| 174 | - return $checkout_id ? intval( $checkout_id ) : false; | |
| 245 | + if ( is_array( $post ) ) { | |
| 246 | + if ( ! empty( $post['_wpfunnels_checkout_id'] ) ) { | |
| 247 | + $checkout_id = filter_var( wp_unslash( $post['_wpfunnels_checkout_id'] ), FILTER_SANITIZE_NUMBER_INT ); | |
| 248 | + if ( $checkout_id ) { | |
| 249 | + return intval( $checkout_id ); | |
| 250 | + } | |
| 175 | 251 | } |
| 252 | + if ( ! empty( $post['wpfnl_step_id'] ) ) { | |
| 253 | + $checkout_id = self::get_posted_non_offer_step_id( wp_unslash( $post['wpfnl_step_id'] ) ); | |
| 254 | + if ( $checkout_id ) { | |
| 255 | + return $checkout_id; | |
| 256 | + } | |
| 257 | + } | |
| 258 | + if ( ! empty( $post['step_id'] ) ) { | |
| 259 | + $checkout_id = self::get_posted_non_offer_step_id( wp_unslash( $post['step_id'] ) ); | |
| 260 | + if ( $checkout_id ) { | |
| 261 | + return $checkout_id; | |
| 262 | + } | |
| 263 | + } | |
| 264 | + | |
| 265 | + if ( isset( $post['post_data'] ) ) { | |
| 266 | + $post_data = array(); | |
| 267 | + wp_parse_str( wp_unslash( $post['post_data'] ), $post_data ); | |
| 268 | + if ( ! empty( $post_data['_wpfunnels_checkout_id'] ) ) { | |
| 269 | + $checkout_id = filter_var( $post_data['_wpfunnels_checkout_id'], FILTER_SANITIZE_NUMBER_INT ); | |
| 270 | + if ( $checkout_id ) { | |
| 271 | + return intval( $checkout_id ); | |
| 272 | + } | |
| 273 | + } | |
| 274 | + if ( ! empty( $post_data['wpfnl_step_id'] ) ) { | |
| 275 | + $checkout_id = self::get_posted_non_offer_step_id( $post_data['wpfnl_step_id'] ); | |
| 276 | + if ( $checkout_id ) { | |
| 277 | + return $checkout_id; | |
| 278 | + } | |
| 279 | + } | |
| 280 | + if ( ! empty( $post_data['step_id'] ) ) { | |
| 281 | + $checkout_id = self::get_posted_non_offer_step_id( $post_data['step_id'] ); | |
| 282 | + if ( $checkout_id ) { | |
| 283 | + return $checkout_id; | |
| 284 | + } | |
| 285 | + } | |
| 286 | + } | |
| 176 | 287 | } |
| 288 | + | |
| 177 | 289 | return false; |
| 178 | 290 | } |
| 179 | 291 | |
| 180 | 292 | |
| 181 | 293 | /** |
| 294 | + * Get a posted generic step id, unless it belongs to an offer step. | |
| 295 | + * | |
| 296 | + * Upsell/downsell requests post `step_id` too (accept/reject, gateway calls, | |
| 297 | + * the offer fallback payment form), but they are not checkout requests. | |
| 298 | + * Resolving them marks the request as a funnel step page, and released Pro | |
| 299 | + * versions then reject it with "Your session is expired". | |
| 300 | + * | |
| 301 | + * @param mixed $value Posted step id. | |
| 302 | + * | |
| 303 | + * @return bool|int | |
| 304 | + * @since 3.13.2 | |
| 305 | + */ | |
| 306 | + private static function get_posted_non_offer_step_id( $value ) { | |
| 307 | + $step_id = intval( filter_var( $value, FILTER_SANITIZE_NUMBER_INT ) ); | |
| 308 | + if ( $step_id <= 0 ) { | |
| 309 | + return false; | |
| 310 | + } | |
| 311 | + | |
| 312 | + $step_type = get_post_meta( $step_id, '_step_type', true ); | |
| 313 | + if ( in_array( $step_type, array( 'upsell', 'downsell' ), true ) ) { | |
| 314 | + return false; | |
| 315 | + } | |
| 316 | + | |
| 317 | + return $step_id; | |
| 318 | + } | |
| 319 | + | |
| 320 | + | |
| 321 | + /** | |
| 182 | 322 | * Check order bump clicked or not from post data |
| 183 | 323 | * |
| 184 | 324 | * @return bool True|False |
| 185 | 325 | * @since 2.8.0 |
| @@ -240,8 +380,25 @@ | ||
| 240 | 380 | } |
| 241 | 381 | |
| 242 | 382 | |
| 243 | 383 | /** |
| 384 | + * Check if the customer has already accepted an order bump this session, | |
| 385 | + * via the `wpfnl_order_bump` cookie set on accept/reject. Used to keep a | |
| 386 | + * plain page reload from wiping an accepted bump back out of the cart. | |
| 387 | + * | |
| 388 | + * @return bool | |
| 389 | + * @since 3.13.1 | |
| 390 | + */ | |
| 391 | + public static function is_order_bump_already_accepted_from_cookie() { | |
| 392 | + if ( empty( $_COOKIE['wpfnl_order_bump'] ) ) { | |
| 393 | + return false; | |
| 394 | + } | |
| 395 | + $cookie = json_decode( wp_unslash( $_COOKIE['wpfnl_order_bump'] ), true ); | |
| 396 | + return ! empty( $cookie['order_bump_accepted'] ) && 'yes' === $cookie['order_bump_accepted']; | |
| 397 | + } | |
| 398 | + | |
| 399 | + | |
| 400 | + /** | |
| 244 | 401 | * Check product option is selected or not from post data |
| 245 | 402 | * |
| 246 | 403 | * @return bool True|False |
| 247 | 404 | * @since 2.8.0 |
| @@ -443,8 +600,12 @@ | ||
| 443 | 600 | if ( $page === WPFNL_STORE_CHECKOUT_SLUG && $key === 'store_checkout' ) { |
| 444 | 601 | return true; |
| 445 | 602 | } |
| 446 | 603 | |
| 604 | + if ( defined( 'WPFNL_AI_FUNNEL_PAGE_SLUG' ) && $page === WPFNL_AI_FUNNEL_PAGE_SLUG && $key === 'ai_assistant' ) { | |
| 605 | + return true; | |
| 606 | + } | |
| 607 | + | |
| 447 | 608 | return false; |
| 448 | 609 | } |
| 449 | 610 | |
| 450 | 611 | |
| @@ -577,8 +738,23 @@ | ||
| 577 | 738 | 'id' => $post_data['_wpfunnels_checkout_id'], |
| 578 | 739 | ); |
| 579 | 740 | } |
| 580 | 741 | } |
| 742 | + | |
| 743 | + // Some ajax actions (e.g. the order bump accept/reject handler) post the | |
| 744 | + // checkout step id directly as `step_id` instead of a serialized `post_data` | |
| 745 | + // form. Fall back to that so quantity/checkout-id dependent filters still | |
| 746 | + // resolve the correct step during those requests. | |
| 747 | + if ( ! empty( $_POST['step_id'] ) ) { | |
| 748 | + $step_id = filter_var( wp_unslash( $_POST['step_id'] ), FILTER_SANITIZE_NUMBER_INT ); | |
| 749 | + if ( $step_id && self::check_if_this_is_step_type_by_id( intval( $step_id ), 'checkout' ) ) { | |
| 750 | + return array( | |
| 751 | + 'status' => true, | |
| 752 | + 'id' => intval( $step_id ), | |
| 753 | + ); | |
| 754 | + } | |
| 755 | + } | |
| 756 | + | |
| 581 | 757 | return array( |
| 582 | 758 | 'status' => false, |
| 583 | 759 | 'id' => '', |
| 584 | 760 | ); |
| @@ -1257,9 +1433,8 @@ | ||
| 1257 | 1433 | 'paypal_reference' => 'off', |
| 1258 | 1434 | 'order_bump' => 'off', |
| 1259 | 1435 | 'ab_testing' => 'off', |
| 1260 | 1436 | 'allow_funnels' => array( 'administrator' => 'true' ), |
| 1261 | - 'funnel_type' => 'sales', | |
| 1262 | 1437 | 'create_child_order' => 'off', |
| 1263 | 1438 | 'disable_theme_style' => 'off', |
| 1264 | 1439 | 'enable_log_status' => 'off', |
| 1265 | 1440 | 'enable_skip_cart' => 'off', |
| @@ -1264,9 +1439,9 @@ | ||
| 1264 | 1439 | 'enable_log_status' => 'off', |
| 1265 | 1440 | 'enable_skip_cart' => 'off', |
| 1266 | 1441 | 'skip_cart_for' => 'whole', |
| 1267 | 1442 | 'uninstall_cleanup' => 'off', |
| 1268 | - 'funnel_builder_mode' => 'horizontal', | |
| 1443 | + 'funnel_builder_mode' => self::DEFAULT_FUNNEL_BUILDER_MODE, | |
| 1269 | 1444 | ) |
| 1270 | 1445 | ); |
| 1271 | 1446 | $saved_settings = self::get_admin_settings( '_wpfunnels_general_settings', $default_settings ); |
| 1272 | 1447 | return wp_parse_args( $saved_settings, $default_settings ); |
| @@ -1273,8 +1448,58 @@ | ||
| 1273 | 1448 | } |
| 1274 | 1449 | |
| 1275 | 1450 | |
| 1276 | 1451 | /** |
| 1452 | + * Valid canvas layouts. | |
| 1453 | + * | |
| 1454 | + * @return array | |
| 1455 | + * @since 3.12.13 | |
| 1456 | + */ | |
| 1457 | + public static function get_funnel_builder_modes() { | |
| 1458 | + return array( 'horizontal', 'vertical' ); | |
| 1459 | + } | |
| 1460 | + | |
| 1461 | + | |
| 1462 | + /** | |
| 1463 | + * Site-wide default canvas layout, as chosen in Settings. | |
| 1464 | + * | |
| 1465 | + * @return string 'horizontal' or 'vertical'. | |
| 1466 | + * @since 3.12.13 | |
| 1467 | + */ | |
| 1468 | + public static function get_default_funnel_builder_mode() { | |
| 1469 | + $general_settings = self::get_general_settings(); | |
| 1470 | + $mode = isset( $general_settings['funnel_builder_mode'] ) ? $general_settings['funnel_builder_mode'] : self::DEFAULT_FUNNEL_BUILDER_MODE; | |
| 1471 | + | |
| 1472 | + return in_array( $mode, self::get_funnel_builder_modes(), true ) ? $mode : self::DEFAULT_FUNNEL_BUILDER_MODE; | |
| 1473 | + } | |
| 1474 | + | |
| 1475 | + | |
| 1476 | + /** | |
| 1477 | + * Canvas layout to open the builder in for a given user. | |
| 1478 | + * | |
| 1479 | + * A user who has switched views on the canvas keeps that view on their next | |
| 1480 | + * visit; everyone else gets the site-wide default from Settings. | |
| 1481 | + * | |
| 1482 | + * @param int $user_id User to resolve for. Defaults to the current user. | |
| 1483 | + * | |
| 1484 | + * @return string 'horizontal' or 'vertical'. | |
| 1485 | + * @since 3.12.13 | |
| 1486 | + */ | |
| 1487 | + public static function get_canvas_view_mode( $user_id = 0 ) { | |
| 1488 | + $user_id = $user_id ? $user_id : get_current_user_id(); | |
| 1489 | + | |
| 1490 | + if ( $user_id ) { | |
| 1491 | + $preference = get_user_meta( $user_id, self::CANVAS_VIEW_MODE_META_KEY, true ); | |
| 1492 | + if ( in_array( $preference, self::get_funnel_builder_modes(), true ) ) { | |
| 1493 | + return $preference; | |
| 1494 | + } | |
| 1495 | + } | |
| 1496 | + | |
| 1497 | + return self::get_default_funnel_builder_mode(); | |
| 1498 | + } | |
| 1499 | + | |
| 1500 | + | |
| 1501 | + /** | |
| 1277 | 1502 | * Get permalink settings data |
| 1278 | 1503 | * |
| 1279 | 1504 | * @return array |
| 1280 | 1505 | * @since 1.0.0 |
| @@ -1334,8 +1559,342 @@ | ||
| 1334 | 1559 | return wp_parse_args( $saved_settings, $default_settings ); |
| 1335 | 1560 | } |
| 1336 | 1561 | |
| 1337 | 1562 | /** |
| 1563 | + * Derive the shipping rate that applies to an offer (upsell/downsell) product. | |
| 1564 | + * | |
| 1565 | + * Two sources are consulted, in order: | |
| 1566 | + * | |
| 1567 | + * 1. The shipping method already chosen on the parent order, re-priced against | |
| 1568 | + * the offer product's own shipping class when the store runs flat rate and | |
| 1569 | + * the offer is merged into the main order. | |
| 1570 | + * 2. The shipping zone matching the order's destination. This is the only | |
| 1571 | + * source available when the parent order never shipped — a virtual-only | |
| 1572 | + * checkout carries no shipping method to clone. | |
| 1573 | + * | |
| 1574 | + * An offer product that does not need shipping never derives a rate, so a | |
| 1575 | + * virtual upsell can never add shipping to a physical parent order. | |
| 1576 | + * | |
| 1577 | + * @param \WC_Order $order Parent order. | |
| 1578 | + * @param int $product_id Offer product id. | |
| 1579 | + * @param float $total_price Offer line total, used as the shipping package cost. | |
| 1580 | + * @param int $qty Offer quantity. | |
| 1581 | + * | |
| 1582 | + * @return array Shipping data, or an empty array when no shipping applies. | |
| 1583 | + * @since 1.0.0 | |
| 1584 | + */ | |
| 1585 | + public static function get_offer_shipping( $order, $product_id, $total_price = 0, $qty = 1 ) { | |
| 1586 | + if ( ! $product_id || ! $order instanceof \WC_Order ) { | |
| 1587 | + return array(); | |
| 1588 | + } | |
| 1589 | + | |
| 1590 | + $product = wc_get_product( $product_id ); | |
| 1591 | + | |
| 1592 | + if ( ! $product || ! $product->needs_shipping() ) { | |
| 1593 | + return array(); | |
| 1594 | + } | |
| 1595 | + | |
| 1596 | + $shipping = self::get_offer_shipping_from_parent_order( $order, $product ); | |
| 1597 | + | |
| 1598 | + if ( empty( $shipping ) ) { | |
| 1599 | + $shipping = self::get_offer_shipping_from_zones( $order, $product, $total_price, $qty ); | |
| 1600 | + } | |
| 1601 | + | |
| 1602 | + /** | |
| 1603 | + * Filter the shipping data derived for an offer product. | |
| 1604 | + * | |
| 1605 | + * @param array $shipping Derived shipping data, empty when none applies. | |
| 1606 | + * @param \WC_Order $order Parent order. | |
| 1607 | + * @param \WC_Product $product Offer product. | |
| 1608 | + * | |
| 1609 | + * @since 1.0.0 | |
| 1610 | + */ | |
| 1611 | + return apply_filters( 'wpfunnels/offer_shipping', $shipping, $order, $product ); | |
| 1612 | + } | |
| 1613 | + | |
| 1614 | + | |
| 1615 | + /** | |
| 1616 | + * Clone the parent order's shipping method for the offer product. | |
| 1617 | + * | |
| 1618 | + * When the store runs flat rate and offers are merged into the main order the | |
| 1619 | + * cloned cost is re-priced against the offer product's shipping class, which is | |
| 1620 | + * the behaviour this plugin has always described. | |
| 1621 | + * | |
| 1622 | + * @param \WC_Order $order Parent order. | |
| 1623 | + * @param \WC_Product $product Offer product. | |
| 1624 | + * | |
| 1625 | + * @return array | |
| 1626 | + * @since 1.0.0 | |
| 1627 | + */ | |
| 1628 | + private static function get_offer_shipping_from_parent_order( $order, $product ) { | |
| 1629 | + $shipping = array(); | |
| 1630 | + $shipping_methods = $order->get_shipping_methods(); | |
| 1631 | + | |
| 1632 | + if ( empty( $shipping_methods ) ) { | |
| 1633 | + return $shipping; | |
| 1634 | + } | |
| 1635 | + | |
| 1636 | + foreach ( $shipping_methods as $method ) { | |
| 1637 | + if ( ! $method ) { | |
| 1638 | + continue; | |
| 1639 | + } | |
| 1640 | + | |
| 1641 | + $shipping = array( | |
| 1642 | + 'name' => $method->get_name(), | |
| 1643 | + 'method_title' => $method->get_method_title(), | |
| 1644 | + 'method_id' => $method->get_method_id(), | |
| 1645 | + 'instance_id' => $method->get_instance_id(), | |
| 1646 | + 'total' => (float) $method->get_total(), | |
| 1647 | + 'total_tax' => (float) $method->get_total_tax(), | |
| 1648 | + 'taxes' => $method->get_taxes(), | |
| 1649 | + ); | |
| 1650 | + } | |
| 1651 | + | |
| 1652 | + if ( empty( $shipping ) ) { | |
| 1653 | + return $shipping; | |
| 1654 | + } | |
| 1655 | + | |
| 1656 | + $offer_settings = self::get_offer_settings(); | |
| 1657 | + $offer_orders = isset( $offer_settings['offer_orders'] ) ? $offer_settings['offer_orders'] : ''; | |
| 1658 | + | |
| 1659 | + if ( 'main-order' !== $offer_orders || 'flat_rate' !== $shipping['method_id'] ) { | |
| 1660 | + return $shipping; | |
| 1661 | + } | |
| 1662 | + | |
| 1663 | + $tax_percentage = 0.0 !== (float) $shipping['total'] ? ( $shipping['total_tax'] * 100 ) / $shipping['total'] : 0; | |
| 1664 | + | |
| 1665 | + foreach ( \WC_Shipping_Zones::get_zones() as $zone ) { | |
| 1666 | + if ( empty( $zone['shipping_methods'] ) ) { | |
| 1667 | + continue; | |
| 1668 | + } | |
| 1669 | + | |
| 1670 | + foreach ( $zone['shipping_methods'] as $method ) { | |
| 1671 | + if ( ! $method instanceof \WC_Shipping_Flat_Rate ) { | |
| 1672 | + continue; | |
| 1673 | + } | |
| 1674 | + | |
| 1675 | + if ( ! isset( $method->instance_id ) || $method->instance_id != $shipping['instance_id'] ) { // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison | |
| 1676 | + continue; | |
| 1677 | + } | |
| 1678 | + | |
| 1679 | + $instance_settings = isset( $method->instance_settings ) ? $method->instance_settings : array(); | |
| 1680 | + $class_cost_key = 'class_cost_' . $product->get_shipping_class_id(); | |
| 1681 | + | |
| 1682 | + if ( isset( $instance_settings[ $class_cost_key ] ) ) { | |
| 1683 | + $shipping_cost = $instance_settings[ $class_cost_key ]; | |
| 1684 | + } elseif ( isset( $instance_settings['no_class_cost'] ) ) { | |
| 1685 | + $shipping_cost = $instance_settings['no_class_cost']; | |
| 1686 | + } else { | |
| 1687 | + continue; | |
| 1688 | + } | |
| 1689 | + | |
| 1690 | + $shipping_cost = $shipping_cost ? (float) $shipping_cost : 0; | |
| 1691 | + $total_tax = ( $shipping_cost * $tax_percentage ) / 100; | |
| 1692 | + $shipping['total'] = $shipping_cost; | |
| 1693 | + $shipping['total_tax'] = $total_tax; | |
| 1694 | + $shipping['taxes'] = array( 'total' => array( $total_tax ) ); | |
| 1695 | + } | |
| 1696 | + } | |
| 1697 | + | |
| 1698 | + return $shipping; | |
| 1699 | + } | |
| 1700 | + | |
| 1701 | + | |
| 1702 | + /** | |
| 1703 | + * Derive a shipping rate for the offer product from the matching shipping zone. | |
| 1704 | + * | |
| 1705 | + * Used when the parent order carries no shipping method to clone — a physical | |
| 1706 | + * offer accepted on top of a virtual-only order. | |
| 1707 | + * | |
| 1708 | + * @param \WC_Order $order Parent order. | |
| 1709 | + * @param \WC_Product $product Offer product. | |
| 1710 | + * @param float $total_price Offer line total. | |
| 1711 | + * @param int $qty Offer quantity. | |
| 1712 | + * | |
| 1713 | + * @return array | |
| 1714 | + * @since 1.0.0 | |
| 1715 | + */ | |
| 1716 | + private static function get_offer_shipping_from_zones( $order, $product, $total_price, $qty ) { | |
| 1717 | + if ( ! class_exists( '\WC_Shipping_Zones' ) ) { | |
| 1718 | + return array(); | |
| 1719 | + } | |
| 1720 | + | |
| 1721 | + $package = self::get_offer_shipping_package( $order, $product, $total_price, $qty ); | |
| 1722 | + | |
| 1723 | + if ( empty( $package['destination']['country'] ) ) { | |
| 1724 | + return array(); | |
| 1725 | + } | |
| 1726 | + | |
| 1727 | + $zone = \WC_Shipping_Zones::get_zone_matching_package( $package ); | |
| 1728 | + | |
| 1729 | + if ( ! $zone ) { | |
| 1730 | + return array(); | |
| 1731 | + } | |
| 1732 | + | |
| 1733 | + foreach ( $zone->get_shipping_methods( true ) as $method ) { | |
| 1734 | + $rates = $method->get_rates_for_package( $package ); | |
| 1735 | + | |
| 1736 | + if ( empty( $rates ) ) { | |
| 1737 | + continue; | |
| 1738 | + } | |
| 1739 | + | |
| 1740 | + $rate = reset( $rates ); | |
| 1741 | + | |
| 1742 | + return array( | |
| 1743 | + 'name' => $rate->get_label(), | |
| 1744 | + 'method_title' => $rate->get_label(), | |
| 1745 | + 'method_id' => $rate->get_method_id(), | |
| 1746 | + 'instance_id' => $rate->get_instance_id(), | |
| 1747 | + 'total' => (float) $rate->get_cost(), | |
| 1748 | + 'total_tax' => (float) $rate->get_shipping_tax(), | |
| 1749 | + 'taxes' => $rate->get_taxes(), | |
| 1750 | + ); | |
| 1751 | + } | |
| 1752 | + | |
| 1753 | + return array(); | |
| 1754 | + } | |
| 1755 | + | |
| 1756 | + | |
| 1757 | + /** | |
| 1758 | + * Build the shipping package for a single offer product. | |
| 1759 | + * | |
| 1760 | + * The destination falls back to the billing address because a virtual-only | |
| 1761 | + * parent order has no shipping address — WooCommerce never collects one when | |
| 1762 | + * the cart does not need shipping. | |
| 1763 | + * | |
| 1764 | + * @param \WC_Order $order Parent order. | |
| 1765 | + * @param \WC_Product $product Offer product. | |
| 1766 | + * @param float $total_price Offer line total. | |
| 1767 | + * @param int $qty Offer quantity. | |
| 1768 | + * | |
| 1769 | + * @return array | |
| 1770 | + * @since 1.0.0 | |
| 1771 | + */ | |
| 1772 | + private static function get_offer_shipping_package( $order, $product, $total_price, $qty ) { | |
| 1773 | + $qty = max( 1, (int) $qty ); | |
| 1774 | + $total_price = (float) $total_price; | |
| 1775 | + | |
| 1776 | + $destination = array( | |
| 1777 | + 'country' => $order->get_shipping_country() ? $order->get_shipping_country() : $order->get_billing_country(), | |
| 1778 | + 'state' => $order->get_shipping_state() ? $order->get_shipping_state() : $order->get_billing_state(), | |
| 1779 | + 'postcode' => $order->get_shipping_postcode() ? $order->get_shipping_postcode() : $order->get_billing_postcode(), | |
| 1780 | + 'city' => $order->get_shipping_city() ? $order->get_shipping_city() : $order->get_billing_city(), | |
| 1781 | + 'address' => $order->get_shipping_address_1() ? $order->get_shipping_address_1() : $order->get_billing_address_1(), | |
| 1782 | + 'address_1' => $order->get_shipping_address_1() ? $order->get_shipping_address_1() : $order->get_billing_address_1(), | |
| 1783 | + 'address_2' => $order->get_shipping_address_2() ? $order->get_shipping_address_2() : $order->get_billing_address_2(), | |
| 1784 | + ); | |
| 1785 | + | |
| 1786 | + return array( | |
| 1787 | + 'contents' => array( | |
| 1788 | + $product->get_id() => array( | |
| 1789 | + 'key' => $product->get_id(), | |
| 1790 | + 'product_id' => $product->get_id(), | |
| 1791 | + 'variation_id' => 0, | |
| 1792 | + 'variation' => array(), | |
| 1793 | + 'quantity' => $qty, | |
| 1794 | + 'data' => $product, | |
| 1795 | + 'line_total' => $total_price, | |
| 1796 | + 'line_subtotal' => $total_price, | |
| 1797 | + 'line_tax' => 0, | |
| 1798 | + 'line_subtotal_tax' => 0, | |
| 1799 | + ), | |
| 1800 | + ), | |
| 1801 | + 'contents_cost' => $total_price, | |
| 1802 | + 'applied_coupons' => array(), | |
| 1803 | + 'user' => array( 'ID' => $order->get_customer_id() ), | |
| 1804 | + 'destination' => $destination, | |
| 1805 | + 'cart_subtotal' => $total_price, | |
| 1806 | + ); | |
| 1807 | + } | |
| 1808 | + | |
| 1809 | + | |
| 1810 | + /** | |
| 1811 | + * Record derived offer shipping on an order as a real shipping line item. | |
| 1812 | + * | |
| 1813 | + * Passing empty shipping data is a no-op, so an offer that needs no shipping | |
| 1814 | + * leaves the order — including any shipping line it already carries — alone. | |
| 1815 | + * | |
| 1816 | + * @param \WC_Order $order Order to add the shipping line to. | |
| 1817 | + * @param array $shipping Shipping data from get_offer_shipping(). | |
| 1818 | + * @param string $offer_name Offer product name, recorded against the line. | |
| 1819 | + * @param int $qty Offer quantity. | |
| 1820 | + * | |
| 1821 | + * @return \WC_Order | |
| 1822 | + * @since 1.0.0 | |
| 1823 | + */ | |
| 1824 | + public static function add_offer_shipping_to_order( $order, $shipping, $offer_name = '', $qty = 1 ) { | |
| 1825 | + if ( ! $order instanceof \WC_Order || empty( $shipping ) || ! isset( $shipping['total'] ) ) { | |
| 1826 | + return $order; | |
| 1827 | + } | |
| 1828 | + | |
| 1829 | + $item = new \WC_Order_Item_Shipping(); | |
| 1830 | + $item->set_props( | |
| 1831 | + array( | |
| 1832 | + 'method_title' => ! empty( $shipping['method_title'] ) ? $shipping['method_title'] : __( 'Shipping', 'wpfnl' ), | |
| 1833 | + 'method_id' => isset( $shipping['method_id'] ) ? $shipping['method_id'] : '', | |
| 1834 | + 'instance_id' => isset( $shipping['instance_id'] ) ? $shipping['instance_id'] : '', | |
| 1835 | + 'total' => wc_format_decimal( $shipping['total'] ), | |
| 1836 | + 'taxes' => isset( $shipping['taxes'] ) && is_array( $shipping['taxes'] ) ? $shipping['taxes'] : array(), | |
| 1837 | + ) | |
| 1838 | + ); | |
| 1839 | + | |
| 1840 | + if ( $offer_name ) { | |
| 1841 | + $item->add_meta_data( 'Items', $offer_name . ' × ' . max( 1, (int) $qty ), true ); | |
| 1842 | + } | |
| 1843 | + | |
| 1844 | + $item->save(); | |
| 1845 | + $order->add_item( $item ); | |
| 1846 | + | |
| 1847 | + self::maybe_backfill_shipping_address( $order ); | |
| 1848 | + | |
| 1849 | + $order->calculate_shipping(); | |
| 1850 | + $order->calculate_totals(); | |
| 1851 | + $order->save(); | |
| 1852 | + | |
| 1853 | + return $order; | |
| 1854 | + } | |
| 1855 | + | |
| 1856 | + | |
| 1857 | + /** | |
| 1858 | + * Copy the billing address onto the shipping address when the order has none. | |
| 1859 | + * | |
| 1860 | + * WooCommerce skips the billing-to-shipping copy at checkout whenever the cart | |
| 1861 | + * does not need a shipping address, so an order that only becomes shippable | |
| 1862 | + * through an offer would otherwise reveal an empty address block in the admin | |
| 1863 | + * order summary. | |
| 1864 | + * | |
| 1865 | + * @param \WC_Order $order Order to back-fill. | |
| 1866 | + * | |
| 1867 | + * @return \WC_Order | |
| 1868 | + * @since 1.0.0 | |
| 1869 | + */ | |
| 1870 | + public static function maybe_backfill_shipping_address( $order ) { | |
| 1871 | + if ( ! $order instanceof \WC_Order || $order->has_shipping_address() ) { | |
| 1872 | + return $order; | |
| 1873 | + } | |
| 1874 | + | |
| 1875 | + $fields = array( 'first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'phone' ); | |
| 1876 | + | |
| 1877 | + foreach ( $fields as $field ) { | |
| 1878 | + $getter = 'get_billing_' . $field; | |
| 1879 | + $setter = 'set_shipping_' . $field; | |
| 1880 | + | |
| 1881 | + if ( ! is_callable( array( $order, $getter ) ) || ! is_callable( array( $order, $setter ) ) ) { | |
| 1882 | + continue; | |
| 1883 | + } | |
| 1884 | + | |
| 1885 | + $value = $order->{$getter}(); | |
| 1886 | + | |
| 1887 | + if ( '' !== $value && null !== $value ) { | |
| 1888 | + $order->{$setter}( $value ); | |
| 1889 | + } | |
| 1890 | + } | |
| 1891 | + | |
| 1892 | + return $order; | |
| 1893 | + } | |
| 1894 | + | |
| 1895 | + | |
| 1896 | + /** | |
| 1338 | 1897 | * Get GTM events |
| 1339 | 1898 | * |
| 1340 | 1899 | * @return array |
| 1341 | 1900 | */ |
| @@ -1476,8 +2035,109 @@ | ||
| 1476 | 2035 | } |
| 1477 | 2036 | |
| 1478 | 2037 | |
| 1479 | 2038 | /** |
| 2039 | + * Notification settings seeded into the database on a brand-new install. | |
| 2040 | + * | |
| 2041 | + * The store revenue report ships on for sites installed from 3.14.0 onward. | |
| 2042 | + * It is written in once, by Wpfnl_Activator, so the decision lives in the | |
| 2043 | + * database rather than in the fallback used by get_notification_settings(): | |
| 2044 | + * a site that upgrades has no stored value, keeps the disabled fallback, and | |
| 2045 | + * never starts mailing on its own. | |
| 2046 | + * | |
| 2047 | + * @return array | |
| 2048 | + * @since 3.14.0 | |
| 2049 | + */ | |
| 2050 | + public static function get_new_install_notification_settings() { | |
| 2051 | + return array( | |
| 2052 | + 'enable_revenue_report' => 'yes', | |
| 2053 | + 'revenue_report_frequency' => 'weekly', | |
| 2054 | + 'revenue_report_recipient' => get_option( 'admin_email' ), | |
| 2055 | + 'revenue_report_subject' => 'Store Revenue Report - {period}', | |
| 2056 | + 'send_time' => '10:00', | |
| 2057 | + ); | |
| 2058 | + } | |
| 2059 | + | |
| 2060 | + | |
| 2061 | + /** | |
| 2062 | + * Schedule the recurring revenue report email through Action Scheduler. | |
| 2063 | + * | |
| 2064 | + * Callers are responsible for clearing any existing schedule first; use | |
| 2065 | + * is_revenue_report_scheduled() when a duplicate must be avoided. | |
| 2066 | + * | |
| 2067 | + * @param array $settings Notification settings. | |
| 2068 | + * | |
| 2069 | + * @return void | |
| 2070 | + * @since 3.14.0 | |
| 2071 | + */ | |
| 2072 | + public static function schedule_revenue_report_email( $settings ) { | |
| 2073 | + if ( ! function_exists( 'as_schedule_recurring_action' ) ) { | |
| 2074 | + return; | |
| 2075 | + } | |
| 2076 | + | |
| 2077 | + // Calculate next run time based on send_time. | |
| 2078 | + $send_time = isset( $settings['send_time'] ) ? $settings['send_time'] : '10:00'; | |
| 2079 | + $frequency = isset( $settings['revenue_report_frequency'] ) ? $settings['revenue_report_frequency'] : 'weekly'; | |
| 2080 | + | |
| 2081 | + // Parse time (format: HH:MM). | |
| 2082 | + list( $hour, $minute ) = array_pad( explode( ':', $send_time ), 2, '0' ); | |
| 2083 | + | |
| 2084 | + // Get current time. | |
| 2085 | + $now = current_time( 'timestamp' ); | |
| 2086 | + $today = strtotime( date( 'Y-m-d', $now ) ); | |
| 2087 | + | |
| 2088 | + // Calculate first run time (today at specified time). | |
| 2089 | + $first_run = $today + ( $hour * HOUR_IN_SECONDS ) + ( $minute * MINUTE_IN_SECONDS ); | |
| 2090 | + | |
| 2091 | + // If the time has already passed today, schedule for tomorrow. | |
| 2092 | + if ( $first_run <= $now ) { | |
| 2093 | + $first_run += DAY_IN_SECONDS; | |
| 2094 | + } | |
| 2095 | + | |
| 2096 | + // Calculate interval based on frequency. | |
| 2097 | + $interval = ( 'monthly' === $frequency ) ? MONTH_IN_SECONDS : WEEK_IN_SECONDS; | |
| 2098 | + | |
| 2099 | + as_schedule_recurring_action( | |
| 2100 | + $first_run, | |
| 2101 | + $interval, | |
| 2102 | + self::REVENUE_REPORT_HOOK, | |
| 2103 | + array(), | |
| 2104 | + 'wpfunnels' | |
| 2105 | + ); | |
| 2106 | + } | |
| 2107 | + | |
| 2108 | + | |
| 2109 | + /** | |
| 2110 | + * Remove the recurring revenue report email schedule. | |
| 2111 | + * | |
| 2112 | + * @return void | |
| 2113 | + * @since 3.14.0 | |
| 2114 | + */ | |
| 2115 | + public static function unschedule_revenue_report_email() { | |
| 2116 | + if ( ! function_exists( 'as_unschedule_all_actions' ) ) { | |
| 2117 | + return; | |
| 2118 | + } | |
| 2119 | + | |
| 2120 | + as_unschedule_all_actions( self::REVENUE_REPORT_HOOK, array(), 'wpfunnels' ); | |
| 2121 | + } | |
| 2122 | + | |
| 2123 | + | |
| 2124 | + /** | |
| 2125 | + * Whether a revenue report email is already queued. | |
| 2126 | + * | |
| 2127 | + * @return bool | |
| 2128 | + * @since 3.14.0 | |
| 2129 | + */ | |
| 2130 | + public static function is_revenue_report_scheduled() { | |
| 2131 | + if ( ! function_exists( 'as_next_scheduled_action' ) ) { | |
| 2132 | + return false; | |
| 2133 | + } | |
| 2134 | + | |
| 2135 | + return (bool) as_next_scheduled_action( self::REVENUE_REPORT_HOOK, array(), 'wpfunnels' ); | |
| 2136 | + } | |
| 2137 | + | |
| 2138 | + | |
| 2139 | + /** | |
| 1480 | 2140 | * Get advanced settings |
| 1481 | 2141 | */ |
| 1482 | 2142 | public static function get_advanced_settings() { |
| 1483 | 2143 | $default_enable_settings = array( |
| @@ -3162,18 +3822,8 @@ | ||
| 3162 | 3822 | 'slug' => 'lead', |
| 3163 | 3823 | 'label' => 'Lead Gen Templates', |
| 3164 | 3824 | ); |
| 3165 | 3825 | |
| 3166 | - // If funnel type is set to lead only, show only lead templates | |
| 3167 | - // if ( isset( $general_settings['funnel_type'] ) && 'lead' === $general_settings['funnel_type'] ) { | |
| 3168 | - // $types = array( | |
| 3169 | - // array( | |
| 3170 | - // 'slug' => 'lead', | |
| 3171 | - // 'label' => 'Lead Gen Templates', | |
| 3172 | - // ), | |
| 3173 | - // ); | |
| 3174 | - // } | |
| 3175 | - | |
| 3176 | 3826 | return apply_filters( 'wpfunnels/modify_template_type', $types ); |
| 3177 | 3827 | |
| 3178 | 3828 | } |
| 3179 | 3829 | |
| @@ -3178,22 +3828,8 @@ | ||
| 3178 | 3828 | } |
| 3179 | 3829 | |
| 3180 | 3830 | |
| 3181 | 3831 | /** |
| 3182 | - * Get global funnel type | |
| 3183 | - * | |
| 3184 | - * @return Mix | |
| 3185 | - */ | |
| 3186 | - public static function get_global_funnel_type() { | |
| 3187 | - $general_settings = self::get_general_settings(); | |
| 3188 | - if ( isset( $general_settings['funnel_type'] ) ) { | |
| 3189 | - return $general_settings['funnel_type']; | |
| 3190 | - } | |
| 3191 | - return false; | |
| 3192 | - } | |
| 3193 | - | |
| 3194 | - | |
| 3195 | - /** | |
| 3196 | 3832 | * May be allow to create sales funnel |
| 3197 | 3833 | * |
| 3198 | 3834 | * @return Bool |
| 3199 | 3835 | */ |
| @@ -3299,8 +3935,60 @@ | ||
| 3299 | 3935 | } |
| 3300 | 3936 | |
| 3301 | 3937 | |
| 3302 | 3938 | /** |
| 3939 | + * Resolve a checkout step setting that builders expose under two different keys. | |
| 3940 | + * | |
| 3941 | + * Every builder persists these settings to the underscore-prefixed post meta | |
| 3942 | + * (`_wpfnl_checkout_layout`, `_wpfnl_floating_label`). The Gutenberg checkout | |
| 3943 | + * block instead publishes its live attributes through the | |
| 3944 | + * `wpfunnels/checkout_meta_{$key}` filter, keyed without the underscore, so that | |
| 3945 | + * the editor preview reflects unsaved changes. | |
| 3946 | + * | |
| 3947 | + * Read the filtered value first so the block editor keeps winning, then fall back | |
| 3948 | + * to the stored meta so Elementor, Bricks, Divi, Oxygen and shortcode steps — | |
| 3949 | + * which register no such filter — resolve to their real setting instead of the | |
| 3950 | + * default. | |
| 3951 | + * | |
| 3952 | + * @param int $checkout_id Checkout step ID. | |
| 3953 | + * @param string $key Unprefixed meta key, e.g. `wpfnl_checkout_layout`. | |
| 3954 | + * @param string $default Value to use when nothing is set anywhere. | |
| 3955 | + * | |
| 3956 | + * @return string | |
| 3957 | + * @since 3.13.2 | |
| 3958 | + */ | |
| 3959 | + public static function get_checkout_step_setting( $checkout_id, $key, $default = '' ) { | |
| 3960 | + if ( ! $checkout_id ) { | |
| 3961 | + return $default; | |
| 3962 | + } | |
| 3963 | + | |
| 3964 | + // Passing false as the default keeps get_checkout_meta_value() from | |
| 3965 | + // substituting one, so an empty result really means "no builder filter". | |
| 3966 | + $value = Wpfnl::get_instance()->meta->get_checkout_meta_value( $checkout_id, $key, false ); | |
| 3967 | + | |
| 3968 | + if ( ! $value ) { | |
| 3969 | + $value = get_post_meta( $checkout_id, '_' . $key, true ); | |
| 3970 | + } | |
| 3971 | + | |
| 3972 | + return $value ? $value : $default; | |
| 3973 | + } | |
| 3974 | + | |
| 3975 | + | |
| 3976 | + /** | |
| 3977 | + * Get the checkout layout for a step. | |
| 3978 | + * | |
| 3979 | + * @param int $checkout_id Checkout step ID. | |
| 3980 | + * @param string $default Layout to use when nothing is set. | |
| 3981 | + * | |
| 3982 | + * @return string | |
| 3983 | + * @since 3.13.2 | |
| 3984 | + */ | |
| 3985 | + public static function get_checkout_layout( $checkout_id, $default = 'wpfnl-col-2' ) { | |
| 3986 | + return self::get_checkout_step_setting( $checkout_id, 'wpfnl_checkout_layout', $default ); | |
| 3987 | + } | |
| 3988 | + | |
| 3989 | + | |
| 3990 | + /** | |
| 3303 | 3991 | * Check the checkout layout is express or not |
| 3304 | 3992 | * |
| 3305 | 3993 | * @return Bool |
| 3306 | 3994 | * @since 2.4.18 |
| @@ -3348,23 +4036,8 @@ | ||
| 3348 | 4036 | |
| 3349 | 4037 | |
| 3350 | 4038 | |
| 3351 | 4039 | /** |
| 3352 | - * Update funnel type to lead | |
| 3353 | - * | |
| 3354 | - * @since 2.5.1 | |
| 3355 | - */ | |
| 3356 | - public static function update_funnel_type_to_lead() { | |
| 3357 | - | |
| 3358 | - $saved_settings = self::get_admin_settings( '_wpfunnels_general_settings' ); | |
| 3359 | - if ( isset( $saved_settings['funnel_type'] ) ) { | |
| 3360 | - $saved_settings['funnel_type'] = 'lead'; | |
| 3361 | - update_option( '_wpfunnels_general_settings', $saved_settings ); | |
| 3362 | - } | |
| 3363 | - } | |
| 3364 | - | |
| 3365 | - | |
| 3366 | - /** | |
| 3367 | 4040 | * Delete template transients |
| 3368 | 4041 | * |
| 3369 | 4042 | * @since 2.5.1 |
| 3370 | 4043 | */ |
| @@ -3859,12 +4532,9 @@ | ||
| 3859 | 4532 | $order_comment = array( |
| 3860 | 4533 | 'type' => 'textarea', |
| 3861 | 4534 | 'class' => array( 'notes' ), |
| 3862 | 4535 | 'label' => __( 'Order notes', 'woocommerce' ), |
| 3863 | - 'placeholder' => esc_attr__( | |
| 3864 | - 'Notes about your order, e.g. special notes for delivery.', | |
| 3865 | - 'woocommerce' | |
| 3866 | - ), | |
| 4536 | + 'placeholder' => esc_attr__( 'Notes about your order', 'wpfnl' ), | |
| 3867 | 4537 | 'name' => 'order_comments', |
| 3868 | 4538 | 'required' => false, |
| 3869 | 4539 | 'enable' => 1, |
| 3870 | 4540 | 'show' => 1, |
| @@ -4092,11 +4762,261 @@ | ||
| 4092 | 4762 | return $all_steps_including_ab_ids; |
| 4093 | 4763 | } |
| 4094 | 4764 | |
| 4095 | 4765 | /** |
| 4766 | + * Records that the current session placed the given order. | |
| 4767 | + * | |
| 4768 | + * Call this immediately after a funnel order is created, so later offer | |
| 4769 | + * requests can prove ownership of that specific order. | |
| 4770 | + * | |
| 4771 | + * @param int $order_id The order the session just placed. | |
| 4772 | + * @return void | |
| 4773 | + * @since 3.12.14 | |
| 4774 | + */ | |
| 4775 | + public static function mark_order_owned_by_session( $order_id ) { | |
| 4776 | + $order_id = absint( $order_id ); | |
| 4777 | + | |
| 4778 | + if ( ! $order_id || ! self::is_wc_active() || ! WC()->session ) { | |
| 4779 | + return; | |
| 4780 | + } | |
| 4781 | + | |
| 4782 | + $owned = WC()->session->get( self::OWNED_ORDERS_SESSION_KEY, array() ); | |
| 4783 | + $owned = is_array( $owned ) ? array_map( 'absint', $owned ) : array(); | |
| 4784 | + | |
| 4785 | + if ( in_array( $order_id, $owned, true ) ) { | |
| 4786 | + return; | |
| 4787 | + } | |
| 4788 | + | |
| 4789 | + $owned[] = $order_id; | |
| 4790 | + | |
| 4791 | + // Keep the list bounded. This is written on every funnel checkout and a | |
| 4792 | + // session has no business owning an unbounded number of orders. | |
| 4793 | + if ( count( $owned ) > self::MAX_OWNED_ORDERS ) { | |
| 4794 | + $owned = array_slice( $owned, - self::MAX_OWNED_ORDERS ); | |
| 4795 | + } | |
| 4796 | + | |
| 4797 | + WC()->session->set( self::OWNED_ORDERS_SESSION_KEY, $owned ); | |
| 4798 | + } | |
| 4799 | + | |
| 4800 | + /** | |
| 4801 | + * Whether the current session placed the given order. | |
| 4802 | + * | |
| 4803 | + * @param int $order_id The order to check. | |
| 4804 | + * @return bool | |
| 4805 | + * @since 3.12.14 | |
| 4806 | + */ | |
| 4807 | + public static function session_owns_order( $order_id ) { | |
| 4808 | + $order_id = absint( $order_id ); | |
| 4809 | + | |
| 4810 | + if ( ! $order_id || ! self::is_wc_active() || ! WC()->session ) { | |
| 4811 | + return false; | |
| 4812 | + } | |
| 4813 | + | |
| 4814 | + $owned = WC()->session->get( self::OWNED_ORDERS_SESSION_KEY, array() ); | |
| 4815 | + | |
| 4816 | + if ( ! is_array( $owned ) ) { | |
| 4817 | + return false; | |
| 4818 | + } | |
| 4819 | + | |
| 4820 | + return in_array( $order_id, array_map( 'absint', $owned ), true ); | |
| 4821 | + } | |
| 4822 | + | |
| 4823 | + /** | |
| 4824 | + * Signs the privileged settings an opt-in form was rendered with. | |
| 4825 | + * | |
| 4826 | + * The notification recipient, its subject and the "register this visitor" | |
| 4827 | + * switch are rendered as hidden inputs, so they arrive back as request data | |
| 4828 | + * and cannot be trusted on their own. Signing them at render time lets the | |
| 4829 | + * submission handler tell its own configuration apart from a caller-supplied | |
| 4830 | + * one, without having to re-parse the page the form was placed on. | |
| 4831 | + * | |
| 4832 | + * @param string $admin_email Notification recipient list as configured. | |
| 4833 | + * @param string $subject Notification subject as configured. | |
| 4834 | + * @param string $allow_registration 'yes' when the form may create a user. | |
| 4835 | + * @return string | |
| 4836 | + * @since 3.12.14 | |
| 4837 | + */ | |
| 4838 | + public static function get_optin_settings_token( $admin_email, $subject, $allow_registration = '' ) { | |
| 4839 | + return wp_hash( 'wpfnl_optin_settings|' . $admin_email . '|' . $subject . '|' . $allow_registration ); | |
| 4840 | + } | |
| 4841 | + | |
| 4842 | + /** | |
| 4843 | + * Whether the opt-in settings submitted with a form are the ones it was rendered with. | |
| 4844 | + * | |
| 4845 | + * @param string $token Token supplied by the request. | |
| 4846 | + * @param string $admin_email Notification recipient list from the request. | |
| 4847 | + * @param string $subject Notification subject from the request. | |
| 4848 | + * @param string $allow_registration Registration switch from the request. | |
| 4849 | + * @return bool | |
| 4850 | + * @since 3.12.14 | |
| 4851 | + */ | |
| 4852 | + public static function is_valid_optin_settings_token( $token, $admin_email, $subject, $allow_registration = '' ) { | |
| 4853 | + if ( ! is_string( $token ) || '' === $token ) { | |
| 4854 | + return false; | |
| 4855 | + } | |
| 4856 | + | |
| 4857 | + return hash_equals( | |
| 4858 | + self::get_optin_settings_token( $admin_email, $subject, $allow_registration ), | |
| 4859 | + $token | |
| 4860 | + ); | |
| 4861 | + } | |
| 4862 | + | |
| 4863 | + /** | |
| 4864 | + * Resolves what an order bump is actually configured to sell. | |
| 4865 | + * | |
| 4866 | + * The bump handler runs for unauthenticated callers, so the discount must be | |
| 4867 | + * pinned to the product it was configured for. Without this, a bump's discount | |
| 4868 | + * can be borrowed from any step on the site and applied to any product. | |
| 4869 | + * | |
| 4870 | + * Settings are read through the same filters the checkout renderer uses, so a | |
| 4871 | + * dynamically resolved (auto-match) bump validates against the product that was | |
| 4872 | + * actually offered rather than the one stored in the builder. | |
| 4873 | + * | |
| 4874 | + * @param int $step_id Checkout step holding the bump settings. | |
| 4875 | + * @param mixed $key Which bump on that step. | |
| 4876 | + * @param int $product_id Product the request asked for. | |
| 4877 | + * @param int $variation_id Variation the request asked for, if any. | |
| 4878 | + * @return array|WP_Error product_id, variation_id, cart_product_id, quantity and settings, or an error. | |
| 4879 | + * @since 3.12.14 | |
| 4880 | + */ | |
| 4881 | + public static function resolve_order_bump_item( $step_id, $key, $product_id, $variation_id = 0 ) { | |
| 4882 | + $step_id = absint( $step_id ); | |
| 4883 | + $product_id = absint( $product_id ); | |
| 4884 | + $variation_id = absint( $variation_id ); | |
| 4885 | + | |
| 4886 | + if ( ! self::is_wc_active() ) { | |
| 4887 | + return new \WP_Error( 'wpfnl_wc_inactive', __( 'WooCommerce is not active.', 'wpfnl' ) ); | |
| 4888 | + } | |
| 4889 | + | |
| 4890 | + // The bump must live on a real checkout step, not any post id the caller names. | |
| 4891 | + if ( ! $step_id || 'checkout' !== get_post_meta( $step_id, '_step_type', true ) ) { | |
| 4892 | + return new \WP_Error( 'wpfnl_invalid_step', __( 'Invalid checkout step.', 'wpfnl' ) ); | |
| 4893 | + } | |
| 4894 | + | |
| 4895 | + $funnel_id = self::get_funnel_id_from_step( $step_id ); | |
| 4896 | + $ob_settings = get_post_meta( $step_id, 'order-bump-settings', true ); | |
| 4897 | + $ob_settings = apply_filters( 'wpfunnels/order_bump_settings', $ob_settings, $funnel_id, $step_id ); | |
| 4898 | + $ob_settings = apply_filters( 'wpfunnels/order_bump_settings_on_frontend', $ob_settings, $funnel_id, $step_id ); | |
| 4899 | + | |
| 4900 | + if ( ! is_array( $ob_settings ) || ! isset( $ob_settings[ $key ] ) || ! is_array( $ob_settings[ $key ] ) ) { | |
| 4901 | + return new \WP_Error( 'wpfnl_invalid_bump', __( 'Invalid order bump.', 'wpfnl' ) ); | |
| 4902 | + } | |
| 4903 | + | |
| 4904 | + $settings = $ob_settings[ $key ]; | |
| 4905 | + | |
| 4906 | + if ( empty( $settings['product'] ) ) { | |
| 4907 | + return new \WP_Error( 'wpfnl_invalid_bump', __( 'This order bump has no product configured.', 'wpfnl' ) ); | |
| 4908 | + } | |
| 4909 | + | |
| 4910 | + $configured_id = absint( $settings['product'] ); | |
| 4911 | + | |
| 4912 | + if ( ! wc_get_product( $configured_id ) ) { | |
| 4913 | + return new \WP_Error( 'wpfnl_invalid_product', __( 'The configured order bump product no longer exists.', 'wpfnl' ) ); | |
| 4914 | + } | |
| 4915 | + | |
| 4916 | + // The discount belongs to the configured product. Anything else is a | |
| 4917 | + // request to price one product using another product's offer. | |
| 4918 | + if ( $product_id !== $configured_id ) { | |
| 4919 | + return new \WP_Error( 'wpfnl_product_mismatch', __( 'This product is not part of the selected offer.', 'wpfnl' ) ); | |
| 4920 | + } | |
| 4921 | + | |
| 4922 | + if ( $variation_id ) { | |
| 4923 | + $variation = wc_get_product( $variation_id ); | |
| 4924 | + | |
| 4925 | + if ( ! $variation || $variation->get_parent_id() !== $configured_id ) { | |
| 4926 | + return new \WP_Error( 'wpfnl_invalid_variation', __( 'Please choose a valid variation for this offer.', 'wpfnl' ) ); | |
| 4927 | + } | |
| 4928 | + } | |
| 4929 | + | |
| 4930 | + // Quantity is the merchant's. Letting the request set it widens the | |
| 4931 | + // flat-discount cap, which is compared against price multiplied by quantity. | |
| 4932 | + $quantity = isset( $settings['quantity'] ) ? absint( $settings['quantity'] ) : 0; | |
| 4933 | + $quantity = max( 1, $quantity ); | |
| 4934 | + | |
| 4935 | + return array( | |
| 4936 | + 'product_id' => $configured_id, | |
| 4937 | + 'variation_id' => $variation_id, | |
| 4938 | + 'cart_product_id' => $variation_id ? $variation_id : $configured_id, | |
| 4939 | + 'quantity' => $quantity, | |
| 4940 | + ); | |
| 4941 | + } | |
| 4942 | + | |
| 4943 | + /** | |
| 4944 | + * Resolves what an offer step is actually configured to sell. | |
| 4945 | + * | |
| 4946 | + * The offer cart handler runs for unauthenticated callers, so nothing about | |
| 4947 | + * the item may be taken from the request: the product and the quantity come | |
| 4948 | + * from the step's own configuration. The only choice the request gets is | |
| 4949 | + * which variation of that configured product to buy. | |
| 4950 | + * | |
| 4951 | + * @param int $step_id The offer step being purchased. | |
| 4952 | + * @param int $candidate_variation_id Variation the request selected, if any. | |
| 4953 | + * @return array|WP_Error product_id, variation_id and quantity, or an error. | |
| 4954 | + * @since 3.12.14 | |
| 4955 | + */ | |
| 4956 | + public static function resolve_offer_cart_item( $step_id, $candidate_variation_id = 0 ) { | |
| 4957 | + $step_id = absint( $step_id ); | |
| 4958 | + | |
| 4959 | + if ( ! self::is_wc_active() ) { | |
| 4960 | + return new \WP_Error( 'wpfnl_wc_inactive', __( 'WooCommerce is not active.', 'wpfnl' ) ); | |
| 4961 | + } | |
| 4962 | + | |
| 4963 | + if ( ! $step_id ) { | |
| 4964 | + return new \WP_Error( 'wpfnl_invalid_step', __( 'Invalid offer step.', 'wpfnl' ) ); | |
| 4965 | + } | |
| 4966 | + | |
| 4967 | + // Only a real upsell/downsell step may drive this. Without it, any post id | |
| 4968 | + // could be used to borrow another step's discount configuration. | |
| 4969 | + $step_type = get_post_meta( $step_id, '_step_type', true ); | |
| 4970 | + | |
| 4971 | + if ( ! in_array( $step_type, array( 'upsell', 'downsell' ), true ) ) { | |
| 4972 | + return new \WP_Error( 'wpfnl_invalid_step', __( 'Invalid offer step.', 'wpfnl' ) ); | |
| 4973 | + } | |
| 4974 | + | |
| 4975 | + $configured = get_post_meta( $step_id, '_wpfnl_' . $step_type . '_products', true ); | |
| 4976 | + | |
| 4977 | + if ( ! is_array( $configured ) || empty( $configured[0]['id'] ) ) { | |
| 4978 | + return new \WP_Error( 'wpfnl_no_offer_product', __( 'This offer has no product configured.', 'wpfnl' ) ); | |
| 4979 | + } | |
| 4980 | + | |
| 4981 | + $product_id = absint( $configured[0]['id'] ); | |
| 4982 | + $product = wc_get_product( $product_id ); | |
| 4983 | + | |
| 4984 | + if ( ! $product ) { | |
| 4985 | + return new \WP_Error( 'wpfnl_invalid_product', __( 'The configured offer product no longer exists.', 'wpfnl' ) ); | |
| 4986 | + } | |
| 4987 | + | |
| 4988 | + // Quantity is the merchant's, not the caller's. Offer pages expose no | |
| 4989 | + // quantity control, and letting the request set it turns the discount | |
| 4990 | + // guard (which compares against the line total) into a lever. | |
| 4991 | + $quantity = isset( $configured[0]['quantity'] ) ? absint( $configured[0]['quantity'] ) : 0; | |
| 4992 | + $quantity = max( 1, $quantity ); | |
| 4993 | + | |
| 4994 | + $variation_id = absint( $candidate_variation_id ); | |
| 4995 | + | |
| 4996 | + if ( $variation_id ) { | |
| 4997 | + $variation = wc_get_product( $variation_id ); | |
| 4998 | + | |
| 4999 | + if ( ! $variation || $variation->get_parent_id() !== $product_id ) { | |
| 5000 | + return new \WP_Error( 'wpfnl_invalid_variation', __( 'Please choose a valid variation for this offer.', 'wpfnl' ) ); | |
| 5001 | + } | |
| 5002 | + } | |
| 5003 | + | |
| 5004 | + return array( | |
| 5005 | + 'product_id' => $product_id, | |
| 5006 | + 'variation_id' => $variation_id, | |
| 5007 | + 'quantity' => $quantity, | |
| 5008 | + ); | |
| 5009 | + } | |
| 5010 | + | |
| 5011 | + /** | |
| 4096 | 5012 | * Validates the order owner's authorization. |
| 4097 | 5013 | * |
| 4098 | - * @param WC_Order $order The order object to validate. | |
| 5014 | + * Every caller that resolves an order from a request-supplied id must pass | |
| 5015 | + * through here before acting on that order. | |
| 5016 | + * | |
| 5017 | + * @param WC_Order $order The order object to validate. | |
| 5018 | + * @param string $order_key Order key supplied by the request, if any. | |
| 4099 | 5019 | * @return bool |
| 4100 | 5020 | * @since 2.7.13 |
| 4101 | 5021 | */ |
| 4102 | 5022 | public static function is_valid_order_owner( $order, $order_key = '' ) { |
| @@ -4112,26 +5032,19 @@ | ||
| 4112 | 5032 | if ( ! empty( $order_key ) && $order->key_is_valid( $order_key ) ) { |
| 4113 | 5033 | return true; |
| 4114 | 5034 | } |
| 4115 | 5035 | |
| 4116 | - // If it's not a guest order, current user can pay but only if it's their own order, | |
| 4117 | - // or they are an admin or shop manager or they are in a same session(for guest). | |
| 4118 | - | |
| 4119 | - $order_has_customer = ! empty( $order->get_customer_id() ); | |
| 4120 | - $is_authorized_owner = false; | |
| 4121 | - | |
| 4122 | - $has_owner = ! empty( WC()->session->get( 'wpfnl_order_owner' ) ); | |
| 4123 | - | |
| 4124 | - if ( $order_has_customer ) { | |
| 4125 | - $is_order_owned_by_current_user = $order->get_customer_id() === get_current_user_id(); | |
| 4126 | - | |
| 4127 | - $is_authorized_owner = $is_order_owned_by_current_user || current_user_can( 'manage_woocommerce' ); | |
| 5036 | + // A logged-in customer may act on their own order; so may a shop manager. | |
| 5037 | + if ( ! empty( $order->get_customer_id() ) ) { | |
| 5038 | + if ( $order->get_customer_id() === get_current_user_id() || current_user_can( 'manage_woocommerce' ) ) { | |
| 5039 | + return true; | |
| 5040 | + } | |
| 4128 | 5041 | } |
| 4129 | 5042 | |
| 4130 | - if ( ! $is_authorized_owner && ! $has_owner ) { | |
| 4131 | - return false; | |
| 4132 | - } | |
| 4133 | - return true; | |
| 5043 | + // Guest flows: this session must have placed this specific order. Note the | |
| 5044 | + // check is against the order id, not a bare "has checked out" marker, so a | |
| 5045 | + // session cannot authorise an order it did not create. | |
| 5046 | + return self::session_owns_order( $order->get_id() ); | |
| 4134 | 5047 | } |
| 4135 | 5048 | |
| 4136 | 5049 | |
| 4137 | 5050 | /** |
| @@ -4692,8 +5605,24 @@ | ||
| 4692 | 5605 | */ |
| 4693 | 5606 | public static function get_text() { |
| 4694 | 5607 | return array( |
| 4695 | 5608 | 'error_occurred' => __( 'Error Occurred!', 'wpfnl' ), |
| 5609 | + | |
| 5610 | + // Funnel import progress. Read by admin/assets/js/wpfnl-admin.js, which predates | |
| 5611 | + // the Vue bundles and has no wp.i18n of its own. | |
| 5612 | + 'import_preparing' => __( 'Preparing import…', 'wpfnl' ), | |
| 5613 | + 'import_in_progress' => __( 'Importing funnels…', 'wpfnl' ), | |
| 5614 | + 'import_completed' => __( 'Import complete.', 'wpfnl' ), | |
| 5615 | + 'import_failed' => __( 'The import could not be completed.', 'wpfnl' ), | |
| 5616 | + 'import_failed_item' => __( 'failed', 'wpfnl' ), | |
| 5617 | + 'import_partial' => __( 'some steps failed', 'wpfnl' ), | |
| 5618 | + 'import_funnels_word' => __( 'funnels', 'wpfnl' ), | |
| 5619 | + 'import_steps_word' => __( 'steps', 'wpfnl' ), | |
| 5620 | + 'import_stalled' => __( 'The import seems to have stopped responding. Cancel it to try again.', 'wpfnl' ), | |
| 5621 | + 'import_taking_long' => __( 'This import is taking longer than expected. Refresh the page to check on it.', 'wpfnl' ), | |
| 5622 | + 'import_status_unavailable' => __( 'Lost contact with the import. Refresh the page to check on it.', 'wpfnl' ), | |
| 5623 | + 'import_cancelled' => __( 'The import has been cancelled.', 'wpfnl' ), | |
| 5624 | + 'import_cancel_failed' => __( 'Could not cancel the import.', 'wpfnl' ), | |
| 4696 | 5625 | 'stats' => __( 'Stats', 'wpfnl' ), |
| 4697 | 5626 | 'stats_tooltip_txt' => __( 'Toggle to get a quick view of visits & conversion of steps right on the canvas.', 'wpfnl' ), |
| 4698 | 5627 | 'find_your_templates' => __( 'Find your templates', 'wpfnl' ), |
| 4699 | 5628 | 'enable_global_funnel' => __( 'Enable Global Funnel', 'wpfnl' ), |
| @@ -4978,8 +5907,11 @@ | ||
| 4978 | 5907 | 'do_you_want_to_save_automation' => __( 'Do you want to save the automation?', 'wpfnl' ), |
| 4979 | 5908 | 'no_back' => __( 'No & Back', 'wpfnl' ), |
| 4980 | 5909 | 'yes_back' => __( 'Yes & Back', 'wpfnl' ), |
| 4981 | 5910 | 'saving' => __( 'Saving...', 'wpfnl' ), |
| 5911 | + 'unsaved_changes' => __( 'Unsaved changes', 'wpfnl' ), | |
| 5912 | + 'automation_save_failed' => __( 'Could not save your changes', 'wpfnl' ), | |
| 5913 | + 'remove_automation_step_confirm' => __( 'Remove this step from the automation?', 'wpfnl' ), | |
| 4982 | 5914 | |
| 4983 | 5915 | 'choose_list' => __( 'Choose List', 'wpfnl' ), |
| 4984 | 5916 | 'select_lists' => __( 'Select Lists', 'wpfnl' ), |
| 4985 | 5917 | 'search_list' => __( 'Search List', 'wpfnl' ), |
| @@ -5238,8 +6170,23 @@ | ||
| 5238 | 6170 | 'run_ab_split_test' => __( 'Run A/B Split Test', 'wpfnl' ), |
| 5239 | 6171 | 'to_funnel_update_msg' => __( 'Please update to WPFunnels Pro 1.7.3 or higher to use this feature.', 'wpfnl' ), |
| 5240 | 6172 | 'conditions' => __( 'Conditions', 'wpfnl' ), |
| 5241 | 6173 | |
| 6174 | + // -----vertical flow canvas------ | |
| 6175 | + 'order_bumps' => __( 'Order Bumps', 'wpfnl' ), | |
| 6176 | + 'checkout_products' => __( 'Products', 'wpfnl' ), | |
| 6177 | + 'store_conditions' => __( 'Store Conditions', 'wpfnl' ), | |
| 6178 | + 'add_featured_image' => __( 'Add Featured Image', 'wpfnl' ), | |
| 6179 | + 'automation' => __( 'Automation', 'wpfnl' ), | |
| 6180 | + 'add_conditional_next_step' => __( 'Add Conditional Next Step', 'wpfnl' ), | |
| 6181 | + 'add_step_here' => __( 'Add step here', 'wpfnl' ), | |
| 6182 | + 'paste_here' => __( 'Paste here', 'wpfnl' ), | |
| 6183 | + 'conditional' => __( 'Conditional', 'wpfnl' ), | |
| 6184 | + 'click_to_configure' => __( 'Click to configure', 'wpfnl' ), | |
| 6185 | + 'journey_ends_here' => __( 'Journey ends here', 'wpfnl' ), | |
| 6186 | + 'activated_conditional_flow' => __( 'Activated Conditional Flow', 'wpfnl' ), | |
| 6187 | + 'need_reconfigure_condition' => __( 'Need reconfigure condition', 'wpfnl' ), | |
| 6188 | + | |
| 5242 | 6189 | // -----step settings drawer------ |
| 5243 | 6190 | 'assign_the_main_funnel_product' => __( 'Assign the main funnel product here.', 'wpfnl' ), |
| 5244 | 6191 | 'courses' => __( 'Courses', 'wpfnl' ), |
| 5245 | 6192 | 'products' => __( 'Products', 'wpfnl' ), |
| @@ -5427,9 +6374,19 @@ | ||
| 5427 | 6374 | 'views' => __('Views', 'wpfnl'), |
| 5428 | 6375 | 'conversion' => __('Conversion', 'wpfnl'), |
| 5429 | 6376 | 'conversion_rate' => __('Conversion Rate', 'wpfnl'), |
| 5430 | 6377 | 'no_top_performing_funnels' => __('You have no top performing funnels', 'wpfnl'), |
| 6378 | + 'view_all' => __('View all', 'wpfnl'), | |
| 5431 | 6379 | 'today' => __('Today', 'wpfnl'), |
| 6380 | + 'yesterday' => __('Yesterday', 'wpfnl'), | |
| 6381 | + 'this_week' => __('This Week', 'wpfnl'), | |
| 6382 | + 'last_week' => __('Last Week', 'wpfnl'), | |
| 6383 | + 'this_month' => __('This Month', 'wpfnl'), | |
| 6384 | + 'last_month' => __('Last Month', 'wpfnl'), | |
| 6385 | + 'previous_month' => __('Previous month', 'wpfnl'), | |
| 6386 | + 'next_month' => __('Next month', 'wpfnl'), | |
| 6387 | + 'cancel' => __('Cancel', 'wpfnl'), | |
| 6388 | + 'apply' => __('Apply', 'wpfnl'), | |
| 5432 | 6389 | 'week_to_date' => __('Week to date', 'wpfnl'), |
| 5433 | 6390 | 'month_to_date' => __('Month to date', 'wpfnl'), |
| 5434 | 6391 | 'year_to_date' => __('Year to date', 'wpfnl'), |
| 5435 | 6392 | 'previous_period' => __('Previous period', 'wpfnl'), |
| @@ -6408,8 +7365,79 @@ | ||
| 6408 | 7365 | } |
| 6409 | 7366 | } |
| 6410 | 7367 | } |
| 6411 | 7368 | return $custom_price; |
| 7369 | + } | |
| 7370 | + | |
| 7371 | + /** | |
| 7372 | + * Enqueue the stylesheet a production webpack build extracted for a bundle. | |
| 7373 | + * | |
| 7374 | + * A production build (`npm run build`) extracts every entry's CSS to | |
| 7375 | + * admin/assets/dist/styles/<entry>.<contenthash>.css instead of injecting it | |
| 7376 | + * from JS, so each bundle needs its stylesheet enqueued alongside its script | |
| 7377 | + * or the screen renders unstyled. The filename carries a content hash, hence | |
| 7378 | + * the glob. | |
| 7379 | + * | |
| 7380 | + * A development build (`npm run start`) injects styles at runtime and emits | |
| 7381 | + * no stylesheet at all — nothing matches the glob and this is a no-op, which | |
| 7382 | + * is exactly what a dev build needs. | |
| 7383 | + * | |
| 7384 | + * @param string $entry Webpack entry name, e.g. `automation-canvas`. | |
| 7385 | + * @param string $handle Style handle to register it under. | |
| 7386 | + * @param array $deps Style handles this stylesheet must load after. Only | |
| 7387 | + * pass handles guaranteed to be registered on the same | |
| 7388 | + * screen — WordPress silently drops a style whose | |
| 7389 | + * dependency is missing. | |
| 7390 | + * | |
| 7391 | + * @return void | |
| 7392 | + * @since 3.13.0 | |
| 7393 | + */ | |
| 7394 | + public static function enqueue_bundle_style( $entry, $handle, $deps = array() ) { | |
| 7395 | + $url = self::get_bundle_style_url( $entry ); | |
| 7396 | + | |
| 7397 | + if ( ! $url ) { | |
| 7398 | + return; | |
| 7399 | + } | |
| 7400 | + | |
| 7401 | + wp_enqueue_style( $handle, $url, $deps, WPFNL_VERSION ); | |
| 7402 | + } | |
| 7403 | + | |
| 7404 | + | |
| 7405 | + /** | |
| 7406 | + * URL of a webpack entry's extracted stylesheet, if there is one. | |
| 7407 | + * | |
| 7408 | + * A development build injects its styles from JavaScript and emits no | |
| 7409 | + * stylesheet at all, so an empty string here means "nothing to load", not | |
| 7410 | + * "something went wrong". | |
| 7411 | + * | |
| 7412 | + * @param string $entry Webpack entry name. | |
| 7413 | + * | |
| 7414 | + * @return string Stylesheet URL, or '' when the build emitted none. | |
| 7415 | + * @since 3.13.8 | |
| 7416 | + */ | |
| 7417 | + public static function get_bundle_style_url( $entry ) { | |
| 7418 | + $styles_dir = WPFNL_DIR . '/admin/assets/dist/styles/'; | |
| 7419 | + | |
| 7420 | + if ( ! is_dir( $styles_dir ) ) { | |
| 7421 | + return ''; | |
| 7422 | + } | |
| 7423 | + | |
| 7424 | + $files = glob( $styles_dir . $entry . '.*.css' ); | |
| 7425 | + | |
| 7426 | + if ( empty( $files ) ) { | |
| 7427 | + return ''; | |
| 7428 | + } | |
| 7429 | + | |
| 7430 | + // Newest wins, so a stale hash left behind by an interrupted build never | |
| 7431 | + // shadows the current one. | |
| 7432 | + usort( | |
| 7433 | + $files, | |
| 7434 | + function ( $a, $b ) { | |
| 7435 | + return filemtime( $b ) - filemtime( $a ); | |
| 7436 | + } | |
| 7437 | + ); | |
| 7438 | + | |
| 7439 | + return WPFNL_URL . 'admin/assets/dist/styles/' . basename( $files[0] ); | |
| 6412 | 7440 | } |
| 6413 | 7441 | |
| 6414 | 7442 | /** |
| 6415 | 7443 | * Helper method to return the item description, which is composed of item |