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 +1104 -72 3.12.4 → 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 );
@@ -1195,9 +1371,13 @@
1195 1371 *
1196 1372 * @since 2.0.5
1197 1373 */
1198 1374 public static function get_funnel_data( $funnel_id ) {
1199 - return get_post_meta( $funnel_id, '_funnel_data', true );
1375 + static $cache = [];
1376 + if ( ! array_key_exists( $funnel_id, $cache ) ) {
1377 + $cache[ $funnel_id ] = get_post_meta( $funnel_id, '_funnel_data', true );
1378 + }
1379 + return $cache[ $funnel_id ];
1200 1380 }
1201 1381
1202 1382
1203 1383 /**
@@ -1253,9 +1433,8 @@
1253 1433 'paypal_reference' => 'off',
1254 1434 'order_bump' => 'off',
1255 1435 'ab_testing' => 'off',
1256 1436 'allow_funnels' => array( 'administrator' => 'true' ),
1257 - 'funnel_type' => 'sales',
1258 1437 'create_child_order' => 'off',
1259 1438 'disable_theme_style' => 'off',
1260 1439 'enable_log_status' => 'off',
1261 1440 'enable_skip_cart' => 'off',
@@ -1260,9 +1439,9 @@
1260 1439 'enable_log_status' => 'off',
1261 1440 'enable_skip_cart' => 'off',
1262 1441 'skip_cart_for' => 'whole',
1263 1442 'uninstall_cleanup' => 'off',
1264 - 'funnel_builder_mode' => 'horizontal',
1443 + 'funnel_builder_mode' => self::DEFAULT_FUNNEL_BUILDER_MODE,
1265 1444 )
1266 1445 );
1267 1446 $saved_settings = self::get_admin_settings( '_wpfunnels_general_settings', $default_settings );
1268 1447 return wp_parse_args( $saved_settings, $default_settings );
@@ -1269,8 +1448,58 @@
1269 1448 }
1270 1449
1271 1450
1272 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 + /**
1273 1502 * Get permalink settings data
1274 1503 *
1275 1504 * @return array
1276 1505 * @since 1.0.0
@@ -1330,8 +1559,342 @@
1330 1559 return wp_parse_args( $saved_settings, $default_settings );
1331 1560 }
1332 1561
1333 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 + /**
1334 1897 * Get GTM events
1335 1898 *
1336 1899 * @return array
1337 1900 */
@@ -1472,8 +2035,109 @@
1472 2035 }
1473 2036
1474 2037
1475 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 + /**
1476 2140 * Get advanced settings
1477 2141 */
1478 2142 public static function get_advanced_settings() {
1479 2143 $default_enable_settings = array(
@@ -3158,18 +3822,8 @@
3158 3822 'slug' => 'lead',
3159 3823 'label' => 'Lead Gen Templates',
3160 3824 );
3161 3825
3162 - // If funnel type is set to lead only, show only lead templates
3163 - // if ( isset( $general_settings['funnel_type'] ) && 'lead' === $general_settings['funnel_type'] ) {
3164 - // $types = array(
3165 - // array(
3166 - // 'slug' => 'lead',
3167 - // 'label' => 'Lead Gen Templates',
3168 - // ),
3169 - // );
3170 - // }
3171 -
3172 3826 return apply_filters( 'wpfunnels/modify_template_type', $types );
3173 3827
3174 3828 }
3175 3829
@@ -3174,22 +3828,8 @@
3174 3828 }
3175 3829
3176 3830
3177 3831 /**
3178 - * Get global funnel type
3179 - *
3180 - * @return Mix
3181 - */
3182 - public static function get_global_funnel_type() {
3183 - $general_settings = self::get_general_settings();
3184 - if ( isset( $general_settings['funnel_type'] ) ) {
3185 - return $general_settings['funnel_type'];
3186 - }
3187 - return false;
3188 - }
3189 -
3190 -
3191 - /**
3192 3832 * May be allow to create sales funnel
3193 3833 *
3194 3834 * @return Bool
3195 3835 */
@@ -3295,8 +3935,60 @@
3295 3935 }
3296 3936
3297 3937
3298 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 + /**
3299 3991 * Check the checkout layout is express or not
3300 3992 *
3301 3993 * @return Bool
3302 3994 * @since 2.4.18
@@ -3344,23 +4036,8 @@
3344 4036
3345 4037
3346 4038
3347 4039 /**
3348 - * Update funnel type to lead
3349 - *
3350 - * @since 2.5.1
3351 - */
3352 - public static function update_funnel_type_to_lead() {
3353 -
3354 - $saved_settings = self::get_admin_settings( '_wpfunnels_general_settings' );
3355 - if ( isset( $saved_settings['funnel_type'] ) ) {
3356 - $saved_settings['funnel_type'] = 'lead';
3357 - update_option( '_wpfunnels_general_settings', $saved_settings );
3358 - }
3359 - }
3360 -
3361 -
3362 - /**
3363 4040 * Delete template transients
3364 4041 *
3365 4042 * @since 2.5.1
3366 4043 */
@@ -3855,12 +4532,9 @@
3855 4532 $order_comment = array(
3856 4533 'type' => 'textarea',
3857 4534 'class' => array( 'notes' ),
3858 4535 'label' => __( 'Order notes', 'woocommerce' ),
3859 - 'placeholder' => esc_attr__(
3860 - 'Notes about your order, e.g. special notes for delivery.',
3861 - 'woocommerce'
3862 - ),
4536 + 'placeholder' => esc_attr__( 'Notes about your order', 'wpfnl' ),
3863 4537 'name' => 'order_comments',
3864 4538 'required' => false,
3865 4539 'enable' => 1,
3866 4540 'show' => 1,
@@ -4088,11 +4762,261 @@
4088 4762 return $all_steps_including_ab_ids;
4089 4763 }
4090 4764
4091 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 + /**
4092 5012 * Validates the order owner's authorization.
4093 5013 *
4094 - * @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.
4095 5019 * @return bool
4096 5020 * @since 2.7.13
4097 5021 */
4098 5022 public static function is_valid_order_owner( $order, $order_key = '' ) {
@@ -4108,26 +5032,19 @@
4108 5032 if ( ! empty( $order_key ) && $order->key_is_valid( $order_key ) ) {
4109 5033 return true;
4110 5034 }
4111 5035
4112 - // If it's not a guest order, current user can pay but only if it's their own order,
4113 - // or they are an admin or shop manager or they are in a same session(for guest).
4114 -
4115 - $order_has_customer = ! empty( $order->get_customer_id() );
4116 - $is_authorized_owner = false;
4117 -
4118 - $has_owner = ! empty( WC()->session->get( 'wpfnl_order_owner' ) );
4119 -
4120 - if ( $order_has_customer ) {
4121 - $is_order_owned_by_current_user = $order->get_customer_id() === get_current_user_id();
4122 -
4123 - $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 + }
4124 5041 }
4125 5042
4126 - if ( ! $is_authorized_owner && ! $has_owner ) {
4127 - return false;
4128 - }
4129 - 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() );
4130 5047 }
4131 5048
4132 5049
4133 5050 /**
@@ -4688,8 +5605,24 @@
4688 5605 */
4689 5606 public static function get_text() {
4690 5607 return array(
4691 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' ),
4692 5625 'stats' => __( 'Stats', 'wpfnl' ),
4693 5626 'stats_tooltip_txt' => __( 'Toggle to get a quick view of visits & conversion of steps right on the canvas.', 'wpfnl' ),
4694 5627 'find_your_templates' => __( 'Find your templates', 'wpfnl' ),
4695 5628 'enable_global_funnel' => __( 'Enable Global Funnel', 'wpfnl' ),
@@ -4974,8 +5907,11 @@
4974 5907 'do_you_want_to_save_automation' => __( 'Do you want to save the automation?', 'wpfnl' ),
4975 5908 'no_back' => __( 'No & Back', 'wpfnl' ),
4976 5909 'yes_back' => __( 'Yes & Back', 'wpfnl' ),
4977 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' ),
4978 5914
4979 5915 'choose_list' => __( 'Choose List', 'wpfnl' ),
4980 5916 'select_lists' => __( 'Select Lists', 'wpfnl' ),
4981 5917 'search_list' => __( 'Search List', 'wpfnl' ),
@@ -5234,8 +6170,23 @@
5234 6170 'run_ab_split_test' => __( 'Run A/B Split Test', 'wpfnl' ),
5235 6171 'to_funnel_update_msg' => __( 'Please update to WPFunnels Pro 1.7.3 or higher to use this feature.', 'wpfnl' ),
5236 6172 'conditions' => __( 'Conditions', 'wpfnl' ),
5237 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 +
5238 6189 // -----step settings drawer------
5239 6190 'assign_the_main_funnel_product' => __( 'Assign the main funnel product here.', 'wpfnl' ),
5240 6191 'courses' => __( 'Courses', 'wpfnl' ),
5241 6192 'products' => __( 'Products', 'wpfnl' ),
@@ -5423,9 +6374,19 @@
5423 6374 'views' => __('Views', 'wpfnl'),
5424 6375 'conversion' => __('Conversion', 'wpfnl'),
5425 6376 'conversion_rate' => __('Conversion Rate', 'wpfnl'),
5426 6377 'no_top_performing_funnels' => __('You have no top performing funnels', 'wpfnl'),
6378 + 'view_all' => __('View all', 'wpfnl'),
5427 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'),
5428 6389 'week_to_date' => __('Week to date', 'wpfnl'),
5429 6390 'month_to_date' => __('Month to date', 'wpfnl'),
5430 6391 'year_to_date' => __('Year to date', 'wpfnl'),
5431 6392 'previous_period' => __('Previous period', 'wpfnl'),
@@ -6404,8 +7365,79 @@
6404 7365 }
6405 7366 }
6406 7367 }
6407 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] );
6408 7440 }
6409 7441
6410 7442 /**
6411 7443 * Helper method to return the item description, which is composed of item