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/core/rest-api/Controllers/class-funnel-controller.php +216 -56 3.10.6 → 3.13.3 View file →
@@ -141,8 +141,22 @@
141 141 ],
142 142 ],
143 143 ]);
144 144
145 + register_rest_route($this->namespace, '/' . $this->rest_base . '/getFunnelStats/', [
146 + [
147 + 'methods' => \WP_REST_Server::READABLE,
148 + 'callback' => [
149 + $this,
150 + 'get_funnel_stats'
151 + ],
152 + 'permission_callback' => [
153 + $this,
154 + 'update_items_permissions_check'
155 + ],
156 + ],
157 + ]);
158 +
145 159 register_rest_route($this->namespace, '/' . $this->rest_base . '/getStepType/', [
146 160 [
147 161 'methods' => \WP_REST_Server::READABLE,
148 162 'callback' => [
@@ -741,8 +755,67 @@
741 755 }
742 756
743 757
744 758 /**
759 + * Retrieves visit/conversion figures for a funnel's steps plus funnel totals.
760 + *
761 + * Deliberately kept out of get_funnel_data() so that opening a funnel on the
762 + * canvas never blocks on analytics. On sites with large amounts of tracked
763 + * traffic these aggregates can be slow, so the canvas renders its structure
764 + * first and fills the numbers in once this responds.
765 + *
766 + * @param mixed $request The request data.
767 + * @return \WP_REST_Response
768 + *
769 + * @since 3.12.12
770 + */
771 + public function get_funnel_stats($request)
772 + {
773 + $funnel_id = isset($request['funnel_id']) ? absint($request['funnel_id']) : 0;
774 +
775 + $response = [
776 + 'success' => false,
777 + 'steps' => (object) [],
778 + 'funnel_analytics' => [
779 + 'total_orders' => 0,
780 + 'total_revenue' => 0,
781 + 'aov' => 0,
782 + ],
783 + ];
784 +
785 + if (!$funnel_id) {
786 + return $this->prepare_item_for_response($response, $request);
787 + }
788 +
789 + $cache_key = 'wpfnl_funnel_stats_' . $funnel_id;
790 + $cached = get_transient($cache_key);
791 +
792 + if (false !== $cached) {
793 + return $this->prepare_item_for_response($cached, $request);
794 + }
795 +
796 + $steps = get_post_meta($funnel_id, '_steps', true);
797 + $steps = is_array($steps) ? $steps : [];
798 + $step_ids = array_map(
799 + function ($step) {
800 + return isset($step['id']) ? $step['id'] : null;
801 + },
802 + $steps
803 + );
804 +
805 + $response['success'] = true;
806 + $response['steps'] = self::get_steps_analytics_map($step_ids);
807 + $response['funnel_analytics'] = self::get_funnel_analytics($funnel_id);
808 +
809 + // Cached so repeated canvas opens don't recompute the aggregates. Kept
810 + // short enough that the numbers still track recent activity.
811 + set_transient($cache_key, $response, 10 * MINUTE_IN_SECONDS);
812 +
813 + return $this->prepare_item_for_response($response, $request);
814 + }
815 +
816 +
817 + /**
745 818 * Prepares the response data for the funnel data based on the provided parameters.
746 819 *
747 820 * @param int $funnel_id The ID of the funnel.
748 821 * @param mixed $request The request data.
@@ -749,8 +822,39 @@
749 822 * @return array The prepared response data.
750 823 *
751 824 * @since 2.7.17
752 825 */
826 +
827 + /**
828 + * Restore node_identifier precision from the html field.
829 + *
830 + * PHP json_encode truncates floats, so node_identifier stored as a number loses digits.
831 + * The html field is a string (e.g. "addstep311.7949003047232") and preserves full precision.
832 + * This method extracts node_identifier from html so the API response always matches what JS stored.
833 + *
834 + * @param array $funnel_data
835 + * @return array
836 + */
837 + private function fix_node_identifier_precision($funnel_data)
838 + {
839 + if (!isset($funnel_data['drawflow']['Home']['data']) || !is_array($funnel_data['drawflow']['Home']['data'])) {
840 + return $funnel_data;
841 + }
842 + foreach ($funnel_data['drawflow']['Home']['data'] as &$node) {
843 + if (!isset($node['html'], $node['data']['node_identifier'])) {
844 + continue;
845 + }
846 + $html = $node['html'];
847 + if (strpos($html, 'addstep') === 0) {
848 + $node['data']['node_identifier'] = substr($html, 7);
849 + } elseif (strpos($html, 'conditional') === 0) {
850 + $node['data']['node_identifier'] = substr($html, 11);
851 + }
852 + }
853 + unset($node);
854 + return $funnel_data;
855 + }
856 +
753 857 private function prepare_funnel_data_response($funnel_id)
754 858 {
755 859 $response = [
756 860 'status' => 'error',
@@ -800,10 +904,11 @@
800 904 }
801 905
802 906 $response['success'] = true;
803 907 $step_order_data = self::get_steps_order_data($_steps_order);
804 - $response['steps_order'] = $step_order_data['steps_order'];
805 - $response['is_ob'] = $step_order_data['is_ob'];
908 + $response['steps_order'] = isset( $step_order_data['steps_order'] ) ? $step_order_data['steps_order'] : [];
909 + $response['is_ob'] = isset( $step_order_data['is_ob'] ) ? $step_order_data['is_ob'] : false;
910 + $response['ob_steps'] = isset( $step_order_data['ob_steps'] ) ? $step_order_data['ob_steps'] : [];
806 911
807 912 $init_position = [
808 913 'pos_x' => 383,
809 914 'pos_y' => 143
@@ -809,8 +914,9 @@
809 914 'pos_y' => 143
810 915 ];
811 916
812 917 $funnel_data = Wpfnl_functions::remove_disconnected_addstep_node($funnel_data, $funnel_id);
918 + $funnel_data = $this->fix_node_identifier_precision($funnel_data);
813 919
814 920 if ($funnel_data) {
815 921 $response = [
816 922 'status' => 'success',
@@ -820,9 +926,10 @@
820 926 'funnel_status' => $status,
821 927 'title' => $title,
822 928 'link' => $view_link,
823 929 'reset_funnel' => get_post_meta($funnel_id, '_wpfnl_is_reset', true),
824 - 'is_ob' => isset ($response['is_ob']) ? $response['is_ob'] : false
930 + 'is_ob' => isset ($response['is_ob']) ? $response['is_ob'] : false,
931 + 'ob_steps' => isset ($response['ob_steps']) ? $response['ob_steps'] : []
825 932 ];
826 933
827 934 if (empty($funnel_data['drawflow']['Home']['data'])) {
828 935 $data = $this->get_default_add_step_node_data($init_position);
@@ -861,9 +968,12 @@
861 968
862 969
863 970 $response['first_node_id'] = $this->get_first_step_node($first_step_id, $response['funnel_data']);
864 971 $response['conditional_data'] = $this->get_conditional_step($response['funnel_data']);
865 - $response['funnel_analytics'] = self::get_funnel_analytics($funnel_id);
972 +
973 + // Visit/conversion figures and funnel totals are intentionally absent
974 + // here — the canvas fetches them separately via getFunnelStats so that
975 + // opening a funnel never waits on analytics. See get_funnel_stats().
866 976 return $response;
867 977 }
868 978
869 979
@@ -1045,21 +1155,30 @@
1045 1155 */
1046 1156 private static function get_steps_order_data($steps_order)
1047 1157 {
1048 1158 $is_order_bump = false;
1159 + $ob_steps = [];
1049 1160 $formatted_steps_order = [];
1161 +
1050 1162 foreach ($steps_order as $step) {
1051 1163 $step_id = isset($step['id']) ? $step['id'] : null;
1052 1164 $_temp_step = $step;
1053 - $analytics = self::get_step_analytics($step_id);
1054 - $_temp_step['visit'] = $analytics['visit'];
1055 - $_temp_step['conversion'] = $analytics['conversion'];
1165 +
1166 + // Null rather than 0 so the canvas can tell "not loaded yet" apart
1167 + // from a genuine zero and show a placeholder until getFunnelStats
1168 + // responds.
1169 + $_temp_step['visit'] = null;
1170 + $_temp_step['conversion'] = null;
1056 1171 $_temp_step['name'] = get_the_title($step_id);
1057 1172 $_step_type = get_post_meta($step_id, '_step_type', true);
1058 1173 $should_assign_product = in_array($_step_type, ['checkout', 'upsell', 'downsell']) && !get_post_meta($step_id, '_wpfnl_' . $_step_type . '_products', true);
1059 1174
1060 1175 if ('checkout' === $_step_type) {
1061 - $is_order_bump = self::is_order_bump($step_id);
1176 + $step_has_ob = self::is_order_bump($step_id);
1177 + if ($step_has_ob) {
1178 + $is_order_bump = true;
1179 + $ob_steps[] = (int) $step_id;
1180 + }
1062 1181 }
1063 1182
1064 1183 $_temp_step['should_assign_product'] = $should_assign_product;
1065 1184
@@ -1074,33 +1193,45 @@
1074 1193 }
1075 1194
1076 1195 return [
1077 1196 'steps_order' => $formatted_steps_order,
1078 - 'is_ob' => $is_order_bump
1197 + 'is_ob' => $is_order_bump,
1198 + 'ob_steps' => $ob_steps,
1079 1199 ];
1080 1200 }
1081 1201
1082 1202 /**
1083 - * Get visit and conversion analytics data for a step.
1203 + * Get visit and conversion analytics data for a set of steps in one batch.
1084 1204 *
1085 - * Queries the analytics tables to get total visits and conversions for a given step.
1205 + * Replaces N per-step queries (previously run once per step in
1206 + * get_steps_order_data()) with two GROUP BY queries covering the whole
1207 + * funnel, avoiding an N+1 query pattern that caused the funnel canvas to
1208 + * time out on funnels with several steps. Count semantics are unchanged
1209 + * from the previous per-step queries — grouping by step_id and running
1210 + * the same WHERE/JOIN conditions together is equivalent to running them
1211 + * individually per step.
1086 1212 *
1087 - * @param int $step_id The step ID.
1088 - * @return array Array with 'visit' and 'conversion' counts.
1213 + * @param array $step_ids The step IDs to fetch analytics for.
1214 + * @return array Map of step_id => ['visit' => int, 'conversion' => int].
1089 1215 *
1090 - * @since 3.5.0
1216 + * @since 3.12.12
1091 1217 */
1092 - private static function get_step_analytics($step_id)
1218 + private static function get_steps_analytics_map($step_ids)
1093 1219 {
1094 - $total = array(
1095 - 'visit' => 0,
1096 - 'conversion' => 0,
1097 - );
1220 + $step_ids = array_values(array_unique(array_filter($step_ids)));
1098 1221
1099 - if (!$step_id) {
1100 - return $total;
1222 + $map = [];
1223 + foreach ($step_ids as $step_id) {
1224 + $map[$step_id] = array(
1225 + 'visit' => 0,
1226 + 'conversion' => 0,
1227 + );
1101 1228 }
1102 1229
1230 + if (empty($step_ids)) {
1231 + return $map;
1232 + }
1233 +
1103 1234 global $wpdb;
1104 1235 $analytics_table = $wpdb->prefix . 'wpfnl_analytics';
1105 1236 $analytics_meta_table = $wpdb->prefix . 'wpfnl_analytics_meta';
1106 1237
@@ -1106,55 +1237,70 @@
1106 1237
1107 1238 // Check if the analytics table exists before querying.
1108 1239 $table_exists = $wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $analytics_table));
1109 1240 if (!$table_exists) {
1110 - return $total;
1241 + return $map;
1111 1242 }
1112 1243
1113 - // Get total visits.
1114 - $cache_key_visits = 'wpfnl_pro_visits_' . $step_id;
1115 - $visits_data = wp_cache_get($cache_key_visits, 'wpfnl_pro');
1244 + $cache_key = 'wpfnl_pro_steps_analytics_' . md5(implode(',', $step_ids));
1245 + $cached_map = wp_cache_get($cache_key, 'wpfnl_pro');
1116 1246
1117 - if (false === $visits_data) {
1118 - $query = $wpdb->prepare(
1119 - "SELECT wpft1.step_id, COUNT( DISTINCT( wpft1.id ) ) AS total_visits
1120 - FROM {$analytics_table} as wpft1
1121 - WHERE wpft1.step_id = %s
1122 - ORDER BY NULL",
1123 - $step_id
1124 - ); // phpcs:ignore
1125 -
1126 - $visits_data = $wpdb->get_row($query); // phpcs:ignore
1127 - wp_cache_set($cache_key_visits, $visits_data, 'wpfnl_pro', 3600);
1247 + if (false !== $cached_map) {
1248 + return $cached_map;
1128 1249 }
1129 1250
1130 - // Get conversions.
1131 - $cache_key_conversion = 'wpfnl_pro_conversion_' . $step_id;
1132 - $conversion_data = wp_cache_get($cache_key_conversion, 'wpfnl_pro');
1251 + $placeholders = implode(', ', array_fill(0, count($step_ids), '%s'));
1133 1252
1134 - if (false === $conversion_data) {
1135 - $query = $wpdb->prepare(
1136 - "SELECT wpft1.step_id, COUNT( CASE WHEN wpft2.meta_key = 'conversion' AND wpft2.meta_value = 'yes' THEN wpft1.step_id ELSE NULL END ) AS conversions
1137 - FROM {$analytics_table} as wpft1
1138 - INNER JOIN {$analytics_meta_table} as wpft2 ON wpft1.id = wpft2.analytics_id
1139 - WHERE wpft1.step_id = %s
1140 - ORDER BY NULL",
1141 - $step_id
1142 - ); // phpcs:ignore
1253 + // Get total visits, grouped per step.
1254 + $visits_query = $wpdb->prepare(
1255 + "SELECT wpft1.step_id, COUNT( DISTINCT( wpft1.id ) ) AS total_visits
1256 + FROM {$analytics_table} as wpft1
1257 + WHERE wpft1.step_id IN ({$placeholders})
1258 + GROUP BY wpft1.step_id
1259 + ORDER BY NULL",
1260 + $step_ids
1261 + ); // phpcs:ignore
1262 + $visits_rows = $wpdb->get_results($visits_query); // phpcs:ignore
1143 1263
1144 - $conversion_data = $wpdb->get_row($query); // phpcs:ignore
1145 - wp_cache_set($cache_key_conversion, $conversion_data, 'wpfnl_pro', 3600);
1264 + foreach ($visits_rows as $row) {
1265 + if (isset($map[$row->step_id])) {
1266 + $map[$row->step_id]['visit'] = $row->total_visits;
1267 + }
1146 1268 }
1147 1269
1148 - if ($visits_data) {
1149 - $total['visit'] = $visits_data->total_visits;
1270 + // Get conversions, grouped per step.
1271 + //
1272 + // Read the meta table directly on its denormalised step_id rather than
1273 + // joining back through the analytics table. The join form had to walk
1274 + // every visit for the funnel (tens of thousands of rows) and look up
1275 + // each one's meta rows, which dominated the whole request on sites
1276 + // with real traffic volume. Filtering on step_id touches only the
1277 + // conversion rows themselves — a few thousand instead of ~150k.
1278 + //
1279 + // Counting meta rows keeps the original numbers intact, including the
1280 + // pre-existing behaviour where a visit carrying two 'conversion'='yes'
1281 + // meta rows counts twice.
1282 + $conversion_query = $wpdb->prepare(
1283 + "SELECT step_id, COUNT(*) AS conversions
1284 + FROM {$analytics_meta_table}
1285 + WHERE step_id IN ({$placeholders})
1286 + AND meta_key = 'conversion'
1287 + AND meta_value = 'yes'
1288 + GROUP BY step_id
1289 + ORDER BY NULL",
1290 + $step_ids
1291 + ); // phpcs:ignore
1292 + $conversion_rows = $wpdb->get_results($conversion_query); // phpcs:ignore
1293 +
1294 + foreach ($conversion_rows as $row) {
1295 + if (isset($map[$row->step_id])) {
1296 + $map[$row->step_id]['conversion'] = $row->conversions;
1297 + }
1150 1298 }
1151 1299
1152 - if ($conversion_data) {
1153 - $total['conversion'] = $conversion_data->conversions;
1154 - }
1300 + wp_cache_set($cache_key, $map, 'wpfnl_pro', 3600);
1155 1301
1156 - return $total;
1302 + return $map;
1157 1303 }
1158 1304
1159 1305 /**
1160 1306 * Get funnel-level analytics data (total orders, revenue, AOV, orderbump revenue).
@@ -1322,8 +1468,21 @@
1322 1468 foreach ($steps as $key => $step) {
1323 1469 if ($step) {
1324 1470 $node_data = $step['data'];
1325 1471 if (isset($node_data["step_name"])) unset($node_data["step_name"]);
1472 +
1473 + // Preserve node_identifier at full precision by extracting it from the html
1474 + // field (a string) rather than using the float value decoded by json_decode,
1475 + // which PHP truncates before we can read it.
1476 + if (isset($step['html'], $node_data['node_identifier'])) {
1477 + $html = $step['html'];
1478 + if (strpos($html, 'addstep') === 0) {
1479 + $node_data['node_identifier'] = substr($html, 7);
1480 + } elseif (strpos($html, 'conditional') === 0) {
1481 + $node_data['node_identifier'] = substr($html, 11);
1482 + }
1483 + }
1484 +
1326 1485 $step['data'] = $node_data;
1327 1486 $_steps[$key] = $step;
1328 1487
1329 1488 }
@@ -2028,8 +2187,9 @@
2028 2187 $checkout_step_id = null;
2029 2188 foreach ( $new_steps_order as $step ) {
2030 2189 if ( isset( $step['step_type'] ) && 'checkout' === $step['step_type'] ) {
2031 2190 $checkout_step_id = $step['id'];
2191 + delete_post_meta( absint( $step['id'] ), '_wpfnl_checkout_products' );
2032 2192 break;
2033 2193 }
2034 2194 }
2035 2195