PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.1
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.1
1.6.1 1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
← All changes | inc/admin/analytics.php +273 -12 1.4.0 → 1.6.1 View file →
@@ -6,8 +6,11 @@
6 6 */
7 7
8 8 namespace SureDonation\Inc\Admin;
9 9
10 +use SureDonation\Inc\Campaign_Templates\Campaign_Templates;
11 +use SureDonation\Inc\Campaigns\Campaign_Cpt;
12 +use SureDonation\Inc\Emails\Email_Reports;
10 13 use SureDonation\Inc\Helper;
11 14 use SureDonation\Inc\Payments\Offline\Offline_Helper;
12 15 use SureDonation\Inc\Payments\Payment_Helper;
13 16 use SureDonation\Inc\Payments\PayPal\PayPal_Helper;
@@ -40,12 +43,37 @@
40 43 * @var array<string, string>
41 44 * @since 1.3.0
42 45 */
43 46 public const TRACKED_EVENTS = [
44 - 'configure_gateway' => 'configure_gateway_notice_react_cta',
45 - 'webhook' => 'webhook_notice_react_cta',
46 - 'test_mode' => 'test_mode_notice_react_cta',
47 - 'quick_access' => 'quick_access_configure_gateway_react_cta',
47 + 'configure_gateway' => 'configure_gateway_notice_react_cta',
48 + 'webhook' => 'webhook_notice_react_cta',
49 + 'test_mode' => 'test_mode_notice_react_cta',
50 + 'quick_access' => 'quick_access_configure_gateway_react_cta',
51 + // The PayPal settings panel warns when PayPal has told us the connected
52 + // account cannot be paid. The CTA sends the merchant to PayPal to finish
53 + // setup; the dismiss says they saw it and moved on, which is worth
54 + // separating from never having seen it.
55 + 'paypal_account' => 'paypal_account_warning_react_cta',
56 + 'paypal_account_x' => 'paypal_account_warning_react_dismiss',
57 + 'paypal_webhook_x' => 'paypal_webhook_error_react_dismiss',
58 + // The Stripe settings panel warns when Stripe has told us the connected
59 + // account cannot charge cards. The CTA sends the site owner to their
60 + // Stripe dashboard to resolve it; the dismiss says they saw it and moved
61 + // on, which is worth separating from never having seen it.
62 + 'stripe_account' => 'stripe_account_warning_react_cta',
63 + 'stripe_account_x' => 'stripe_account_warning_react_dismiss',
64 + // The form-side capability notices link here with ?sd_notice=, so the
65 + // settings screen records that one of them is what brought the admin
66 + // over. Their own page is a public donation form, which is no place to
67 + // be loading a tracker.
68 + 'stripe_capability' => 'stripe_capability_notice_cta',
69 + // The other three donation-form notices reach the settings screen the
70 + // same way. Their own page is a public donation form, which is no place
71 + // to be loading a tracker, so the CTA carries a marker and the arrival
72 + // is what gets recorded.
73 + 'frontend_test_mode' => 'frontend_test_mode_notice_cta',
74 + 'frontend_gateway_unavailable' => 'frontend_gateway_unavailable_notice_cta',
75 + 'frontend_gateway_setup' => 'frontend_gateway_setup_notice_cta',
48 76 ];
49 77
50 78 /**
51 79 * BSF_Analytics_Events instance for one-time event tracking.
@@ -90,8 +118,12 @@
90 118
91 119 // Event tracking hooks. Registered outside is_admin() on purpose —
92 120 // onboarding completion and campaign publishes fire during REST requests.
93 121 add_action( 'suredonation_onboarding_user_details_saved', [ $this, 'track_onboarding_completed' ] );
122 + add_action( 'suredonation_campaign_tour_shown', [ $this, 'track_campaign_tour_shown' ] );
123 + add_action( 'suredonation_campaign_tour_outcome', [ $this, 'track_campaign_tour_outcome' ], 10, 2 );
124 + add_action( 'suredonation_campaign_template_picker_opened', [ $this, 'track_campaign_template_picker_opened' ] );
125 + add_action( 'suredonation_campaign_created_from_template', [ $this, 'track_campaign_created_from_template' ] );
94 126 add_action( 'transition_post_status', [ $this, 'track_first_campaign_published' ], 10, 3 );
95 127 add_action( 'current_screen', [ $this, 'track_first_campaign_editor_opened' ] );
96 128 add_action( 'suredonation_privacy_data_exported', [ $this, 'track_privacy_data_exported' ] );
97 129 add_action( 'suredonation_privacy_data_erased', [ $this, 'track_privacy_data_erased' ] );
@@ -278,11 +310,16 @@
278 310 : 'self';
279 311
280 312 $privacy_settings = Privacy_Settings::get_settings();
281 313
314 + $email_reports_settings = Email_Reports::get_settings();
315 +
316 + // Computed once: the headline total below is derived from the same rows.
317 + $template_usage = $this->get_campaign_template_usage();
318 +
282 319 $plugin_data = [
283 - 'free_version' => SUREDONATION_VER,
284 - 'numeric_values' => [
320 + 'free_version' => SUREDONATION_VER,
321 + 'numeric_values' => [
285 322 'total_campaigns' => absint( $campaign_counts->publish ?? 0 ),
286 323 'total_donation_forms' => absint( $form_counts->publish ?? 0 ),
287 324 'total_donations' => $aggregates['total'],
288 325 'completed_donations' => $aggregates['completed'],
@@ -290,10 +327,22 @@
290 327 'total_donors' => $this->get_total_donors(),
291 328 'forms_with_image_block' => $this->get_image_block_form_count(),
292 329 'posts_with_social_sharing_block' => $this->get_social_sharing_block_count(),
293 330 'stripe_accounts_count' => count( Stripe_Helper::get_all_accounts() ),
331 + // Campaigns started from a gallery template — excludes both the
332 + // scratch path and the `general` fallback, so this is the count
333 + // of campaigns that actually adopted a cause template.
334 + 'campaigns_from_template' => array_sum(
335 + array_diff_key(
336 + $template_usage,
337 + [
338 + 'scratch' => 0,
339 + Campaign_Templates::GENERAL => 0,
340 + ]
341 + )
342 + ),
294 343 ],
295 - 'boolean_values' => [
344 + 'boolean_values' => [
296 345 'stripe_enabled' => Stripe_Helper::is_stripe_connected(),
297 346 'paypal_enabled' => PayPal_Helper::is_paypal_connected(),
298 347 'offline_enabled' => Offline_Helper::is_offline_enabled(),
299 348 // True only when the OttoKit plugin is active AND authenticated,
@@ -301,14 +350,20 @@
301 350 'ottokit_connected' => Helper::is_suretriggers_ready(),
302 351 'contact_consent_enabled' => ! empty( $privacy_settings['contact_consent_field'] ),
303 352 'privacy_policy_field_enabled' => ! empty( $privacy_settings['privacy_policy_field'] ),
304 353 'terms_field_enabled' => ! empty( $privacy_settings['terms_conditions_field'] ),
354 + // Scheduled sends only go out in live mode, so read this
355 + // against the payment_mode already on the KPI records to tell
356 + // sites actually receiving a report from sites where it is on
357 + // but paused.
358 + 'email_reports_enabled' => ! empty( $email_reports_settings['enabled'] ),
305 359 ],
306 - 'data_retention_period' => isset( $privacy_settings['minimum_data_retention_period'] ) ? Helper::get_string_value( $privacy_settings['minimum_data_retention_period'] ) : 'none',
307 - 'block_usage' => $this->get_block_usage(),
308 - 'elementor_widget_usage' => $this->get_elementor_widget_usage(),
309 - 'bricks_element_usage' => $this->get_bricks_element_usage(),
310 - 'internal_referer' => $internal_referer,
360 + 'data_retention_period' => isset( $privacy_settings['minimum_data_retention_period'] ) ? Helper::get_string_value( $privacy_settings['minimum_data_retention_period'] ) : 'none',
361 + 'block_usage' => $this->get_block_usage(),
362 + 'campaign_template_usage' => $template_usage,
363 + 'elementor_widget_usage' => $this->get_elementor_widget_usage(),
364 + 'bricks_element_usage' => $this->get_bricks_element_usage(),
365 + 'internal_referer' => $internal_referer,
311 366 ];
312 367
313 368 // Add KPI tracking data.
314 369 $kpi_data = $this->get_kpi_tracking_data();
@@ -380,8 +435,118 @@
380 435 );
381 436 }
382 437
383 438 /**
439 + * Track the first time the campaign guided tour is shown to a user.
440 + *
441 + * Fired from the REST layer on the tour's first render. The events tracker
442 + * dedups by name, so this is recorded once per site regardless of how many
443 + * users see the tour or how often it re-triggers.
444 + *
445 + * @return void
446 + * @since 1.5.0
447 + */
448 + public function track_campaign_tour_shown() {
449 + $events = self::events();
450 + if ( null === $events ) {
451 + return;
452 + }
453 +
454 + $events->track( 'campaign_tour_shown' );
455 + }
456 +
457 + /**
458 + * Track how a campaign guided-tour run ended.
459 + *
460 + * `campaign_tour_shown` tells us the tour was seen; these tell us whether it
461 + * worked. Four outcomes, each recorded under its own event name:
462 + *
463 + * - `completed` — the user reached the final step.
464 + * - `dismissed` — closed part-way; the step key rides along as the
465 + * event value so we can see where runs are abandoned.
466 + * - `opted_out` — ticked "Don't show this again".
467 + * - `manual_started` — replayed deliberately via "Take a tour".
468 + *
469 + * `dismissed` is tracked with $force so the most recent drop-off step wins
470 + * rather than only the first one ever recorded on the site; the others keep
471 + * the default once-per-site semantics.
472 + *
473 + * @param string $outcome How the run ended. Unknown values are ignored.
474 + * @param string $step Step key the run ended on. Only used for `dismissed`.
475 + * @return void
476 + * @since 1.5.0
477 + */
478 + public function track_campaign_tour_outcome( $outcome, $step = '' ) {
479 + $events = self::events();
480 + if ( null === $events ) {
481 + return;
482 + }
483 +
484 + $outcome = Helper::get_string_value( $outcome );
485 + $step = Helper::get_string_value( $step );
486 +
487 + switch ( $outcome ) {
488 + case 'completed':
489 + $events->track( 'campaign_tour_completed' );
490 + break;
491 + case 'dismissed':
492 + // Retrackable: the latest abandonment point is the useful one.
493 + $events->track( 'campaign_tour_dismissed', $step, [], true );
494 + break;
495 + case 'opted_out':
496 + $events->track( 'campaign_tour_opted_out', $step );
497 + break;
498 + case 'manual_started':
499 + $events->track( 'campaign_tour_manual_started' );
500 + break;
501 + }
502 + }
503 +
504 + /**
505 + * Track the first time the campaign template picker is opened.
506 + *
507 + * Deduped, so it answers "did this site ever discover the picker?" — the
508 + * denominator for template adoption, since `campaign_template_usage` in the
509 + * stats payload only counts campaigns that were actually created from one.
510 + *
511 + * @return void
512 + * @since 1.5.0
513 + */
514 + public function track_campaign_template_picker_opened() {
515 + $events = self::events();
516 + if ( null === $events ) {
517 + return;
518 + }
519 +
520 + $events->track( 'campaign_template_picker_opened' );
521 + }
522 +
523 + /**
524 + * Track the first campaign a site creates from a template.
525 + *
526 + * Deduped, so the event value is the template the site reached for *first* —
527 + * the running per-template totals live in `campaign_template_usage` on the
528 + * stats payload, which is recomputed on every send.
529 + *
530 + * @param string $template_id Template the campaign was created from.
531 + * @return void
532 + * @since 1.5.0
533 + */
534 + public function track_campaign_created_from_template( $template_id ) {
535 + $events = self::events();
536 + if ( null === $events ) {
537 + return;
538 + }
539 +
540 + $template_id = Helper::get_string_value( $template_id );
541 + if ( '' === $template_id ) {
542 + return;
543 + }
544 +
545 + $events->track( 'first_campaign_from_template', $template_id );
546 + }
547 +
548 + /**
384 549 * Track first personal-data export that included SureDonation data
385 550 * (adoption event — deduped, sent once).
386 551 *
387 552 * @since 1.2.0
@@ -564,8 +729,9 @@
564 729 'campaign_goal' => 'suredonation/campaign-goal',
565 730 'campaign_stats' => 'suredonation/campaign-stats',
566 731 'campaign_donations' => 'suredonation/campaign-donations',
567 732 'campaign_donors' => 'suredonation/campaign-donors',
733 + 'campaign_donor_comments' => 'suredonation/campaign-donor-comments',
568 734 'campaign_donate_button' => 'suredonation/campaign-donate-button',
569 735 'campaign_social_sharing' => 'suredonation/campaign-social-sharing',
570 736 'donation_form' => 'suredonation/donation-form',
571 737 ];
@@ -598,8 +764,81 @@
598 764 return $usage;
599 765 }
600 766
601 767 /**
768 + * How many published campaigns were created from each campaign template.
769 + *
770 + * The running answer to "which template gets used, and how often" — a
771 + * snapshot rather than an event, because the stats payload is rebuilt on
772 + * every send while events dedup by name and fire once per site.
773 + *
774 + * Every known template id is seeded to 0 so the payload keeps the same shape
775 + * across sites (same contract as get_block_usage()). Campaigns with no
776 + * template meta — anything created before templates shipped, or via "Start
777 + * from scratch" — land in `scratch`. Ids that are no longer registered are
778 + * dropped rather than passed through, so a stale or hand-edited meta value
779 + * can never widen the payload.
780 + *
781 + * @return array<string, int> Template id => number of published campaigns.
782 + * @since 1.5.0
783 + */
784 + private function get_campaign_template_usage() {
785 + global $wpdb;
786 +
787 + $registry = Campaign_Templates::get_instance();
788 +
789 + // Seed the known ids, plus the two buckets that are not gallery cards:
790 + // `general` (the built-in fallback) and `scratch` (no meta at all).
791 + $usage = [ 'scratch' => 0 ];
792 + foreach ( $registry->get_all() as $template ) {
793 + $id = Helper::get_string_value( $template['id'] ?? '' );
794 + if ( '' !== $id ) {
795 + $usage[ $id ] = 0;
796 + }
797 + }
798 + $usage[ Campaign_Templates::GENERAL ] = 0;
799 +
800 + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single grouped scan at analytics send time only.
801 + $rows = $wpdb->get_results(
802 + $wpdb->prepare(
803 + 'SELECT pm.meta_value AS template_id, COUNT(*) AS total
804 + FROM %i AS p
805 + LEFT JOIN %i AS pm ON pm.post_id = p.ID AND pm.meta_key = %s
806 + WHERE p.post_type = %s AND p.post_status = %s
807 + GROUP BY pm.meta_value',
808 + $wpdb->posts,
809 + $wpdb->postmeta,
810 + Campaign_Cpt::META_TEMPLATE_ID,
811 + SUREDONATION_POST_TYPE,
812 + 'publish'
813 + ),
814 + ARRAY_A
815 + );
816 +
817 + if ( ! is_array( $rows ) ) {
818 + return $usage;
819 + }
820 +
821 + foreach ( $rows as $row ) {
822 + $id = Helper::get_string_value( $row['template_id'] ?? '' );
823 + $total = absint( $row['total'] ?? 0 );
824 +
825 + // No meta (NULL from the LEFT JOIN, or an empty string) => scratch.
826 + if ( '' === $id ) {
827 + $usage['scratch'] += $total;
828 + continue;
829 + }
830 +
831 + // Only report ids we still recognise.
832 + if ( array_key_exists( $id, $usage ) ) {
833 + $usage[ $id ] += $total;
834 + }
835 + }
836 +
837 + return $usage;
838 + }
839 +
840 + /**
602 841 * How many published posts use each SureDonation Elementor widget.
603 842 *
604 843 * The Elementor counterpart of get_block_usage(): widgets live in the
605 844 * _elementor_data postmeta (JSON with a quoted "widgetType"), not in the
@@ -616,8 +855,9 @@
616 855 'campaign_goal' => 'suredonation-campaign-goal',
617 856 'campaign_stats' => 'suredonation-campaign-stats',
618 857 'campaign_donations' => 'suredonation-campaign-donations',
619 858 'campaign_donors' => 'suredonation-campaign-donors',
859 + 'campaign_donor_comments' => 'suredonation-campaign-donor-comments',
620 860 'campaign_donate_button' => 'suredonation-campaign-donate-button',
621 861 'campaign_social_sharing' => 'suredonation-campaign-social-sharing',
622 862 'donation_form' => 'suredonation-donation-form',
623 863 ];
@@ -670,8 +910,9 @@
670 910 'campaign_goal' => 'suredonation-campaign-goal',
671 911 'campaign_stats' => 'suredonation-campaign-stats',
672 912 'campaign_donations' => 'suredonation-campaign-donations',
673 913 'campaign_donors' => 'suredonation-campaign-donors',
914 + 'campaign_donor_comments' => 'suredonation-campaign-donor-comments',
674 915 'campaign_donate_button' => 'suredonation-campaign-donate-button',
675 916 'campaign_social_sharing' => 'suredonation-campaign-social-sharing',
676 917 'donation_form' => 'suredonation-donation-form',
677 918 ];
@@ -905,8 +1146,28 @@
905 1146
906 1147 // stripe_connected: detect connection state.
907 1148 if ( Stripe_Helper::is_stripe_connected() ) {
908 1149 $events->track( 'stripe_connected', $mode );
1150 + }
1151 +
1152 + // stripe_card_capability_blocked: connected but Stripe will not let the
1153 + // account charge cards, so the card form is hidden and donations are
1154 + // being lost or diverted. Detected here rather than where the notices
1155 + // render: this is site state, not a page event, and the render path is
1156 + // a public request that should not be paying for analytics.
1157 + $blocked_accounts = 0;
1158 + foreach ( array_keys( Stripe_Helper::get_all_accounts() ) as $blocked_candidate ) {
1159 + if ( Stripe_Helper::is_card_capability_blocked( (string) $blocked_candidate, $mode ) ) {
1160 + ++$blocked_accounts;
1161 + }
1162 + }
1163 +
1164 + if ( $blocked_accounts > 0 ) {
1165 + $events->track(
1166 + 'stripe_card_capability_blocked',
1167 + $mode,
1168 + [ 'blocked_accounts' => $blocked_accounts ]
1169 + );
909 1170 }
910 1171
911 1172 // paypal_connected: detect connection state.
912 1173 if ( PayPal_Helper::is_paypal_connected() ) {