| @@ -35,8 +35,32 @@ | ||
| 35 | 35 | */ |
| 36 | 36 | const CANVAS_VIEW_MODE_META_KEY = '_wpfnl_canvas_view_mode'; |
| 37 | 37 | |
| 38 | 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 | + /** | |
| 39 | 63 | * Post meta keys that must not travel between sites on funnel export/import. |
| 40 | 64 | * |
| 41 | 65 | * These hold per-site render caches or bookkeeping that is only meaningful on the site |
| 42 | 66 | * that produced them. Carrying them into a new site makes the imported step inherit the |
| @@ -213,26 +237,89 @@ | ||
| 213 | 237 | * |
| 214 | 238 | * @return bool|int |
| 215 | 239 | */ |
| 216 | 240 | public static function get_checkout_id_from_post( $post = null ) { |
| 217 | - if ( is_array( $post ) && ! empty( $post['_wpfunnels_checkout_id'] ) ) { | |
| 218 | - $checkout_id = filter_var( wp_unslash( $post['_wpfunnels_checkout_id'] ), FILTER_SANITIZE_NUMBER_INT ); | |
| 219 | - return $checkout_id ? intval( $checkout_id ) : false; | |
| 241 | + if ( null === $post && ! empty( $_POST ) ) { | |
| 242 | + $post = $_POST; | |
| 220 | 243 | } |
| 221 | 244 | |
| 222 | - if ( is_array( $post ) && isset( $post['post_data'] ) ) { | |
| 223 | - $post_data = array(); | |
| 224 | - wp_parse_str( wp_unslash( $post['post_data'] ), $post_data ); | |
| 225 | - if ( ! empty( $post_data['_wpfunnels_checkout_id'] ) ) { | |
| 226 | - $checkout_id = filter_var( $post_data['_wpfunnels_checkout_id'], FILTER_SANITIZE_NUMBER_INT ); | |
| 227 | - 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 | + } | |
| 228 | 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 | + } | |
| 229 | 287 | } |
| 288 | + | |
| 230 | 289 | return false; |
| 231 | 290 | } |
| 232 | 291 | |
| 233 | 292 | |
| 234 | 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 | + /** | |
| 235 | 322 | * Check order bump clicked or not from post data |
| 236 | 323 | * |
| 237 | 324 | * @return bool True|False |
| 238 | 325 | * @since 2.8.0 |
| @@ -293,8 +380,25 @@ | ||
| 293 | 380 | } |
| 294 | 381 | |
| 295 | 382 | |
| 296 | 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 | + /** | |
| 297 | 401 | * Check product option is selected or not from post data |
| 298 | 402 | * |
| 299 | 403 | * @return bool True|False |
| 300 | 404 | * @since 2.8.0 |
| @@ -484,9 +588,10 @@ | ||
| 484 | 588 | if ( $page === WPFNL_ADDONS_SLUG && $key === 'addons' ) { |
| 485 | 589 | return true; |
| 486 | 590 | } |
| 487 | 591 | |
| 488 | - if ( $page === 'wpfunnels_integrations' && $key === 'integrations' ) { | |
| 592 | + // The legacy page; the React `#/integrations` route never renders this nav. | |
| 593 | + if ( $page === WPFNL_INTEGRATIONS_MAIN_PAGE_SLUG && $key === 'integrations' ) { | |
| 489 | 594 | return true; |
| 490 | 595 | } |
| 491 | 596 | |
| 492 | 597 | if ( $page === WPFNL_TEMPLATE_PAGE_SLUG && $key === 'templates' ) { |
| @@ -496,8 +601,12 @@ | ||
| 496 | 601 | if ( $page === WPFNL_STORE_CHECKOUT_SLUG && $key === 'store_checkout' ) { |
| 497 | 602 | return true; |
| 498 | 603 | } |
| 499 | 604 | |
| 605 | + if ( defined( 'WPFNL_AI_FUNNEL_PAGE_SLUG' ) && $page === WPFNL_AI_FUNNEL_PAGE_SLUG && $key === 'ai_assistant' ) { | |
| 606 | + return true; | |
| 607 | + } | |
| 608 | + | |
| 500 | 609 | return false; |
| 501 | 610 | } |
| 502 | 611 | |
| 503 | 612 | |
| @@ -630,8 +739,23 @@ | ||
| 630 | 739 | 'id' => $post_data['_wpfunnels_checkout_id'], |
| 631 | 740 | ); |
| 632 | 741 | } |
| 633 | 742 | } |
| 743 | + | |
| 744 | + // Some ajax actions (e.g. the order bump accept/reject handler) post the | |
| 745 | + // checkout step id directly as `step_id` instead of a serialized `post_data` | |
| 746 | + // form. Fall back to that so quantity/checkout-id dependent filters still | |
| 747 | + // resolve the correct step during those requests. | |
| 748 | + if ( ! empty( $_POST['step_id'] ) ) { | |
| 749 | + $step_id = filter_var( wp_unslash( $_POST['step_id'] ), FILTER_SANITIZE_NUMBER_INT ); | |
| 750 | + if ( $step_id && self::check_if_this_is_step_type_by_id( intval( $step_id ), 'checkout' ) ) { | |
| 751 | + return array( | |
| 752 | + 'status' => true, | |
| 753 | + 'id' => intval( $step_id ), | |
| 754 | + ); | |
| 755 | + } | |
| 756 | + } | |
| 757 | + | |
| 634 | 758 | return array( |
| 635 | 759 | 'status' => false, |
| 636 | 760 | 'id' => '', |
| 637 | 761 | ); |
| @@ -1310,9 +1434,8 @@ | ||
| 1310 | 1434 | 'paypal_reference' => 'off', |
| 1311 | 1435 | 'order_bump' => 'off', |
| 1312 | 1436 | 'ab_testing' => 'off', |
| 1313 | 1437 | 'allow_funnels' => array( 'administrator' => 'true' ), |
| 1314 | - 'funnel_type' => 'sales', | |
| 1315 | 1438 | 'create_child_order' => 'off', |
| 1316 | 1439 | 'disable_theme_style' => 'off', |
| 1317 | 1440 | 'enable_log_status' => 'off', |
| 1318 | 1441 | 'enable_skip_cart' => 'off', |
| @@ -1437,8 +1560,342 @@ | ||
| 1437 | 1560 | return wp_parse_args( $saved_settings, $default_settings ); |
| 1438 | 1561 | } |
| 1439 | 1562 | |
| 1440 | 1563 | /** |
| 1564 | + * Derive the shipping rate that applies to an offer (upsell/downsell) product. | |
| 1565 | + * | |
| 1566 | + * Two sources are consulted, in order: | |
| 1567 | + * | |
| 1568 | + * 1. The shipping method already chosen on the parent order, re-priced against | |
| 1569 | + * the offer product's own shipping class when the store runs flat rate and | |
| 1570 | + * the offer is merged into the main order. | |
| 1571 | + * 2. The shipping zone matching the order's destination. This is the only | |
| 1572 | + * source available when the parent order never shipped — a virtual-only | |
| 1573 | + * checkout carries no shipping method to clone. | |
| 1574 | + * | |
| 1575 | + * An offer product that does not need shipping never derives a rate, so a | |
| 1576 | + * virtual upsell can never add shipping to a physical parent order. | |
| 1577 | + * | |
| 1578 | + * @param \WC_Order $order Parent order. | |
| 1579 | + * @param int $product_id Offer product id. | |
| 1580 | + * @param float $total_price Offer line total, used as the shipping package cost. | |
| 1581 | + * @param int $qty Offer quantity. | |
| 1582 | + * | |
| 1583 | + * @return array Shipping data, or an empty array when no shipping applies. | |
| 1584 | + * @since 1.0.0 | |
| 1585 | + */ | |
| 1586 | + public static function get_offer_shipping( $order, $product_id, $total_price = 0, $qty = 1 ) { | |
| 1587 | + if ( ! $product_id || ! $order instanceof \WC_Order ) { | |
| 1588 | + return array(); | |
| 1589 | + } | |
| 1590 | + | |
| 1591 | + $product = wc_get_product( $product_id ); | |
| 1592 | + | |
| 1593 | + if ( ! $product || ! $product->needs_shipping() ) { | |
| 1594 | + return array(); | |
| 1595 | + } | |
| 1596 | + | |
| 1597 | + $shipping = self::get_offer_shipping_from_parent_order( $order, $product ); | |
| 1598 | + | |
| 1599 | + if ( empty( $shipping ) ) { | |
| 1600 | + $shipping = self::get_offer_shipping_from_zones( $order, $product, $total_price, $qty ); | |
| 1601 | + } | |
| 1602 | + | |
| 1603 | + /** | |
| 1604 | + * Filter the shipping data derived for an offer product. | |
| 1605 | + * | |
| 1606 | + * @param array $shipping Derived shipping data, empty when none applies. | |
| 1607 | + * @param \WC_Order $order Parent order. | |
| 1608 | + * @param \WC_Product $product Offer product. | |
| 1609 | + * | |
| 1610 | + * @since 1.0.0 | |
| 1611 | + */ | |
| 1612 | + return apply_filters( 'wpfunnels/offer_shipping', $shipping, $order, $product ); | |
| 1613 | + } | |
| 1614 | + | |
| 1615 | + | |
| 1616 | + /** | |
| 1617 | + * Clone the parent order's shipping method for the offer product. | |
| 1618 | + * | |
| 1619 | + * When the store runs flat rate and offers are merged into the main order the | |
| 1620 | + * cloned cost is re-priced against the offer product's shipping class, which is | |
| 1621 | + * the behaviour this plugin has always described. | |
| 1622 | + * | |
| 1623 | + * @param \WC_Order $order Parent order. | |
| 1624 | + * @param \WC_Product $product Offer product. | |
| 1625 | + * | |
| 1626 | + * @return array | |
| 1627 | + * @since 1.0.0 | |
| 1628 | + */ | |
| 1629 | + private static function get_offer_shipping_from_parent_order( $order, $product ) { | |
| 1630 | + $shipping = array(); | |
| 1631 | + $shipping_methods = $order->get_shipping_methods(); | |
| 1632 | + | |
| 1633 | + if ( empty( $shipping_methods ) ) { | |
| 1634 | + return $shipping; | |
| 1635 | + } | |
| 1636 | + | |
| 1637 | + foreach ( $shipping_methods as $method ) { | |
| 1638 | + if ( ! $method ) { | |
| 1639 | + continue; | |
| 1640 | + } | |
| 1641 | + | |
| 1642 | + $shipping = array( | |
| 1643 | + 'name' => $method->get_name(), | |
| 1644 | + 'method_title' => $method->get_method_title(), | |
| 1645 | + 'method_id' => $method->get_method_id(), | |
| 1646 | + 'instance_id' => $method->get_instance_id(), | |
| 1647 | + 'total' => (float) $method->get_total(), | |
| 1648 | + 'total_tax' => (float) $method->get_total_tax(), | |
| 1649 | + 'taxes' => $method->get_taxes(), | |
| 1650 | + ); | |
| 1651 | + } | |
| 1652 | + | |
| 1653 | + if ( empty( $shipping ) ) { | |
| 1654 | + return $shipping; | |
| 1655 | + } | |
| 1656 | + | |
| 1657 | + $offer_settings = self::get_offer_settings(); | |
| 1658 | + $offer_orders = isset( $offer_settings['offer_orders'] ) ? $offer_settings['offer_orders'] : ''; | |
| 1659 | + | |
| 1660 | + if ( 'main-order' !== $offer_orders || 'flat_rate' !== $shipping['method_id'] ) { | |
| 1661 | + return $shipping; | |
| 1662 | + } | |
| 1663 | + | |
| 1664 | + $tax_percentage = 0.0 !== (float) $shipping['total'] ? ( $shipping['total_tax'] * 100 ) / $shipping['total'] : 0; | |
| 1665 | + | |
| 1666 | + foreach ( \WC_Shipping_Zones::get_zones() as $zone ) { | |
| 1667 | + if ( empty( $zone['shipping_methods'] ) ) { | |
| 1668 | + continue; | |
| 1669 | + } | |
| 1670 | + | |
| 1671 | + foreach ( $zone['shipping_methods'] as $method ) { | |
| 1672 | + if ( ! $method instanceof \WC_Shipping_Flat_Rate ) { | |
| 1673 | + continue; | |
| 1674 | + } | |
| 1675 | + | |
| 1676 | + if ( ! isset( $method->instance_id ) || $method->instance_id != $shipping['instance_id'] ) { // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison | |
| 1677 | + continue; | |
| 1678 | + } | |
| 1679 | + | |
| 1680 | + $instance_settings = isset( $method->instance_settings ) ? $method->instance_settings : array(); | |
| 1681 | + $class_cost_key = 'class_cost_' . $product->get_shipping_class_id(); | |
| 1682 | + | |
| 1683 | + if ( isset( $instance_settings[ $class_cost_key ] ) ) { | |
| 1684 | + $shipping_cost = $instance_settings[ $class_cost_key ]; | |
| 1685 | + } elseif ( isset( $instance_settings['no_class_cost'] ) ) { | |
| 1686 | + $shipping_cost = $instance_settings['no_class_cost']; | |
| 1687 | + } else { | |
| 1688 | + continue; | |
| 1689 | + } | |
| 1690 | + | |
| 1691 | + $shipping_cost = $shipping_cost ? (float) $shipping_cost : 0; | |
| 1692 | + $total_tax = ( $shipping_cost * $tax_percentage ) / 100; | |
| 1693 | + $shipping['total'] = $shipping_cost; | |
| 1694 | + $shipping['total_tax'] = $total_tax; | |
| 1695 | + $shipping['taxes'] = array( 'total' => array( $total_tax ) ); | |
| 1696 | + } | |
| 1697 | + } | |
| 1698 | + | |
| 1699 | + return $shipping; | |
| 1700 | + } | |
| 1701 | + | |
| 1702 | + | |
| 1703 | + /** | |
| 1704 | + * Derive a shipping rate for the offer product from the matching shipping zone. | |
| 1705 | + * | |
| 1706 | + * Used when the parent order carries no shipping method to clone — a physical | |
| 1707 | + * offer accepted on top of a virtual-only order. | |
| 1708 | + * | |
| 1709 | + * @param \WC_Order $order Parent order. | |
| 1710 | + * @param \WC_Product $product Offer product. | |
| 1711 | + * @param float $total_price Offer line total. | |
| 1712 | + * @param int $qty Offer quantity. | |
| 1713 | + * | |
| 1714 | + * @return array | |
| 1715 | + * @since 1.0.0 | |
| 1716 | + */ | |
| 1717 | + private static function get_offer_shipping_from_zones( $order, $product, $total_price, $qty ) { | |
| 1718 | + if ( ! class_exists( '\WC_Shipping_Zones' ) ) { | |
| 1719 | + return array(); | |
| 1720 | + } | |
| 1721 | + | |
| 1722 | + $package = self::get_offer_shipping_package( $order, $product, $total_price, $qty ); | |
| 1723 | + | |
| 1724 | + if ( empty( $package['destination']['country'] ) ) { | |
| 1725 | + return array(); | |
| 1726 | + } | |
| 1727 | + | |
| 1728 | + $zone = \WC_Shipping_Zones::get_zone_matching_package( $package ); | |
| 1729 | + | |
| 1730 | + if ( ! $zone ) { | |
| 1731 | + return array(); | |
| 1732 | + } | |
| 1733 | + | |
| 1734 | + foreach ( $zone->get_shipping_methods( true ) as $method ) { | |
| 1735 | + $rates = $method->get_rates_for_package( $package ); | |
| 1736 | + | |
| 1737 | + if ( empty( $rates ) ) { | |
| 1738 | + continue; | |
| 1739 | + } | |
| 1740 | + | |
| 1741 | + $rate = reset( $rates ); | |
| 1742 | + | |
| 1743 | + return array( | |
| 1744 | + 'name' => $rate->get_label(), | |
| 1745 | + 'method_title' => $rate->get_label(), | |
| 1746 | + 'method_id' => $rate->get_method_id(), | |
| 1747 | + 'instance_id' => $rate->get_instance_id(), | |
| 1748 | + 'total' => (float) $rate->get_cost(), | |
| 1749 | + 'total_tax' => (float) $rate->get_shipping_tax(), | |
| 1750 | + 'taxes' => $rate->get_taxes(), | |
| 1751 | + ); | |
| 1752 | + } | |
| 1753 | + | |
| 1754 | + return array(); | |
| 1755 | + } | |
| 1756 | + | |
| 1757 | + | |
| 1758 | + /** | |
| 1759 | + * Build the shipping package for a single offer product. | |
| 1760 | + * | |
| 1761 | + * The destination falls back to the billing address because a virtual-only | |
| 1762 | + * parent order has no shipping address — WooCommerce never collects one when | |
| 1763 | + * the cart does not need shipping. | |
| 1764 | + * | |
| 1765 | + * @param \WC_Order $order Parent order. | |
| 1766 | + * @param \WC_Product $product Offer product. | |
| 1767 | + * @param float $total_price Offer line total. | |
| 1768 | + * @param int $qty Offer quantity. | |
| 1769 | + * | |
| 1770 | + * @return array | |
| 1771 | + * @since 1.0.0 | |
| 1772 | + */ | |
| 1773 | + private static function get_offer_shipping_package( $order, $product, $total_price, $qty ) { | |
| 1774 | + $qty = max( 1, (int) $qty ); | |
| 1775 | + $total_price = (float) $total_price; | |
| 1776 | + | |
| 1777 | + $destination = array( | |
| 1778 | + 'country' => $order->get_shipping_country() ? $order->get_shipping_country() : $order->get_billing_country(), | |
| 1779 | + 'state' => $order->get_shipping_state() ? $order->get_shipping_state() : $order->get_billing_state(), | |
| 1780 | + 'postcode' => $order->get_shipping_postcode() ? $order->get_shipping_postcode() : $order->get_billing_postcode(), | |
| 1781 | + 'city' => $order->get_shipping_city() ? $order->get_shipping_city() : $order->get_billing_city(), | |
| 1782 | + 'address' => $order->get_shipping_address_1() ? $order->get_shipping_address_1() : $order->get_billing_address_1(), | |
| 1783 | + 'address_1' => $order->get_shipping_address_1() ? $order->get_shipping_address_1() : $order->get_billing_address_1(), | |
| 1784 | + 'address_2' => $order->get_shipping_address_2() ? $order->get_shipping_address_2() : $order->get_billing_address_2(), | |
| 1785 | + ); | |
| 1786 | + | |
| 1787 | + return array( | |
| 1788 | + 'contents' => array( | |
| 1789 | + $product->get_id() => array( | |
| 1790 | + 'key' => $product->get_id(), | |
| 1791 | + 'product_id' => $product->get_id(), | |
| 1792 | + 'variation_id' => 0, | |
| 1793 | + 'variation' => array(), | |
| 1794 | + 'quantity' => $qty, | |
| 1795 | + 'data' => $product, | |
| 1796 | + 'line_total' => $total_price, | |
| 1797 | + 'line_subtotal' => $total_price, | |
| 1798 | + 'line_tax' => 0, | |
| 1799 | + 'line_subtotal_tax' => 0, | |
| 1800 | + ), | |
| 1801 | + ), | |
| 1802 | + 'contents_cost' => $total_price, | |
| 1803 | + 'applied_coupons' => array(), | |
| 1804 | + 'user' => array( 'ID' => $order->get_customer_id() ), | |
| 1805 | + 'destination' => $destination, | |
| 1806 | + 'cart_subtotal' => $total_price, | |
| 1807 | + ); | |
| 1808 | + } | |
| 1809 | + | |
| 1810 | + | |
| 1811 | + /** | |
| 1812 | + * Record derived offer shipping on an order as a real shipping line item. | |
| 1813 | + * | |
| 1814 | + * Passing empty shipping data is a no-op, so an offer that needs no shipping | |
| 1815 | + * leaves the order — including any shipping line it already carries — alone. | |
| 1816 | + * | |
| 1817 | + * @param \WC_Order $order Order to add the shipping line to. | |
| 1818 | + * @param array $shipping Shipping data from get_offer_shipping(). | |
| 1819 | + * @param string $offer_name Offer product name, recorded against the line. | |
| 1820 | + * @param int $qty Offer quantity. | |
| 1821 | + * | |
| 1822 | + * @return \WC_Order | |
| 1823 | + * @since 1.0.0 | |
| 1824 | + */ | |
| 1825 | + public static function add_offer_shipping_to_order( $order, $shipping, $offer_name = '', $qty = 1 ) { | |
| 1826 | + if ( ! $order instanceof \WC_Order || empty( $shipping ) || ! isset( $shipping['total'] ) ) { | |
| 1827 | + return $order; | |
| 1828 | + } | |
| 1829 | + | |
| 1830 | + $item = new \WC_Order_Item_Shipping(); | |
| 1831 | + $item->set_props( | |
| 1832 | + array( | |
| 1833 | + 'method_title' => ! empty( $shipping['method_title'] ) ? $shipping['method_title'] : __( 'Shipping', 'wpfnl' ), | |
| 1834 | + 'method_id' => isset( $shipping['method_id'] ) ? $shipping['method_id'] : '', | |
| 1835 | + 'instance_id' => isset( $shipping['instance_id'] ) ? $shipping['instance_id'] : '', | |
| 1836 | + 'total' => wc_format_decimal( $shipping['total'] ), | |
| 1837 | + 'taxes' => isset( $shipping['taxes'] ) && is_array( $shipping['taxes'] ) ? $shipping['taxes'] : array(), | |
| 1838 | + ) | |
| 1839 | + ); | |
| 1840 | + | |
| 1841 | + if ( $offer_name ) { | |
| 1842 | + $item->add_meta_data( 'Items', $offer_name . ' × ' . max( 1, (int) $qty ), true ); | |
| 1843 | + } | |
| 1844 | + | |
| 1845 | + $item->save(); | |
| 1846 | + $order->add_item( $item ); | |
| 1847 | + | |
| 1848 | + self::maybe_backfill_shipping_address( $order ); | |
| 1849 | + | |
| 1850 | + $order->calculate_shipping(); | |
| 1851 | + $order->calculate_totals(); | |
| 1852 | + $order->save(); | |
| 1853 | + | |
| 1854 | + return $order; | |
| 1855 | + } | |
| 1856 | + | |
| 1857 | + | |
| 1858 | + /** | |
| 1859 | + * Copy the billing address onto the shipping address when the order has none. | |
| 1860 | + * | |
| 1861 | + * WooCommerce skips the billing-to-shipping copy at checkout whenever the cart | |
| 1862 | + * does not need a shipping address, so an order that only becomes shippable | |
| 1863 | + * through an offer would otherwise reveal an empty address block in the admin | |
| 1864 | + * order summary. | |
| 1865 | + * | |
| 1866 | + * @param \WC_Order $order Order to back-fill. | |
| 1867 | + * | |
| 1868 | + * @return \WC_Order | |
| 1869 | + * @since 1.0.0 | |
| 1870 | + */ | |
| 1871 | + public static function maybe_backfill_shipping_address( $order ) { | |
| 1872 | + if ( ! $order instanceof \WC_Order || $order->has_shipping_address() ) { | |
| 1873 | + return $order; | |
| 1874 | + } | |
| 1875 | + | |
| 1876 | + $fields = array( 'first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'phone' ); | |
| 1877 | + | |
| 1878 | + foreach ( $fields as $field ) { | |
| 1879 | + $getter = 'get_billing_' . $field; | |
| 1880 | + $setter = 'set_shipping_' . $field; | |
| 1881 | + | |
| 1882 | + if ( ! is_callable( array( $order, $getter ) ) || ! is_callable( array( $order, $setter ) ) ) { | |
| 1883 | + continue; | |
| 1884 | + } | |
| 1885 | + | |
| 1886 | + $value = $order->{$getter}(); | |
| 1887 | + | |
| 1888 | + if ( '' !== $value && null !== $value ) { | |
| 1889 | + $order->{$setter}( $value ); | |
| 1890 | + } | |
| 1891 | + } | |
| 1892 | + | |
| 1893 | + return $order; | |
| 1894 | + } | |
| 1895 | + | |
| 1896 | + | |
| 1897 | + /** | |
| 1441 | 1898 | * Get GTM events |
| 1442 | 1899 | * |
| 1443 | 1900 | * @return array |
| 1444 | 1901 | */ |
| @@ -1579,8 +2036,109 @@ | ||
| 1579 | 2036 | } |
| 1580 | 2037 | |
| 1581 | 2038 | |
| 1582 | 2039 | /** |
| 2040 | + * Notification settings seeded into the database on a brand-new install. | |
| 2041 | + * | |
| 2042 | + * The store revenue report ships on for sites installed from 3.14.0 onward. | |
| 2043 | + * It is written in once, by Wpfnl_Activator, so the decision lives in the | |
| 2044 | + * database rather than in the fallback used by get_notification_settings(): | |
| 2045 | + * a site that upgrades has no stored value, keeps the disabled fallback, and | |
| 2046 | + * never starts mailing on its own. | |
| 2047 | + * | |
| 2048 | + * @return array | |
| 2049 | + * @since 3.14.0 | |
| 2050 | + */ | |
| 2051 | + public static function get_new_install_notification_settings() { | |
| 2052 | + return array( | |
| 2053 | + 'enable_revenue_report' => 'yes', | |
| 2054 | + 'revenue_report_frequency' => 'weekly', | |
| 2055 | + 'revenue_report_recipient' => get_option( 'admin_email' ), | |
| 2056 | + 'revenue_report_subject' => 'Store Revenue Report - {period}', | |
| 2057 | + 'send_time' => '10:00', | |
| 2058 | + ); | |
| 2059 | + } | |
| 2060 | + | |
| 2061 | + | |
| 2062 | + /** | |
| 2063 | + * Schedule the recurring revenue report email through Action Scheduler. | |
| 2064 | + * | |
| 2065 | + * Callers are responsible for clearing any existing schedule first; use | |
| 2066 | + * is_revenue_report_scheduled() when a duplicate must be avoided. | |
| 2067 | + * | |
| 2068 | + * @param array $settings Notification settings. | |
| 2069 | + * | |
| 2070 | + * @return void | |
| 2071 | + * @since 3.14.0 | |
| 2072 | + */ | |
| 2073 | + public static function schedule_revenue_report_email( $settings ) { | |
| 2074 | + if ( ! function_exists( 'as_schedule_recurring_action' ) ) { | |
| 2075 | + return; | |
| 2076 | + } | |
| 2077 | + | |
| 2078 | + // Calculate next run time based on send_time. | |
| 2079 | + $send_time = isset( $settings['send_time'] ) ? $settings['send_time'] : '10:00'; | |
| 2080 | + $frequency = isset( $settings['revenue_report_frequency'] ) ? $settings['revenue_report_frequency'] : 'weekly'; | |
| 2081 | + | |
| 2082 | + // Parse time (format: HH:MM). | |
| 2083 | + list( $hour, $minute ) = array_pad( explode( ':', $send_time ), 2, '0' ); | |
| 2084 | + | |
| 2085 | + // Get current time. | |
| 2086 | + $now = current_time( 'timestamp' ); | |
| 2087 | + $today = strtotime( date( 'Y-m-d', $now ) ); | |
| 2088 | + | |
| 2089 | + // Calculate first run time (today at specified time). | |
| 2090 | + $first_run = $today + ( $hour * HOUR_IN_SECONDS ) + ( $minute * MINUTE_IN_SECONDS ); | |
| 2091 | + | |
| 2092 | + // If the time has already passed today, schedule for tomorrow. | |
| 2093 | + if ( $first_run <= $now ) { | |
| 2094 | + $first_run += DAY_IN_SECONDS; | |
| 2095 | + } | |
| 2096 | + | |
| 2097 | + // Calculate interval based on frequency. | |
| 2098 | + $interval = ( 'monthly' === $frequency ) ? MONTH_IN_SECONDS : WEEK_IN_SECONDS; | |
| 2099 | + | |
| 2100 | + as_schedule_recurring_action( | |
| 2101 | + $first_run, | |
| 2102 | + $interval, | |
| 2103 | + self::REVENUE_REPORT_HOOK, | |
| 2104 | + array(), | |
| 2105 | + 'wpfunnels' | |
| 2106 | + ); | |
| 2107 | + } | |
| 2108 | + | |
| 2109 | + | |
| 2110 | + /** | |
| 2111 | + * Remove the recurring revenue report email schedule. | |
| 2112 | + * | |
| 2113 | + * @return void | |
| 2114 | + * @since 3.14.0 | |
| 2115 | + */ | |
| 2116 | + public static function unschedule_revenue_report_email() { | |
| 2117 | + if ( ! function_exists( 'as_unschedule_all_actions' ) ) { | |
| 2118 | + return; | |
| 2119 | + } | |
| 2120 | + | |
| 2121 | + as_unschedule_all_actions( self::REVENUE_REPORT_HOOK, array(), 'wpfunnels' ); | |
| 2122 | + } | |
| 2123 | + | |
| 2124 | + | |
| 2125 | + /** | |
| 2126 | + * Whether a revenue report email is already queued. | |
| 2127 | + * | |
| 2128 | + * @return bool | |
| 2129 | + * @since 3.14.0 | |
| 2130 | + */ | |
| 2131 | + public static function is_revenue_report_scheduled() { | |
| 2132 | + if ( ! function_exists( 'as_next_scheduled_action' ) ) { | |
| 2133 | + return false; | |
| 2134 | + } | |
| 2135 | + | |
| 2136 | + return (bool) as_next_scheduled_action( self::REVENUE_REPORT_HOOK, array(), 'wpfunnels' ); | |
| 2137 | + } | |
| 2138 | + | |
| 2139 | + | |
| 2140 | + /** | |
| 1583 | 2141 | * Get advanced settings |
| 1584 | 2142 | */ |
| 1585 | 2143 | public static function get_advanced_settings() { |
| 1586 | 2144 | $default_enable_settings = array( |
| @@ -1806,8 +2364,28 @@ | ||
| 1806 | 2364 | } |
| 1807 | 2365 | |
| 1808 | 2366 | |
| 1809 | 2367 | /** |
| 2368 | + * Where "edit this funnel" links go. | |
| 2369 | + * | |
| 2370 | + * The React canvas (`admin.php?page=wpfunnels#/funnels/{id}/canvas`) by | |
| 2371 | + * default, the classic `edit_funnel` screen on a site that turned the | |
| 2372 | + * redirect off. Every link generator should use this rather than spelling | |
| 2373 | + * out `page=edit_funnel`. | |
| 2374 | + * | |
| 2375 | + * @param int $funnel_id Funnel (post) id. | |
| 2376 | + * @param array $link Optional deep link into a step's drawer: | |
| 2377 | + * `step`, `open` (`settings`|`automation`), `tab`. | |
| 2378 | + * | |
| 2379 | + * @return string Absolute admin URL. | |
| 2380 | + * @since 3.13.8 | |
| 2381 | + */ | |
| 2382 | + public static function get_funnel_edit_url( $funnel_id, $link = array() ) { | |
| 2383 | + return \WPFunnels\Menu\Wpfnl_Menus::funnel_edit_url( $funnel_id, $link ); | |
| 2384 | + } | |
| 2385 | + | |
| 2386 | + | |
| 2387 | + /** | |
| 1810 | 2388 | * Check if pro is activated/deactivated |
| 1811 | 2389 | * |
| 1812 | 2390 | * @return bool |
| 1813 | 2391 | * @since 1.0.0 |
| @@ -3265,18 +3843,8 @@ | ||
| 3265 | 3843 | 'slug' => 'lead', |
| 3266 | 3844 | 'label' => 'Lead Gen Templates', |
| 3267 | 3845 | ); |
| 3268 | 3846 | |
| 3269 | - // If funnel type is set to lead only, show only lead templates | |
| 3270 | - // if ( isset( $general_settings['funnel_type'] ) && 'lead' === $general_settings['funnel_type'] ) { | |
| 3271 | - // $types = array( | |
| 3272 | - // array( | |
| 3273 | - // 'slug' => 'lead', | |
| 3274 | - // 'label' => 'Lead Gen Templates', | |
| 3275 | - // ), | |
| 3276 | - // ); | |
| 3277 | - // } | |
| 3278 | - | |
| 3279 | 3847 | return apply_filters( 'wpfunnels/modify_template_type', $types ); |
| 3280 | 3848 | |
| 3281 | 3849 | } |
| 3282 | 3850 | |
| @@ -3281,22 +3849,8 @@ | ||
| 3281 | 3849 | } |
| 3282 | 3850 | |
| 3283 | 3851 | |
| 3284 | 3852 | /** |
| 3285 | - * Get global funnel type | |
| 3286 | - * | |
| 3287 | - * @return Mix | |
| 3288 | - */ | |
| 3289 | - public static function get_global_funnel_type() { | |
| 3290 | - $general_settings = self::get_general_settings(); | |
| 3291 | - if ( isset( $general_settings['funnel_type'] ) ) { | |
| 3292 | - return $general_settings['funnel_type']; | |
| 3293 | - } | |
| 3294 | - return false; | |
| 3295 | - } | |
| 3296 | - | |
| 3297 | - | |
| 3298 | - /** | |
| 3299 | 3853 | * May be allow to create sales funnel |
| 3300 | 3854 | * |
| 3301 | 3855 | * @return Bool |
| 3302 | 3856 | */ |
| @@ -3402,8 +3956,60 @@ | ||
| 3402 | 3956 | } |
| 3403 | 3957 | |
| 3404 | 3958 | |
| 3405 | 3959 | /** |
| 3960 | + * Resolve a checkout step setting that builders expose under two different keys. | |
| 3961 | + * | |
| 3962 | + * Every builder persists these settings to the underscore-prefixed post meta | |
| 3963 | + * (`_wpfnl_checkout_layout`, `_wpfnl_floating_label`). The Gutenberg checkout | |
| 3964 | + * block instead publishes its live attributes through the | |
| 3965 | + * `wpfunnels/checkout_meta_{$key}` filter, keyed without the underscore, so that | |
| 3966 | + * the editor preview reflects unsaved changes. | |
| 3967 | + * | |
| 3968 | + * Read the filtered value first so the block editor keeps winning, then fall back | |
| 3969 | + * to the stored meta so Elementor, Bricks, Divi, Oxygen and shortcode steps — | |
| 3970 | + * which register no such filter — resolve to their real setting instead of the | |
| 3971 | + * default. | |
| 3972 | + * | |
| 3973 | + * @param int $checkout_id Checkout step ID. | |
| 3974 | + * @param string $key Unprefixed meta key, e.g. `wpfnl_checkout_layout`. | |
| 3975 | + * @param string $default Value to use when nothing is set anywhere. | |
| 3976 | + * | |
| 3977 | + * @return string | |
| 3978 | + * @since 3.13.2 | |
| 3979 | + */ | |
| 3980 | + public static function get_checkout_step_setting( $checkout_id, $key, $default = '' ) { | |
| 3981 | + if ( ! $checkout_id ) { | |
| 3982 | + return $default; | |
| 3983 | + } | |
| 3984 | + | |
| 3985 | + // Passing false as the default keeps get_checkout_meta_value() from | |
| 3986 | + // substituting one, so an empty result really means "no builder filter". | |
| 3987 | + $value = Wpfnl::get_instance()->meta->get_checkout_meta_value( $checkout_id, $key, false ); | |
| 3988 | + | |
| 3989 | + if ( ! $value ) { | |
| 3990 | + $value = get_post_meta( $checkout_id, '_' . $key, true ); | |
| 3991 | + } | |
| 3992 | + | |
| 3993 | + return $value ? $value : $default; | |
| 3994 | + } | |
| 3995 | + | |
| 3996 | + | |
| 3997 | + /** | |
| 3998 | + * Get the checkout layout for a step. | |
| 3999 | + * | |
| 4000 | + * @param int $checkout_id Checkout step ID. | |
| 4001 | + * @param string $default Layout to use when nothing is set. | |
| 4002 | + * | |
| 4003 | + * @return string | |
| 4004 | + * @since 3.13.2 | |
| 4005 | + */ | |
| 4006 | + public static function get_checkout_layout( $checkout_id, $default = 'wpfnl-col-2' ) { | |
| 4007 | + return self::get_checkout_step_setting( $checkout_id, 'wpfnl_checkout_layout', $default ); | |
| 4008 | + } | |
| 4009 | + | |
| 4010 | + | |
| 4011 | + /** | |
| 3406 | 4012 | * Check the checkout layout is express or not |
| 3407 | 4013 | * |
| 3408 | 4014 | * @return Bool |
| 3409 | 4015 | * @since 2.4.18 |
| @@ -3451,23 +4057,8 @@ | ||
| 3451 | 4057 | |
| 3452 | 4058 | |
| 3453 | 4059 | |
| 3454 | 4060 | /** |
| 3455 | - * Update funnel type to lead | |
| 3456 | - * | |
| 3457 | - * @since 2.5.1 | |
| 3458 | - */ | |
| 3459 | - public static function update_funnel_type_to_lead() { | |
| 3460 | - | |
| 3461 | - $saved_settings = self::get_admin_settings( '_wpfunnels_general_settings' ); | |
| 3462 | - if ( isset( $saved_settings['funnel_type'] ) ) { | |
| 3463 | - $saved_settings['funnel_type'] = 'lead'; | |
| 3464 | - update_option( '_wpfunnels_general_settings', $saved_settings ); | |
| 3465 | - } | |
| 3466 | - } | |
| 3467 | - | |
| 3468 | - | |
| 3469 | - /** | |
| 3470 | 4061 | * Delete template transients |
| 3471 | 4062 | * |
| 3472 | 4063 | * @since 2.5.1 |
| 3473 | 4064 | */ |
| @@ -3962,12 +4553,9 @@ | ||
| 3962 | 4553 | $order_comment = array( |
| 3963 | 4554 | 'type' => 'textarea', |
| 3964 | 4555 | 'class' => array( 'notes' ), |
| 3965 | 4556 | 'label' => __( 'Order notes', 'woocommerce' ), |
| 3966 | - 'placeholder' => esc_attr__( | |
| 3967 | - 'Notes about your order, e.g. special notes for delivery.', | |
| 3968 | - 'woocommerce' | |
| 3969 | - ), | |
| 4557 | + 'placeholder' => esc_attr__( 'Notes about your order', 'wpfnl' ), | |
| 3970 | 4558 | 'name' => 'order_comments', |
| 3971 | 4559 | 'required' => false, |
| 3972 | 4560 | 'enable' => 1, |
| 3973 | 4561 | 'show' => 1, |
| @@ -4195,11 +4783,351 @@ | ||
| 4195 | 4783 | return $all_steps_including_ab_ids; |
| 4196 | 4784 | } |
| 4197 | 4785 | |
| 4198 | 4786 | /** |
| 4787 | + * Records that the current session placed the given order. | |
| 4788 | + * | |
| 4789 | + * Call this immediately after a funnel order is created, so later offer | |
| 4790 | + * requests can prove ownership of that specific order. | |
| 4791 | + * | |
| 4792 | + * @param int $order_id The order the session just placed. | |
| 4793 | + * @return void | |
| 4794 | + * @since 3.12.14 | |
| 4795 | + */ | |
| 4796 | + public static function mark_order_owned_by_session( $order_id ) { | |
| 4797 | + $order_id = absint( $order_id ); | |
| 4798 | + | |
| 4799 | + if ( ! $order_id || ! self::is_wc_active() || ! WC()->session ) { | |
| 4800 | + return; | |
| 4801 | + } | |
| 4802 | + | |
| 4803 | + $owned = WC()->session->get( self::OWNED_ORDERS_SESSION_KEY, array() ); | |
| 4804 | + $owned = is_array( $owned ) ? array_map( 'absint', $owned ) : array(); | |
| 4805 | + | |
| 4806 | + if ( in_array( $order_id, $owned, true ) ) { | |
| 4807 | + return; | |
| 4808 | + } | |
| 4809 | + | |
| 4810 | + $owned[] = $order_id; | |
| 4811 | + | |
| 4812 | + // Keep the list bounded. This is written on every funnel checkout and a | |
| 4813 | + // session has no business owning an unbounded number of orders. | |
| 4814 | + if ( count( $owned ) > self::MAX_OWNED_ORDERS ) { | |
| 4815 | + $owned = array_slice( $owned, - self::MAX_OWNED_ORDERS ); | |
| 4816 | + } | |
| 4817 | + | |
| 4818 | + WC()->session->set( self::OWNED_ORDERS_SESSION_KEY, $owned ); | |
| 4819 | + } | |
| 4820 | + | |
| 4821 | + /** | |
| 4822 | + * Whether the current session placed the given order. | |
| 4823 | + * | |
| 4824 | + * @param int $order_id The order to check. | |
| 4825 | + * @return bool | |
| 4826 | + * @since 3.12.14 | |
| 4827 | + */ | |
| 4828 | + public static function session_owns_order( $order_id ) { | |
| 4829 | + $order_id = absint( $order_id ); | |
| 4830 | + | |
| 4831 | + if ( ! $order_id || ! self::is_wc_active() || ! WC()->session ) { | |
| 4832 | + return false; | |
| 4833 | + } | |
| 4834 | + | |
| 4835 | + $owned = WC()->session->get( self::OWNED_ORDERS_SESSION_KEY, array() ); | |
| 4836 | + | |
| 4837 | + if ( ! is_array( $owned ) ) { | |
| 4838 | + return false; | |
| 4839 | + } | |
| 4840 | + | |
| 4841 | + return in_array( $order_id, array_map( 'absint', $owned ), true ); | |
| 4842 | + } | |
| 4843 | + | |
| 4844 | + /** | |
| 4845 | + * Signs the privileged settings an opt-in form was rendered with. | |
| 4846 | + * | |
| 4847 | + * The notification recipient, its subject and the "register this visitor" | |
| 4848 | + * switch are rendered as hidden inputs, so they arrive back as request data | |
| 4849 | + * and cannot be trusted on their own. Signing them at render time lets the | |
| 4850 | + * submission handler tell its own configuration apart from a caller-supplied | |
| 4851 | + * one, without having to re-parse the page the form was placed on. | |
| 4852 | + * | |
| 4853 | + * @param string $admin_email Notification recipient list as configured. | |
| 4854 | + * @param string $subject Notification subject as configured. | |
| 4855 | + * @param string $allow_registration 'yes' when the form may create a user. | |
| 4856 | + * @return string | |
| 4857 | + * @since 3.12.14 | |
| 4858 | + */ | |
| 4859 | + public static function get_optin_settings_token( $admin_email, $subject, $allow_registration = '' ) { | |
| 4860 | + return wp_hash( 'wpfnl_optin_settings|' . $admin_email . '|' . $subject . '|' . $allow_registration ); | |
| 4861 | + } | |
| 4862 | + | |
| 4863 | + /** | |
| 4864 | + * Whether the opt-in settings submitted with a form are the ones it was rendered with. | |
| 4865 | + * | |
| 4866 | + * @param string $token Token supplied by the request. | |
| 4867 | + * @param string $admin_email Notification recipient list from the request. | |
| 4868 | + * @param string $subject Notification subject from the request. | |
| 4869 | + * @param string $allow_registration Registration switch from the request. | |
| 4870 | + * @return bool | |
| 4871 | + * @since 3.12.14 | |
| 4872 | + */ | |
| 4873 | + public static function is_valid_optin_settings_token( $token, $admin_email, $subject, $allow_registration = '' ) { | |
| 4874 | + if ( ! is_string( $token ) || '' === $token ) { | |
| 4875 | + return false; | |
| 4876 | + } | |
| 4877 | + | |
| 4878 | + return hash_equals( | |
| 4879 | + self::get_optin_settings_token( $admin_email, $subject, $allow_registration ), | |
| 4880 | + $token | |
| 4881 | + ); | |
| 4882 | + } | |
| 4883 | + | |
| 4884 | + /** | |
| 4885 | + * Resolves what an order bump is actually configured to sell. | |
| 4886 | + * | |
| 4887 | + * The bump handler runs for unauthenticated callers, so the discount must be | |
| 4888 | + * pinned to the product it was configured for. Without this, a bump's discount | |
| 4889 | + * can be borrowed from any step on the site and applied to any product. | |
| 4890 | + * | |
| 4891 | + * Settings are read through the same filters the checkout renderer uses, so a | |
| 4892 | + * dynamically resolved (auto-match) bump validates against the product that was | |
| 4893 | + * actually offered rather than the one stored in the builder. | |
| 4894 | + * | |
| 4895 | + * @param int $step_id Checkout step holding the bump settings. | |
| 4896 | + * @param mixed $key Which bump on that step. | |
| 4897 | + * @param int $product_id Product the request asked for. | |
| 4898 | + * @param int $variation_id Variation the request asked for, if any. | |
| 4899 | + * @param array $variation_data Attribute values the customer picked (attribute_* => value). Only used to fill in "Any" attributes. | |
| 4900 | + * @param bool $require_variation Whether a variable product must come with a purchasable, fully specified variation (true when adding the bump to the cart). | |
| 4901 | + * @return array|WP_Error product_id, variation_id, cart_product_id, quantity, variation_data and settings, or an error. | |
| 4902 | + * @since 3.12.14 | |
| 4903 | + */ | |
| 4904 | + public static function resolve_order_bump_item( $step_id, $key, $product_id, $variation_id = 0, $variation_data = array(), $require_variation = false ) { | |
| 4905 | + $step_id = absint( $step_id ); | |
| 4906 | + $product_id = absint( $product_id ); | |
| 4907 | + $variation_id = absint( $variation_id ); | |
| 4908 | + | |
| 4909 | + if ( ! self::is_wc_active() ) { | |
| 4910 | + return new \WP_Error( 'wpfnl_wc_inactive', __( 'WooCommerce is not active.', 'wpfnl' ) ); | |
| 4911 | + } | |
| 4912 | + | |
| 4913 | + // The bump must live on a real checkout step, not any post id the caller names. | |
| 4914 | + if ( ! $step_id || 'checkout' !== get_post_meta( $step_id, '_step_type', true ) ) { | |
| 4915 | + return new \WP_Error( 'wpfnl_invalid_step', __( 'Invalid checkout step.', 'wpfnl' ) ); | |
| 4916 | + } | |
| 4917 | + | |
| 4918 | + $funnel_id = self::get_funnel_id_from_step( $step_id ); | |
| 4919 | + $ob_settings = get_post_meta( $step_id, 'order-bump-settings', true ); | |
| 4920 | + $ob_settings = apply_filters( 'wpfunnels/order_bump_settings', $ob_settings, $funnel_id, $step_id ); | |
| 4921 | + $ob_settings = apply_filters( 'wpfunnels/order_bump_settings_on_frontend', $ob_settings, $funnel_id, $step_id ); | |
| 4922 | + | |
| 4923 | + if ( ! is_array( $ob_settings ) || ! isset( $ob_settings[ $key ] ) || ! is_array( $ob_settings[ $key ] ) ) { | |
| 4924 | + return new \WP_Error( 'wpfnl_invalid_bump', __( 'Invalid order bump.', 'wpfnl' ) ); | |
| 4925 | + } | |
| 4926 | + | |
| 4927 | + $settings = $ob_settings[ $key ]; | |
| 4928 | + | |
| 4929 | + if ( empty( $settings['product'] ) ) { | |
| 4930 | + return new \WP_Error( 'wpfnl_invalid_bump', __( 'This order bump has no product configured.', 'wpfnl' ) ); | |
| 4931 | + } | |
| 4932 | + | |
| 4933 | + $configured_id = absint( $settings['product'] ); | |
| 4934 | + | |
| 4935 | + $configured_product = wc_get_product( $configured_id ); | |
| 4936 | + | |
| 4937 | + if ( ! $configured_product ) { | |
| 4938 | + return new \WP_Error( 'wpfnl_invalid_product', __( 'The configured order bump product no longer exists.', 'wpfnl' ) ); | |
| 4939 | + } | |
| 4940 | + | |
| 4941 | + // The discount belongs to the configured product. Anything else is a | |
| 4942 | + // request to price one product using another product's offer. | |
| 4943 | + if ( $product_id !== $configured_id ) { | |
| 4944 | + return new \WP_Error( 'wpfnl_product_mismatch', __( 'This product is not part of the selected offer.', 'wpfnl' ) ); | |
| 4945 | + } | |
| 4946 | + | |
| 4947 | + if ( $variation_id ) { | |
| 4948 | + $variation = wc_get_product( $variation_id ); | |
| 4949 | + | |
| 4950 | + if ( ! $variation || $variation->get_parent_id() !== $configured_id ) { | |
| 4951 | + return new \WP_Error( 'wpfnl_invalid_variation', __( 'Please choose a valid variation for this offer.', 'wpfnl' ) ); | |
| 4952 | + } | |
| 4953 | + } | |
| 4954 | + | |
| 4955 | + // Quantity is the merchant's. Letting the request set it widens the | |
| 4956 | + // flat-discount cap, which is compared against price multiplied by quantity. | |
| 4957 | + $quantity = isset( $settings['quantity'] ) ? absint( $settings['quantity'] ) : 0; | |
| 4958 | + $quantity = max( 1, $quantity ); | |
| 4959 | + | |
| 4960 | + $resolved_attributes = array(); | |
| 4961 | + | |
| 4962 | + if ( $require_variation ) { | |
| 4963 | + $resolved = self::resolve_order_bump_variation( $configured_product, $variation_id, $variation_data, $quantity ); | |
| 4964 | + | |
| 4965 | + if ( is_wp_error( $resolved ) ) { | |
| 4966 | + return $resolved; | |
| 4967 | + } | |
| 4968 | + | |
| 4969 | + $resolved_attributes = $resolved; | |
| 4970 | + } | |
| 4971 | + | |
| 4972 | + return array( | |
| 4973 | + 'product_id' => $configured_id, | |
| 4974 | + 'variation_id' => $variation_id, | |
| 4975 | + 'cart_product_id' => $variation_id ? $variation_id : $configured_id, | |
| 4976 | + 'quantity' => $quantity, | |
| 4977 | + 'variation_data' => $resolved_attributes, | |
| 4978 | + ); | |
| 4979 | + } | |
| 4980 | + | |
| 4981 | + /** | |
| 4982 | + * Whether an order bump offers a variable product the customer has to pick a variation of. | |
| 4983 | + * | |
| 4984 | + * Read from the product itself rather than a flag saved with the bump, so a | |
| 4985 | + * product that was changed to or from variable after the bump was saved is | |
| 4986 | + * still handled correctly. | |
| 4987 | + * | |
| 4988 | + * @param array $settings Order bump settings. | |
| 4989 | + * @return bool | |
| 4990 | + * @since 3.13.5 | |
| 4991 | + */ | |
| 4992 | + public static function is_variable_order_bump( $settings ) { | |
| 4993 | + if ( empty( $settings['product'] ) || ! self::is_wc_active() ) { | |
| 4994 | + return false; | |
| 4995 | + } | |
| 4996 | + | |
| 4997 | + $product = wc_get_product( absint( $settings['product'] ) ); | |
| 4998 | + | |
| 4999 | + return $product && $product->is_type( array( 'variable', 'variable-subscription' ) ); | |
| 5000 | + } | |
| 5001 | + | |
| 5002 | + /** | |
| 5003 | + * Checks that a variable order bump is added as a purchasable, fully specified variation. | |
| 5004 | + * | |
| 5005 | + * A variable parent cannot be added to the cart on its own, so the customer has to have | |
| 5006 | + * picked a variation. A variation that leaves an attribute on "Any" also needs the value | |
| 5007 | + * the customer chose for it, which WooCommerce validates again when adding to the cart. | |
| 5008 | + * | |
| 5009 | + * @param \WC_Product $configured_product The product configured on the bump. | |
| 5010 | + * @param int $variation_id Variation the request asked for, if any. | |
| 5011 | + * @param array $variation_data Attribute values the customer picked (attribute_* => value). | |
| 5012 | + * @param int $quantity Quantity the merchant configured. | |
| 5013 | + * @return array|\WP_Error Attributes to add the variation with (empty for a simple product), or an error. | |
| 5014 | + * @since 3.13.5 | |
| 5015 | + */ | |
| 5016 | + public static function resolve_order_bump_variation( $configured_product, $variation_id, $variation_data, $quantity = 1 ) { | |
| 5017 | + if ( $configured_product->is_type( 'variation' ) ) { | |
| 5018 | + $variation = $configured_product; | |
| 5019 | + } elseif ( $configured_product->is_type( array( 'variable', 'variable-subscription' ) ) ) { | |
| 5020 | + if ( ! $variation_id ) { | |
| 5021 | + return new \WP_Error( 'wpfnl_variation_required', __( 'Please choose an option for this offer.', 'wpfnl' ) ); | |
| 5022 | + } | |
| 5023 | + | |
| 5024 | + $variation = wc_get_product( $variation_id ); | |
| 5025 | + } else { | |
| 5026 | + return array(); | |
| 5027 | + } | |
| 5028 | + | |
| 5029 | + if ( ! $variation || ! $variation->is_purchasable() || ! $variation->is_in_stock() || ! $variation->has_enough_stock( $quantity ) ) { | |
| 5030 | + return new \WP_Error( 'wpfnl_variation_unavailable', __( 'This option is not available right now.', 'wpfnl' ) ); | |
| 5031 | + } | |
| 5032 | + | |
| 5033 | + $attributes = $variation->get_variation_attributes(); | |
| 5034 | + $submitted = is_array( $variation_data ) ? $variation_data : array(); | |
| 5035 | + | |
| 5036 | + foreach ( $attributes as $attribute_key => $attribute_value ) { | |
| 5037 | + // The variation fixes this attribute itself; only "Any" attributes take the customer's value. | |
| 5038 | + if ( '' !== $attribute_value ) { | |
| 5039 | + continue; | |
| 5040 | + } | |
| 5041 | + | |
| 5042 | + $chosen = isset( $submitted[ $attribute_key ] ) ? wc_clean( $submitted[ $attribute_key ] ) : ''; | |
| 5043 | + | |
| 5044 | + if ( '' === $chosen ) { | |
| 5045 | + return new \WP_Error( 'wpfnl_variation_attributes', __( 'Please choose an option for this offer.', 'wpfnl' ) ); | |
| 5046 | + } | |
| 5047 | + | |
| 5048 | + $attributes[ $attribute_key ] = $chosen; | |
| 5049 | + } | |
| 5050 | + | |
| 5051 | + return $attributes; | |
| 5052 | + } | |
| 5053 | + | |
| 5054 | + /** | |
| 5055 | + * Resolves what an offer step is actually configured to sell. | |
| 5056 | + * | |
| 5057 | + * The offer cart handler runs for unauthenticated callers, so nothing about | |
| 5058 | + * the item may be taken from the request: the product and the quantity come | |
| 5059 | + * from the step's own configuration. The only choice the request gets is | |
| 5060 | + * which variation of that configured product to buy. | |
| 5061 | + * | |
| 5062 | + * @param int $step_id The offer step being purchased. | |
| 5063 | + * @param int $candidate_variation_id Variation the request selected, if any. | |
| 5064 | + * @return array|WP_Error product_id, variation_id and quantity, or an error. | |
| 5065 | + * @since 3.12.14 | |
| 5066 | + */ | |
| 5067 | + public static function resolve_offer_cart_item( $step_id, $candidate_variation_id = 0 ) { | |
| 5068 | + $step_id = absint( $step_id ); | |
| 5069 | + | |
| 5070 | + if ( ! self::is_wc_active() ) { | |
| 5071 | + return new \WP_Error( 'wpfnl_wc_inactive', __( 'WooCommerce is not active.', 'wpfnl' ) ); | |
| 5072 | + } | |
| 5073 | + | |
| 5074 | + if ( ! $step_id ) { | |
| 5075 | + return new \WP_Error( 'wpfnl_invalid_step', __( 'Invalid offer step.', 'wpfnl' ) ); | |
| 5076 | + } | |
| 5077 | + | |
| 5078 | + // Only a real upsell/downsell step may drive this. Without it, any post id | |
| 5079 | + // could be used to borrow another step's discount configuration. | |
| 5080 | + $step_type = get_post_meta( $step_id, '_step_type', true ); | |
| 5081 | + | |
| 5082 | + if ( ! in_array( $step_type, array( 'upsell', 'downsell' ), true ) ) { | |
| 5083 | + return new \WP_Error( 'wpfnl_invalid_step', __( 'Invalid offer step.', 'wpfnl' ) ); | |
| 5084 | + } | |
| 5085 | + | |
| 5086 | + $configured = get_post_meta( $step_id, '_wpfnl_' . $step_type . '_products', true ); | |
| 5087 | + | |
| 5088 | + if ( ! is_array( $configured ) || empty( $configured[0]['id'] ) ) { | |
| 5089 | + return new \WP_Error( 'wpfnl_no_offer_product', __( 'This offer has no product configured.', 'wpfnl' ) ); | |
| 5090 | + } | |
| 5091 | + | |
| 5092 | + $product_id = absint( $configured[0]['id'] ); | |
| 5093 | + $product = wc_get_product( $product_id ); | |
| 5094 | + | |
| 5095 | + if ( ! $product ) { | |
| 5096 | + return new \WP_Error( 'wpfnl_invalid_product', __( 'The configured offer product no longer exists.', 'wpfnl' ) ); | |
| 5097 | + } | |
| 5098 | + | |
| 5099 | + // Quantity is the merchant's, not the caller's. Offer pages expose no | |
| 5100 | + // quantity control, and letting the request set it turns the discount | |
| 5101 | + // guard (which compares against the line total) into a lever. | |
| 5102 | + $quantity = isset( $configured[0]['quantity'] ) ? absint( $configured[0]['quantity'] ) : 0; | |
| 5103 | + $quantity = max( 1, $quantity ); | |
| 5104 | + | |
| 5105 | + $variation_id = absint( $candidate_variation_id ); | |
| 5106 | + | |
| 5107 | + if ( $variation_id ) { | |
| 5108 | + $variation = wc_get_product( $variation_id ); | |
| 5109 | + | |
| 5110 | + if ( ! $variation || $variation->get_parent_id() !== $product_id ) { | |
| 5111 | + return new \WP_Error( 'wpfnl_invalid_variation', __( 'Please choose a valid variation for this offer.', 'wpfnl' ) ); | |
| 5112 | + } | |
| 5113 | + } | |
| 5114 | + | |
| 5115 | + return array( | |
| 5116 | + 'product_id' => $product_id, | |
| 5117 | + 'variation_id' => $variation_id, | |
| 5118 | + 'quantity' => $quantity, | |
| 5119 | + ); | |
| 5120 | + } | |
| 5121 | + | |
| 5122 | + /** | |
| 4199 | 5123 | * Validates the order owner's authorization. |
| 4200 | 5124 | * |
| 4201 | - * @param WC_Order $order The order object to validate. | |
| 5125 | + * Every caller that resolves an order from a request-supplied id must pass | |
| 5126 | + * through here before acting on that order. | |
| 5127 | + * | |
| 5128 | + * @param WC_Order $order The order object to validate. | |
| 5129 | + * @param string $order_key Order key supplied by the request, if any. | |
| 4202 | 5130 | * @return bool |
| 4203 | 5131 | * @since 2.7.13 |
| 4204 | 5132 | */ |
| 4205 | 5133 | public static function is_valid_order_owner( $order, $order_key = '' ) { |
| @@ -4215,26 +5143,19 @@ | ||
| 4215 | 5143 | if ( ! empty( $order_key ) && $order->key_is_valid( $order_key ) ) { |
| 4216 | 5144 | return true; |
| 4217 | 5145 | } |
| 4218 | 5146 | |
| 4219 | - // If it's not a guest order, current user can pay but only if it's their own order, | |
| 4220 | - // or they are an admin or shop manager or they are in a same session(for guest). | |
| 4221 | - | |
| 4222 | - $order_has_customer = ! empty( $order->get_customer_id() ); | |
| 4223 | - $is_authorized_owner = false; | |
| 4224 | - | |
| 4225 | - $has_owner = ! empty( WC()->session->get( 'wpfnl_order_owner' ) ); | |
| 4226 | - | |
| 4227 | - if ( $order_has_customer ) { | |
| 4228 | - $is_order_owned_by_current_user = $order->get_customer_id() === get_current_user_id(); | |
| 4229 | - | |
| 4230 | - $is_authorized_owner = $is_order_owned_by_current_user || current_user_can( 'manage_woocommerce' ); | |
| 5147 | + // A logged-in customer may act on their own order; so may a shop manager. | |
| 5148 | + if ( ! empty( $order->get_customer_id() ) ) { | |
| 5149 | + if ( $order->get_customer_id() === get_current_user_id() || current_user_can( 'manage_woocommerce' ) ) { | |
| 5150 | + return true; | |
| 5151 | + } | |
| 4231 | 5152 | } |
| 4232 | 5153 | |
| 4233 | - if ( ! $is_authorized_owner && ! $has_owner ) { | |
| 4234 | - return false; | |
| 4235 | - } | |
| 4236 | - return true; | |
| 5154 | + // Guest flows: this session must have placed this specific order. Note the | |
| 5155 | + // check is against the order id, not a bare "has checked out" marker, so a | |
| 5156 | + // session cannot authorise an order it did not create. | |
| 5157 | + return self::session_owns_order( $order->get_id() ); | |
| 4237 | 5158 | } |
| 4238 | 5159 | |
| 4239 | 5160 | |
| 4240 | 5161 | /** |
| @@ -4795,8 +5716,24 @@ | ||
| 4795 | 5716 | */ |
| 4796 | 5717 | public static function get_text() { |
| 4797 | 5718 | return array( |
| 4798 | 5719 | 'error_occurred' => __( 'Error Occurred!', 'wpfnl' ), |
| 5720 | + | |
| 5721 | + // Funnel import progress. Read by admin/assets/js/wpfnl-admin.js, which predates | |
| 5722 | + // the Vue bundles and has no wp.i18n of its own. | |
| 5723 | + 'import_preparing' => __( 'Preparing import…', 'wpfnl' ), | |
| 5724 | + 'import_in_progress' => __( 'Importing funnels…', 'wpfnl' ), | |
| 5725 | + 'import_completed' => __( 'Import complete.', 'wpfnl' ), | |
| 5726 | + 'import_failed' => __( 'The import could not be completed.', 'wpfnl' ), | |
| 5727 | + 'import_failed_item' => __( 'failed', 'wpfnl' ), | |
| 5728 | + 'import_partial' => __( 'some steps failed', 'wpfnl' ), | |
| 5729 | + 'import_funnels_word' => __( 'funnels', 'wpfnl' ), | |
| 5730 | + 'import_steps_word' => __( 'steps', 'wpfnl' ), | |
| 5731 | + 'import_stalled' => __( 'The import seems to have stopped responding. Cancel it to try again.', 'wpfnl' ), | |
| 5732 | + 'import_taking_long' => __( 'This import is taking longer than expected. Refresh the page to check on it.', 'wpfnl' ), | |
| 5733 | + 'import_status_unavailable' => __( 'Lost contact with the import. Refresh the page to check on it.', 'wpfnl' ), | |
| 5734 | + 'import_cancelled' => __( 'The import has been cancelled.', 'wpfnl' ), | |
| 5735 | + 'import_cancel_failed' => __( 'Could not cancel the import.', 'wpfnl' ), | |
| 4799 | 5736 | 'stats' => __( 'Stats', 'wpfnl' ), |
| 4800 | 5737 | 'stats_tooltip_txt' => __( 'Toggle to get a quick view of visits & conversion of steps right on the canvas.', 'wpfnl' ), |
| 4801 | 5738 | 'find_your_templates' => __( 'Find your templates', 'wpfnl' ), |
| 4802 | 5739 | 'enable_global_funnel' => __( 'Enable Global Funnel', 'wpfnl' ), |
| @@ -5081,8 +6018,11 @@ | ||
| 5081 | 6018 | 'do_you_want_to_save_automation' => __( 'Do you want to save the automation?', 'wpfnl' ), |
| 5082 | 6019 | 'no_back' => __( 'No & Back', 'wpfnl' ), |
| 5083 | 6020 | 'yes_back' => __( 'Yes & Back', 'wpfnl' ), |
| 5084 | 6021 | 'saving' => __( 'Saving...', 'wpfnl' ), |
| 6022 | + 'unsaved_changes' => __( 'Unsaved changes', 'wpfnl' ), | |
| 6023 | + 'automation_save_failed' => __( 'Could not save your changes', 'wpfnl' ), | |
| 6024 | + 'remove_automation_step_confirm' => __( 'Remove this step from the automation?', 'wpfnl' ), | |
| 5085 | 6025 | |
| 5086 | 6026 | 'choose_list' => __( 'Choose List', 'wpfnl' ), |
| 5087 | 6027 | 'select_lists' => __( 'Select Lists', 'wpfnl' ), |
| 5088 | 6028 | 'search_list' => __( 'Search List', 'wpfnl' ), |
| @@ -5545,9 +6485,19 @@ | ||
| 5545 | 6485 | 'views' => __('Views', 'wpfnl'), |
| 5546 | 6486 | 'conversion' => __('Conversion', 'wpfnl'), |
| 5547 | 6487 | 'conversion_rate' => __('Conversion Rate', 'wpfnl'), |
| 5548 | 6488 | 'no_top_performing_funnels' => __('You have no top performing funnels', 'wpfnl'), |
| 6489 | + 'view_all' => __('View all', 'wpfnl'), | |
| 5549 | 6490 | 'today' => __('Today', 'wpfnl'), |
| 6491 | + 'yesterday' => __('Yesterday', 'wpfnl'), | |
| 6492 | + 'this_week' => __('This Week', 'wpfnl'), | |
| 6493 | + 'last_week' => __('Last Week', 'wpfnl'), | |
| 6494 | + 'this_month' => __('This Month', 'wpfnl'), | |
| 6495 | + 'last_month' => __('Last Month', 'wpfnl'), | |
| 6496 | + 'previous_month' => __('Previous month', 'wpfnl'), | |
| 6497 | + 'next_month' => __('Next month', 'wpfnl'), | |
| 6498 | + 'cancel' => __('Cancel', 'wpfnl'), | |
| 6499 | + 'apply' => __('Apply', 'wpfnl'), | |
| 5550 | 6500 | 'week_to_date' => __('Week to date', 'wpfnl'), |
| 5551 | 6501 | 'month_to_date' => __('Month to date', 'wpfnl'), |
| 5552 | 6502 | 'year_to_date' => __('Year to date', 'wpfnl'), |
| 5553 | 6503 | 'previous_period' => __('Previous period', 'wpfnl'), |
| @@ -6526,8 +7476,79 @@ | ||
| 6526 | 7476 | } |
| 6527 | 7477 | } |
| 6528 | 7478 | } |
| 6529 | 7479 | return $custom_price; |
| 7480 | + } | |
| 7481 | + | |
| 7482 | + /** | |
| 7483 | + * Enqueue the stylesheet a production webpack build extracted for a bundle. | |
| 7484 | + * | |
| 7485 | + * A production build (`npm run build`) extracts every entry's CSS to | |
| 7486 | + * admin/assets/dist/styles/<entry>.<contenthash>.css instead of injecting it | |
| 7487 | + * from JS, so each bundle needs its stylesheet enqueued alongside its script | |
| 7488 | + * or the screen renders unstyled. The filename carries a content hash, hence | |
| 7489 | + * the glob. | |
| 7490 | + * | |
| 7491 | + * A development build (`npm run start`) injects styles at runtime and emits | |
| 7492 | + * no stylesheet at all — nothing matches the glob and this is a no-op, which | |
| 7493 | + * is exactly what a dev build needs. | |
| 7494 | + * | |
| 7495 | + * @param string $entry Webpack entry name, e.g. `automation-canvas`. | |
| 7496 | + * @param string $handle Style handle to register it under. | |
| 7497 | + * @param array $deps Style handles this stylesheet must load after. Only | |
| 7498 | + * pass handles guaranteed to be registered on the same | |
| 7499 | + * screen — WordPress silently drops a style whose | |
| 7500 | + * dependency is missing. | |
| 7501 | + * | |
| 7502 | + * @return void | |
| 7503 | + * @since 3.13.0 | |
| 7504 | + */ | |
| 7505 | + public static function enqueue_bundle_style( $entry, $handle, $deps = array() ) { | |
| 7506 | + $url = self::get_bundle_style_url( $entry ); | |
| 7507 | + | |
| 7508 | + if ( ! $url ) { | |
| 7509 | + return; | |
| 7510 | + } | |
| 7511 | + | |
| 7512 | + wp_enqueue_style( $handle, $url, $deps, WPFNL_VERSION ); | |
| 7513 | + } | |
| 7514 | + | |
| 7515 | + | |
| 7516 | + /** | |
| 7517 | + * URL of a webpack entry's extracted stylesheet, if there is one. | |
| 7518 | + * | |
| 7519 | + * A development build injects its styles from JavaScript and emits no | |
| 7520 | + * stylesheet at all, so an empty string here means "nothing to load", not | |
| 7521 | + * "something went wrong". | |
| 7522 | + * | |
| 7523 | + * @param string $entry Webpack entry name. | |
| 7524 | + * | |
| 7525 | + * @return string Stylesheet URL, or '' when the build emitted none. | |
| 7526 | + * @since 3.13.8 | |
| 7527 | + */ | |
| 7528 | + public static function get_bundle_style_url( $entry ) { | |
| 7529 | + $styles_dir = WPFNL_DIR . '/admin/assets/dist/styles/'; | |
| 7530 | + | |
| 7531 | + if ( ! is_dir( $styles_dir ) ) { | |
| 7532 | + return ''; | |
| 7533 | + } | |
| 7534 | + | |
| 7535 | + $files = glob( $styles_dir . $entry . '.*.css' ); | |
| 7536 | + | |
| 7537 | + if ( empty( $files ) ) { | |
| 7538 | + return ''; | |
| 7539 | + } | |
| 7540 | + | |
| 7541 | + // Newest wins, so a stale hash left behind by an interrupted build never | |
| 7542 | + // shadows the current one. | |
| 7543 | + usort( | |
| 7544 | + $files, | |
| 7545 | + function ( $a, $b ) { | |
| 7546 | + return filemtime( $b ) - filemtime( $a ); | |
| 7547 | + } | |
| 7548 | + ); | |
| 7549 | + | |
| 7550 | + return WPFNL_URL . 'admin/assets/dist/styles/' . basename( $files[0] ); | |
| 6530 | 7551 | } |
| 6531 | 7552 | |
| 6532 | 7553 | /** |
| 6533 | 7554 | * Helper method to return the item description, which is composed of item |