PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.3.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.3.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
suredonation / inc / admin / analytics.php

analytics.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.3.0, at inc/admin/analytics.php

951 lines 31.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Analytics class helps to connect BSF Analytics.
4 *
5 * @package SureDonation
6 */
7
8 namespace SureDonation\Inc\Admin;
9
10 use SureDonation\Inc\Helper;
11 use SureDonation\Inc\Payments\Offline\Offline_Helper;
12 use SureDonation\Inc\Payments\Payment_Helper;
13 use SureDonation\Inc\Payments\PayPal\PayPal_Helper;
14 use SureDonation\Inc\Payments\Stripe\Stripe_Helper;
15 use SureDonation\Inc\Privacy\Privacy_Settings;
16 use SureDonation\Inc\Traits\Get_Instance;
17
18 // Exit if accessed directly.
19 if ( ! defined( 'ABSPATH' ) ) {
20 exit;
21 }
22
23 /**
24 * Analytics class.
25 *
26 * @since 1.0.0
27 */
28 class Analytics {
29 use Get_Instance;
30
31 /**
32 * Allowlist of React admin-notice / UI analytics events.
33 *
34 * Single source of truth for the track-notice-event REST endpoint: the notice
35 * registrations (Admin::register_react_notices) and the dashboard Quick Access
36 * item set an event to one of these values, and handle_track_notice_event()
37 * only records events present here. Add a new event here (and reference it on
38 * the notice/item) to track it end-to-end.
39 *
40 * @var array<string, string>
41 * @since 1.3.0
42 */
43 public const TRACKED_EVENTS = [
44 'configure_gateway' => 'configure_gateway_notice_react_cta',
45 'webhook' => 'webhook_notice_react_cta',
46 'test_mode' => 'test_mode_notice_react_cta',
47 'quick_access' => 'quick_access_configure_gateway_react_cta',
48 ];
49
50 /**
51 * BSF_Analytics_Events instance for one-time event tracking.
52 *
53 * @var \BSF_Analytics_Events|null
54 * @since 1.0.0
55 */
56 private static $events = null;
57
58 /**
59 * Request-cached donation aggregates.
60 *
61 * @var array<string, int>|null
62 * @since 1.0.0
63 */
64 private static $donation_aggregates = null;
65
66 /**
67 * Class constructor.
68 *
69 * @return void
70 * @since 1.0.0
71 */
72 public function __construct() {
73 /*
74 * Entity registration is deferred to init priority 0 so that add-on
75 * plugins (e.g. SureDonation Pro) registering filters such as
76 * suredonation_deactivation_survey_data on plugins_loaded are in
77 * place before the deactivation survey data is filtered, and the
78 * entity is still set before the BSF Analytics loader consumes it on
79 * init priority 10.
80 */
81 add_action( 'init', [ $this, 'register_entity' ], 0 );
82
83 // REST route the React admin app calls to record notice/UI clicks.
84 add_action( 'rest_api_init', [ $this, 'register_routes' ] );
85
86 add_filter( 'bsf_core_stats', [ $this, 'add_suredonation_analytics_data' ] );
87
88 // Keep analytics sends (and their stat queries) off the frontend.
89 add_filter( 'suredonation_tracking_enabled', [ $this, 'restrict_tracking_to_admin' ] );
90
91 // Event tracking hooks. Registered outside is_admin() on purpose —
92 // onboarding completion and campaign publishes fire during REST requests.
93 add_action( 'suredonation_onboarding_user_details_saved', [ $this, 'track_onboarding_completed' ] );
94 add_action( 'transition_post_status', [ $this, 'track_first_campaign_published' ], 10, 3 );
95 add_action( 'current_screen', [ $this, 'track_first_campaign_editor_opened' ] );
96 add_action( 'suredonation_privacy_data_exported', [ $this, 'track_privacy_data_exported' ] );
97 add_action( 'suredonation_privacy_data_erased', [ $this, 'track_privacy_data_erased' ] );
98
99 // Detect state-based events (daily throttle; dedup prevents repeat
100 // tracking). Admin-only so the detection never runs on the frontend.
101 if ( is_admin() ) {
102 $this->detect_state_events();
103 }
104 }
105
106 /**
107 * Register the SureDonation entity with the BSF Analytics loader.
108 *
109 * Runs on init priority 0 — after add-on plugins have registered their
110 * filters on plugins_loaded, and before the loader's own init callback
111 * loads the analytics library.
112 *
113 * @return void
114 * @since 1.0.0
115 */
116 public function register_entity() {
117 if ( ! class_exists( 'BSF_Analytics_Loader' ) ) {
118 require_once SUREDONATION_DIR . 'inc/lib/bsf-analytics/class-bsf-analytics-loader.php';
119 }
120
121 if ( ! class_exists( 'BSF_Admin_Notices' ) ) {
122 require_once SUREDONATION_DIR . 'inc/lib/astra-notices/class-bsf-admin-notices.php';
123 }
124
125 /**
126 * The loader's get_instance() carries no return type.
127 *
128 * @var \BSF_Analytics_Loader $suredonation_bsf_analytics
129 */
130 $suredonation_bsf_analytics = \BSF_Analytics_Loader::get_instance();
131
132 $suredonation_bsf_analytics->set_entity(
133 [
134 'suredonation' => [
135 'product_name' => 'SureDonation',
136 'path' => SUREDONATION_DIR . 'inc/lib/bsf-analytics',
137 'author' => 'SureDonation',
138 'time_to_display' => '+24 hours',
139 'deactivation_survey' => apply_filters(
140 'suredonation_deactivation_survey_data',
141 [
142 [
143 'id' => 'deactivation-survey-suredonation',
144 'popup_logo' => SUREDONATION_URL . 'images/suredonation-icon.svg',
145 'plugin_slug' => 'suredonation',
146 'popup_title' => __( 'Quick Feedback', 'suredonation' ),
147 'support_url' => 'https://suredonation.com/support/',
148 'popup_description' => __( 'If you have a moment, please share why you are deactivating SureDonation:', 'suredonation' ),
149 'show_on_screens' => [ 'plugins' ],
150 'plugin_version' => SUREDONATION_VER,
151 ],
152 ]
153 ),
154 'hide_optin_checkbox' => true,
155 ],
156 ]
157 );
158 }
159
160 /**
161 * Get the shared BSF_Analytics_Events instance.
162 *
163 * Uses SureDonation's Helper option methods so the event data stays
164 * inside the consolidated suredonation_options row.
165 *
166 * @return \BSF_Analytics_Events|null Events instance, or null when the library is unavailable.
167 * @since 1.0.0
168 */
169 public static function events() {
170 if ( null === self::$events ) {
171 if ( ! class_exists( 'BSF_Analytics_Events' ) ) {
172 $events_file = SUREDONATION_DIR . 'inc/lib/bsf-analytics/class-bsf-analytics-events.php';
173 if ( file_exists( $events_file ) ) {
174 require_once $events_file;
175 }
176 }
177
178 if ( ! class_exists( 'BSF_Analytics_Events' ) ) {
179 return null;
180 }
181
182 self::$events = new \BSF_Analytics_Events(
183 'suredonation',
184 [
185 'get' => [ Helper::class, 'get_suredonation_option' ],
186 'update' => [ Helper::class, 'update_suredonation_option' ],
187 ]
188 );
189 }
190
191 return self::$events;
192 }
193
194 /**
195 * Register REST routes.
196 *
197 * Hooked - rest_api_init
198 *
199 * @return void
200 * @since 1.3.0
201 */
202 public function register_routes() {
203 register_rest_route(
204 'suredonation/v1',
205 '/track-notice-event',
206 [
207 'methods' => \WP_REST_Server::CREATABLE,
208 'callback' => [ $this, 'handle_track_notice_event' ],
209 'permission_callback' => static function () {
210 return current_user_can( 'manage_options' );
211 },
212 'args' => [
213 'event' => [
214 'type' => 'string',
215 'required' => true,
216 ],
217 ],
218 ]
219 );
220 }
221
222 /**
223 * Record a React notice/UI interaction event.
224 *
225 * Validates the event against an allowlist (so arbitrary events cannot be
226 * injected) and records it via the shared analytics events, respecting the
227 * usage-tracking opt-in. Event names are suffixed `_react` to keep them
228 * distinct from the wp-admin notice events.
229 *
230 * @param \WP_REST_Request<array<string, mixed>> $request REST request.
231 * @return \WP_REST_Response
232 * @since 1.3.0
233 */
234 public function handle_track_notice_event( $request ) {
235 $event = sanitize_key( (string) $request->get_param( 'event' ) );
236
237 if ( ! in_array( $event, self::TRACKED_EVENTS, true ) ) {
238 return new \WP_REST_Response( [ 'success' => false ], 400 );
239 }
240
241 $events = self::events();
242 if ( null !== $events ) {
243 $events->track( $event );
244 }
245
246 return new \WP_REST_Response( [ 'success' => true ], 200 );
247 }
248
249 /**
250 * Callback function to add SureDonation specific analytics data.
251 *
252 * @param array<string, mixed> $stats_data Existing stats data.
253 * @return array<string, mixed>
254 * @since 1.0.0
255 */
256 public function add_suredonation_analytics_data( $stats_data ) {
257 $aggregates = $this->get_donation_aggregates();
258 $campaign_counts = wp_count_posts( 'suredonation_cmpgn' );
259 $form_counts = wp_count_posts( 'suredonation_form' );
260
261 $bsf_internal_referrer = get_option( 'bsf_product_referers', [] );
262 $internal_referer = is_array( $bsf_internal_referrer ) && ! empty( $bsf_internal_referrer['suredonation'] )
263 ? sanitize_text_field( (string) $bsf_internal_referrer['suredonation'] )
264 : 'self';
265
266 $privacy_settings = Privacy_Settings::get_settings();
267
268 $plugin_data = [
269 'free_version' => SUREDONATION_VER,
270 'numeric_values' => [
271 'total_campaigns' => absint( $campaign_counts->publish ?? 0 ),
272 'total_donation_forms' => absint( $form_counts->publish ?? 0 ),
273 'total_donations' => $aggregates['total'],
274 'completed_donations' => $aggregates['completed'],
275 'recurring_donations' => $aggregates['recurring'],
276 'total_donors' => $this->get_total_donors(),
277 'forms_with_image_block' => $this->get_image_block_form_count(),
278 'posts_with_social_sharing_block' => $this->get_social_sharing_block_count(),
279 'stripe_accounts_count' => count( Stripe_Helper::get_all_accounts() ),
280 ],
281 'boolean_values' => [
282 'stripe_enabled' => Stripe_Helper::is_stripe_connected(),
283 'paypal_enabled' => PayPal_Helper::is_paypal_connected(),
284 'offline_enabled' => Offline_Helper::is_offline_enabled(),
285 // True only when the OttoKit plugin is active AND authenticated,
286 // so this implies the plugin is active.
287 'ottokit_connected' => Helper::is_suretriggers_ready(),
288 'contact_consent_enabled' => ! empty( $privacy_settings['contact_consent_field'] ),
289 'privacy_policy_field_enabled' => ! empty( $privacy_settings['privacy_policy_field'] ),
290 'terms_field_enabled' => ! empty( $privacy_settings['terms_conditions_field'] ),
291 ],
292 'data_retention_period' => isset( $privacy_settings['minimum_data_retention_period'] ) ? Helper::get_string_value( $privacy_settings['minimum_data_retention_period'] ) : 'none',
293 'block_usage' => $this->get_block_usage(),
294 'elementor_widget_usage' => $this->get_elementor_widget_usage(),
295 'bricks_element_usage' => $this->get_bricks_element_usage(),
296 'internal_referer' => $internal_referer,
297 ];
298
299 // Add KPI tracking data.
300 $kpi_data = $this->get_kpi_tracking_data();
301 if ( ! empty( $kpi_data ) ) {
302 $plugin_data['kpi_records'] = $kpi_data;
303 }
304
305 // Flush pending events into payload (only if any exist).
306 $events = self::events();
307 if ( null !== $events ) {
308 $pending_events = $events->flush_pending();
309 if ( ! empty( $pending_events ) ) {
310 $plugin_data['events_record'] = $pending_events;
311 }
312 }
313
314 if ( ! isset( $stats_data['plugin_data'] ) || ! is_array( $stats_data['plugin_data'] ) ) {
315 $stats_data['plugin_data'] = [];
316 }
317
318 $stats_data['plugin_data']['suredonation'] = $plugin_data;
319
320 return $stats_data;
321 }
322
323 /**
324 * Keep analytics sends off the frontend.
325 *
326 * Filter callback for `suredonation_tracking_enabled`. The library
327 * evaluates this on every request via `is_tracking_enabled()`; gating on
328 * is_admin() means the stats queries never run on frontend page loads.
329 * Deliberately NOT narrowed further (e.g. to plugin screens): the library
330 * also consults this filter from `register_usage_tracking_setting()` on
331 * admin_init, where returning false aborts settings registration for all
332 * registered BSF products.
333 *
334 * @param bool $is_enabled Whether tracking is enabled (opt-in state).
335 * @return bool
336 * @since 1.0.0
337 */
338 public function restrict_tracking_to_admin( $is_enabled ) {
339 return $is_enabled && is_admin();
340 }
341
342 /**
343 * Track onboarding completion when lead-capture details are saved.
344 *
345 * The payload contains PII (name/email) — only the opt-in flag is
346 * forwarded to analytics.
347 *
348 * @param array<string, mixed> $payload Sanitized onboarding payload.
349 * @return void
350 * @since 1.0.0
351 */
352 public function track_onboarding_completed( $payload ) {
353 $events = self::events();
354 if ( null === $events ) {
355 return;
356 }
357
358 $payload = is_array( $payload ) ? $payload : [];
359
360 $events->track(
361 'onboarding_completed',
362 '',
363 [
364 'opted_in' => ! empty( $payload['opted_in'] ) ? 'yes' : 'no',
365 ]
366 );
367 }
368
369 /**
370 * Track first personal-data export that included SureDonation data
371 * (adoption event — deduped, sent once).
372 *
373 * @since 1.2.0
374 * @return void
375 */
376 public function track_privacy_data_exported() {
377 $events = self::events();
378 if ( null === $events ) {
379 return;
380 }
381
382 $events->track( 'privacy_data_export_used' );
383 }
384
385 /**
386 * Track first personal-data erasure processed for SureDonation data
387 * (adoption event — deduped, sent once).
388 *
389 * @since 1.2.0
390 * @param array<string, mixed> $outcome Erasure outcome flags.
391 * @return void
392 */
393 public function track_privacy_data_erased( $outcome ) {
394 $events = self::events();
395 if ( null === $events ) {
396 return;
397 }
398
399 $outcome = is_array( $outcome ) ? $outcome : [];
400
401 $events->track(
402 'privacy_data_erasure_used',
403 '',
404 [
405 'items_removed' => ! empty( $outcome['items_removed'] ) ? 'yes' : 'no',
406 'items_retained' => ! empty( $outcome['items_retained'] ) ? 'yes' : 'no',
407 'erase_failed' => ! empty( $outcome['erase_failed'] ) ? 'yes' : 'no',
408 ]
409 );
410 }
411
412 /**
413 * Track first time a campaign is published (activation event).
414 *
415 * @param string $new_status New post status.
416 * @param string $old_status Old post status.
417 * @param \WP_Post $post Post object.
418 * @return void
419 * @since 1.0.0
420 */
421 public function track_first_campaign_published( $new_status, $old_status, $post ) {
422 if ( 'publish' !== $new_status || 'publish' === $old_status || ! $post instanceof \WP_Post || 'suredonation_cmpgn' !== $post->post_type ) {
423 return;
424 }
425
426 $events = self::events();
427 if ( null === $events ) {
428 return;
429 }
430
431 $meta = Helper::get_campaign_meta( $post->ID );
432 $goal_amount = isset( $meta['goal_amount'] ) && is_numeric( $meta['goal_amount'] ) ? (float) $meta['goal_amount'] : 0.0;
433 $goal_type = isset( $meta['goal_type'] ) && is_scalar( $meta['goal_type'] ) ? sanitize_text_field( (string) $meta['goal_type'] ) : '';
434
435 $events->track(
436 'first_campaign_published',
437 (string) $post->ID,
438 [
439 'goal_type' => $goal_type,
440 'has_goal' => $goal_amount > 0 ? '1' : '0',
441 ]
442 );
443 }
444
445 /**
446 * Track first time a user opens the campaign editor.
447 *
448 * @param \WP_Screen $screen Current screen object.
449 * @return void
450 * @since 1.0.0
451 */
452 public function track_first_campaign_editor_opened( $screen ) {
453 if ( ! $screen instanceof \WP_Screen || 'suredonation_cmpgn' !== $screen->post_type || 'post' !== $screen->base ) {
454 return;
455 }
456
457 $events = self::events();
458 if ( null === $events ) {
459 return;
460 }
461
462 $events->track( 'first_campaign_editor_opened' );
463 }
464
465 /**
466 * Get donation aggregates in a single request-cached query.
467 *
468 * Feeds both the stats numeric values and the state-event detection so
469 * the donations table is only ever hit once per request.
470 *
471 * @return array<string, int> Aggregate counts.
472 * @since 1.0.0
473 */
474 private function get_donation_aggregates() {
475 if ( null !== self::$donation_aggregates ) {
476 return self::$donation_aggregates;
477 }
478
479 global $wpdb;
480
481 $defaults = [
482 'total' => 0,
483 'completed' => 0,
484 'recurring' => 0,
485 'anonymous_completed' => 0,
486 'fees_covered_completed' => 0,
487 'refunded' => 0,
488 ];
489
490 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single aggregate query on a custom table, request-cached in a static.
491 $row = $wpdb->get_row(
492 $wpdb->prepare(
493 "SELECT COUNT(*) AS total,
494 COALESCE(SUM(payment_status = 'completed'),0) AS completed,
495 COALESCE(SUM(subscription_id IS NOT NULL AND subscription_id <> ''),0) AS recurring,
496 COALESCE(SUM(is_anonymous = 1 AND payment_status = 'completed'),0) AS anonymous_completed,
497 COALESCE(SUM(fees_covered > 0 AND payment_status = 'completed'),0) AS fees_covered_completed,
498 COALESCE(SUM(payment_status IN ('refunded','partially_refunded')),0) AS refunded
499 FROM %i",
500 $wpdb->prefix . 'suredonation_donations'
501 ),
502 ARRAY_A
503 );
504
505 self::$donation_aggregates = is_array( $row )
506 ? array_map( 'absint', array_merge( $defaults, $row ) )
507 : $defaults;
508
509 return self::$donation_aggregates;
510 }
511
512 /**
513 * Get the total number of donors.
514 *
515 * @return int
516 * @since 1.0.0
517 */
518 private function get_total_donors() {
519 global $wpdb;
520
521 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single COUNT query on a custom table, runs only at analytics send time.
522 $count = $wpdb->get_var(
523 $wpdb->prepare(
524 'SELECT COUNT(*) FROM %i',
525 $wpdb->prefix . 'suredonation_donors'
526 )
527 );
528
529 return absint( $count );
530 }
531
532 /**
533 * How many published posts use each SureDonation campaign/donation block.
534 *
535 * A privacy-preserving usage count (no content leaves the site) so we can see
536 * which blocks are actually adopted. One conditional-SUM query (a single table
537 * scan), run only at analytics send time. Elementor/Bricks placements are not
538 * counted here (they store their config outside the block grammar) — see
539 * get_elementor_widget_usage().
540 *
541 * @return array<string, int> Block key => number of published posts using it.
542 * @since 1.2.0
543 */
544 private function get_block_usage() {
545 global $wpdb;
546
547 $blocks = [
548 'campaign_goal' => 'suredonation/campaign-goal',
549 'campaign_stats' => 'suredonation/campaign-stats',
550 'campaign_donations' => 'suredonation/campaign-donations',
551 'campaign_donors' => 'suredonation/campaign-donors',
552 'campaign_donate_button' => 'suredonation/campaign-donate-button',
553 'campaign_social_sharing' => 'suredonation/campaign-social-sharing',
554 'donation_form' => 'suredonation/donation-form',
555 ];
556
557 // prepare() fills placeholders in SQL order: the SELECT-list %s LIKEs
558 // first, then the FROM %i, then the status %s.
559 $selects = [];
560 $values = [];
561 foreach ( array_keys( $blocks ) as $key ) {
562 $selects[] = "SUM(post_content LIKE %s) AS {$key}";
563 // The space after the block name is the delimiter the serializer always
564 // emits (before attrs JSON, "-->" or "/-->"), so a future
565 // "campaign-goal-x" block can't prefix-match campaign-goal.
566 $values[] = '%' . $wpdb->esc_like( '<!-- wp:' . $blocks[ $key ] . ' ' ) . '%';
567 }
568 $values[] = $wpdb->posts;
569 $values[] = 'publish';
570
571 // 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.
572 $row = $wpdb->get_row(
573 $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.
574 ARRAY_A
575 );
576
577 $usage = [];
578 foreach ( array_keys( $blocks ) as $key ) {
579 $usage[ $key ] = absint( is_array( $row ) ? ( $row[ $key ] ?? 0 ) : 0 );
580 }
581
582 return $usage;
583 }
584
585 /**
586 * How many published posts use each SureDonation Elementor widget.
587 *
588 * The Elementor counterpart of get_block_usage(): widgets live in the
589 * _elementor_data postmeta (JSON with a quoted "widgetType"), not in the
590 * block grammar. Same privacy-preserving single-scan shape, run only at
591 * analytics send time.
592 *
593 * @return array<string, int> Widget key => number of published posts using it.
594 * @since 1.2.0
595 */
596 private function get_elementor_widget_usage() {
597 global $wpdb;
598
599 $widgets = [
600 'campaign_goal' => 'suredonation-campaign-goal',
601 'campaign_stats' => 'suredonation-campaign-stats',
602 'campaign_donations' => 'suredonation-campaign-donations',
603 'campaign_donors' => 'suredonation-campaign-donors',
604 'campaign_donate_button' => 'suredonation-campaign-donate-button',
605 'campaign_social_sharing' => 'suredonation-campaign-social-sharing',
606 'donation_form' => 'suredonation-donation-form',
607 ];
608
609 // prepare() fills placeholders in SQL order: the SELECT-list %s LIKEs
610 // first, then the two FROM/JOIN %i tables, then meta_key and status.
611 $selects = [];
612 $values = [];
613 foreach ( array_keys( $widgets ) as $key ) {
614 $selects[] = "SUM(pm.meta_value LIKE %s) AS {$key}";
615 // Quoted as stored in the _elementor_data JSON ("widgetType":"…"),
616 // which bounds the match on both sides.
617 $values[] = '%' . $wpdb->esc_like( '"' . $widgets[ $key ] . '"' ) . '%';
618 }
619 $values[] = $wpdb->postmeta;
620 $values[] = $wpdb->posts;
621 $values[] = '_elementor_data';
622 $values[] = 'publish';
623
624 // 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.
625 $row = $wpdb->get_row(
626 $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.
627 ARRAY_A
628 );
629
630 $usage = [];
631 foreach ( array_keys( $widgets ) as $key ) {
632 $usage[ $key ] = absint( is_array( $row ) ? ( $row[ $key ] ?? 0 ) : 0 );
633 }
634
635 return $usage;
636 }
637
638 /**
639 * How many published posts use each SureDonation Bricks element.
640 *
641 * A privacy-preserving usage count (no content leaves the site) so we can see
642 * which Bricks elements are actually adopted — the Bricks counterpart of the
643 * Gutenberg block_usage stat. Bricks stores builder data as serialized element
644 * arrays in postmeta, so each element name is matched inside its quotes.
645 * One conditional-SUM query (a single scan), run only at analytics send time.
646 *
647 * @return array<string, int> Element key => number of published posts using it.
648 * @since 1.2.0
649 */
650 private function get_bricks_element_usage() {
651 global $wpdb;
652
653 $elements = [
654 'campaign_goal' => 'suredonation-campaign-goal',
655 'campaign_stats' => 'suredonation-campaign-stats',
656 'campaign_donations' => 'suredonation-campaign-donations',
657 'campaign_donors' => 'suredonation-campaign-donors',
658 'campaign_donate_button' => 'suredonation-campaign-donate-button',
659 'campaign_social_sharing' => 'suredonation-campaign-social-sharing',
660 'donation_form' => 'suredonation-donation-form',
661 ];
662
663 // prepare() fills placeholders in SQL order: the SELECT-list %s LIKEs
664 // first, then the two FROM/JOIN %i tables, then meta keys and status.
665 $selects = [];
666 $values = [];
667 foreach ( array_keys( $elements ) as $key ) {
668 $selects[] = "SUM(pm.meta_value LIKE %s) AS {$key}";
669 // Quoted as stored in the serialized Bricks element data, which
670 // bounds the match on both sides.
671 $values[] = '%' . $wpdb->esc_like( '"' . $elements[ $key ] . '"' ) . '%';
672 }
673 $values[] = $wpdb->postmeta;
674 $values[] = $wpdb->posts;
675 $values[] = '_bricks_page_content_2';
676 $values[] = '_bricks_page_header_2';
677 $values[] = '_bricks_page_footer_2';
678 $values[] = 'publish';
679
680 // 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.
681 $row = $wpdb->get_row(
682 $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.
683 ARRAY_A
684 );
685
686 $usage = [];
687 foreach ( array_keys( $elements ) as $key ) {
688 $usage[ $key ] = absint( is_array( $row ) ? ( $row[ $key ] ?? 0 ) : 0 );
689 }
690
691 return $usage;
692 }
693
694 /**
695 * How many published posts use the Campaign Social Sharing block.
696 *
697 * A privacy-preserving adoption count (no content leaves the site), run only
698 * at analytics send time. The trailing space is the delimiter the block
699 * serializer always emits after the block name, so a future
700 * "campaign-social-sharing-x" block can't prefix-match.
701 *
702 * @return int Number of published posts containing the block.
703 * @since 1.2.0
704 */
705 private function get_social_sharing_block_count() {
706 global $wpdb;
707
708 $like = '%' . $wpdb->esc_like( '<!-- wp:suredonation/campaign-social-sharing ' ) . '%';
709
710 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single aggregate COUNT run only at analytics send time.
711 $count = $wpdb->get_var(
712 $wpdb->prepare(
713 'SELECT COUNT(ID) FROM %i WHERE post_status = %s AND post_content LIKE %s',
714 $wpdb->posts,
715 'publish',
716 $like
717 )
718 );
719
720 return absint( $count );
721 }
722
723 /**
724 * How many published donation forms use the Image block.
725 *
726 * A privacy-preserving adoption count (no content leaves the site), run only
727 * at analytics send time. The trailing space is the delimiter the block
728 * serializer always emits after the block name, so a future
729 * "image-x" block can't prefix-match.
730 *
731 * @return int Number of published donation forms containing the block.
732 * @since 1.3.0
733 */
734 private function get_image_block_form_count() {
735 global $wpdb;
736
737 $like = '%' . $wpdb->esc_like( '<!-- wp:suredonation/image ' ) . '%';
738
739 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single aggregate COUNT run only at analytics send time.
740 $count = $wpdb->get_var(
741 $wpdb->prepare(
742 'SELECT COUNT(ID) FROM %i WHERE post_type = %s AND post_status = %s AND post_content LIKE %s',
743 $wpdb->posts,
744 'suredonation_form',
745 'publish',
746 $like
747 )
748 );
749
750 return absint( $count );
751 }
752
753 /**
754 * Get KPI tracking data for the last 2 full days (excluding today).
755 *
756 * Single grouped query; raw revenue never enters the payload — only
757 * the donation count and a coarse revenue tier per day.
758 *
759 * Date boundaries use GMT because `created_at` is written with
760 * current_time( 'mysql', true ).
761 *
762 * @return array<string, array<string, array<string, mixed>>> KPI data keyed by Y-m-d date.
763 * @since 1.0.0
764 */
765 private function get_kpi_tracking_data() {
766 global $wpdb;
767
768 $start = gmdate( 'Y-m-d', strtotime( '-2 days' ) ) . ' 00:00:00';
769 $end = gmdate( 'Y-m-d' ) . ' 00:00:00';
770
771 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single grouped query on a custom table, runs only at analytics send time.
772 $rows = $wpdb->get_results(
773 $wpdb->prepare(
774 "SELECT DATE(created_at) AS day, COUNT(*) AS donations, COALESCE(SUM(amount),0) AS revenue
775 FROM %i
776 WHERE payment_status = 'completed' AND created_at >= %s AND created_at < %s
777 GROUP BY day",
778 $wpdb->prefix . 'suredonation_donations',
779 $start,
780 $end
781 ),
782 ARRAY_A
783 );
784
785 $kpi_data = [];
786
787 // Seed both days so dates with zero donations are still reported.
788 for ( $i = 2; $i >= 1; $i-- ) {
789 $day = gmdate( 'Y-m-d', strtotime( '-' . $i . ' days' ) );
790
791 $kpi_data[ $day ] = [
792 'numeric_values' => [
793 'donations' => 0,
794 ],
795 'string_values' => [
796 'donation_revenue_tier' => '0',
797 ],
798 ];
799 }
800
801 $rows = is_array( $rows ) ? $rows : [];
802
803 foreach ( $rows as $row ) {
804 if ( empty( $row['day'] ) || ! isset( $kpi_data[ $row['day'] ] ) ) {
805 continue;
806 }
807
808 $kpi_data[ $row['day'] ] = [
809 'numeric_values' => [
810 'donations' => absint( $row['donations'] ?? 0 ),
811 ],
812 'string_values' => [
813 'donation_revenue_tier' => $this->get_revenue_tier( (float) ( $row['revenue'] ?? 0 ) ),
814 ],
815 ];
816 }
817
818 return $kpi_data;
819 }
820
821 /**
822 * Map a raw daily revenue amount to a coarse reporting tier.
823 *
824 * @param float $revenue Daily revenue.
825 * @return string Revenue tier label.
826 * @since 1.0.0
827 */
828 private function get_revenue_tier( float $revenue ): string {
829 if ( $revenue <= 0 ) {
830 return '0';
831 }
832 if ( $revenue < 100 ) {
833 return '1-100';
834 }
835 if ( $revenue < 500 ) {
836 return '100-500';
837 }
838 if ( $revenue < 1000 ) {
839 return '500-1000';
840 }
841 if ( $revenue < 5000 ) {
842 return '1000-5000';
843 }
844 return '5000+';
845 }
846
847 /**
848 * Detect state-based events that can't use direct hooks.
849 *
850 * Throttled by a daily transient; uses the request-cached donation
851 * aggregates plus option reads only — no extra queries. The events
852 * tracker dedups, so repeated calls are safe.
853 *
854 * @return void
855 * @since 1.0.0
856 */
857 private function detect_state_events() {
858 if ( get_transient( 'suredonation_state_events_checked' ) ) {
859 return;
860 }
861
862 $events = self::events();
863 if ( null === $events ) {
864 return; // Tracker unavailable — retry on next admin load.
865 }
866
867 // Set only after the tracker is confirmed available.
868 set_transient( 'suredonation_state_events_checked', true, DAY_IN_SECONDS );
869
870 $aggregates = $this->get_donation_aggregates();
871 $mode = Payment_Helper::get_payment_mode();
872
873 // plugin_activated: dedup ensures this fires only once.
874 $bsf_referrers = get_option( 'bsf_product_referers', [] );
875 $source = is_array( $bsf_referrers ) && ! empty( $bsf_referrers['suredonation'] )
876 ? sanitize_text_field( (string) $bsf_referrers['suredonation'] )
877 : 'self';
878 $events->track( 'plugin_activated', SUREDONATION_VER, [ 'source' => $source ] );
879
880 // plugin_updated: re-track on every version change.
881 $tracked_version = get_option( 'suredonation_tracked_version', '' );
882 if ( SUREDONATION_VER !== $tracked_version ) {
883 if ( ! empty( $tracked_version ) && is_string( $tracked_version ) ) {
884 $events->flush_pushed( [ 'plugin_updated' ] );
885 $events->track( 'plugin_updated', SUREDONATION_VER, [ 'from_version' => $tracked_version ] );
886 }
887 update_option( 'suredonation_tracked_version', SUREDONATION_VER, false );
888 }
889
890 // stripe_connected: detect connection state.
891 if ( Stripe_Helper::is_stripe_connected() ) {
892 $events->track( 'stripe_connected', $mode );
893 }
894
895 // paypal_connected: detect connection state.
896 if ( PayPal_Helper::is_paypal_connected() ) {
897 $events->track( 'paypal_connected', $mode );
898 }
899
900 // payment_mode_live: site switched to live payments.
901 if ( 'live' === $mode ) {
902 $events->track( 'payment_mode_live' );
903 }
904
905 // first_donation_received: time-to-value milestone.
906 if ( $aggregates['completed'] > 0 ) {
907 $install_time_raw = get_site_option( 'suredonation_usage_installed_time', 0 );
908 $install_time = is_numeric( $install_time_raw ) ? (int) $install_time_raw : 0;
909 $days_since_install = $install_time > 0 ? (int) floor( ( time() - $install_time ) / DAY_IN_SECONDS ) : 0;
910
911 $events->track(
912 'first_donation_received',
913 Payment_Helper::get_currency(),
914 [
915 'days_since_install' => (string) $days_since_install,
916 'payment_mode' => $mode,
917 ]
918 );
919 }
920
921 // anonymous_donation_submitted: at least one completed anonymous donation.
922 if ( $aggregates['anonymous_completed'] > 0 ) {
923 $events->track( 'anonymous_donation_submitted' );
924 }
925
926 // cover_fees_used: at least one completed donation covered fees.
927 if ( $aggregates['fees_covered_completed'] > 0 ) {
928 $events->track( 'cover_fees_used' );
929 }
930
931 // first_refund_processed: at least one (partially) refunded donation.
932 if ( $aggregates['refunded'] > 0 ) {
933 $events->track( 'first_refund_processed' );
934 }
935
936 // webhook_configured: a Stripe webhook secret is stored for the current
937 // mode on any connected account (multi-account aware — reading only the
938 // default account would false-negative on sites using a non-default one).
939 $webhook_configured = false;
940 foreach ( array_keys( Stripe_Helper::get_all_accounts() ) as $wh_account_id ) {
941 if ( '' !== Stripe_Helper::get_webhook_secret( $mode, (string) $wh_account_id ) ) {
942 $webhook_configured = true;
943 break;
944 }
945 }
946 if ( $webhook_configured ) {
947 $events->track( 'webhook_configured', $mode );
948 }
949 }
950 }
951