PluginProbe
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell / 3.13.3
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell v3.13.3
3.13.3 3.13.2 3.13.1 3.13.0 3.12.13 3.12.12 3.12.11 3.12.10 3.12.9 3.12.8 3.12.7 3.12.6 3.12.5 3.12.4 3.12.3 3.12.1 3.12.2 3.12.0 3.11.1 3.11.0 3.10.9 3.10.8 3.10.7 3.10.6 2.8.16 All 261 releases
← All changes | includes/utils/class-wpfnl-functions.php +654 -61 3.13.0 → 3.13.3 View file →
@@ -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 . ' &times; ' . 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