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