| @@ -52,8 +52,15 @@ | ||
| 52 | 52 | */ |
| 53 | 53 | const MAX_OWNED_ORDERS = 50; |
| 54 | 54 | |
| 55 | 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 | + /** | |
| 56 | 63 | * Post meta keys that must not travel between sites on funnel export/import. |
| 57 | 64 | * |
| 58 | 65 | * These hold per-site render caches or bookkeeping that is only meaningful on the site |
| 59 | 66 | * that produced them. Carrying them into a new site makes the imported step inherit the |
| @@ -230,26 +237,89 @@ | ||
| 230 | 237 | * |
| 231 | 238 | * @return bool|int |
| 232 | 239 | */ |
| 233 | 240 | public static function get_checkout_id_from_post( $post = null ) { |
| 234 | - if ( is_array( $post ) && ! empty( $post['_wpfunnels_checkout_id'] ) ) { | |
| 235 | - $checkout_id = filter_var( wp_unslash( $post['_wpfunnels_checkout_id'] ), FILTER_SANITIZE_NUMBER_INT ); | |
| 236 | - return $checkout_id ? intval( $checkout_id ) : false; | |
| 241 | + if ( null === $post && ! empty( $_POST ) ) { | |
| 242 | + $post = $_POST; | |
| 237 | 243 | } |
| 238 | 244 | |
| 239 | - if ( is_array( $post ) && isset( $post['post_data'] ) ) { | |
| 240 | - $post_data = array(); | |
| 241 | - wp_parse_str( wp_unslash( $post['post_data'] ), $post_data ); | |
| 242 | - if ( ! empty( $post_data['_wpfunnels_checkout_id'] ) ) { | |
| 243 | - $checkout_id = filter_var( $post_data['_wpfunnels_checkout_id'], FILTER_SANITIZE_NUMBER_INT ); | |
| 244 | - 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 | + } | |
| 245 | 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 | + } | |
| 246 | 287 | } |
| 288 | + | |
| 247 | 289 | return false; |
| 248 | 290 | } |
| 249 | 291 | |
| 250 | 292 | |
| 251 | 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 | + /** | |
| 252 | 322 | * Check order bump clicked or not from post data |
| 253 | 323 | * |
| 254 | 324 | * @return bool True|False |
| 255 | 325 | * @since 2.8.0 |
| @@ -310,8 +380,25 @@ | ||
| 310 | 380 | } |
| 311 | 381 | |
| 312 | 382 | |
| 313 | 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 | + /** | |
| 314 | 401 | * Check product option is selected or not from post data |
| 315 | 402 | * |
| 316 | 403 | * @return bool True|False |
| 317 | 404 | * @since 2.8.0 |
| @@ -651,8 +738,23 @@ | ||
| 651 | 738 | 'id' => $post_data['_wpfunnels_checkout_id'], |
| 652 | 739 | ); |
| 653 | 740 | } |
| 654 | 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 | + | |
| 655 | 757 | return array( |
| 656 | 758 | 'status' => false, |
| 657 | 759 | 'id' => '', |
| 658 | 760 | ); |
| @@ -1331,9 +1433,8 @@ | ||
| 1331 | 1433 | 'paypal_reference' => 'off', |
| 1332 | 1434 | 'order_bump' => 'off', |
| 1333 | 1435 | 'ab_testing' => 'off', |
| 1334 | 1436 | 'allow_funnels' => array( 'administrator' => 'true' ), |
| 1335 | - 'funnel_type' => 'sales', | |
| 1336 | 1437 | 'create_child_order' => 'off', |
| 1337 | 1438 | 'disable_theme_style' => 'off', |
| 1338 | 1439 | 'enable_log_status' => 'off', |
| 1339 | 1440 | 'enable_skip_cart' => 'off', |
| @@ -1458,8 +1559,342 @@ | ||
| 1458 | 1559 | return wp_parse_args( $saved_settings, $default_settings ); |
| 1459 | 1560 | } |
| 1460 | 1561 | |
| 1461 | 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 | + /** | |
| 1462 | 1897 | * Get GTM events |
| 1463 | 1898 | * |
| 1464 | 1899 | * @return array |
| 1465 | 1900 | */ |
| @@ -1600,8 +2035,109 @@ | ||
| 1600 | 2035 | } |
| 1601 | 2036 | |
| 1602 | 2037 | |
| 1603 | 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 | + /** | |
| 1604 | 2140 | * Get advanced settings |
| 1605 | 2141 | */ |
| 1606 | 2142 | public static function get_advanced_settings() { |
| 1607 | 2143 | $default_enable_settings = array( |
| @@ -3286,18 +3822,8 @@ | ||
| 3286 | 3822 | 'slug' => 'lead', |
| 3287 | 3823 | 'label' => 'Lead Gen Templates', |
| 3288 | 3824 | ); |
| 3289 | 3825 | |
| 3290 | - // If funnel type is set to lead only, show only lead templates | |
| 3291 | - // if ( isset( $general_settings['funnel_type'] ) && 'lead' === $general_settings['funnel_type'] ) { | |
| 3292 | - // $types = array( | |
| 3293 | - // array( | |
| 3294 | - // 'slug' => 'lead', | |
| 3295 | - // 'label' => 'Lead Gen Templates', | |
| 3296 | - // ), | |
| 3297 | - // ); | |
| 3298 | - // } | |
| 3299 | - | |
| 3300 | 3826 | return apply_filters( 'wpfunnels/modify_template_type', $types ); |
| 3301 | 3827 | |
| 3302 | 3828 | } |
| 3303 | 3829 | |
| @@ -3302,22 +3828,8 @@ | ||
| 3302 | 3828 | } |
| 3303 | 3829 | |
| 3304 | 3830 | |
| 3305 | 3831 | /** |
| 3306 | - * Get global funnel type | |
| 3307 | - * | |
| 3308 | - * @return Mix | |
| 3309 | - */ | |
| 3310 | - public static function get_global_funnel_type() { | |
| 3311 | - $general_settings = self::get_general_settings(); | |
| 3312 | - if ( isset( $general_settings['funnel_type'] ) ) { | |
| 3313 | - return $general_settings['funnel_type']; | |
| 3314 | - } | |
| 3315 | - return false; | |
| 3316 | - } | |
| 3317 | - | |
| 3318 | - | |
| 3319 | - /** | |
| 3320 | 3832 | * May be allow to create sales funnel |
| 3321 | 3833 | * |
| 3322 | 3834 | * @return Bool |
| 3323 | 3835 | */ |
| @@ -3423,8 +3935,60 @@ | ||
| 3423 | 3935 | } |
| 3424 | 3936 | |
| 3425 | 3937 | |
| 3426 | 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 | + /** | |
| 3427 | 3991 | * Check the checkout layout is express or not |
| 3428 | 3992 | * |
| 3429 | 3993 | * @return Bool |
| 3430 | 3994 | * @since 2.4.18 |
| @@ -3472,23 +4036,8 @@ | ||
| 3472 | 4036 | |
| 3473 | 4037 | |
| 3474 | 4038 | |
| 3475 | 4039 | /** |
| 3476 | - * Update funnel type to lead | |
| 3477 | - * | |
| 3478 | - * @since 2.5.1 | |
| 3479 | - */ | |
| 3480 | - public static function update_funnel_type_to_lead() { | |
| 3481 | - | |
| 3482 | - $saved_settings = self::get_admin_settings( '_wpfunnels_general_settings' ); | |
| 3483 | - if ( isset( $saved_settings['funnel_type'] ) ) { | |
| 3484 | - $saved_settings['funnel_type'] = 'lead'; | |
| 3485 | - update_option( '_wpfunnels_general_settings', $saved_settings ); | |
| 3486 | - } | |
| 3487 | - } | |
| 3488 | - | |
| 3489 | - | |
| 3490 | - /** | |
| 3491 | 4040 | * Delete template transients |
| 3492 | 4041 | * |
| 3493 | 4042 | * @since 2.5.1 |
| 3494 | 4043 | */ |
| @@ -3983,12 +4532,9 @@ | ||
| 3983 | 4532 | $order_comment = array( |
| 3984 | 4533 | 'type' => 'textarea', |
| 3985 | 4534 | 'class' => array( 'notes' ), |
| 3986 | 4535 | 'label' => __( 'Order notes', 'woocommerce' ), |
| 3987 | - 'placeholder' => esc_attr__( | |
| 3988 | - 'Notes about your order, e.g. special notes for delivery.', | |
| 3989 | - 'woocommerce' | |
| 3990 | - ), | |
| 4536 | + 'placeholder' => esc_attr__( 'Notes about your order', 'wpfnl' ), | |
| 3991 | 4537 | 'name' => 'order_comments', |
| 3992 | 4538 | 'required' => false, |
| 3993 | 4539 | 'enable' => 1, |
| 3994 | 4540 | 'show' => 1, |
| @@ -5059,8 +5605,24 @@ | ||
| 5059 | 5605 | */ |
| 5060 | 5606 | public static function get_text() { |
| 5061 | 5607 | return array( |
| 5062 | 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' ), | |
| 5063 | 5625 | 'stats' => __( 'Stats', 'wpfnl' ), |
| 5064 | 5626 | 'stats_tooltip_txt' => __( 'Toggle to get a quick view of visits & conversion of steps right on the canvas.', 'wpfnl' ), |
| 5065 | 5627 | 'find_your_templates' => __( 'Find your templates', 'wpfnl' ), |
| 5066 | 5628 | 'enable_global_funnel' => __( 'Enable Global Funnel', 'wpfnl' ), |
| @@ -5345,8 +5907,11 @@ | ||
| 5345 | 5907 | 'do_you_want_to_save_automation' => __( 'Do you want to save the automation?', 'wpfnl' ), |
| 5346 | 5908 | 'no_back' => __( 'No & Back', 'wpfnl' ), |
| 5347 | 5909 | 'yes_back' => __( 'Yes & Back', 'wpfnl' ), |
| 5348 | 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' ), | |
| 5349 | 5914 | |
| 5350 | 5915 | 'choose_list' => __( 'Choose List', 'wpfnl' ), |
| 5351 | 5916 | 'select_lists' => __( 'Select Lists', 'wpfnl' ), |
| 5352 | 5917 | 'search_list' => __( 'Search List', 'wpfnl' ), |
| @@ -5809,9 +6374,19 @@ | ||
| 5809 | 6374 | 'views' => __('Views', 'wpfnl'), |
| 5810 | 6375 | 'conversion' => __('Conversion', 'wpfnl'), |
| 5811 | 6376 | 'conversion_rate' => __('Conversion Rate', 'wpfnl'), |
| 5812 | 6377 | 'no_top_performing_funnels' => __('You have no top performing funnels', 'wpfnl'), |
| 6378 | + 'view_all' => __('View all', 'wpfnl'), | |
| 5813 | 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'), | |
| 5814 | 6389 | 'week_to_date' => __('Week to date', 'wpfnl'), |
| 5815 | 6390 | 'month_to_date' => __('Month to date', 'wpfnl'), |
| 5816 | 6391 | 'year_to_date' => __('Year to date', 'wpfnl'), |
| 5817 | 6392 | 'previous_period' => __('Previous period', 'wpfnl'), |
| @@ -6816,18 +7391,41 @@ | ||
| 6816 | 7391 | * @return void |
| 6817 | 7392 | * @since 3.13.0 |
| 6818 | 7393 | */ |
| 6819 | 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 ) { | |
| 6820 | 7418 | $styles_dir = WPFNL_DIR . '/admin/assets/dist/styles/'; |
| 6821 | 7419 | |
| 6822 | 7420 | if ( ! is_dir( $styles_dir ) ) { |
| 6823 | - return; | |
| 7421 | + return ''; | |
| 6824 | 7422 | } |
| 6825 | 7423 | |
| 6826 | 7424 | $files = glob( $styles_dir . $entry . '.*.css' ); |
| 6827 | 7425 | |
| 6828 | 7426 | if ( empty( $files ) ) { |
| 6829 | - return; | |
| 7427 | + return ''; | |
| 6830 | 7428 | } |
| 6831 | 7429 | |
| 6832 | 7430 | // Newest wins, so a stale hash left behind by an interrupted build never |
| 6833 | 7431 | // shadows the current one. |
| @@ -6837,14 +7435,9 @@ | ||
| 6837 | 7435 | return filemtime( $b ) - filemtime( $a ); |
| 6838 | 7436 | } |
| 6839 | 7437 | ); |
| 6840 | 7438 | |
| 6841 | - wp_enqueue_style( | |
| 6842 | - $handle, | |
| 6843 | - WPFNL_URL . 'admin/assets/dist/styles/' . basename( $files[0] ), | |
| 6844 | - $deps, | |
| 6845 | - WPFNL_VERSION | |
| 6846 | - ); | |
| 7439 | + return WPFNL_URL . 'admin/assets/dist/styles/' . basename( $files[0] ); | |
| 6847 | 7440 | } |
| 6848 | 7441 | |
| 6849 | 7442 | /** |
| 6850 | 7443 | * Helper method to return the item description, which is composed of item |