PluginProbe
EasyTest – Simplify A/B Testing / trunk
EasyTest – Simplify A/B Testing vtrunk
1.0.4 1.0.3 trunk 1.0.1 1.0.2
convertpro / includes / function.php

function.php in EasyTest – Simplify A/B Testing trunk, at includes/function.php

1,180 lines 40.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit; // Called directly, nothing to do here.
5 }
6
7 use ConvertPro\Classes\Repo;
8
9 /**
10 * How long a visitor stays assigned to the variation they were given.
11 */
12 if (!defined('CONVERTPRO_COOKIE_LIFETIME')) {
13 define('CONVERTPRO_COOKIE_LIFETIME', 86400 * 30);
14 }
15
16 /**
17 * SQL expression for the day a visitor entered a test.
18 *
19 * Rows written before `created_at` was populated hold a zero date, which would
20 * otherwise all pile up under 0000-00-00, so those fall back to `updated_at`.
21 *
22 * @param string $alias Table alias, without the trailing dot.
23 * @return string
24 */
25 function convertpro_entry_date_sql($alias = 'i')
26 {
27 return "COALESCE(NULLIF({$alias}.created_at, '0000-00-00 00:00:00'), {$alias}.updated_at)";
28 }
29
30 /**
31 * The path of the request being served, with no host, query or edge slashes.
32 *
33 * Kept deliberately raw — it is only ever compared against another path, never
34 * printed, and running it through sanitize_text_field() would strip percent
35 * escapes and make an encoded path stop matching itself.
36 *
37 * @return string
38 */
39 function convertpro_current_path()
40 {
41 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
42 $uri = isset($_SERVER['REQUEST_URI']) ? wp_unslash($_SERVER['REQUEST_URI']) : '';
43 $path = wp_parse_url($uri, PHP_URL_PATH);
44
45 return is_string($path) ? trim($path, '/') : '';
46 }
47
48 /**
49 * Would redirecting here send the browser straight back to where it already is?
50 *
51 * Tests saved before 1.0.3 could use a test URL that was also one of the pages
52 * being tested, which loops until the browser gives up. Saving now refuses that,
53 * but rows already in the table keep looping after an update, and the person
54 * seeing the loop often cannot reach wp-admin to fix it.
55 *
56 * @param string $url Redirect target.
57 * @return bool
58 */
59 function convertpro_redirect_loops($url)
60 {
61 if (empty($url) || !is_string($url)) {
62 return true;
63 }
64
65 $path = wp_parse_url($url, PHP_URL_PATH);
66 $path = is_string($path) ? trim($path, '/') : '';
67
68 return $path === convertpro_current_path();
69 }
70
71 /**
72 * Carry the query string the visitor arrived with over to the redirect target.
73 *
74 * Someone landing on a test URL from an ad arrives with the campaign tags the
75 * test exists to measure. Rebuilding the target from the page permalink dropped
76 * every one of them, so the variation page saw no utm_source, no gclid, and the
77 * attribution was gone before the page rendered.
78 *
79 * The target keeps its own parameters; anything the visitor arrived with wins on
80 * a clash.
81 *
82 * @param string $url Redirect target.
83 * @return string
84 */
85 function convertpro_forward_query_string($url)
86 {
87 if (empty($url) || !is_string($url)) {
88 return $url;
89 }
90
91 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended
92 $incoming = isset($_SERVER['QUERY_STRING']) ? wp_unslash($_SERVER['QUERY_STRING']) : '';
93
94 if ('' === $incoming) {
95 return $url;
96 }
97
98 $args = array();
99 wp_parse_str($incoming, $args);
100
101 if (empty($args)) {
102 return $url;
103 }
104
105 // Values are re-encoded by add_query_arg() and the result goes through
106 // wp_redirect(), which strips anything that could break the header, so
107 // stripping tags is all that is needed here.
108 $args = map_deep($args, 'wp_strip_all_tags');
109
110 return add_query_arg($args, $url);
111 }
112
113 /**
114 * What the free tier allows, before any per-site exemption.
115 *
116 * **This is the switch.** Zero means no limit, and zero everywhere means the
117 * limits are not in force at all — no counting, no notices, nothing recorded.
118 *
119 * They are held at zero on purpose. The store-page banner still says "Unlimited
120 * Tests & Variations", and shipping a limit while the shop window promises the
121 * opposite is how you earn one-star reviews. Turn these on in the same release
122 * that the banner changes, not before.
123 *
124 * When that happens, the numbers to use are 3 and 2 — one better than AB Split
125 * Test's free tier, which allows a single test with a single variation, and
126 * better than Nelio's one test.
127 *
128 * @return array
129 */
130 function convertpro_free_limit_defaults()
131 {
132 return array(
133 'tests' => 0,
134 'variations' => 0,
135 );
136 }
137
138 /**
139 * How much the free plugin allows, for this site.
140 *
141 * Zero means no limit. Sites that had EasyTest before the limits came in get
142 * zero for everything and never see any of this — see
143 * ConvertPro::remember_free_limits().
144 *
145 * @param string $what Either 'tests' or 'variations'.
146 * @return int
147 */
148 function convertpro_free_limit($what)
149 {
150 $limits = convertpro_free_limit_defaults();
151
152 $limit = isset($limits[$what]) ? $limits[$what] : 0;
153
154 if ('off' === get_option('convertpro_free_limits')) {
155 $limit = 0;
156 }
157
158 /**
159 * Filter a free-tier limit. Return 0 for no limit.
160 *
161 * @param int $limit
162 * @param string $what 'tests' or 'variations'.
163 */
164 return (int) apply_filters('convertpro_free_limit', $limit, $what);
165 }
166
167 /**
168 * How many tests are running on this site right now.
169 *
170 * @return int
171 */
172 function convertpro_active_test_count()
173 {
174 global $wpdb;
175
176 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
177 return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}convertpro WHERE active = 1");
178 }
179
180 /**
181 * Is there room for another test?
182 *
183 * @return bool
184 */
185 function convertpro_can_add_test()
186 {
187 $limit = convertpro_free_limit('tests');
188
189 return !$limit || convertpro_active_test_count() < $limit;
190 }
191
192 /**
193 * Note that this site has filled up its free allowance.
194 *
195 * Recorded once, the first time it happens, so we can answer one question before
196 * building anything paid: how many people actually run out of room, and how long
197 * it takes them. Asking that with a prompt would only hear from the enthusiastic;
198 * counting hears from everyone.
199 *
200 * Nothing leaves the site unless the owner has opted into usage tracking — see
201 * convertpro_free_tier_usage().
202 *
203 * @return void
204 */
205 function convertpro_record_cap_reached()
206 {
207 $usage = get_option('convertpro_cap_usage', array());
208
209 if (!empty($usage['reached'])) {
210 return;
211 }
212
213 $usage['reached'] = time();
214
215 update_option('convertpro_cap_usage', $usage);
216 }
217
218 /**
219 * Note a save that was refused for being over the limit.
220 *
221 * Rarer than reaching the cap, and a stronger signal: the form hides the controls
222 * at the limit, so getting here means someone went round the UI to try anyway.
223 *
224 * @param string $what 'tests' or 'variations'.
225 * @return void
226 */
227 function convertpro_record_cap_blocked($what)
228 {
229 $usage = get_option('convertpro_cap_usage', array());
230 $key = 'blocked_' . $what;
231
232 $usage[$key] = isset($usage[$key]) ? (int) $usage[$key] + 1 : 1;
233
234 update_option('convertpro_cap_usage', $usage);
235 }
236
237 /**
238 * What gets added to the usage report, for sites that opted into one.
239 *
240 * Attached through Finestics' add_extra(), which only runs when
241 * `convertpro_allow_tracking` is 'yes'. No new outbound request, no personal
242 * data — counts and timestamps only.
243 *
244 * @return array
245 */
246 function convertpro_free_tier_usage()
247 {
248 $usage = get_option('convertpro_cap_usage', array());
249 $review = convertpro_review_state();
250
251 return array(
252 'free_limits' => (string) get_option('convertpro_free_limits', 'unknown'),
253 'installed_at' => (int) get_option('convertpro_installed', 0),
254 'tests' => convertpro_active_test_count(),
255 'cap_reached' => isset($usage['reached']) ? (int) $usage['reached'] : 0,
256 'cap_blocked_tests' => isset($usage['blocked_tests']) ? (int) $usage['blocked_tests'] : 0,
257 'cap_blocked_variations' => isset($usage['blocked_variations']) ? (int) $usage['blocked_variations'] : 0,
258
259 // The review ask. `review_clicked_at` means they went to WordPress.org,
260 // not that they left a review — WordPress.org tells us nothing back, so
261 // there is no honest way to know. Do not label it as reviews.
262 'review_asked_at' => (int) $review['asked_at'],
263 'review_answer' => (string) $review['answer'],
264 'review_clicked_at' => (int) $review['clicked_at'],
265 );
266 }
267
268 /**
269 * Everything the site has done with the review ask.
270 *
271 * One option, one decision per site rather than per user: two administrators
272 * should not each be asked.
273 *
274 * @return array
275 */
276 function convertpro_review_state()
277 {
278 $state = get_option('convertpro_review', array());
279
280 return wp_parse_args(is_array($state) ? $state : array(), array(
281 'asked_at' => 0, // first time the ask was shown
282 'answer' => '', // happy | unhappy | later | dismissed
283 'answered_at' => 0,
284 'clicked_at' => 0, // went to wordpress.org
285 ));
286 }
287
288 /**
289 * Record something the visitor did with the ask.
290 *
291 * @param array $changes
292 * @return void
293 */
294 function convertpro_review_update($changes)
295 {
296 update_option('convertpro_review', array_merge(convertpro_review_state(), $changes));
297 }
298
299 /**
300 * Has this test produced something worth reviewing the plugin over?
301 *
302 * The bar is that the plugin visibly did its job in the current run: visitors
303 * were split across at least two versions, and at least one conversion was
304 * recorded. That is assignment and conversion tracking both demonstrably
305 * working, which is the whole thing being reviewed.
306 *
307 * It was briefly stricter — conversions on *two* versions — and that turned out
308 * to be nearly unreachable. Across five real tests carrying 11 to 66 visitors,
309 * only two ever qualified, so most report screens would have shown nothing at
310 * all. Conversions land on one version long before they land on both.
311 *
312 * @param int $test_id
313 * @return bool
314 */
315 function convertpro_test_has_result($test_id)
316 {
317 global $wpdb;
318
319 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
320 $row = $wpdb->get_row(
321 $wpdb->prepare(
322 "SELECT COUNT(DISTINCT variation_id) AS versions,
323 SUM(CASE WHEN type = 'conversion' THEN 1 ELSE 0 END) AS conversions
324 FROM {$wpdb->prefix}convertpro_interactions
325 WHERE splittest_id = %d AND run = %d",
326 (int) $test_id,
327 convertpro_get_test_run($test_id)
328 )
329 );
330
331 if (!$row) {
332 return false;
333 }
334
335 return (int) $row->versions >= 2 && (int) $row->conversions >= 1;
336 }
337
338 /**
339 * Should the review ask appear on this report screen?
340 *
341 * @param int $test_id
342 * @return bool
343 */
344 function convertpro_should_ask_for_review($test_id)
345 {
346 if (!current_user_can('manage_options')) {
347 return false;
348 }
349
350 $state = convertpro_review_state();
351
352 // Asked once. Dismissed, answered happily, or already clicked through, and
353 // that is the end of it.
354 if ($state['clicked_at'] || in_array($state['answer'], array('happy', 'unhappy', 'dismissed'), true)) {
355 return false;
356 }
357
358 // "Not now" earns a long silence, and only then if they are still using it.
359 if ('later' === $state['answer'] && $state['answered_at'] > time() - (30 * DAY_IN_SECONDS)) {
360 return false;
361 }
362
363 /**
364 * Filter the review ask off entirely.
365 *
366 * @param bool $show
367 */
368 if (!apply_filters('convertpro_show_review_ask', true)) {
369 return false;
370 }
371
372 return convertpro_test_has_result($test_id);
373 }
374
375 /**
376 * Should the follow-up — the one carrying the review link — still be shown?
377 *
378 * Only after they answered, only until they act on it, and **only for a
379 * fortnight**. Without the time limit it would sit on the report screen for the
380 * life of the install waiting to be clicked, which is a nag by any other name.
381 * Two weeks of not clicking is an answer.
382 *
383 * @return bool
384 */
385 function convertpro_should_show_review_link()
386 {
387 if (!current_user_can('manage_options')) {
388 return false;
389 }
390
391 $state = convertpro_review_state();
392
393 if ($state['clicked_at'] || !in_array($state['answer'], array('happy', 'unhappy'), true)) {
394 return false;
395 }
396
397 if (!apply_filters('convertpro_show_review_ask', true)) {
398 return false;
399 }
400
401 return $state['answered_at'] > time() - (14 * DAY_IN_SECONDS);
402 }
403
404 /**
405 * Where the review link points.
406 *
407 * @return string
408 */
409 function convertpro_review_url()
410 {
411 return 'https://wordpress.org/support/plugin/convertpro/reviews/#new-post';
412 }
413
414 /**
415 * A nonce-checked link that records an answer before going anywhere.
416 *
417 * @param string $answer happy | unhappy | later | dismissed
418 * @param int $test_id Report being viewed, so we can come back to it.
419 * @return string
420 */
421 function convertpro_review_action_url($answer, $test_id)
422 {
423 return wp_nonce_url(
424 admin_url(sprintf(
425 'admin.php?page=convertpro-settings&scope=test&action=review&answer=%s&id=%d',
426 rawurlencode($answer),
427 (int) $test_id
428 )),
429 'convertpro-review'
430 );
431 }
432
433 /**
434 * What a page is called in the pickers.
435 *
436 * Duplicating a page is how most people build a second version, so two pages
437 * with the same title is the normal case here rather than an oddity. When it
438 * happens the title on its own tells you nothing, so the address goes beside it.
439 *
440 * @param WP_Post $page The page being listed.
441 * @param array $pages Every page in the same list.
442 * @return string
443 */
444 function convertpro_page_option_label($page, $pages)
445 {
446 static $seen = null;
447
448 if (null === $seen) {
449 $seen = array();
450
451 foreach ($pages as $other) {
452 $title = $other->post_title;
453 $seen[$title] = isset($seen[$title]) ? $seen[$title] + 1 : 1;
454 }
455 }
456
457 $title = $page->post_title;
458
459 if (empty($seen[$title]) || $seen[$title] < 2) {
460 return $title;
461 }
462
463 return sprintf('%s (/%s/)', $title, $page->post_name);
464 }
465
466 /**
467 * Tell page caches not to store this response.
468 *
469 * A cached response is the same for everyone, so whichever variation the first
470 * visitor happened to get would be served to all of them and the test would
471 * quietly measure nothing. Called only on requests that actually take part in a
472 * test, so the rest of the site keeps its cache.
473 *
474 * Recognised by WP Rocket, W3 Total Cache, LiteSpeed Cache, WP Super Cache and
475 * others through the DONOTCACHEPAGE constant. Caches that sit in front of PHP,
476 * at the host or a CDN, never see this and need their own exclusion rule.
477 *
478 * @return void
479 */
480 function convertpro_prevent_page_cache()
481 {
482 /**
483 * Filter whether to ask page caches to skip this response.
484 *
485 * @param bool $prevent
486 */
487 if (!apply_filters('convertpro_prevent_page_cache', true)) {
488 return;
489 }
490
491 if (!defined('DONOTCACHEPAGE')) {
492 define('DONOTCACHEPAGE', true);
493 }
494
495 if (!defined('DONOTCACHEOBJECT')) {
496 define('DONOTCACHEOBJECT', true);
497 }
498
499 if (!defined('DONOTCACHEDB')) {
500 define('DONOTCACHEDB', true);
501 }
502
503 if (headers_sent()) {
504 return;
505 }
506
507 nocache_headers();
508 header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0, private');
509 }
510
511 /**
512 * Which edge cache, if any, sits in front of this site.
513 *
514 * These run before PHP does, so nothing the plugin sends can reach them. All we
515 * can do is recognise them and tell the person what rule to add.
516 *
517 * @return array|null Name and instructions, or null when nothing is detected.
518 */
519 function convertpro_detect_edge_cache()
520 {
521 $test_url = home_url('/your-test-url/');
522
523 if (isset($_SERVER['HTTP_CF_RAY']) || isset($_SERVER['HTTP_CF_CONNECTING_IP'])) {
524 return array(
525 'name' => 'Cloudflare',
526 'how' => sprintf(
527 /* translators: %s: example test URL. */
528 __('In Cloudflare, add a Cache Rule for %s with caching set to Bypass. Without it Cloudflare answers from its own copy and every visitor lands on the same version.', 'convertpro'),
529 $test_url
530 ),
531 );
532 }
533
534 if (isset($_SERVER['HTTP_X_SUCURI_CLIENTIP'])) {
535 return array(
536 'name' => 'Sucuri',
537 'how' => __('In the Sucuri firewall, add your test URL to the cache exceptions list.', 'convertpro'),
538 );
539 }
540
541 if (defined('KINSTAMU_VERSION')) {
542 return array(
543 'name' => 'Kinsta',
544 'how' => __('Ask Kinsta support to exclude your test URL from the server cache, or add it under Tools → Cache exclusions.', 'convertpro'),
545 );
546 }
547
548 if (class_exists('WpeCommon')) {
549 return array(
550 'name' => 'WP Engine',
551 'how' => __('In the WP Engine portal, add your test URL as a cache exclusion.', 'convertpro'),
552 );
553 }
554
555 if (defined('SG_CACHE_PLUGIN_DIR') || isset($_SERVER['HTTP_X_PROXY_CACHE'])) {
556 return array(
557 'name' => 'SiteGround',
558 'how' => __('In SG Optimizer, add your test URL under Dynamic Caching exclusions.', 'convertpro'),
559 );
560 }
561
562 return null;
563 }
564
565 /**
566 * Load the click tracker for visitors who are in a test that counts clicks.
567 *
568 * Nothing is enqueued for visitors who have not been put into such a test, so
569 * the vast majority of page views carry no extra script at all.
570 *
571 * @return void
572 */
573 function convertpro_enqueue_click_goals()
574 {
575 if (is_admin()) {
576 return;
577 }
578
579 global $wpdb;
580
581 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
582 $tests = $wpdb->get_results("SELECT id, test_type, conversion_url FROM {$wpdb->prefix}convertpro WHERE active = 1 AND conversion_type = 'click' AND conversion_url != ''");
583
584 if (!$tests) {
585 return;
586 }
587
588 $watching = array();
589
590 foreach ($tests as $test) {
591 $prefix = ('elements' === $test->test_type) ? 'convert_pro_elm_variation_id_' : 'convert_pro_variation_id_';
592 $cookie = convertpro_test_cookie_name($prefix, $test->id);
593
594 if (empty($_COOKIE[$cookie])) {
595 continue;
596 }
597
598 $patterns = array_values(array_filter(array_map('trim', explode(',', $test->conversion_url))));
599
600 if (!$patterns) {
601 continue;
602 }
603
604 $watching[] = array(
605 'id' => (int) $test->id,
606 'variation' => (int) sanitize_text_field(wp_unslash($_COOKIE[$cookie])),
607 'patterns' => $patterns,
608 );
609 }
610
611 if (!$watching) {
612 return;
613 }
614
615 wp_enqueue_script('convertpro-click-goal');
616 wp_localize_script('convertpro-click-goal', 'convertproClickGoals', array(
617 'endpoint' => esc_url_raw(rest_url('convertpro/v1/conversion')),
618 'tests' => $watching,
619 ));
620 }
621 add_action('wp_enqueue_scripts', 'convertpro_enqueue_click_goals');
622
623 /**
624 * Mark this visitor's interaction with a test as a conversion.
625 *
626 * Flipping the row they already have keeps one row per visitor per run, so a
627 * visitor can only ever be counted once however many times this is called.
628 *
629 * @param int $test_id
630 * @param int $variation_id
631 * @param string $client_id Visitor uid from the cookie.
632 * @return bool Whether a row was updated.
633 */
634 function convertpro_record_conversion($test_id, $variation_id, $client_id)
635 {
636 if (!$test_id || !$variation_id || '' === $client_id) {
637 return false;
638 }
639
640 global $wpdb;
641
642 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
643 return (bool) $wpdb->update(
644 $wpdb->prefix . 'convertpro_interactions',
645 array('type' => 'conversion'),
646 array(
647 'splittest_id' => (int) $test_id,
648 'variation_id' => (int) $variation_id,
649 'client_id' => $client_id,
650 'run' => convertpro_get_test_run($test_id),
651 )
652 );
653 }
654
655 /**
656 * Record a conversion reported by the click tracker on the front end.
657 *
658 * Deliberately unauthenticated and nonce-free: visitors are anonymous, and a
659 * nonce baked into a cached page goes stale and silently loses conversions.
660 * Nothing here can be abused beyond marking your own visit as converted, which
661 * you could do by clicking anyway.
662 *
663 * @param WP_REST_Request $request
664 * @return WP_REST_Response
665 */
666 function convertpro_rest_record_click($request)
667 {
668 $test_id = (int) $request->get_param('test');
669 $variation_id = (int) $request->get_param('variation');
670 $client_id = isset($_COOKIE['convert_pro_uid'])
671 ? sanitize_text_field(wp_unslash($_COOKIE['convert_pro_uid']))
672 : '';
673
674 $recorded = convertpro_record_conversion($test_id, $variation_id, $client_id);
675
676 return new WP_REST_Response(array('recorded' => $recorded), 200);
677 }
678
679 add_action('rest_api_init', function () {
680 register_rest_route(
681 'convertpro/v1',
682 '/conversion',
683 array(
684 'methods' => 'POST',
685 'callback' => 'convertpro_rest_record_click',
686 'permission_callback' => '__return_true',
687 'args' => array(
688 'test' => array('required' => true, 'sanitize_callback' => 'absint'),
689 'variation' => array('required' => true, 'sanitize_callback' => 'absint'),
690 ),
691 )
692 );
693 });
694
695 /**
696 * Current run (round) number for a test.
697 *
698 * Resetting a test increments this, which both changes the cookie names — so
699 * previously assigned visitors are re-bucketed — and separates the new data
700 * from the previous round's history.
701 *
702 * @param int $test_id
703 * @return int
704 */
705 function convertpro_get_test_run($test_id)
706 {
707 return max(1, (int) get_option('convertpro_test_run_' . (int) $test_id, 1));
708 }
709
710 /**
711 * Keep a version's CSS class to the characters a class can actually contain.
712 *
713 * The class is typed by hand and the element engine hands it straight to a CSS
714 * selector, so anything that is selector syntax rather than a name changes what
715 * the selector matches. A class of `*` matches every element on the page,
716 * including <html>, and removing those leaves the visitor a blank page.
717 *
718 * @param string $class
719 * @return string The class, or an empty string when it is not a plain class name.
720 */
721 function convertpro_safe_class_name($class)
722 {
723 $class = trim((string) $class);
724
725 return preg_match('/^[A-Za-z0-9_-]+$/', $class) ? $class : '';
726 }
727
728 /**
729 * Hold on to what someone typed when a save is turned away.
730 *
731 * Saving redirects, so $_POST is gone by the time the form is drawn again and
732 * the form only knows how to fill itself in from a saved test. That is fine for
733 * an edit and useless for a create: the work is simply lost, which is harsh when
734 * the only thing wrong was that the page had been open too long.
735 *
736 * The draft is keyed to the person, used once, and short-lived.
737 *
738 * @param int $test_id Test being updated, or 0 when creating.
739 * @return void
740 */
741 function convertpro_stash_form($test_id = 0)
742 {
743 // phpcs:disable WordPress.Security.NonceVerification.Missing -- callers verify the nonce first.
744 $variations = array();
745
746 if (isset($_POST['test-variation']) && is_array($_POST['test-variation'])) {
747 foreach (wp_unslash($_POST['test-variation']) as $row) {
748 if (!is_array($row)) {
749 continue;
750 }
751
752 $variations[] = array(
753 'id' => isset($row['id']) ? sanitize_text_field($row['id']) : '',
754 'name' => isset($row['name']) ? sanitize_text_field($row['name']) : '',
755 'page_id' => isset($row['page-id']) ? (int) $row['page-id'] : 0,
756 'percentage' => isset($row['percentage']) ? sanitize_text_field($row['percentage']) : '',
757 'class_name' => isset($row['customclass']) ? sanitize_text_field($row['customclass']) : '',
758 );
759 }
760 }
761
762 $draft = array(
763 'test_id' => (int) $test_id,
764 'name' => isset($_POST['test-name']) ? sanitize_text_field(wp_unslash($_POST['test-name'])) : '',
765 'test_type' => isset($_POST['convertpro-test-type']) ? sanitize_text_field(wp_unslash($_POST['convertpro-test-type'])) : '',
766 'test_uri' => isset($_POST['test-uri']) ? sanitize_text_field(wp_unslash($_POST['test-uri'])) : '',
767 'conversion_type' => isset($_POST['test-conversion-type']) ? sanitize_text_field(wp_unslash($_POST['test-conversion-type'])) : '',
768 'conversion_page_id' => isset($_POST['test-conversion-page']) ? (int) $_POST['test-conversion-page'] : 0,
769 'conversion_url' => isset($_POST['test-conversion-selector']) ? wp_strip_all_tags(wp_unslash($_POST['test-conversion-selector'])) : '',
770 'variations' => $variations,
771 );
772 // phpcs:enable WordPress.Security.NonceVerification.Missing
773
774 set_transient('convertpro_form_draft_' . get_current_user_id(), $draft, 15 * MINUTE_IN_SECONDS);
775 }
776
777 /**
778 * Take back the draft left by a turned-away save, if it belongs on this form.
779 *
780 * Used once: a draft left by a rejected create must not reappear on an unrelated
781 * test's edit screen, where saving would write it over that test.
782 *
783 * @param int $test_id Test being edited, or 0 for the create form.
784 * @return array|false
785 */
786 function convertpro_take_form_draft($test_id = 0)
787 {
788 $key = 'convertpro_form_draft_' . get_current_user_id();
789 $draft = get_transient($key);
790
791 if (!is_array($draft)) {
792 return false;
793 }
794
795 // Belongs to a different form. Leave it alone rather than consuming it —
796 // glancing at another test's form should not throw away the work someone
797 // still has waiting on the form it came from. It expires on its own.
798 if ((int) $test_id !== (isset($draft['test_id']) ? (int) $draft['test_id'] : 0)) {
799 return false;
800 }
801
802 delete_transient($key);
803
804 return $draft;
805 }
806
807 /**
808 * Build a run-scoped cookie name, e.g. convert_pro_test_5_r2.
809 *
810 * @param string $prefix Cookie prefix, including the trailing underscore.
811 * @param int $test_id
812 * @return string
813 */
814 function convertpro_test_cookie_name($prefix, $test_id)
815 {
816 return $prefix . (int) $test_id . '_r' . convertpro_get_test_run($test_id);
817 }
818
819 function convertpro_interactions_report_html()
820 {
821
822
823 $id = isset($_GET['id']) ? intval(sanitize_text_field(wp_unslash($_GET['id']))) : 0;
824 $range = isset($_GET['range']) ? sanitize_text_field(wp_unslash(($_GET['range']))) : 7;
825
826 $repo = new Repo();
827 $results = $repo->getVariations($id);
828
829 // Gather the numbers first so every row can be compared against the control
830 // (the first variation) and the totals can be shown.
831 $rows = array();
832 $control_rate = null;
833
834 if ($results) {
835 foreach ($results as $result) {
836 $conversion_count = (int) convertpro_get_conversion($id, $result->id, $range);
837 $total_views = (int) convertpro_get_views($id, $result->id, $range);
838 $conversion_rate = $total_views > 0 ? ($conversion_count / $total_views) * 100 : 0;
839
840 if (null === $control_rate) {
841 $control_rate = $conversion_rate;
842 }
843
844 $rows[] = array(
845 'name' => $result->name,
846 'percentage' => $result->percentage,
847 'views' => $total_views,
848 'conversions' => $conversion_count,
849 'rate' => $conversion_rate,
850 );
851 }
852 }
853
854 ?>
855 <div class="convertpro-fullreport">
856 <table>
857 <tr>
858 <th><?php esc_html_e('Version', 'convertpro'); ?></th>
859 <th><?php esc_html_e('Share', 'convertpro'); ?></th>
860 <th><?php esc_html_e('Views', 'convertpro'); ?></th>
861 <th><?php esc_html_e('Conversions', 'convertpro'); ?></th>
862 <th><?php esc_html_e('Conversion Rate', 'convertpro'); ?></th>
863 <th><?php esc_html_e('vs. Control', 'convertpro'); ?></th>
864 </tr>
865 <?php if ($rows) {
866 foreach ($rows as $index => $row) {
867 $comparison_class = '';
868
869 if (0 === $index) {
870 $comparison = esc_html__('Control', 'convertpro');
871 } elseif ($control_rate > 0) {
872 $uplift = (($row['rate'] - $control_rate) / $control_rate) * 100;
873 // A true minus sign rather than a hyphen, so the figure lines
874 // up with the positive case instead of looking cramped.
875 $sign = $uplift < 0 ? "\xE2\x88\x92" : '+';
876 $comparison = $sign . number_format_i18n(abs($uplift), 1) . '%';
877 $comparison_class = $uplift < 0 ? 'is-down' : 'is-up';
878 } else {
879 $comparison = '';
880 }
881 ?>
882 <tr>
883 <td><?php echo esc_html($row['name']); ?></td>
884 <td><?php echo esc_html($row['percentage']); ?></td>
885 <td><?php echo esc_html(number_format_i18n($row['views'])); ?></td>
886 <td><?php echo esc_html(number_format_i18n($row['conversions'])); ?></td>
887 <td><?php echo esc_html(number_format_i18n($row['rate'], 1)); ?>%</td>
888 <td class="convertpro-vs-control <?php echo esc_attr($comparison_class); ?>"><?php echo esc_html($comparison); ?></td>
889
890 </tr>
891 <?php }
892 } else { ?>
893 <tr>
894 <td colspan="6"><?php esc_html_e('No data available', 'convertpro'); ?></td>
895 </tr>
896 <?php } ?>
897 </table>
898 </div>
899 <p class="description">
900 <?php esc_html_e('The last column compares each version with your control, which is the first one in the list. With only a handful of visitors this number swings around a lot, so give the test time to run before you pick a winner.', 'convertpro'); ?>
901 </p>
902 <?php
903
904
905 }
906
907 function convertpro_interactions_report_ajax()
908 {
909 // Reporting is admin-only: enforce capability and nonce (CVE-2025-63031).
910 if (!current_user_can('manage_options')) {
911 wp_send_json_error(esc_html__('You are not allowed to access this resource.', 'convertpro'), 403);
912 }
913 check_ajax_referer('convertpro-report-nonce', 'nonce');
914
915 if (!isset($_GET['id']))
916 return false;
917 ob_start();
918 convertpro_interactions_report_html();
919 wp_send_json(ob_get_clean());
920 }
921 add_action('wp_ajax_convertpro_interactions_report_ajax', 'convertpro_interactions_report_ajax');
922 function convertpro_interactions_chart_query($id, $range = 7)
923 {
924 if (!$id) {
925 return false;
926 }
927
928
929
930 global $wpdb;
931 $table_name = $wpdb->prefix . 'convertpro_interactions';
932 $test_id = $id;
933 // Handle AJAX request to fetch data based on selected date range
934
935
936 // Calculate the start date based on the selected range
937
938 // Rows are grouped by `created_at`, the day the visitor was put into the
939 // test. Grouping by `updated_at` moved a visitor's view into whatever day
940 // they later converted on, which quietly corrupted the whole time series.
941 // Every row is a participant, so views = all rows in that day's cohort and
942 // conversions are the subset of them that converted.
943 $entry_date = convertpro_entry_date_sql('i');
944
945 $query = "";
946 $placeholders = [];
947 $query .= "SELECT
948 v.id AS variation_id,
949 v.name AS variation_name,
950 DATE_FORMAT({$entry_date}, '%%Y-%%m-%%d') AS interaction_date,
951 DATE_FORMAT({$entry_date}, '%%W') AS day_name,
952 COUNT(i.id) AS daily_views,
953 COUNT(CASE WHEN i.type = 'conversion' THEN 1 END) AS daily_conversions,
954 COUNT(i.id) AS daily_total_interactions
955 FROM
956 {$wpdb->prefix}convertpro_variations AS v
957 INNER JOIN {$wpdb->prefix}convertpro_interactions AS i ON v.id = i.variation_id
958 INNER JOIN {$wpdb->prefix}convertpro AS s ON i.splittest_id = s.id
959 WHERE
960 i.splittest_id = %d
961 AND i.run = %d";
962
963 $placeholders[] = $test_id;
964 $placeholders[] = convertpro_get_test_run($test_id);
965
966 if ($range != 'all') {
967 $query .= " AND {$entry_date} <= NOW()
968 AND {$entry_date} >= DATE_SUB(NOW(), INTERVAL %s DAY)";
969 $placeholders[] = intval($range);
970 // $placeholders[] = $endDate;
971 }
972
973 $query .= " GROUP BY
974 variation_id, variation_name, interaction_date
975 ORDER BY
976 interaction_date ASC";
977
978 $query = $wpdb->prepare(// phpcs:ignore
979 $query, // phpcs:ignore
980 $placeholders
981 );
982
983 return $wpdb->get_results($query, ARRAY_A); // phpcs:ignore
984 }
985
986 /**
987 * Colour for a variation, picked by its position in the test so a variation
988 * keeps the same colour across days and any number of variations is supported.
989 *
990 * @param int $index Zero-based position of the variation.
991 * @param float $alpha 1 for the solid colour, less for a translucent fill.
992 * @return string Hex colour, or rgba() when an alpha is given.
993 */
994 function convertpro_variation_color($index, $alpha = 1)
995 {
996 $palette = array('#3767FB', '#3BCB38', '#EE2626', '#F5A623', '#9B51E0', '#00B8D9', '#FF6B9A', '#7A869A');
997 $hex = $palette[$index % count($palette)];
998
999 if ($alpha >= 1) {
1000 return $hex;
1001 }
1002
1003 return sprintf(
1004 'rgba(%d, %d, %d, %s)',
1005 hexdec(substr($hex, 1, 2)),
1006 hexdec(substr($hex, 3, 2)),
1007 hexdec(substr($hex, 5, 2)),
1008 $alpha
1009 );
1010 }
1011
1012 /**
1013 * Turn chart query rows into Chart.js labels and datasets.
1014 *
1015 * Each variation gets a Views bar and a Conversions bar. The old chart plotted
1016 * views and conversions added together, which is not a number anyone can act on.
1017 *
1018 * @param array $results Rows from convertpro_interactions_chart_query().
1019 * @return array {labels: string[], datasets: array[]}
1020 */
1021 function convertpro_build_chart_datasets($results)
1022 {
1023 if (empty($results)) {
1024 return array('labels' => array(), 'datasets' => array());
1025 }
1026
1027 $labels = array_values(array_unique(array_column($results, 'interaction_date')));
1028 sort($labels);
1029
1030 // Colour by variation id order, not by the order rows happen to come back,
1031 // so a variation keeps the same colour between runs and date ranges.
1032 $variation_ids = array_unique(array_map('intval', array_column($results, 'variation_id')));
1033 sort($variation_ids);
1034 $variation_order = array_flip($variation_ids);
1035
1036 $views = array();
1037 $conversions = array();
1038
1039 foreach ($results as $row) {
1040 $key = (int) $row['variation_id'];
1041 $name = $row['variation_name'];
1042 $date = $row['interaction_date'];
1043 $color = convertpro_variation_color($variation_order[$key]);
1044
1045 // Conversions keep the version's colour but sit behind a lighter fill, so
1046 // the two bars in a pair are the same family and still tell apart. A solid
1047 // fill for both made the legend unreadable.
1048 $conversion_fill = convertpro_variation_color($variation_order[$key], 0.3);
1049
1050 if (!isset($views[$key])) {
1051 $views[$key] = array(
1052 /* translators: %s: variation name. */
1053 'label' => sprintf(__('%s — Views', 'convertpro'), $name),
1054 'data' => array_fill_keys($labels, 0),
1055 'backgroundColor' => $color,
1056 );
1057 $conversions[$key] = array(
1058 /* translators: %s: variation name. */
1059 'label' => sprintf(__('%s — Conversions', 'convertpro'), $name),
1060 'data' => array_fill_keys($labels, 0),
1061 'backgroundColor' => $conversion_fill,
1062 'borderColor' => $color,
1063 'borderWidth' => 2,
1064 );
1065 }
1066
1067 $views[$key]['data'][$date] = (int) $row['daily_views'];
1068 $conversions[$key]['data'][$date] = (int) $row['daily_conversions'];
1069 }
1070
1071 // Views first, then conversions, so each pair sits next to its own colour,
1072 // and always in variation order so the legend does not shuffle between loads.
1073 ksort($views);
1074 $datasets = array();
1075 foreach ($views as $key => $dataset) {
1076 $datasets[] = $dataset;
1077 $datasets[] = $conversions[$key];
1078 }
1079
1080 return array('labels' => $labels, 'datasets' => $datasets);
1081 }
1082
1083 function convertpro_get_chart_data()
1084 {
1085 // Reporting is admin-only: enforce capability and nonce (CVE-2025-63031).
1086 if (!current_user_can('manage_options')) {
1087 wp_send_json_error(esc_html__('You are not allowed to access this resource.', 'convertpro'), 403);
1088 }
1089 check_ajax_referer('convertpro-report-nonce', 'nonce');
1090
1091 if (isset($_GET['range'])) {
1092 $test_id = isset($_GET['id']) ? sanitize_text_field(wp_unslash($_GET['id'])) : false;
1093 // Handle AJAX request to fetch data based on selected date range
1094 $range = isset($_GET['range']) ? sanitize_text_field(wp_unslash($_GET['range'])) : '';
1095
1096 $results = convertpro_interactions_chart_query($test_id, $range);
1097
1098 wp_send_json(convertpro_build_chart_datasets($results));
1099 }
1100 }
1101
1102 // Hook the AJAX handler function to a WordPress AJAX action
1103 add_action('wp_ajax_convertpro_get_chart_data', 'convertpro_get_chart_data');
1104
1105
1106 function convertpro_get_views($test_id, $variation_id, $range = 7)
1107 {
1108 global $wpdb;
1109 $table_name = $wpdb->prefix . 'convertpro_interactions';
1110
1111 // Every participant row counts as a view — a visitor who later converted
1112 // still saw the variation — so this deliberately does not filter on `type`.
1113 // Rows are dated by `created_at` (when the visitor was assigned); using
1114 // `updated_at` would move a view into the day its conversion happened.
1115 $views_query = "";
1116 $views_placeholders = [];
1117 $views_query .= "SELECT COUNT(*) FROM {$table_name} WHERE splittest_id = %d AND variation_id = %d AND run = %d";
1118 $views_placeholders[] = $test_id;
1119 $views_placeholders[] = $variation_id;
1120 $views_placeholders[] = convertpro_get_test_run($test_id);
1121
1122 if ($range != 'all') {
1123
1124 $entry_date = convertpro_entry_date_sql($table_name);
1125 $views_query .= " AND {$entry_date} <= NOW()
1126 AND {$entry_date} >= DATE_SUB(NOW(), INTERVAL %s DAY)";
1127 $views_placeholders[] = intval($range);
1128 }
1129
1130 $views_query = $wpdb->prepare(
1131 $views_query,// phpcs:ignore
1132 $views_placeholders
1133 );
1134
1135 return $wpdb->get_var($views_query);// phpcs:ignore
1136 }
1137 function convertpro_get_conversion($test_id, $variation_id, $range = 7)
1138 {
1139
1140 global $wpdb;
1141 $table_name = $wpdb->prefix . 'convertpro_interactions';
1142
1143 $conversion_query = "";
1144 $conversion_placeholders = [];
1145 $conversion_query .= "SELECT COUNT(*) FROM {$table_name} WHERE type = 'conversion' AND splittest_id = %d AND variation_id = %d AND run = %d";
1146 $conversion_placeholders[] = $test_id;
1147 $conversion_placeholders[] = $variation_id;
1148 $conversion_placeholders[] = convertpro_get_test_run($test_id);
1149
1150 if ($range != 'all') {
1151
1152 // The same window views are counted over, and for the same reason:
1153 // a conversion belongs to the visit that produced it. Filtering these
1154 // on updated_at instead counted someone who arrived a fortnight ago
1155 // and converted yesterday as a conversion with no view behind it, so
1156 // the rate could read above 100% and never matched the chart.
1157 $entry_date = convertpro_entry_date_sql($table_name);
1158 $conversion_query .= " AND {$entry_date} <= NOW()
1159 AND {$entry_date} >= DATE_SUB(NOW(), INTERVAL %s DAY)";
1160 $conversion_placeholders[] = intval($range);
1161 }
1162
1163 $conversion_query = $wpdb->prepare(
1164 $conversion_query,// phpcs:ignore
1165 $conversion_placeholders
1166 );
1167
1168
1169 // Get the count of conversions
1170 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching
1171 return $wpdb->get_var($conversion_query);// phpcs:ignore
1172 }
1173
1174 // Element conversions are recorded by ElementRedirection::update_conversion()
1175 // on the goal page itself. There used to be an admin-ajax route here that did
1176 // the same write for logged-out visitors, guarded only by a referer check and
1177 // blind to which run the visitor belonged to. Nothing had called it since the
1178 // front-end code was commented out, so it has been removed rather than left
1179 // sitting there as an unauthenticated write.
1180