PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.1.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.1.0
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 -669 trunk1.1.0 View file →
@@ -6,16 +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 10 use SureDonation\Inc\Helper;
13 11 use SureDonation\Inc\Payments\Offline\Offline_Helper;
14 12 use SureDonation\Inc\Payments\Payment_Helper;
15 13 use SureDonation\Inc\Payments\PayPal\PayPal_Helper;
16 14 use SureDonation\Inc\Payments\Stripe\Stripe_Helper;
17 -use SureDonation\Inc\Privacy\Privacy_Settings;
18 15 use SureDonation\Inc\Traits\Get_Instance;
19 16
20 17 // Exit if accessed directly.
21 18 if ( ! defined( 'ABSPATH' ) ) {
@@ -30,52 +27,8 @@
30 27 class Analytics {
31 28 use Get_Instance;
32 29
33 30 /**
34 - * Allowlist of React admin-notice / UI analytics events.
35 - *
36 - * Single source of truth for the track-notice-event REST endpoint: the notice
37 - * registrations (Admin::register_react_notices) and the dashboard Quick Access
38 - * item set an event to one of these values, and handle_track_notice_event()
39 - * only records events present here. Add a new event here (and reference it on
40 - * the notice/item) to track it end-to-end.
41 - *
42 - * @var array<string, string>
43 - * @since 1.3.0
44 - */
45 - public const TRACKED_EVENTS = [
46 - 'configure_gateway' => 'configure_gateway_notice_react_cta',
47 - 'webhook' => 'webhook_notice_react_cta',
48 - 'test_mode' => 'test_mode_notice_react_cta',
49 - 'quick_access' => 'quick_access_configure_gateway_react_cta',
50 - // The PayPal settings panel warns when PayPal has told us the connected
51 - // account cannot be paid. The CTA sends the merchant to PayPal to finish
52 - // setup; the dismiss says they saw it and moved on, which is worth
53 - // separating from never having seen it.
54 - 'paypal_account' => 'paypal_account_warning_react_cta',
55 - 'paypal_account_x' => 'paypal_account_warning_react_dismiss',
56 - 'paypal_webhook_x' => 'paypal_webhook_error_react_dismiss',
57 - // The Stripe settings panel warns when Stripe has told us the connected
58 - // account cannot charge cards. The CTA sends the site owner to their
59 - // Stripe dashboard to resolve it; the dismiss says they saw it and moved
60 - // on, which is worth separating from never having seen it.
61 - 'stripe_account' => 'stripe_account_warning_react_cta',
62 - 'stripe_account_x' => 'stripe_account_warning_react_dismiss',
63 - // The form-side capability notices link here with ?sd_notice=, so the
64 - // settings screen records that one of them is what brought the admin
65 - // over. Their own page is a public donation form, which is no place to
66 - // be loading a tracker.
67 - 'stripe_capability' => 'stripe_capability_notice_cta',
68 - // The other three donation-form notices reach the settings screen the
69 - // same way. Their own page is a public donation form, which is no place
70 - // to be loading a tracker, so the CTA carries a marker and the arrival
71 - // is what gets recorded.
72 - 'frontend_test_mode' => 'frontend_test_mode_notice_cta',
73 - 'frontend_gateway_unavailable' => 'frontend_gateway_unavailable_notice_cta',
74 - 'frontend_gateway_setup' => 'frontend_gateway_setup_notice_cta',
75 - ];
76 -
77 - /**
78 31 * BSF_Analytics_Events instance for one-time event tracking.
79 32 *
80 33 * @var \BSF_Analytics_Events|null
81 34 * @since 1.0.0
@@ -106,11 +59,8 @@
106 59 * init priority 10.
107 60 */
108 61 add_action( 'init', [ $this, 'register_entity' ], 0 );
109 62
110 - // REST route the React admin app calls to record notice/UI clicks.
111 - add_action( 'rest_api_init', [ $this, 'register_routes' ] );
112 -
113 63 add_filter( 'bsf_core_stats', [ $this, 'add_suredonation_analytics_data' ] );
114 64
115 65 // Keep analytics sends (and their stat queries) off the frontend.
116 66 add_filter( 'suredonation_tracking_enabled', [ $this, 'restrict_tracking_to_admin' ] );
@@ -117,16 +67,10 @@
117 67
118 68 // Event tracking hooks. Registered outside is_admin() on purpose —
119 69 // onboarding completion and campaign publishes fire during REST requests.
120 70 add_action( 'suredonation_onboarding_user_details_saved', [ $this, 'track_onboarding_completed' ] );
121 - add_action( 'suredonation_campaign_tour_shown', [ $this, 'track_campaign_tour_shown' ] );
122 - add_action( 'suredonation_campaign_tour_outcome', [ $this, 'track_campaign_tour_outcome' ], 10, 2 );
123 - add_action( 'suredonation_campaign_template_picker_opened', [ $this, 'track_campaign_template_picker_opened' ] );
124 - add_action( 'suredonation_campaign_created_from_template', [ $this, 'track_campaign_created_from_template' ] );
125 71 add_action( 'transition_post_status', [ $this, 'track_first_campaign_published' ], 10, 3 );
126 72 add_action( 'current_screen', [ $this, 'track_first_campaign_editor_opened' ] );
127 - add_action( 'suredonation_privacy_data_exported', [ $this, 'track_privacy_data_exported' ] );
128 - add_action( 'suredonation_privacy_data_erased', [ $this, 'track_privacy_data_erased' ] );
129 73
130 74 // Detect state-based events (daily throttle; dedup prevents repeat
131 75 // tracking). Admin-only so the detection never runs on the frontend.
132 76 if ( is_admin() ) {
@@ -222,77 +166,8 @@
222 166 return self::$events;
223 167 }
224 168
225 169 /**
226 - * Register REST routes.
227 - *
228 - * Hooked - rest_api_init
229 - *
230 - * @return void
231 - * @since 1.3.0
232 - */
233 - public function register_routes() {
234 - register_rest_route(
235 - 'suredonation/v1',
236 - '/track-notice-event',
237 - [
238 - 'methods' => \WP_REST_Server::CREATABLE,
239 - 'callback' => [ $this, 'handle_track_notice_event' ],
240 - // A named method rather than a closure: the capability guard in
241 - // tests/unit/inc/test-rest-api.php introspects every write
242 - // route's permission_callback, and a closure is opaque to it.
243 - 'permission_callback' => [ $this, 'check_permissions' ],
244 - 'args' => [
245 - 'event' => [
246 - 'type' => 'string',
247 - 'required' => true,
248 - ],
249 - ],
250 - ]
251 - );
252 - }
253 -
254 - /**
255 - * Whether the current user may record notice events.
256 - *
257 - * Named check_permissions to match the other REST controllers, which is also
258 - * what the write-route capability guard asserts on.
259 - *
260 - * @return bool True when the user can manage options.
261 - * @since 1.4.0
262 - */
263 - public function check_permissions() {
264 - return current_user_can( 'manage_options' );
265 - }
266 -
267 - /**
268 - * Record a React notice/UI interaction event.
269 - *
270 - * Validates the event against an allowlist (so arbitrary events cannot be
271 - * injected) and records it via the shared analytics events, respecting the
272 - * usage-tracking opt-in. Event names are suffixed `_react` to keep them
273 - * distinct from the wp-admin notice events.
274 - *
275 - * @param \WP_REST_Request<array<string, mixed>> $request REST request.
276 - * @return \WP_REST_Response
277 - * @since 1.3.0
278 - */
279 - public function handle_track_notice_event( $request ) {
280 - $event = sanitize_key( (string) $request->get_param( 'event' ) );
281 -
282 - if ( ! in_array( $event, self::TRACKED_EVENTS, true ) ) {
283 - return new \WP_REST_Response( [ 'success' => false ], 400 );
284 - }
285 -
286 - $events = self::events();
287 - if ( null !== $events ) {
288 - $events->track( $event );
289 - }
290 -
291 - return new \WP_REST_Response( [ 'success' => true ], 200 );
292 - }
293 -
294 - /**
295 170 * Callback function to add SureDonation specific analytics data.
296 171 *
297 172 * @param array<string, mixed> $stats_data Existing stats data.
298 173 * @return array<string, mixed>
@@ -307,55 +182,24 @@
307 182 $internal_referer = is_array( $bsf_internal_referrer ) && ! empty( $bsf_internal_referrer['suredonation'] )
308 183 ? sanitize_text_field( (string) $bsf_internal_referrer['suredonation'] )
309 184 : 'self';
310 185
311 - $privacy_settings = Privacy_Settings::get_settings();
312 -
313 - // Computed once: the headline total below is derived from the same rows.
314 - $template_usage = $this->get_campaign_template_usage();
315 -
316 186 $plugin_data = [
317 - 'free_version' => SUREDONATION_VER,
318 - 'numeric_values' => [
319 - 'total_campaigns' => absint( $campaign_counts->publish ?? 0 ),
320 - 'total_donation_forms' => absint( $form_counts->publish ?? 0 ),
321 - 'total_donations' => $aggregates['total'],
322 - 'completed_donations' => $aggregates['completed'],
323 - 'recurring_donations' => $aggregates['recurring'],
324 - 'total_donors' => $this->get_total_donors(),
325 - 'forms_with_image_block' => $this->get_image_block_form_count(),
326 - 'posts_with_social_sharing_block' => $this->get_social_sharing_block_count(),
327 - 'stripe_accounts_count' => count( Stripe_Helper::get_all_accounts() ),
328 - // Campaigns started from a gallery template — excludes both the
329 - // scratch path and the `general` fallback, so this is the count
330 - // of campaigns that actually adopted a cause template.
331 - 'campaigns_from_template' => array_sum(
332 - array_diff_key(
333 - $template_usage,
334 - [
335 - 'scratch' => 0,
336 - Campaign_Templates::GENERAL => 0,
337 - ]
338 - )
339 - ),
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(),
340 195 ],
341 - 'boolean_values' => [
342 - 'stripe_enabled' => Stripe_Helper::is_stripe_connected(),
343 - 'paypal_enabled' => PayPal_Helper::is_paypal_connected(),
344 - 'offline_enabled' => Offline_Helper::is_offline_enabled(),
345 - // True only when the OttoKit plugin is active AND authenticated,
346 - // so this implies the plugin is active.
347 - 'ottokit_connected' => Helper::is_suretriggers_ready(),
348 - 'contact_consent_enabled' => ! empty( $privacy_settings['contact_consent_field'] ),
349 - 'privacy_policy_field_enabled' => ! empty( $privacy_settings['privacy_policy_field'] ),
350 - 'terms_field_enabled' => ! empty( $privacy_settings['terms_conditions_field'] ),
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(),
351 200 ],
352 - 'data_retention_period' => isset( $privacy_settings['minimum_data_retention_period'] ) ? Helper::get_string_value( $privacy_settings['minimum_data_retention_period'] ) : 'none',
353 - 'block_usage' => $this->get_block_usage(),
354 - 'campaign_template_usage' => $template_usage,
355 - 'elementor_widget_usage' => $this->get_elementor_widget_usage(),
356 - 'bricks_element_usage' => $this->get_bricks_element_usage(),
357 - 'internal_referer' => $internal_referer,
201 + 'internal_referer' => $internal_referer,
358 202 ];
359 203
360 204 // Add KPI tracking data.
361 205 $kpi_data = $this->get_kpi_tracking_data();
@@ -427,161 +271,8 @@
427 271 );
428 272 }
429 273
430 274 /**
431 - * Track the first time the campaign guided tour is shown to a user.
432 - *
433 - * Fired from the REST layer on the tour's first render. The events tracker
434 - * dedups by name, so this is recorded once per site regardless of how many
435 - * users see the tour or how often it re-triggers.
436 - *
437 - * @return void
438 - * @since 1.5.0
439 - */
440 - public function track_campaign_tour_shown() {
441 - $events = self::events();
442 - if ( null === $events ) {
443 - return;
444 - }
445 -
446 - $events->track( 'campaign_tour_shown' );
447 - }
448 -
449 - /**
450 - * Track how a campaign guided-tour run ended.
451 - *
452 - * `campaign_tour_shown` tells us the tour was seen; these tell us whether it
453 - * worked. Four outcomes, each recorded under its own event name:
454 - *
455 - * - `completed` — the user reached the final step.
456 - * - `dismissed` — closed part-way; the step key rides along as the
457 - * event value so we can see where runs are abandoned.
458 - * - `opted_out` — ticked "Don't show this again".
459 - * - `manual_started` — replayed deliberately via "Take a tour".
460 - *
461 - * `dismissed` is tracked with $force so the most recent drop-off step wins
462 - * rather than only the first one ever recorded on the site; the others keep
463 - * the default once-per-site semantics.
464 - *
465 - * @param string $outcome How the run ended. Unknown values are ignored.
466 - * @param string $step Step key the run ended on. Only used for `dismissed`.
467 - * @return void
468 - * @since 1.5.0
469 - */
470 - public function track_campaign_tour_outcome( $outcome, $step = '' ) {
471 - $events = self::events();
472 - if ( null === $events ) {
473 - return;
474 - }
475 -
476 - $outcome = Helper::get_string_value( $outcome );
477 - $step = Helper::get_string_value( $step );
478 -
479 - switch ( $outcome ) {
480 - case 'completed':
481 - $events->track( 'campaign_tour_completed' );
482 - break;
483 - case 'dismissed':
484 - // Retrackable: the latest abandonment point is the useful one.
485 - $events->track( 'campaign_tour_dismissed', $step, [], true );
486 - break;
487 - case 'opted_out':
488 - $events->track( 'campaign_tour_opted_out', $step );
489 - break;
490 - case 'manual_started':
491 - $events->track( 'campaign_tour_manual_started' );
492 - break;
493 - }
494 - }
495 -
496 - /**
497 - * Track the first time the campaign template picker is opened.
498 - *
499 - * Deduped, so it answers "did this site ever discover the picker?" — the
500 - * denominator for template adoption, since `campaign_template_usage` in the
501 - * stats payload only counts campaigns that were actually created from one.
502 - *
503 - * @return void
504 - * @since 1.5.0
505 - */
506 - public function track_campaign_template_picker_opened() {
507 - $events = self::events();
508 - if ( null === $events ) {
509 - return;
510 - }
511 -
512 - $events->track( 'campaign_template_picker_opened' );
513 - }
514 -
515 - /**
516 - * Track the first campaign a site creates from a template.
517 - *
518 - * Deduped, so the event value is the template the site reached for *first* —
519 - * the running per-template totals live in `campaign_template_usage` on the
520 - * stats payload, which is recomputed on every send.
521 - *
522 - * @param string $template_id Template the campaign was created from.
523 - * @return void
524 - * @since 1.5.0
525 - */
526 - public function track_campaign_created_from_template( $template_id ) {
527 - $events = self::events();
528 - if ( null === $events ) {
529 - return;
530 - }
531 -
532 - $template_id = Helper::get_string_value( $template_id );
533 - if ( '' === $template_id ) {
534 - return;
535 - }
536 -
537 - $events->track( 'first_campaign_from_template', $template_id );
538 - }
539 -
540 - /**
541 - * Track first personal-data export that included SureDonation data
542 - * (adoption event — deduped, sent once).
543 - *
544 - * @since 1.2.0
545 - * @return void
546 - */
547 - public function track_privacy_data_exported() {
548 - $events = self::events();
549 - if ( null === $events ) {
550 - return;
551 - }
552 -
553 - $events->track( 'privacy_data_export_used' );
554 - }
555 -
556 - /**
557 - * Track first personal-data erasure processed for SureDonation data
558 - * (adoption event — deduped, sent once).
559 - *
560 - * @since 1.2.0
561 - * @param array<string, mixed> $outcome Erasure outcome flags.
562 - * @return void
563 - */
564 - public function track_privacy_data_erased( $outcome ) {
565 - $events = self::events();
566 - if ( null === $events ) {
567 - return;
568 - }
569 -
570 - $outcome = is_array( $outcome ) ? $outcome : [];
571 -
572 - $events->track(
573 - 'privacy_data_erasure_used',
574 - '',
575 - [
576 - 'items_removed' => ! empty( $outcome['items_removed'] ) ? 'yes' : 'no',
577 - 'items_retained' => ! empty( $outcome['items_retained'] ) ? 'yes' : 'no',
578 - 'erase_failed' => ! empty( $outcome['erase_failed'] ) ? 'yes' : 'no',
579 - ]
580 - );
581 - }
582 -
583 - /**
584 275 * Track first time a campaign is published (activation event).
585 276 *
586 277 * @param string $new_status New post status.
587 278 * @param string $old_status Old post status.
@@ -651,9 +342,8 @@
651 342
652 343 $defaults = [
653 344 'total' => 0,
654 345 'completed' => 0,
655 - 'completed_live' => 0,
656 346 'recurring' => 0,
657 347 'anonymous_completed' => 0,
658 348 'fees_covered_completed' => 0,
659 349 'refunded' => 0,
@@ -663,9 +353,8 @@
663 353 $row = $wpdb->get_row(
664 354 $wpdb->prepare(
665 355 "SELECT COUNT(*) AS total,
666 356 COALESCE(SUM(payment_status = 'completed'),0) AS completed,
667 - COALESCE(SUM(payment_status = 'completed' AND payment_mode = 'live'),0) AS completed_live,
668 357 COALESCE(SUM(subscription_id IS NOT NULL AND subscription_id <> ''),0) AS recurring,
669 358 COALESCE(SUM(is_anonymous = 1 AND payment_status = 'completed'),0) AS anonymous_completed,
670 359 COALESCE(SUM(fees_covered > 0 AND payment_status = 'completed'),0) AS fees_covered_completed,
671 360 COALESCE(SUM(payment_status IN ('refunded','partially_refunded')),0) AS refunded
@@ -702,305 +391,8 @@
702 391 return absint( $count );
703 392 }
704 393
705 394 /**
706 - * How many published posts use each SureDonation campaign/donation block.
707 - *
708 - * A privacy-preserving usage count (no content leaves the site) so we can see
709 - * which blocks are actually adopted. One conditional-SUM query (a single table
710 - * scan), run only at analytics send time. Elementor/Bricks placements are not
711 - * counted here (they store their config outside the block grammar) — see
712 - * get_elementor_widget_usage().
713 - *
714 - * @return array<string, int> Block key => number of published posts using it.
715 - * @since 1.2.0
716 - */
717 - private function get_block_usage() {
718 - global $wpdb;
719 -
720 - $blocks = [
721 - 'campaign_goal' => 'suredonation/campaign-goal',
722 - 'campaign_stats' => 'suredonation/campaign-stats',
723 - 'campaign_donations' => 'suredonation/campaign-donations',
724 - 'campaign_donors' => 'suredonation/campaign-donors',
725 - 'campaign_donor_comments' => 'suredonation/campaign-donor-comments',
726 - 'campaign_donate_button' => 'suredonation/campaign-donate-button',
727 - 'campaign_social_sharing' => 'suredonation/campaign-social-sharing',
728 - 'donation_form' => 'suredonation/donation-form',
729 - ];
730 -
731 - // prepare() fills placeholders in SQL order: the SELECT-list %s LIKEs
732 - // first, then the FROM %i, then the status %s.
733 - $selects = [];
734 - $values = [];
735 - foreach ( array_keys( $blocks ) as $key ) {
736 - $selects[] = "SUM(post_content LIKE %s) AS {$key}";
737 - // The space after the block name is the delimiter the serializer always
738 - // emits (before attrs JSON, "-->" or "/-->"), so a future
739 - // "campaign-goal-x" block can't prefix-match campaign-goal.
740 - $values[] = '%' . $wpdb->esc_like( '<!-- wp:' . $blocks[ $key ] . ' ' ) . '%';
741 - }
742 - $values[] = $wpdb->posts;
743 - $values[] = 'publish';
744 -
745 - // 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.
746 - $row = $wpdb->get_row(
747 - $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.
748 - ARRAY_A
749 - );
750 -
751 - $usage = [];
752 - foreach ( array_keys( $blocks ) as $key ) {
753 - $usage[ $key ] = absint( is_array( $row ) ? ( $row[ $key ] ?? 0 ) : 0 );
754 - }
755 -
756 - return $usage;
757 - }
758 -
759 - /**
760 - * How many published campaigns were created from each campaign template.
761 - *
762 - * The running answer to "which template gets used, and how often" — a
763 - * snapshot rather than an event, because the stats payload is rebuilt on
764 - * every send while events dedup by name and fire once per site.
765 - *
766 - * Every known template id is seeded to 0 so the payload keeps the same shape
767 - * across sites (same contract as get_block_usage()). Campaigns with no
768 - * template meta — anything created before templates shipped, or via "Start
769 - * from scratch" — land in `scratch`. Ids that are no longer registered are
770 - * dropped rather than passed through, so a stale or hand-edited meta value
771 - * can never widen the payload.
772 - *
773 - * @return array<string, int> Template id => number of published campaigns.
774 - * @since 1.5.0
775 - */
776 - private function get_campaign_template_usage() {
777 - global $wpdb;
778 -
779 - $registry = Campaign_Templates::get_instance();
780 -
781 - // Seed the known ids, plus the two buckets that are not gallery cards:
782 - // `general` (the built-in fallback) and `scratch` (no meta at all).
783 - $usage = [ 'scratch' => 0 ];
784 - foreach ( $registry->get_all() as $template ) {
785 - $id = Helper::get_string_value( $template['id'] ?? '' );
786 - if ( '' !== $id ) {
787 - $usage[ $id ] = 0;
788 - }
789 - }
790 - $usage[ Campaign_Templates::GENERAL ] = 0;
791 -
792 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single grouped scan at analytics send time only.
793 - $rows = $wpdb->get_results(
794 - $wpdb->prepare(
795 - 'SELECT pm.meta_value AS template_id, COUNT(*) AS total
796 - FROM %i AS p
797 - LEFT JOIN %i AS pm ON pm.post_id = p.ID AND pm.meta_key = %s
798 - WHERE p.post_type = %s AND p.post_status = %s
799 - GROUP BY pm.meta_value',
800 - $wpdb->posts,
801 - $wpdb->postmeta,
802 - Campaign_Cpt::META_TEMPLATE_ID,
803 - SUREDONATION_POST_TYPE,
804 - 'publish'
805 - ),
806 - ARRAY_A
807 - );
808 -
809 - if ( ! is_array( $rows ) ) {
810 - return $usage;
811 - }
812 -
813 - foreach ( $rows as $row ) {
814 - $id = Helper::get_string_value( $row['template_id'] ?? '' );
815 - $total = absint( $row['total'] ?? 0 );
816 -
817 - // No meta (NULL from the LEFT JOIN, or an empty string) => scratch.
818 - if ( '' === $id ) {
819 - $usage['scratch'] += $total;
820 - continue;
821 - }
822 -
823 - // Only report ids we still recognise.
824 - if ( array_key_exists( $id, $usage ) ) {
825 - $usage[ $id ] += $total;
826 - }
827 - }
828 -
829 - return $usage;
830 - }
831 -
832 - /**
833 - * How many published posts use each SureDonation Elementor widget.
834 - *
835 - * The Elementor counterpart of get_block_usage(): widgets live in the
836 - * _elementor_data postmeta (JSON with a quoted "widgetType"), not in the
837 - * block grammar. Same privacy-preserving single-scan shape, run only at
838 - * analytics send time.
839 - *
840 - * @return array<string, int> Widget key => number of published posts using it.
841 - * @since 1.2.0
842 - */
843 - private function get_elementor_widget_usage() {
844 - global $wpdb;
845 -
846 - $widgets = [
847 - 'campaign_goal' => 'suredonation-campaign-goal',
848 - 'campaign_stats' => 'suredonation-campaign-stats',
849 - 'campaign_donations' => 'suredonation-campaign-donations',
850 - 'campaign_donors' => 'suredonation-campaign-donors',
851 - 'campaign_donor_comments' => 'suredonation-campaign-donor-comments',
852 - 'campaign_donate_button' => 'suredonation-campaign-donate-button',
853 - 'campaign_social_sharing' => 'suredonation-campaign-social-sharing',
854 - 'donation_form' => 'suredonation-donation-form',
855 - ];
856 -
857 - // prepare() fills placeholders in SQL order: the SELECT-list %s LIKEs
858 - // first, then the two FROM/JOIN %i tables, then meta_key and status.
859 - $selects = [];
860 - $values = [];
861 - foreach ( array_keys( $widgets ) as $key ) {
862 - $selects[] = "SUM(pm.meta_value LIKE %s) AS {$key}";
863 - // Quoted as stored in the _elementor_data JSON ("widgetType":"…"),
864 - // which bounds the match on both sides.
865 - $values[] = '%' . $wpdb->esc_like( '"' . $widgets[ $key ] . '"' ) . '%';
866 - }
867 - $values[] = $wpdb->postmeta;
868 - $values[] = $wpdb->posts;
869 - $values[] = '_elementor_data';
870 - $values[] = 'publish';
871 -
872 - // 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.
873 - $row = $wpdb->get_row(
874 - $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.
875 - ARRAY_A
876 - );
877 -
878 - $usage = [];
879 - foreach ( array_keys( $widgets ) as $key ) {
880 - $usage[ $key ] = absint( is_array( $row ) ? ( $row[ $key ] ?? 0 ) : 0 );
881 - }
882 -
883 - return $usage;
884 - }
885 -
886 - /**
887 - * How many published posts use each SureDonation Bricks element.
888 - *
889 - * A privacy-preserving usage count (no content leaves the site) so we can see
890 - * which Bricks elements are actually adopted — the Bricks counterpart of the
891 - * Gutenberg block_usage stat. Bricks stores builder data as serialized element
892 - * arrays in postmeta, so each element name is matched inside its quotes.
893 - * One conditional-SUM query (a single scan), run only at analytics send time.
894 - *
895 - * @return array<string, int> Element key => number of published posts using it.
896 - * @since 1.2.0
897 - */
898 - private function get_bricks_element_usage() {
899 - global $wpdb;
900 -
901 - $elements = [
902 - 'campaign_goal' => 'suredonation-campaign-goal',
903 - 'campaign_stats' => 'suredonation-campaign-stats',
904 - 'campaign_donations' => 'suredonation-campaign-donations',
905 - 'campaign_donors' => 'suredonation-campaign-donors',
906 - 'campaign_donor_comments' => 'suredonation-campaign-donor-comments',
907 - 'campaign_donate_button' => 'suredonation-campaign-donate-button',
908 - 'campaign_social_sharing' => 'suredonation-campaign-social-sharing',
909 - 'donation_form' => 'suredonation-donation-form',
910 - ];
911 -
912 - // prepare() fills placeholders in SQL order: the SELECT-list %s LIKEs
913 - // first, then the two FROM/JOIN %i tables, then meta keys and status.
914 - $selects = [];
915 - $values = [];
916 - foreach ( array_keys( $elements ) as $key ) {
917 - $selects[] = "SUM(pm.meta_value LIKE %s) AS {$key}";
918 - // Quoted as stored in the serialized Bricks element data, which
919 - // bounds the match on both sides.
920 - $values[] = '%' . $wpdb->esc_like( '"' . $elements[ $key ] . '"' ) . '%';
921 - }
922 - $values[] = $wpdb->postmeta;
923 - $values[] = $wpdb->posts;
924 - $values[] = '_bricks_page_content_2';
925 - $values[] = '_bricks_page_header_2';
926 - $values[] = '_bricks_page_footer_2';
927 - $values[] = 'publish';
928 -
929 - // 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.
930 - $row = $wpdb->get_row(
931 - $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.
932 - ARRAY_A
933 - );
934 -
935 - $usage = [];
936 - foreach ( array_keys( $elements ) as $key ) {
937 - $usage[ $key ] = absint( is_array( $row ) ? ( $row[ $key ] ?? 0 ) : 0 );
938 - }
939 -
940 - return $usage;
941 - }
942 -
943 - /**
944 - * How many published posts use the Campaign Social Sharing block.
945 - *
946 - * A privacy-preserving adoption count (no content leaves the site), run only
947 - * at analytics send time. The trailing space is the delimiter the block
948 - * serializer always emits after the block name, so a future
949 - * "campaign-social-sharing-x" block can't prefix-match.
950 - *
951 - * @return int Number of published posts containing the block.
952 - * @since 1.2.0
953 - */
954 - private function get_social_sharing_block_count() {
955 - global $wpdb;
956 -
957 - $like = '%' . $wpdb->esc_like( '<!-- wp:suredonation/campaign-social-sharing ' ) . '%';
958 -
959 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single aggregate COUNT run only at analytics send time.
960 - $count = $wpdb->get_var(
961 - $wpdb->prepare(
962 - 'SELECT COUNT(ID) FROM %i WHERE post_status = %s AND post_content LIKE %s',
963 - $wpdb->posts,
964 - 'publish',
965 - $like
966 - )
967 - );
968 -
969 - return absint( $count );
970 - }
971 -
972 - /**
973 - * How many published donation forms use the Image block.
974 - *
975 - * A privacy-preserving adoption count (no content leaves the site), run only
976 - * at analytics send time. The trailing space is the delimiter the block
977 - * serializer always emits after the block name, so a future
978 - * "image-x" block can't prefix-match.
979 - *
980 - * @return int Number of published donation forms containing the block.
981 - * @since 1.3.0
982 - */
983 - private function get_image_block_form_count() {
984 - global $wpdb;
985 -
986 - $like = '%' . $wpdb->esc_like( '<!-- wp:suredonation/image ' ) . '%';
987 -
988 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single aggregate COUNT run only at analytics send time.
989 - $count = $wpdb->get_var(
990 - $wpdb->prepare(
991 - 'SELECT COUNT(ID) FROM %i WHERE post_type = %s AND post_status = %s AND post_content LIKE %s',
992 - $wpdb->posts,
993 - 'suredonation_form',
994 - 'publish',
995 - $like
996 - )
997 - );
998 -
999 - return absint( $count );
1000 - }
1001 -
1002 - /**
1003 395 * Get KPI tracking data for the last 2 full days (excluding today).
1004 396 *
1005 397 * Single grouped query; raw revenue never enters the payload — only
1006 398 * the donation count and a coarse revenue tier per day.
@@ -1140,28 +532,8 @@
1140 532 if ( Stripe_Helper::is_stripe_connected() ) {
1141 533 $events->track( 'stripe_connected', $mode );
1142 534 }
1143 535
1144 - // stripe_card_capability_blocked: connected but Stripe will not let the
1145 - // account charge cards, so the card form is hidden and donations are
1146 - // being lost or diverted. Detected here rather than where the notices
1147 - // render: this is site state, not a page event, and the render path is
1148 - // a public request that should not be paying for analytics.
1149 - $blocked_accounts = 0;
1150 - foreach ( array_keys( Stripe_Helper::get_all_accounts() ) as $blocked_candidate ) {
1151 - if ( Stripe_Helper::is_card_capability_blocked( (string) $blocked_candidate, $mode ) ) {
1152 - ++$blocked_accounts;
1153 - }
1154 - }
1155 -
1156 - if ( $blocked_accounts > 0 ) {
1157 - $events->track(
1158 - 'stripe_card_capability_blocked',
1159 - $mode,
1160 - [ 'blocked_accounts' => $blocked_accounts ]
1161 - );
1162 - }
1163 -
1164 536 // paypal_connected: detect connection state.
1165 537 if ( PayPal_Helper::is_paypal_connected() ) {
1166 538 $events->track( 'paypal_connected', $mode );
1167 539 }
@@ -1184,25 +556,8 @@
1184 556 'days_since_install' => (string) $days_since_install,
1185 557 'payment_mode' => $mode,
1186 558 ]
1187 559 );
1188 -
1189 - // first_live_donation_received: first completed LIVE donation. A
1190 - // separate event with its own dedup key — first_donation_received
1191 - // almost always fires on a test donation (sites start in test
1192 - // mode) and the name-only dedup then suppresses it forever, so
1193 - // the live milestone would otherwise never be visible. Gated on
1194 - // the donation rows' own payment_mode, not the mode at detection
1195 - // time, so a later mode switch can't skew the signal.
1196 - if ( $aggregates['completed_live'] > 0 ) {
1197 - $events->track(
1198 - 'first_live_donation_received',
1199 - Payment_Helper::get_currency(),
1200 - [
1201 - 'days_since_install' => (string) $days_since_install,
1202 - ]
1203 - );
1204 - }
1205 560 }
1206 561
1207 562 // anonymous_donation_submitted: at least one completed anonymous donation.
1208 563 if ( $aggregates['anonymous_completed'] > 0 ) {
@@ -1218,19 +573,10 @@
1218 573 if ( $aggregates['refunded'] > 0 ) {
1219 574 $events->track( 'first_refund_processed' );
1220 575 }
1221 576
1222 - // webhook_configured: a Stripe webhook secret is stored for the current
1223 - // mode on any connected account (multi-account aware — reading only the
1224 - // default account would false-negative on sites using a non-default one).
1225 - $webhook_configured = false;
1226 - foreach ( array_keys( Stripe_Helper::get_all_accounts() ) as $wh_account_id ) {
1227 - if ( '' !== Stripe_Helper::get_webhook_secret( $mode, (string) $wh_account_id ) ) {
1228 - $webhook_configured = true;
1229 - break;
1230 - }
1231 - }
1232 - if ( $webhook_configured ) {
577 + // webhook_configured: a Stripe webhook secret is stored for the current mode.
578 + if ( '' !== Stripe_Helper::get_webhook_secret() ) {
1233 579 $events->track( 'webhook_configured', $mode );
1234 580 }
1235 581 }
1236 582 }