PluginProbe
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell / 3.13.1
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell v3.13.1
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 2.8.17 2.8.18 All 259 releases
wpfunnels / includes / core / rest-api / Controllers / class-setup-wizard-controller.php

class-setup-wizard-controller.php in WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell 3.13.1, at includes/core/rest-api/Controllers/class-setup-wizard-controller.php

447 lines 18.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Setup Wizard controller
4 *
5 * @package WPFunnels\Rest\Controllers
6 */
7 namespace WPFunnels\Rest\Controllers;
8
9 use WP_REST_Request;
10 use WP_REST_Server;
11
12 class SetupWizardController extends Wpfnl_REST_Controller {
13
14 /**
15 * Endpoint namespace.
16 *
17 * @var string
18 */
19 protected $namespace = 'wpfunnels/v1';
20
21 /**
22 * Route base.
23 *
24 * @var string
25 */
26 protected $rest_base = 'setup-wizard';
27
28 /**
29 * Register REST routes.
30 */
31 public function register_routes() {
32 register_rest_route(
33 $this->namespace,
34 '/' . $this->rest_base . '/save-consent',
35 array(
36 array(
37 'methods' => WP_REST_Server::CREATABLE,
38 'callback' => array( $this, 'save_consent' ),
39 'permission_callback' => function () {
40 return current_user_can( 'manage_options' );
41 },
42 'args' => array(
43 'consented' => array(
44 'description' => __( 'Whether the user agreed to data sharing.', 'wpfnl' ),
45 'type' => 'boolean',
46 'required' => true,
47 ),
48 ),
49 ),
50 )
51 );
52
53 register_rest_route(
54 $this->namespace,
55 '/' . $this->rest_base . '/track-step',
56 array(
57 array(
58 'methods' => WP_REST_Server::CREATABLE,
59 'callback' => array( $this, 'track_step' ),
60 'permission_callback' => function () {
61 return current_user_can( 'manage_options' );
62 },
63 'args' => array(
64 'event_type' => array(
65 'description' => __( 'Step event type: viewed, completed, or abandoned.', 'wpfnl' ),
66 'type' => 'string',
67 'required' => true,
68 'sanitize_callback' => 'sanitize_key',
69 'enum' => array( 'viewed', 'completed', 'abandoned', 'skipped' ),
70 ),
71 'step_name' => array(
72 'description' => __( 'Step slug identifier.', 'wpfnl' ),
73 'type' => 'string',
74 'required' => true,
75 'sanitize_callback' => 'sanitize_key',
76 ),
77 'step_index' => array(
78 'description' => __( '1-based step position.', 'wpfnl' ),
79 'type' => 'integer',
80 'required' => false,
81 'sanitize_callback' => 'absint',
82 'default' => 0,
83 ),
84 'goal' => array(
85 'description' => __( 'Selected goal slug.', 'wpfnl' ),
86 'type' => 'string',
87 'required' => false,
88 'sanitize_callback' => 'sanitize_text_field',
89 'default' => '',
90 ),
91 'time_on_step' => array(
92 'description' => __( 'Seconds spent on this step.', 'wpfnl' ),
93 'type' => 'integer',
94 'required' => false,
95 'sanitize_callback' => 'absint',
96 'default' => 0,
97 ),
98 'total_steps' => array(
99 'description' => __( 'Total number of steps in the wizard for this goal path.', 'wpfnl' ),
100 'type' => 'integer',
101 'required' => false,
102 'sanitize_callback' => 'absint',
103 'default' => 0,
104 ),
105 ),
106 ),
107 )
108 );
109
110 register_rest_route(
111 $this->namespace,
112 '/' . $this->rest_base . '/complete-step',
113 array(
114 array(
115 'methods' => WP_REST_Server::CREATABLE,
116 'callback' => array( $this, 'complete_step' ),
117 'permission_callback' => function () {
118 return current_user_can( 'manage_options' );
119 },
120 'args' => array(
121 'funnelId' => array(
122 'description' => __( 'Funnel ID.', 'wpfnl' ),
123 'type' => 'integer',
124 'required' => false,
125 'sanitize_callback' => 'absint',
126 ),
127 'action' => array(
128 'description' => __( 'Completion action.', 'wpfnl' ),
129 'type' => 'string',
130 'required' => false,
131 'sanitize_callback' => 'sanitize_text_field',
132 ),
133 'goal' => array(
134 'description' => __( 'Selected goal slug.', 'wpfnl' ),
135 'type' => 'string',
136 'required' => false,
137 'sanitize_callback' => 'sanitize_text_field',
138 'default' => '',
139 ),
140 'total_steps' => array(
141 'description' => __( 'Total number of steps in the wizard for this goal path.', 'wpfnl' ),
142 'type' => 'integer',
143 'required' => false,
144 'sanitize_callback' => 'absint',
145 'default' => 0,
146 ),
147 ),
148 ),
149 )
150 );
151
152 register_rest_route(
153 $this->namespace,
154 '/' . $this->rest_base . '/products',
155 array(
156 array(
157 'methods' => WP_REST_Server::READABLE,
158 'callback' => array( $this, 'get_products' ),
159 'permission_callback' => function () {
160 return current_user_can( 'manage_options' );
161 },
162 'args' => array(
163 'search' => array(
164 'description' => __( 'Search term for narrowing down WooCommerce products.', 'wpfnl' ),
165 'type' => 'string',
166 'required' => false,
167 'sanitize_callback' => 'sanitize_text_field',
168 ),
169 'limit' => array(
170 'description' => __( 'Maximum number of products to return.', 'wpfnl' ),
171 'type' => 'integer',
172 'required' => false,
173 'sanitize_callback' => 'absint',
174 'default' => 3,
175 ),
176 ),
177 ),
178 )
179 );
180 }
181
182 /**
183 * Handle setup wizard completion step.
184 *
185 * @param WP_REST_Request $request The REST request object.
186 *
187 * @return \WP_REST_Response
188 */
189 public function complete_step( WP_REST_Request $request ) {
190 $funnel_id = isset( $request['funnelId'] ) ? absint( $request['funnelId'] ) : 0;
191 $action = isset( $request['action'] ) ? sanitize_text_field( $request['action'] ) : '';
192 $goal = isset( $request['goal'] ) ? sanitize_text_field( $request['goal'] ) : '';
193 $total_steps = isset( $request['total_steps'] ) ? absint( $request['total_steps'] ) : 0;
194
195 /**
196 * Fires when setup wizard completes an action.
197 *
198 * @param int $funnel_id Funnel ID.
199 * @param string $action Completion action.
200 * @param string $goal Completion goal.
201 */
202 do_action( 'wpfunnels_setup_wizard_complete', $funnel_id, $action, $goal );
203
204 // Fire the unified onboarding outcome event.
205 $telemetry = \WPFunnels\Tracking\Telemetry::instance();
206 if ( $telemetry ) {
207 $telemetry->track_onboarding_progress( [
208 'outcome' => 'completed',
209 'last_step_name' => 'complete',
210 'last_step_index' => $total_steps ?: null,
211 'total_steps' => $total_steps,
212 'goal' => $goal,
213 'funnel_id' => $funnel_id,
214 ] );
215 }
216
217 return rest_ensure_response( array(
218 'success' => true,
219 ) );
220 }
221
222 /**
223 * Persist the user's telemetry consent decision.
224 *
225 * Called from Welcome.vue when the user clicks "Start setup" so that
226 * subsequent per-step tracking calls pass the SDK's opt-in gate.
227 *
228 * @param WP_REST_Request $request
229 * @return \WP_REST_Response
230 */
231 public function save_consent( WP_REST_Request $request ) {
232 $consented = (bool) $request['consented'];
233 $telemetry = \WPFunnels\Tracking\Telemetry::instance();
234 if ( $telemetry ) {
235 $telemetry->save_consent( $consented );
236 }
237 return rest_ensure_response( array( 'success' => true ) );
238 }
239
240 /**
241 * Receive an onboarding step event from the setup wizard frontend.
242 *
243 * For viewed/completed events, dispatches track_onboarding_step().
244 * For abandoned events, dispatches track_onboarding_progress() with outcome 'exited'.
245 * For skipped events (user reached template picker but didn't import),
246 * dispatches track_onboarding_progress() with outcome 'skipped'.
247 *
248 * @param WP_REST_Request $request
249 * @return \WP_REST_Response
250 */
251 public function track_step( WP_REST_Request $request ) {
252 $data = array(
253 'event_type' => $request['event_type'],
254 'step_name' => $request['step_name'],
255 'step_index' => (int) $request['step_index'],
256 'goal' => (string) $request['goal'],
257 'time_on_step' => (int) $request['time_on_step'],
258 'total_steps' => (int) ( $request['total_steps'] ?? 0 ),
259 );
260
261 $telemetry = \WPFunnels\Tracking\Telemetry::instance();
262 if ( $telemetry ) {
263 if ( 'abandoned' === $data['event_type'] ) {
264 $telemetry->track_onboarding_progress( [
265 'outcome' => 'exited',
266 'last_step_name' => $data['step_name'],
267 'last_step_index' => $data['step_index'],
268 'total_steps' => $data['total_steps'],
269 'goal' => $data['goal'],
270 'funnel_id' => 0,
271 ] );
272 } elseif ( 'skipped' === $data['event_type'] ) {
273 // User reached the template selection screen but exited without importing
274 $telemetry->track_onboarding_progress( [
275 'outcome' => 'skipped',
276 'last_step_name' => $data['step_name'],
277 'last_step_index' => $data['step_index'],
278 'total_steps' => $data['total_steps'],
279 'goal' => $data['goal'],
280 'funnel_id' => 0,
281 ] );
282 } else {
283 $telemetry->track_onboarding_step( $data );
284 }
285 }
286
287 return rest_ensure_response( array( 'success' => true ) );
288 }
289
290 /**
291 * Retrieve WooCommerce products for the setup wizard Product Sync step.
292 *
293 * @param WP_REST_Request $request The REST request object.
294 *
295 * @return \WP_REST_Response
296 */
297 public function get_products( WP_REST_Request $request ) {
298 if ( ! class_exists( 'WooCommerce' ) ) {
299 return rest_ensure_response( array(
300 'success' => false,
301 'products' => array(),
302 'message' => __( 'WooCommerce needs to be active to sync products.', 'wpfnl' ),
303 ) );
304 }
305
306 $search = $request->get_param( 'search' );
307 $search = $search ? sanitize_text_field( wp_unslash( $search ) ) : '';
308 $default_limit = $search ? 20 : 3;
309 $max_limit = $search ? 50 : 3;
310
311 $limit = $request->get_param( 'limit' );
312 $limit = $limit ? absint( $limit ) : $default_limit;
313 $limit = $limit > 0 ? min( $limit, $max_limit ) : $default_limit;
314
315 $products = array();
316
317 if ( $search ) {
318 $wp_args = array(
319 'post_type' => 'product',
320 'post_status' => 'publish',
321 'posts_per_page' => $limit,
322 's' => $search,
323 'orderby' => 'relevance',
324 'order' => 'ASC',
325 'fields' => 'ids',
326 );
327
328 $query = new \WP_Query( $wp_args );
329 $product_ids = $query->posts;
330
331 if ( ! empty( $product_ids ) ) {
332 foreach ( $product_ids as $pid ) {
333 $product = wc_get_product( $pid );
334 if ( $product ) {
335 $products[] = $product;
336 }
337 }
338 }
339 } else {
340 $args = array(
341 'status' => array( 'publish' ),
342 'limit' => $limit,
343 'order' => 'DESC',
344 'return' => 'objects',
345 'orderby' => 'meta_value_num',
346 'meta_key' => 'total_sales',
347 'meta_type' => 'NUMERIC',
348 );
349
350 $products = wc_get_products( $args );
351 }
352 $prepared_data = array();
353
354 if ( ! empty( $products ) ) {
355 foreach ( $products as $product ) {
356 if ( ! $product ) {
357 continue;
358 }
359
360 $raw_description = $product->get_short_description();
361 if ( empty( $raw_description ) ) {
362 $raw_description = $product->get_description();
363 }
364
365 $description = wp_strip_all_tags( $raw_description );
366 $description = $description ? wp_trim_words( $description, 24, '...' ) : __( 'WooCommerce product', 'wpfnl' );
367
368 $price_html = $product->get_price_html();
369 $price_text = $this->format_product_price( $product );
370 $image_id = $product->get_image_id();
371 $image_url = $image_id ? wp_get_attachment_image_url( $image_id, 'medium' ) : ( function_exists( 'wc_placeholder_img_src' ) ? wc_placeholder_img_src() : '' );
372
373 $prepared_data[] = array(
374 'id' => $product->get_id(),
375 'title' => $product->get_name(),
376 'description' => $description,
377 'price' => $price_text,
378 'price_html' => $price_html,
379 'type' => $product->get_type(),
380 'stock_status' => $product->get_stock_status(),
381 'image' => $image_url,
382 'permalink' => get_permalink( $product->get_id() ),
383 );
384 }
385 }
386
387 return rest_ensure_response( array(
388 'success' => true,
389 'products' => $prepared_data,
390 'total' => count( $prepared_data ),
391 'message' => empty( $prepared_data ) ? __( 'No products matched your query.', 'wpfnl' ) : '',
392 ) );
393 }
394
395 /**
396 * Convert WooCommerce price HTML into a readable one-line string.
397 *
398 * @param string $price_markup Raw WooCommerce price markup.
399 * @return string
400 */
401 private function get_plain_price( $price_markup ) {
402 if ( empty( $price_markup ) ) {
403 return '';
404 }
405
406 // Remove screen reader helper text to avoid verbose sentences after strip tags.
407 $price_markup = preg_replace( '/<span class="screen-reader-text">.*?<\/span>/is', '', $price_markup );
408
409 $price_markup = html_entity_decode( wp_strip_all_tags( $price_markup ) );
410 // Collapse duplicate whitespace.
411 $price_markup = trim( preg_replace( '/\s+/', ' ', $price_markup ) );
412
413 return $price_markup;
414 }
415
416 /**
417 * Build a concise, human-readable price string with sale context when available.
418 *
419 * @param \WC_Product $product WooCommerce product instance.
420 * @return string
421 */
422 private function format_product_price( $product ) {
423 if ( ! $product ) {
424 return '';
425 }
426
427 $regular_price = $product->get_regular_price();
428 $sale_price = $product->get_sale_price();
429
430 if ( $product->is_type( array( 'variable', 'variable-subscription' ) ) ) {
431 $regular_price = $product->get_variation_regular_price( 'min', true );
432 $sale_price = $product->get_variation_sale_price( 'min', true );
433 }
434
435 $regular_markup = $regular_price !== '' ? wc_price( $regular_price ) : wc_price( $product->get_price() );
436 $regular_text = $this->get_plain_price( $regular_markup );
437
438 if ( $sale_price !== '' && $sale_price !== null && (float) $sale_price > 0 && (float) $sale_price < (float) $regular_price ) {
439 $sale_markup = wc_price( $sale_price );
440 $sale_text = $this->get_plain_price( $sale_markup );
441 return sprintf( '%s (%s %s)', $sale_text, __( 'was', 'wpfnl' ), $regular_text );
442 }
443
444 return $regular_text;
445 }
446 }
447