PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.1.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.1.0
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 +15 -677 1.6.1 → 1.1.0 View file →
@@ -6,17 +6,13 @@
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;
13 10 use SureDonation\Inc\Helper;
14 11 use SureDonation\Inc\Payments\Offline\Offline_Helper;
15 12 use SureDonation\Inc\Payments\Payment_Helper;
16 13 use SureDonation\Inc\Payments\PayPal\PayPal_Helper;
17 14 use SureDonation\Inc\Payments\Stripe\Stripe_Helper;
18 -use SureDonation\Inc\Privacy\Privacy_Settings;
19 15 use SureDonation\Inc\Traits\Get_Instance;
20 16
21 17 // Exit if accessed directly.
22 18 if ( ! defined( 'ABSPATH' ) ) {
@@ -31,52 +27,8 @@
31 27 class Analytics {
32 28 use Get_Instance;
33 29
34 30 /**
35 - * Allowlist of React admin-notice / UI analytics events.
36 - *
37 - * Single source of truth for the track-notice-event REST endpoint: the notice
38 - * registrations (Admin::register_react_notices) and the dashboard Quick Access
39 - * item set an event to one of these values, and handle_track_notice_event()
40 - * only records events present here. Add a new event here (and reference it on
41 - * the notice/item) to track it end-to-end.
42 - *
43 - * @var array<string, string>
44 - * @since 1.3.0
45 - */
46 - public const TRACKED_EVENTS = [
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',
76 - ];
77 -
78 - /**
79 31 * BSF_Analytics_Events instance for one-time event tracking.
80 32 *
81 33 * @var \BSF_Analytics_Events|null
82 34 * @since 1.0.0
@@ -107,11 +59,8 @@
107 59 * init priority 10.
108 60 */
109 61 add_action( 'init', [ $this, 'register_entity' ], 0 );
110 62
111 - // REST route the React admin app calls to record notice/UI clicks.
112 - add_action( 'rest_api_init', [ $this, 'register_routes' ] );
113 -
114 63 add_filter( 'bsf_core_stats', [ $this, 'add_suredonation_analytics_data' ] );
115 64
116 65 // Keep analytics sends (and their stat queries) off the frontend.
117 66 add_filter( 'suredonation_tracking_enabled', [ $this, 'restrict_tracking_to_admin' ] );
@@ -118,16 +67,10 @@
118 67
119 68 // Event tracking hooks. Registered outside is_admin() on purpose —
120 69 // onboarding completion and campaign publishes fire during REST requests.
121 70 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' ] );
126 71 add_action( 'transition_post_status', [ $this, 'track_first_campaign_published' ], 10, 3 );
127 72 add_action( 'current_screen', [ $this, 'track_first_campaign_editor_opened' ] );
128 - add_action( 'suredonation_privacy_data_exported', [ $this, 'track_privacy_data_exported' ] );
129 - add_action( 'suredonation_privacy_data_erased', [ $this, 'track_privacy_data_erased' ] );
130 73
131 74 // Detect state-based events (daily throttle; dedup prevents repeat
132 75 // tracking). Admin-only so the detection never runs on the frontend.
133 76 if ( is_admin() ) {
@@ -223,77 +166,8 @@
223 166 return self::$events;
224 167 }
225 168
226 169 /**
227 - * Register REST routes.
228 - *
229 - * Hooked - rest_api_init
230 - *
231 - * @return void
232 - * @since 1.3.0
233 - */
234 - public function register_routes() {
235 - register_rest_route(
236 - 'suredonation/v1',
237 - '/track-notice-event',
238 - [
239 - 'methods' => \WP_REST_Server::CREATABLE,
240 - 'callback' => [ $this, 'handle_track_notice_event' ],
241 - // A named method rather than a closure: the capability guard in
242 - // tests/unit/inc/test-rest-api.php introspects every write
243 - // route's permission_callback, and a closure is opaque to it.
244 - 'permission_callback' => [ $this, 'check_permissions' ],
245 - 'args' => [
246 - 'event' => [
247 - 'type' => 'string',
248 - 'required' => true,
249 - ],
250 - ],
251 - ]
252 - );
253 - }
254 -
255 - /**
256 - * Whether the current user may record notice events.
257 - *
258 - * Named check_permissions to match the other REST controllers, which is also
259 - * what the write-route capability guard asserts on.
260 - *
261 - * @return bool True when the user can manage options.
262 - * @since 1.4.0
263 - */
264 - public function check_permissions() {
265 - return current_user_can( 'manage_options' );
266 - }
267 -
268 - /**
269 - * Record a React notice/UI interaction event.
270 - *
271 - * Validates the event against an allowlist (so arbitrary events cannot be
272 - * injected) and records it via the shared analytics events, respecting the
273 - * usage-tracking opt-in. Event names are suffixed `_react` to keep them
274 - * distinct from the wp-admin notice events.
275 - *
276 - * @param \WP_REST_Request<array<string, mixed>> $request REST request.
277 - * @return \WP_REST_Response
278 - * @since 1.3.0
279 - */
280 - public function handle_track_notice_event( $request ) {
281 - $event = sanitize_key( (string) $request->get_param( 'event' ) );
282 -
283 - if ( ! in_array( $event, self::TRACKED_EVENTS, true ) ) {
284 - return new \WP_REST_Response( [ 'success' => false ], 400 );
285 - }
286 -
287 - $events = self::events();
288 - if ( null !== $events ) {
289 - $events->track( $event );
290 - }
291 -
292 - return new \WP_REST_Response( [ 'success' => true ], 200 );
293 - }
294 -
295 - /**
296 170 * Callback function to add SureDonation specific analytics data.
297 171 *
298 172 * @param array<string, mixed> $stats_data Existing stats data.
299 173 * @return array<string, mixed>
@@ -308,62 +182,24 @@
308 182 $internal_referer = is_array( $bsf_internal_referrer ) && ! empty( $bsf_internal_referrer['suredonation'] )
309 183 ? sanitize_text_field( (string) $bsf_internal_referrer['suredonation'] )
310 184 : 'self';
311 185
312 - $privacy_settings = Privacy_Settings::get_settings();
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 -
319 186 $plugin_data = [
320 - 'free_version' => SUREDONATION_VER,
321 - 'numeric_values' => [
322 - 'total_campaigns' => absint( $campaign_counts->publish ?? 0 ),
323 - 'total_donation_forms' => absint( $form_counts->publish ?? 0 ),
324 - 'total_donations' => $aggregates['total'],
325 - 'completed_donations' => $aggregates['completed'],
326 - 'recurring_donations' => $aggregates['recurring'],
327 - 'total_donors' => $this->get_total_donors(),
328 - 'forms_with_image_block' => $this->get_image_block_form_count(),
329 - 'posts_with_social_sharing_block' => $this->get_social_sharing_block_count(),
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 - ),
187 + 'free_version' => SUREDONATION_VER,
188 + 'numeric_values' => [
189 + 'total_campaigns' => absint( $campaign_counts->publish ?? 0 ),
190 + 'total_donation_forms' => absint( $form_counts->publish ?? 0 ),
191 + 'total_donations' => $aggregates['total'],
192 + 'completed_donations' => $aggregates['completed'],
193 + 'recurring_donations' => $aggregates['recurring'],
194 + 'total_donors' => $this->get_total_donors(),
343 195 ],
344 - 'boolean_values' => [
345 - 'stripe_enabled' => Stripe_Helper::is_stripe_connected(),
346 - 'paypal_enabled' => PayPal_Helper::is_paypal_connected(),
347 - 'offline_enabled' => Offline_Helper::is_offline_enabled(),
348 - // True only when the OttoKit plugin is active AND authenticated,
349 - // so this implies the plugin is active.
350 - 'ottokit_connected' => Helper::is_suretriggers_ready(),
351 - 'contact_consent_enabled' => ! empty( $privacy_settings['contact_consent_field'] ),
352 - 'privacy_policy_field_enabled' => ! empty( $privacy_settings['privacy_policy_field'] ),
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'] ),
196 + 'boolean_values' => [
197 + 'stripe_enabled' => Stripe_Helper::is_stripe_connected(),
198 + 'paypal_enabled' => PayPal_Helper::is_paypal_connected(),
199 + 'offline_enabled' => Offline_Helper::is_offline_enabled(),
359 200 ],
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,
201 + 'internal_referer' => $internal_referer,
366 202 ];
367 203
368 204 // Add KPI tracking data.
369 205 $kpi_data = $this->get_kpi_tracking_data();
@@ -435,161 +271,8 @@
435 271 );
436 272 }
437 273
438 274 /**
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 - /**
549 - * Track first personal-data export that included SureDonation data
550 - * (adoption event — deduped, sent once).
551 - *
552 - * @since 1.2.0
553 - * @return void
554 - */
555 - public function track_privacy_data_exported() {
556 - $events = self::events();
557 - if ( null === $events ) {
558 - return;
559 - }
560 -
561 - $events->track( 'privacy_data_export_used' );
562 - }
563 -
564 - /**
565 - * Track first personal-data erasure processed for SureDonation data
566 - * (adoption event — deduped, sent once).
567 - *
568 - * @since 1.2.0
569 - * @param array<string, mixed> $outcome Erasure outcome flags.
570 - * @return void
571 - */
572 - public function track_privacy_data_erased( $outcome ) {
573 - $events = self::events();
574 - if ( null === $events ) {
575 - return;
576 - }
577 -
578 - $outcome = is_array( $outcome ) ? $outcome : [];
579 -
580 - $events->track(
581 - 'privacy_data_erasure_used',
582 - '',
583 - [
584 - 'items_removed' => ! empty( $outcome['items_removed'] ) ? 'yes' : 'no',
585 - 'items_retained' => ! empty( $outcome['items_retained'] ) ? 'yes' : 'no',
586 - 'erase_failed' => ! empty( $outcome['erase_failed'] ) ? 'yes' : 'no',
587 - ]
588 - );
589 - }
590 -
591 - /**
592 275 * Track first time a campaign is published (activation event).
593 276 *
594 277 * @param string $new_status New post status.
595 278 * @param string $old_status Old post status.
@@ -659,9 +342,8 @@
659 342
660 343 $defaults = [
661 344 'total' => 0,
662 345 'completed' => 0,
663 - 'completed_live' => 0,
664 346 'recurring' => 0,
665 347 'anonymous_completed' => 0,
666 348 'fees_covered_completed' => 0,
667 349 'refunded' => 0,
@@ -671,9 +353,8 @@
671 353 $row = $wpdb->get_row(
672 354 $wpdb->prepare(
673 355 "SELECT COUNT(*) AS total,
674 356 COALESCE(SUM(payment_status = 'completed'),0) AS completed,
675 - COALESCE(SUM(payment_status = 'completed' AND payment_mode = 'live'),0) AS completed_live,
676 357 COALESCE(SUM(subscription_id IS NOT NULL AND subscription_id <> ''),0) AS recurring,
677 358 COALESCE(SUM(is_anonymous = 1 AND payment_status = 'completed'),0) AS anonymous_completed,
678 359 COALESCE(SUM(fees_covered > 0 AND payment_status = 'completed'),0) AS fees_covered_completed,
679 360 COALESCE(SUM(payment_status IN ('refunded','partially_refunded')),0) AS refunded
@@ -710,305 +391,8 @@
710 391 return absint( $count );
711 392 }
712 393
713 394 /**
714 - * How many published posts use each SureDonation campaign/donation block.
715 - *
716 - * A privacy-preserving usage count (no content leaves the site) so we can see
717 - * which blocks are actually adopted. One conditional-SUM query (a single table
718 - * scan), run only at analytics send time. Elementor/Bricks placements are not
719 - * counted here (they store their config outside the block grammar) — see
720 - * get_elementor_widget_usage().
721 - *
722 - * @return array<string, int> Block key => number of published posts using it.
723 - * @since 1.2.0
724 - */
725 - private function get_block_usage() {
726 - global $wpdb;
727 -
728 - $blocks = [
729 - 'campaign_goal' => 'suredonation/campaign-goal',
730 - 'campaign_stats' => 'suredonation/campaign-stats',
731 - 'campaign_donations' => 'suredonation/campaign-donations',
732 - 'campaign_donors' => 'suredonation/campaign-donors',
733 - 'campaign_donor_comments' => 'suredonation/campaign-donor-comments',
734 - 'campaign_donate_button' => 'suredonation/campaign-donate-button',
735 - 'campaign_social_sharing' => 'suredonation/campaign-social-sharing',
736 - 'donation_form' => 'suredonation/donation-form',
737 - ];
738 -
739 - // prepare() fills placeholders in SQL order: the SELECT-list %s LIKEs
740 - // first, then the FROM %i, then the status %s.
741 - $selects = [];
742 - $values = [];
743 - foreach ( array_keys( $blocks ) as $key ) {
744 - $selects[] = "SUM(post_content LIKE %s) AS {$key}";
745 - // The space after the block name is the delimiter the serializer always
746 - // emits (before attrs JSON, "-->" or "/-->"), so a future
747 - // "campaign-goal-x" block can't prefix-match campaign-goal.
748 - $values[] = '%' . $wpdb->esc_like( '<!-- wp:' . $blocks[ $key ] . ' ' ) . '%';
749 - }
750 - $values[] = $wpdb->posts;
751 - $values[] = 'publish';
752 -
753 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Single aggregate scan at analytics send time; SELECT list is built from hardcoded keys and %s placeholders only.
754 - $row = $wpdb->get_row(
755 - $wpdb->prepare( 'SELECT ' . implode( ', ', $selects ) . ' FROM %i WHERE post_status = %s', $values ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- placeholders are built alongside $values; prepare() accepts the array form.
756 - ARRAY_A
757 - );
758 -
759 - $usage = [];
760 - foreach ( array_keys( $blocks ) as $key ) {
761 - $usage[ $key ] = absint( is_array( $row ) ? ( $row[ $key ] ?? 0 ) : 0 );
762 - }
763 -
764 - return $usage;
765 - }
766 -
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 - /**
841 - * How many published posts use each SureDonation Elementor widget.
842 - *
843 - * The Elementor counterpart of get_block_usage(): widgets live in the
844 - * _elementor_data postmeta (JSON with a quoted "widgetType"), not in the
845 - * block grammar. Same privacy-preserving single-scan shape, run only at
846 - * analytics send time.
847 - *
848 - * @return array<string, int> Widget key => number of published posts using it.
849 - * @since 1.2.0
850 - */
851 - private function get_elementor_widget_usage() {
852 - global $wpdb;
853 -
854 - $widgets = [
855 - 'campaign_goal' => 'suredonation-campaign-goal',
856 - 'campaign_stats' => 'suredonation-campaign-stats',
857 - 'campaign_donations' => 'suredonation-campaign-donations',
858 - 'campaign_donors' => 'suredonation-campaign-donors',
859 - 'campaign_donor_comments' => 'suredonation-campaign-donor-comments',
860 - 'campaign_donate_button' => 'suredonation-campaign-donate-button',
861 - 'campaign_social_sharing' => 'suredonation-campaign-social-sharing',
862 - 'donation_form' => 'suredonation-donation-form',
863 - ];
864 -
865 - // prepare() fills placeholders in SQL order: the SELECT-list %s LIKEs
866 - // first, then the two FROM/JOIN %i tables, then meta_key and status.
867 - $selects = [];
868 - $values = [];
869 - foreach ( array_keys( $widgets ) as $key ) {
870 - $selects[] = "SUM(pm.meta_value LIKE %s) AS {$key}";
871 - // Quoted as stored in the _elementor_data JSON ("widgetType":"…"),
872 - // which bounds the match on both sides.
873 - $values[] = '%' . $wpdb->esc_like( '"' . $widgets[ $key ] . '"' ) . '%';
874 - }
875 - $values[] = $wpdb->postmeta;
876 - $values[] = $wpdb->posts;
877 - $values[] = '_elementor_data';
878 - $values[] = 'publish';
879 -
880 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Single aggregate scan at analytics send time; SELECT list is built from hardcoded keys and %s placeholders only.
881 - $row = $wpdb->get_row(
882 - $wpdb->prepare( 'SELECT ' . implode( ', ', $selects ) . ' FROM %i AS pm INNER JOIN %i AS p ON p.ID = pm.post_id WHERE pm.meta_key = %s AND p.post_status = %s', $values ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- placeholders are built alongside $values; prepare() accepts the array form.
883 - ARRAY_A
884 - );
885 -
886 - $usage = [];
887 - foreach ( array_keys( $widgets ) as $key ) {
888 - $usage[ $key ] = absint( is_array( $row ) ? ( $row[ $key ] ?? 0 ) : 0 );
889 - }
890 -
891 - return $usage;
892 - }
893 -
894 - /**
895 - * How many published posts use each SureDonation Bricks element.
896 - *
897 - * A privacy-preserving usage count (no content leaves the site) so we can see
898 - * which Bricks elements are actually adopted — the Bricks counterpart of the
899 - * Gutenberg block_usage stat. Bricks stores builder data as serialized element
900 - * arrays in postmeta, so each element name is matched inside its quotes.
901 - * One conditional-SUM query (a single scan), run only at analytics send time.
902 - *
903 - * @return array<string, int> Element key => number of published posts using it.
904 - * @since 1.2.0
905 - */
906 - private function get_bricks_element_usage() {
907 - global $wpdb;
908 -
909 - $elements = [
910 - 'campaign_goal' => 'suredonation-campaign-goal',
911 - 'campaign_stats' => 'suredonation-campaign-stats',
912 - 'campaign_donations' => 'suredonation-campaign-donations',
913 - 'campaign_donors' => 'suredonation-campaign-donors',
914 - 'campaign_donor_comments' => 'suredonation-campaign-donor-comments',
915 - 'campaign_donate_button' => 'suredonation-campaign-donate-button',
916 - 'campaign_social_sharing' => 'suredonation-campaign-social-sharing',
917 - 'donation_form' => 'suredonation-donation-form',
918 - ];
919 -
920 - // prepare() fills placeholders in SQL order: the SELECT-list %s LIKEs
921 - // first, then the two FROM/JOIN %i tables, then meta keys and status.
922 - $selects = [];
923 - $values = [];
924 - foreach ( array_keys( $elements ) as $key ) {
925 - $selects[] = "SUM(pm.meta_value LIKE %s) AS {$key}";
926 - // Quoted as stored in the serialized Bricks element data, which
927 - // bounds the match on both sides.
928 - $values[] = '%' . $wpdb->esc_like( '"' . $elements[ $key ] . '"' ) . '%';
929 - }
930 - $values[] = $wpdb->postmeta;
931 - $values[] = $wpdb->posts;
932 - $values[] = '_bricks_page_content_2';
933 - $values[] = '_bricks_page_header_2';
934 - $values[] = '_bricks_page_footer_2';
935 - $values[] = 'publish';
936 -
937 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Single aggregate scan at analytics send time; SELECT list is built from hardcoded keys and %s placeholders only.
938 - $row = $wpdb->get_row(
939 - $wpdb->prepare( 'SELECT ' . implode( ', ', $selects ) . ' FROM %i AS pm INNER JOIN %i AS p ON p.ID = pm.post_id WHERE pm.meta_key IN ( %s, %s, %s ) AND p.post_status = %s', $values ), // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- placeholders are built alongside $values; prepare() accepts the array form.
940 - ARRAY_A
941 - );
942 -
943 - $usage = [];
944 - foreach ( array_keys( $elements ) as $key ) {
945 - $usage[ $key ] = absint( is_array( $row ) ? ( $row[ $key ] ?? 0 ) : 0 );
946 - }
947 -
948 - return $usage;
949 - }
950 -
951 - /**
952 - * How many published posts use the Campaign Social Sharing block.
953 - *
954 - * A privacy-preserving adoption count (no content leaves the site), run only
955 - * at analytics send time. The trailing space is the delimiter the block
956 - * serializer always emits after the block name, so a future
957 - * "campaign-social-sharing-x" block can't prefix-match.
958 - *
959 - * @return int Number of published posts containing the block.
960 - * @since 1.2.0
961 - */
962 - private function get_social_sharing_block_count() {
963 - global $wpdb;
964 -
965 - $like = '%' . $wpdb->esc_like( '<!-- wp:suredonation/campaign-social-sharing ' ) . '%';
966 -
967 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single aggregate COUNT run only at analytics send time.
968 - $count = $wpdb->get_var(
969 - $wpdb->prepare(
970 - 'SELECT COUNT(ID) FROM %i WHERE post_status = %s AND post_content LIKE %s',
971 - $wpdb->posts,
972 - 'publish',
973 - $like
974 - )
975 - );
976 -
977 - return absint( $count );
978 - }
979 -
980 - /**
981 - * How many published donation forms use the Image block.
982 - *
983 - * A privacy-preserving adoption count (no content leaves the site), run only
984 - * at analytics send time. The trailing space is the delimiter the block
985 - * serializer always emits after the block name, so a future
986 - * "image-x" block can't prefix-match.
987 - *
988 - * @return int Number of published donation forms containing the block.
989 - * @since 1.3.0
990 - */
991 - private function get_image_block_form_count() {
992 - global $wpdb;
993 -
994 - $like = '%' . $wpdb->esc_like( '<!-- wp:suredonation/image ' ) . '%';
995 -
996 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single aggregate COUNT run only at analytics send time.
997 - $count = $wpdb->get_var(
998 - $wpdb->prepare(
999 - 'SELECT COUNT(ID) FROM %i WHERE post_type = %s AND post_status = %s AND post_content LIKE %s',
1000 - $wpdb->posts,
1001 - 'suredonation_form',
1002 - 'publish',
1003 - $like
1004 - )
1005 - );
1006 -
1007 - return absint( $count );
1008 - }
1009 -
1010 - /**
1011 395 * Get KPI tracking data for the last 2 full days (excluding today).
1012 396 *
1013 397 * Single grouped query; raw revenue never enters the payload — only
1014 398 * the donation count and a coarse revenue tier per day.
@@ -1148,28 +532,8 @@
1148 532 if ( Stripe_Helper::is_stripe_connected() ) {
1149 533 $events->track( 'stripe_connected', $mode );
1150 534 }
1151 535
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 - );
1170 - }
1171 -
1172 536 // paypal_connected: detect connection state.
1173 537 if ( PayPal_Helper::is_paypal_connected() ) {
1174 538 $events->track( 'paypal_connected', $mode );
1175 539 }
@@ -1192,25 +556,8 @@
1192 556 'days_since_install' => (string) $days_since_install,
1193 557 'payment_mode' => $mode,
1194 558 ]
1195 559 );
1196 -
1197 - // first_live_donation_received: first completed LIVE donation. A
1198 - // separate event with its own dedup key — first_donation_received
1199 - // almost always fires on a test donation (sites start in test
1200 - // mode) and the name-only dedup then suppresses it forever, so
1201 - // the live milestone would otherwise never be visible. Gated on
1202 - // the donation rows' own payment_mode, not the mode at detection
1203 - // time, so a later mode switch can't skew the signal.
1204 - if ( $aggregates['completed_live'] > 0 ) {
1205 - $events->track(
1206 - 'first_live_donation_received',
1207 - Payment_Helper::get_currency(),
1208 - [
1209 - 'days_since_install' => (string) $days_since_install,
1210 - ]
1211 - );
1212 - }
1213 560 }
1214 561
1215 562 // anonymous_donation_submitted: at least one completed anonymous donation.
1216 563 if ( $aggregates['anonymous_completed'] > 0 ) {
@@ -1226,19 +573,10 @@
1226 573 if ( $aggregates['refunded'] > 0 ) {
1227 574 $events->track( 'first_refund_processed' );
1228 575 }
1229 576
1230 - // webhook_configured: a Stripe webhook secret is stored for the current
1231 - // mode on any connected account (multi-account aware — reading only the
1232 - // default account would false-negative on sites using a non-default one).
1233 - $webhook_configured = false;
1234 - foreach ( array_keys( Stripe_Helper::get_all_accounts() ) as $wh_account_id ) {
1235 - if ( '' !== Stripe_Helper::get_webhook_secret( $mode, (string) $wh_account_id ) ) {
1236 - $webhook_configured = true;
1237 - break;
1238 - }
1239 - }
1240 - if ( $webhook_configured ) {
577 + // webhook_configured: a Stripe webhook secret is stored for the current mode.
578 + if ( '' !== Stripe_Helper::get_webhook_secret() ) {
1241 579 $events->track( 'webhook_configured', $mode );
1242 580 }
1243 581 }
1244 582 }