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 -666 1.5.1 → 1.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,302 +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_donate_button' => 'suredonation/campaign-donate-button',
726 - 'campaign_social_sharing' => 'suredonation/campaign-social-sharing',
727 - 'donation_form' => 'suredonation/donation-form',
728 - ];
729 -
730 - // prepare() fills placeholders in SQL order: the SELECT-list %s LIKEs
731 - // first, then the FROM %i, then the status %s.
732 - $selects = [];
733 - $values = [];
734 - foreach ( array_keys( $blocks ) as $key ) {
735 - $selects[] = "SUM(post_content LIKE %s) AS {$key}";
736 - // The space after the block name is the delimiter the serializer always
737 - // emits (before attrs JSON, "-->" or "/-->"), so a future
738 - // "campaign-goal-x" block can't prefix-match campaign-goal.
739 - $values[] = '%' . $wpdb->esc_like( '<!-- wp:' . $blocks[ $key ] . ' ' ) . '%';
740 - }
741 - $values[] = $wpdb->posts;
742 - $values[] = 'publish';
743 -
744 - // 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.
745 - $row = $wpdb->get_row(
746 - $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.
747 - ARRAY_A
748 - );
749 -
750 - $usage = [];
751 - foreach ( array_keys( $blocks ) as $key ) {
752 - $usage[ $key ] = absint( is_array( $row ) ? ( $row[ $key ] ?? 0 ) : 0 );
753 - }
754 -
755 - return $usage;
756 - }
757 -
758 - /**
759 - * How many published campaigns were created from each campaign template.
760 - *
761 - * The running answer to "which template gets used, and how often" — a
762 - * snapshot rather than an event, because the stats payload is rebuilt on
763 - * every send while events dedup by name and fire once per site.
764 - *
765 - * Every known template id is seeded to 0 so the payload keeps the same shape
766 - * across sites (same contract as get_block_usage()). Campaigns with no
767 - * template meta — anything created before templates shipped, or via "Start
768 - * from scratch" — land in `scratch`. Ids that are no longer registered are
769 - * dropped rather than passed through, so a stale or hand-edited meta value
770 - * can never widen the payload.
771 - *
772 - * @return array<string, int> Template id => number of published campaigns.
773 - * @since 1.5.0
774 - */
775 - private function get_campaign_template_usage() {
776 - global $wpdb;
777 -
778 - $registry = Campaign_Templates::get_instance();
779 -
780 - // Seed the known ids, plus the two buckets that are not gallery cards:
781 - // `general` (the built-in fallback) and `scratch` (no meta at all).
782 - $usage = [ 'scratch' => 0 ];
783 - foreach ( $registry->get_all() as $template ) {
784 - $id = Helper::get_string_value( $template['id'] ?? '' );
785 - if ( '' !== $id ) {
786 - $usage[ $id ] = 0;
787 - }
788 - }
789 - $usage[ Campaign_Templates::GENERAL ] = 0;
790 -
791 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single grouped scan at analytics send time only.
792 - $rows = $wpdb->get_results(
793 - $wpdb->prepare(
794 - 'SELECT pm.meta_value AS template_id, COUNT(*) AS total
795 - FROM %i AS p
796 - LEFT JOIN %i AS pm ON pm.post_id = p.ID AND pm.meta_key = %s
797 - WHERE p.post_type = %s AND p.post_status = %s
798 - GROUP BY pm.meta_value',
799 - $wpdb->posts,
800 - $wpdb->postmeta,
801 - Campaign_Cpt::META_TEMPLATE_ID,
802 - SUREDONATION_POST_TYPE,
803 - 'publish'
804 - ),
805 - ARRAY_A
806 - );
807 -
808 - if ( ! is_array( $rows ) ) {
809 - return $usage;
810 - }
811 -
812 - foreach ( $rows as $row ) {
813 - $id = Helper::get_string_value( $row['template_id'] ?? '' );
814 - $total = absint( $row['total'] ?? 0 );
815 -
816 - // No meta (NULL from the LEFT JOIN, or an empty string) => scratch.
817 - if ( '' === $id ) {
818 - $usage['scratch'] += $total;
819 - continue;
820 - }
821 -
822 - // Only report ids we still recognise.
823 - if ( array_key_exists( $id, $usage ) ) {
824 - $usage[ $id ] += $total;
825 - }
826 - }
827 -
828 - return $usage;
829 - }
830 -
831 - /**
832 - * How many published posts use each SureDonation Elementor widget.
833 - *
834 - * The Elementor counterpart of get_block_usage(): widgets live in the
835 - * _elementor_data postmeta (JSON with a quoted "widgetType"), not in the
836 - * block grammar. Same privacy-preserving single-scan shape, run only at
837 - * analytics send time.
838 - *
839 - * @return array<string, int> Widget key => number of published posts using it.
840 - * @since 1.2.0
841 - */
842 - private function get_elementor_widget_usage() {
843 - global $wpdb;
844 -
845 - $widgets = [
846 - 'campaign_goal' => 'suredonation-campaign-goal',
847 - 'campaign_stats' => 'suredonation-campaign-stats',
848 - 'campaign_donations' => 'suredonation-campaign-donations',
849 - 'campaign_donors' => 'suredonation-campaign-donors',
850 - 'campaign_donate_button' => 'suredonation-campaign-donate-button',
851 - 'campaign_social_sharing' => 'suredonation-campaign-social-sharing',
852 - 'donation_form' => 'suredonation-donation-form',
853 - ];
854 -
855 - // prepare() fills placeholders in SQL order: the SELECT-list %s LIKEs
856 - // first, then the two FROM/JOIN %i tables, then meta_key and status.
857 - $selects = [];
858 - $values = [];
859 - foreach ( array_keys( $widgets ) as $key ) {
860 - $selects[] = "SUM(pm.meta_value LIKE %s) AS {$key}";
861 - // Quoted as stored in the _elementor_data JSON ("widgetType":"…"),
862 - // which bounds the match on both sides.
863 - $values[] = '%' . $wpdb->esc_like( '"' . $widgets[ $key ] . '"' ) . '%';
864 - }
865 - $values[] = $wpdb->postmeta;
866 - $values[] = $wpdb->posts;
867 - $values[] = '_elementor_data';
868 - $values[] = 'publish';
869 -
870 - // 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.
871 - $row = $wpdb->get_row(
872 - $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.
873 - ARRAY_A
874 - );
875 -
876 - $usage = [];
877 - foreach ( array_keys( $widgets ) as $key ) {
878 - $usage[ $key ] = absint( is_array( $row ) ? ( $row[ $key ] ?? 0 ) : 0 );
879 - }
880 -
881 - return $usage;
882 - }
883 -
884 - /**
885 - * How many published posts use each SureDonation Bricks element.
886 - *
887 - * A privacy-preserving usage count (no content leaves the site) so we can see
888 - * which Bricks elements are actually adopted — the Bricks counterpart of the
889 - * Gutenberg block_usage stat. Bricks stores builder data as serialized element
890 - * arrays in postmeta, so each element name is matched inside its quotes.
891 - * One conditional-SUM query (a single scan), run only at analytics send time.
892 - *
893 - * @return array<string, int> Element key => number of published posts using it.
894 - * @since 1.2.0
895 - */
896 - private function get_bricks_element_usage() {
897 - global $wpdb;
898 -
899 - $elements = [
900 - 'campaign_goal' => 'suredonation-campaign-goal',
901 - 'campaign_stats' => 'suredonation-campaign-stats',
902 - 'campaign_donations' => 'suredonation-campaign-donations',
903 - 'campaign_donors' => 'suredonation-campaign-donors',
904 - 'campaign_donate_button' => 'suredonation-campaign-donate-button',
905 - 'campaign_social_sharing' => 'suredonation-campaign-social-sharing',
906 - 'donation_form' => 'suredonation-donation-form',
907 - ];
908 -
909 - // prepare() fills placeholders in SQL order: the SELECT-list %s LIKEs
910 - // first, then the two FROM/JOIN %i tables, then meta keys and status.
911 - $selects = [];
912 - $values = [];
913 - foreach ( array_keys( $elements ) as $key ) {
914 - $selects[] = "SUM(pm.meta_value LIKE %s) AS {$key}";
915 - // Quoted as stored in the serialized Bricks element data, which
916 - // bounds the match on both sides.
917 - $values[] = '%' . $wpdb->esc_like( '"' . $elements[ $key ] . '"' ) . '%';
918 - }
919 - $values[] = $wpdb->postmeta;
920 - $values[] = $wpdb->posts;
921 - $values[] = '_bricks_page_content_2';
922 - $values[] = '_bricks_page_header_2';
923 - $values[] = '_bricks_page_footer_2';
924 - $values[] = 'publish';
925 -
926 - // 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.
927 - $row = $wpdb->get_row(
928 - $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.
929 - ARRAY_A
930 - );
931 -
932 - $usage = [];
933 - foreach ( array_keys( $elements ) as $key ) {
934 - $usage[ $key ] = absint( is_array( $row ) ? ( $row[ $key ] ?? 0 ) : 0 );
935 - }
936 -
937 - return $usage;
938 - }
939 -
940 - /**
941 - * How many published posts use the Campaign Social Sharing block.
942 - *
943 - * A privacy-preserving adoption count (no content leaves the site), run only
944 - * at analytics send time. The trailing space is the delimiter the block
945 - * serializer always emits after the block name, so a future
946 - * "campaign-social-sharing-x" block can't prefix-match.
947 - *
948 - * @return int Number of published posts containing the block.
949 - * @since 1.2.0
950 - */
951 - private function get_social_sharing_block_count() {
952 - global $wpdb;
953 -
954 - $like = '%' . $wpdb->esc_like( '<!-- wp:suredonation/campaign-social-sharing ' ) . '%';
955 -
956 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single aggregate COUNT run only at analytics send time.
957 - $count = $wpdb->get_var(
958 - $wpdb->prepare(
959 - 'SELECT COUNT(ID) FROM %i WHERE post_status = %s AND post_content LIKE %s',
960 - $wpdb->posts,
961 - 'publish',
962 - $like
963 - )
964 - );
965 -
966 - return absint( $count );
967 - }
968 -
969 - /**
970 - * How many published donation forms use the Image block.
971 - *
972 - * A privacy-preserving adoption count (no content leaves the site), run only
973 - * at analytics send time. The trailing space is the delimiter the block
974 - * serializer always emits after the block name, so a future
975 - * "image-x" block can't prefix-match.
976 - *
977 - * @return int Number of published donation forms containing the block.
978 - * @since 1.3.0
979 - */
980 - private function get_image_block_form_count() {
981 - global $wpdb;
982 -
983 - $like = '%' . $wpdb->esc_like( '<!-- wp:suredonation/image ' ) . '%';
984 -
985 - // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single aggregate COUNT run only at analytics send time.
986 - $count = $wpdb->get_var(
987 - $wpdb->prepare(
988 - 'SELECT COUNT(ID) FROM %i WHERE post_type = %s AND post_status = %s AND post_content LIKE %s',
989 - $wpdb->posts,
990 - 'suredonation_form',
991 - 'publish',
992 - $like
993 - )
994 - );
995 -
996 - return absint( $count );
997 - }
998 -
999 - /**
1000 395 * Get KPI tracking data for the last 2 full days (excluding today).
1001 396 *
1002 397 * Single grouped query; raw revenue never enters the payload — only
1003 398 * the donation count and a coarse revenue tier per day.
@@ -1137,28 +532,8 @@
1137 532 if ( Stripe_Helper::is_stripe_connected() ) {
1138 533 $events->track( 'stripe_connected', $mode );
1139 534 }
1140 535
1141 - // stripe_card_capability_blocked: connected but Stripe will not let the
1142 - // account charge cards, so the card form is hidden and donations are
1143 - // being lost or diverted. Detected here rather than where the notices
1144 - // render: this is site state, not a page event, and the render path is
1145 - // a public request that should not be paying for analytics.
1146 - $blocked_accounts = 0;
1147 - foreach ( array_keys( Stripe_Helper::get_all_accounts() ) as $blocked_candidate ) {
1148 - if ( Stripe_Helper::is_card_capability_blocked( (string) $blocked_candidate, $mode ) ) {
1149 - ++$blocked_accounts;
1150 - }
1151 - }
1152 -
1153 - if ( $blocked_accounts > 0 ) {
1154 - $events->track(
1155 - 'stripe_card_capability_blocked',
1156 - $mode,
1157 - [ 'blocked_accounts' => $blocked_accounts ]
1158 - );
1159 - }
1160 -
1161 536 // paypal_connected: detect connection state.
1162 537 if ( PayPal_Helper::is_paypal_connected() ) {
1163 538 $events->track( 'paypal_connected', $mode );
1164 539 }
@@ -1181,25 +556,8 @@
1181 556 'days_since_install' => (string) $days_since_install,
1182 557 'payment_mode' => $mode,
1183 558 ]
1184 559 );
1185 -
1186 - // first_live_donation_received: first completed LIVE donation. A
1187 - // separate event with its own dedup key — first_donation_received
1188 - // almost always fires on a test donation (sites start in test
1189 - // mode) and the name-only dedup then suppresses it forever, so
1190 - // the live milestone would otherwise never be visible. Gated on
1191 - // the donation rows' own payment_mode, not the mode at detection
1192 - // time, so a later mode switch can't skew the signal.
1193 - if ( $aggregates['completed_live'] > 0 ) {
1194 - $events->track(
1195 - 'first_live_donation_received',
1196 - Payment_Helper::get_currency(),
1197 - [
1198 - 'days_since_install' => (string) $days_since_install,
1199 - ]
1200 - );
1201 - }
1202 560 }
1203 561
1204 562 // anonymous_donation_submitted: at least one completed anonymous donation.
1205 563 if ( $aggregates['anonymous_completed'] > 0 ) {
@@ -1215,19 +573,10 @@
1215 573 if ( $aggregates['refunded'] > 0 ) {
1216 574 $events->track( 'first_refund_processed' );
1217 575 }
1218 576
1219 - // webhook_configured: a Stripe webhook secret is stored for the current
1220 - // mode on any connected account (multi-account aware — reading only the
1221 - // default account would false-negative on sites using a non-default one).
1222 - $webhook_configured = false;
1223 - foreach ( array_keys( Stripe_Helper::get_all_accounts() ) as $wh_account_id ) {
1224 - if ( '' !== Stripe_Helper::get_webhook_secret( $mode, (string) $wh_account_id ) ) {
1225 - $webhook_configured = true;
1226 - break;
1227 - }
1228 - }
1229 - if ( $webhook_configured ) {
577 + // webhook_configured: a Stripe webhook secret is stored for the current mode.
578 + if ( '' !== Stripe_Helper::get_webhook_secret() ) {
1230 579 $events->track( 'webhook_configured', $mode );
1231 580 }
1232 581 }
1233 582 }