PluginProbe
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell / 3.13.4
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell v3.13.4
3.13.4 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 All 262 releases
← All changes | includes/utils/class-wpfnl-functions.php +1092 -71 3.12.13 → 3.13.4 View file →
@@ -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 . ' &times; ' . 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