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 +1190 -91 3.10.6 → 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 /**
@@ -1248,20 +1428,20 @@
1248 1428 public static function get_general_settings() {
1249 1429 $default_settings = apply_filters(
1250 1430 'wpfunnels_general_settings',
1251 1431 array(
1252 - 'builder' => 'gutenberg',
1253 - 'paypal_reference' => 'off',
1254 - 'order_bump' => 'off',
1255 - 'ab_testing' => 'off',
1256 - 'allow_funnels' => array( 'administrator' => 'true' ),
1257 - 'funnel_type' => 'sales',
1258 - 'create_child_order' => 'off',
1259 - 'disable_theme_style' => 'off',
1260 - 'enable_log_status' => 'off',
1261 - 'enable_skip_cart' => 'off',
1262 - 'skip_cart_for' => 'whole',
1263 - 'uninstall_cleanup' => 'off',
1432 + 'builder' => 'gutenberg',
1433 + 'paypal_reference' => 'off',
1434 + 'order_bump' => 'off',
1435 + 'ab_testing' => 'off',
1436 + 'allow_funnels' => array( 'administrator' => 'true' ),
1437 + 'create_child_order' => 'off',
1438 + 'disable_theme_style' => 'off',
1439 + 'enable_log_status' => 'off',
1440 + 'enable_skip_cart' => 'off',
1441 + 'skip_cart_for' => 'whole',
1442 + 'uninstall_cleanup' => 'off',
1443 + 'funnel_builder_mode' => self::DEFAULT_FUNNEL_BUILDER_MODE,
1264 1444 )
1265 1445 );
1266 1446 $saved_settings = self::get_admin_settings( '_wpfunnels_general_settings', $default_settings );
1267 1447 return wp_parse_args( $saved_settings, $default_settings );
@@ -1268,8 +1448,58 @@
1268 1448 }
1269 1449
1270 1450
1271 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 + /**
1272 1502 * Get permalink settings data
1273 1503 *
1274 1504 * @return array
1275 1505 * @since 1.0.0
@@ -1329,8 +1559,342 @@
1329 1559 return wp_parse_args( $saved_settings, $default_settings );
1330 1560 }
1331 1561
1332 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 + /**
1333 1897 * Get GTM events
1334 1898 *
1335 1899 * @return array
1336 1900 */
@@ -1471,8 +2035,109 @@
1471 2035 }
1472 2036
1473 2037
1474 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 + /**
1475 2140 * Get advanced settings
1476 2141 */
1477 2142 public static function get_advanced_settings() {
1478 2143 $default_enable_settings = array(
@@ -1660,8 +2325,46 @@
1660 2325 }
1661 2326
1662 2327
1663 2328 /**
2329 + * Check if Divi 5 is active.
2330 + *
2331 + * This function checks if Divi 5 is enabled using multiple detection methods
2332 + * to ensure compatibility regardless of when the check is performed.
2333 + *
2334 + * @return bool
2335 + * @since 2.9.1
2336 + */
2337 + public static function is_divi5_active(): bool {
2338 + // Primary: Check if the Divi 5 function exists and call it
2339 + if ( function_exists( 'et_builder_d5_enabled' ) ) {
2340 + return et_builder_d5_enabled();
2341 + }
2342 +
2343 + // Fallback 1: Check if Divi 5 classes/interfaces exist
2344 + if ( interface_exists( 'ET\Builder\Framework\DependencyManagement\Interfaces\DependencyInterface' ) ) {
2345 + return true;
2346 + }
2347 +
2348 + // Fallback 2: Check if Divi theme is active and ET_BUILDER_VERSION is 5.x
2349 + if ( get_template() === 'Divi' && defined( 'ET_BUILDER_VERSION' ) ) {
2350 + return version_compare( ET_BUILDER_VERSION, '5.0', '>=' );
2351 + }
2352 +
2353 + // Fallback 3: Check theme version from style.css
2354 + if ( get_template() === 'Divi' ) {
2355 + $theme = wp_get_theme( 'Divi' );
2356 + if ( $theme->exists() ) {
2357 + $version = $theme->get( 'Version' );
2358 + return version_compare( $version, '5.0', '>=' );
2359 + }
2360 + }
2361 +
2362 + return false;
2363 + }
2364 +
2365 +
2366 + /**
1664 2367 * Check if pro is activated/deactivated
1665 2368 *
1666 2369 * @return bool
1667 2370 * @since 1.0.0
@@ -3119,18 +3822,8 @@
3119 3822 'slug' => 'lead',
3120 3823 'label' => 'Lead Gen Templates',
3121 3824 );
3122 3825
3123 - // If funnel type is set to lead only, show only lead templates
3124 - // if ( isset( $general_settings['funnel_type'] ) && 'lead' === $general_settings['funnel_type'] ) {
3125 - // $types = array(
3126 - // array(
3127 - // 'slug' => 'lead',
3128 - // 'label' => 'Lead Gen Templates',
3129 - // ),
3130 - // );
3131 - // }
3132 -
3133 3826 return apply_filters( 'wpfunnels/modify_template_type', $types );
3134 3827
3135 3828 }
3136 3829
@@ -3135,22 +3828,8 @@
3135 3828 }
3136 3829
3137 3830
3138 3831 /**
3139 - * Get global funnel type
3140 - *
3141 - * @return Mix
3142 - */
3143 - public static function get_global_funnel_type() {
3144 - $general_settings = self::get_general_settings();
3145 - if ( isset( $general_settings['funnel_type'] ) ) {
3146 - return $general_settings['funnel_type'];
3147 - }
3148 - return false;
3149 - }
3150 -
3151 -
3152 - /**
3153 3832 * May be allow to create sales funnel
3154 3833 *
3155 3834 * @return Bool
3156 3835 */
@@ -3256,8 +3935,60 @@
3256 3935 }
3257 3936
3258 3937
3259 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 + /**
3260 3991 * Check the checkout layout is express or not
3261 3992 *
3262 3993 * @return Bool
3263 3994 * @since 2.4.18
@@ -3305,23 +4036,8 @@
3305 4036
3306 4037
3307 4038
3308 4039 /**
3309 - * Update funnel type to lead
3310 - *
3311 - * @since 2.5.1
3312 - */
3313 - public static function update_funnel_type_to_lead() {
3314 -
3315 - $saved_settings = self::get_admin_settings( '_wpfunnels_general_settings' );
3316 - if ( isset( $saved_settings['funnel_type'] ) ) {
3317 - $saved_settings['funnel_type'] = 'lead';
3318 - update_option( '_wpfunnels_general_settings', $saved_settings );
3319 - }
3320 - }
3321 -
3322 -
3323 - /**
3324 4040 * Delete template transients
3325 4041 *
3326 4042 * @since 2.5.1
3327 4043 */
@@ -3816,12 +4532,9 @@
3816 4532 $order_comment = array(
3817 4533 'type' => 'textarea',
3818 4534 'class' => array( 'notes' ),
3819 4535 'label' => __( 'Order notes', 'woocommerce' ),
3820 - 'placeholder' => esc_attr__(
3821 - 'Notes about your order, e.g. special notes for delivery.',
3822 - 'woocommerce'
3823 - ),
4536 + 'placeholder' => esc_attr__( 'Notes about your order', 'wpfnl' ),
3824 4537 'name' => 'order_comments',
3825 4538 'required' => false,
3826 4539 'enable' => 1,
3827 4540 'show' => 1,
@@ -4049,11 +4762,261 @@
4049 4762 return $all_steps_including_ab_ids;
4050 4763 }
4051 4764
4052 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 + /**
4053 5012 * Validates the order owner's authorization.
4054 5013 *
4055 - * @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.
4056 5019 * @return bool
4057 5020 * @since 2.7.13
4058 5021 */
4059 5022 public static function is_valid_order_owner( $order, $order_key = '' ) {
@@ -4069,26 +5032,19 @@
4069 5032 if ( ! empty( $order_key ) && $order->key_is_valid( $order_key ) ) {
4070 5033 return true;
4071 5034 }
4072 5035
4073 - // If it's not a guest order, current user can pay but only if it's their own order,
4074 - // or they are an admin or shop manager or they are in a same session(for guest).
4075 -
4076 - $order_has_customer = ! empty( $order->get_customer_id() );
4077 - $is_authorized_owner = false;
4078 -
4079 - $has_owner = ! empty( WC()->session->get( 'wpfnl_order_owner' ) );
4080 -
4081 - if ( $order_has_customer ) {
4082 - $is_order_owned_by_current_user = $order->get_customer_id() === get_current_user_id();
4083 -
4084 - $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 + }
4085 5041 }
4086 5042
4087 - if ( ! $is_authorized_owner && ! $has_owner ) {
4088 - return false;
4089 - }
4090 - 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() );
4091 5047 }
4092 5048
4093 5049
4094 5050 /**
@@ -4649,8 +5605,24 @@
4649 5605 */
4650 5606 public static function get_text() {
4651 5607 return array(
4652 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' ),
4653 5625 'stats' => __( 'Stats', 'wpfnl' ),
4654 5626 'stats_tooltip_txt' => __( 'Toggle to get a quick view of visits & conversion of steps right on the canvas.', 'wpfnl' ),
4655 5627 'find_your_templates' => __( 'Find your templates', 'wpfnl' ),
4656 5628 'enable_global_funnel' => __( 'Enable Global Funnel', 'wpfnl' ),
@@ -4688,9 +5660,10 @@
4688 5660 'add_new_step' => __( 'Add a new step', 'wpfnl' ),
4689 5661 'yes' => __( 'Yes', 'wpfnl' ),
4690 5662 'are_you_sure_delete_product' => __( 'Are you sure you want to delete this product?', 'wpfnl' ),
4691 5663 'thankyou' => __( 'Thank you', 'wpfnl' ),
4692 - 'add_condition' => __( 'Add Condition', 'wpfnl' ),
5664 + 'add_condition' => __( 'Set Entry Rules', 'wpfnl' ),
5665 + 'conditional_drawer_header' => __( 'Conditions', 'wpfnl' ),
4693 5666 'add_featured_image' => __( 'Add Featured Image', 'wpfnl' ),
4694 5667 'title' => __( 'Title', 'wpfnl' ),
4695 5668 'url_slug' => __( 'URL Slug', 'wpfnl' ),
4696 5669 'click_to_copy' => __( 'Click to Copy', 'wpfnl' ),
@@ -4934,8 +5907,11 @@
4934 5907 'do_you_want_to_save_automation' => __( 'Do you want to save the automation?', 'wpfnl' ),
4935 5908 'no_back' => __( 'No & Back', 'wpfnl' ),
4936 5909 'yes_back' => __( 'Yes & Back', 'wpfnl' ),
4937 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' ),
4938 5914
4939 5915 'choose_list' => __( 'Choose List', 'wpfnl' ),
4940 5916 'select_lists' => __( 'Select Lists', 'wpfnl' ),
4941 5917 'search_list' => __( 'Search List', 'wpfnl' ),
@@ -5194,8 +6170,23 @@
5194 6170 'run_ab_split_test' => __( 'Run A/B Split Test', 'wpfnl' ),
5195 6171 'to_funnel_update_msg' => __( 'Please update to WPFunnels Pro 1.7.3 or higher to use this feature.', 'wpfnl' ),
5196 6172 'conditions' => __( 'Conditions', 'wpfnl' ),
5197 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 +
5198 6189 // -----step settings drawer------
5199 6190 'assign_the_main_funnel_product' => __( 'Assign the main funnel product here.', 'wpfnl' ),
5200 6191 'courses' => __( 'Courses', 'wpfnl' ),
5201 6192 'products' => __( 'Products', 'wpfnl' ),
@@ -5383,9 +6374,19 @@
5383 6374 'views' => __('Views', 'wpfnl'),
5384 6375 'conversion' => __('Conversion', 'wpfnl'),
5385 6376 'conversion_rate' => __('Conversion Rate', 'wpfnl'),
5386 6377 'no_top_performing_funnels' => __('You have no top performing funnels', 'wpfnl'),
6378 + 'view_all' => __('View all', 'wpfnl'),
5387 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'),
5388 6389 'week_to_date' => __('Week to date', 'wpfnl'),
5389 6390 'month_to_date' => __('Month to date', 'wpfnl'),
5390 6391 'year_to_date' => __('Year to date', 'wpfnl'),
5391 6392 'previous_period' => __('Previous period', 'wpfnl'),
@@ -5915,9 +6916,9 @@
5915 6916 $function_exist = is_callable( array( $instance, 'get_random_product_in_category_for_offer' ) );
5916 6917 if ( $function_exist ) {
5917 6918 $id = '';
5918 6919 if ( is_admin() ) {
5919 - $dynamic_product = Wpfnl_Pro_GB_Functions::get_random_product_in_shop_for_widget( 1 );
6920 + $dynamic_product = Wpfnl_Pro_GBF_Functions::get_random_product_in_shop_for_widget( 1 );
5920 6921 if ( isset( $dynamic_product[0]->ID ) ) {
5921 6922 $id = $dynamic_product[0]->ID;
5922 6923 }
5923 6924 } else {
@@ -5941,9 +6942,9 @@
5941 6942
5942 6943 if ( $function_exist ) {
5943 6944 $id = '';
5944 6945 if ( is_admin() ) {
5945 - $dynamic_product = Wpfnl_Pro_GB_Functions::get_random_product_in_category_within_price_for_offer_for_widget( 1 );
6946 + $dynamic_product = Wpfnl_Pro_GBF_Functions::get_random_product_in_category_within_price_for_offer_for_widget( 1 );
5946 6947 if ( isset( $dynamic_product[0]->ID ) ) {
5947 6948 $id = $dynamic_product[0]->ID;
5948 6949 }
5949 6950 } else {
@@ -5964,9 +6965,9 @@
5964 6965 if ( $offer_mappings ) {
5965 6966 $category = isset( $offer_mappings['category'] ) ? $offer_mappings['category'] : null;
5966 6967 $function_exist = is_callable( array( $instance, 'get_highest_sold_product_in_category_for_offer' ) );
5967 6968 if ( $function_exist ) {
5968 - $highestSoldInsideCategory = Wpfnl_Pro_GB_Functions::get_highest_sold_product_in_category_for_offer( 1, $category );
6969 + $highestSoldInsideCategory = Wpfnl_Pro_GBF_Functions::get_highest_sold_product_in_category_for_offer( 1, $category );
5969 6970 if ( is_array( $highestSoldInsideCategory ) && isset( $highestSoldInsideCategory['posts'][0]->ID ) ) {
5970 6971 $id = $highestSoldInsideCategory['posts'][0]->ID;
5971 6972 $offer_product = wc_get_product( $id );
5972 6973 $get_product_type = $offer_product ? $offer_product->get_type() : '';
@@ -5978,9 +6979,9 @@
5978 6979 if ( $offer_mappings ) {
5979 6980 $tag = isset( $offer_mappings['tag'] ) ? $offer_mappings['tag'] : null;
5980 6981 $function_exist = is_callable( array( $instance, 'get_highest_sold_product_in_tag_for_offer' ) );
5981 6982 if ( $function_exist ) {
5982 - $highestSoldInsideTag = Wpfnl_Pro_GB_Functions::get_highest_sold_product_in_tag_for_offer( $tag, 1 );
6983 + $highestSoldInsideTag = Wpfnl_Pro_GBF_Functions::get_highest_sold_product_in_tag_for_offer( $tag, 1 );
5983 6984 if ( is_array( $highestSoldInsideTag ) && isset( $highestSoldInsideTag['posts'][0]->ID ) ) {
5984 6985 $id = $highestSoldInsideTag['posts'][0]->ID;
5985 6986 $offer_product = wc_get_product( $id );
5986 6987 $get_product_type = $offer_product ? $offer_product->get_type() : '';
@@ -5989,9 +6990,9 @@
5989 6990 }
5990 6991 } elseif ( 'highestSold' == $rules_type ) {
5991 6992 $function_exist = is_callable( array( $instance, 'get_highest_sold_product_for_offer' ) );
5992 6993 if ( $function_exist ) {
5993 - $highestSold = Wpfnl_Pro_GB_Functions::get_highest_sold_product_for_offer( 1 );
6994 + $highestSold = Wpfnl_Pro_GBF_Functions::get_highest_sold_product_for_offer( 1 );
5994 6995 if ( is_array( $highestSold ) && isset( $highestSold['posts'][0]->ID ) ) {
5995 6996 $id = $highestSold['posts'][0]->ID;
5996 6997 $offer_product = wc_get_product( $id );
5997 6998 $get_product_type = $offer_product ? $offer_product->get_type() : '';
@@ -6001,9 +7002,9 @@
6001 7002 $function_exist = is_callable( array( $instance, 'get_random_product_in_tag_for_offer_for_widget' ) );
6002 7003 if ( $function_exist ) {
6003 7004 $id = '';
6004 7005 if ( is_admin() ) {
6005 - $dynamic_product = Wpfnl_Pro_GB_Functions::get_random_product_in_tag_for_offer_for_widget( 1 );
7006 + $dynamic_product = Wpfnl_Pro_GBF_Functions::get_random_product_in_tag_for_offer_for_widget( 1 );
6006 7007 if ( isset( $dynamic_product[0]->ID ) ) {
6007 7008 $id = $dynamic_product[0]->ID;
6008 7009 }
6009 7010 } else {
@@ -6021,9 +7022,9 @@
6021 7022 $function_exist = is_callable( array( $instance, 'get_random_product_in_tag_within_price_for_offer_for_widget' ) );
6022 7023 if ( $function_exist ) {
6023 7024 $id = '';
6024 7025 if ( is_admin() ) {
6025 - $dynamic_product = Wpfnl_Pro_GB_Functions::get_random_product_in_tag_within_price_for_offer_for_widget( 1 );
7026 + $dynamic_product = Wpfnl_Pro_GBF_Functions::get_random_product_in_tag_within_price_for_offer_for_widget( 1 );
6026 7027
6027 7028 if ( isset( $dynamic_product[0]->ID ) ) {
6028 7029 $id = $dynamic_product[0]->ID;
6029 7030 }
@@ -6040,9 +7041,9 @@
6040 7041 }
6041 7042 } elseif ( 'randomInShop' == $rules_type ) {
6042 7043 $id = '';
6043 7044 if ( is_admin() ) {
6044 - $dynamic_product = Wpfnl_Pro_GB_Functions::get_random_product_in_shop_for_widget( 1 );
7045 + $dynamic_product = Wpfnl_Pro_GBF_Functions::get_random_product_in_shop_for_widget( 1 );
6045 7046 if ( isset( $dynamic_product[0]->ID ) ) {
6046 7047 $id = $dynamic_product[0]->ID;
6047 7048 }
6048 7049 } else {
@@ -6337,8 +7338,106 @@
6337 7338 );
6338 7339 }
6339 7340 }
6340 7341 return $data;
7342 + }
7343 +
7344 +
7345 + /**
7346 + * Calculate discount price
7347 + *
7348 + * @param $discount_type
7349 + * @param $discount_value
7350 + * @param $product_price
7351 + *
7352 + * @return string
7353 + */
7354 + public static function calculate_discount_price_for_widget( $discount_type, $discount_value, $product_price ) {
7355 +
7356 + $custom_price = $product_price;
7357 + if ( ! empty( $discount_type ) ) {
7358 + if ( 'discount-percentage' === $discount_type ) {
7359 + if ( $discount_value > 0 && $discount_value <= 100 ) {
7360 + $custom_price = (float) $product_price - ( (float) ( $product_price * $discount_value ) / 100 );
7361 + }
7362 + } elseif ( 'discount-price' === $discount_type ) {
7363 + if ( $discount_value > 0 ) {
7364 + $custom_price = $product_price - $discount_value;
7365 + }
7366 + }
7367 + }
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] );
6341 7440 }
6342 7441
6343 7442 /**
6344 7443 * Helper method to return the item description, which is composed of item