PluginProbe
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) / 1.8.11.3
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) v1.8.11.3
1.8.13.1 1.8.13 1.8.12.3 1.8.12.2 1.8.12.1 1.8.12 1.8.11.3 1.8.11.2 1.8.11.1 1.8.11 1.6.6 1.6.60 1.6.7 1.6.8 1.6.9 1.7.0 1.7.0.1 1.7.0.11 1.7.0.12 1.7.0.14 1.7.0.2 1.7.0.3 1.7.0.5 1.7.0.6 1.7.0.7 All 212 releases
charitable / includes / admin / dashboard / class-charitable-dashboard.php

class-charitable-dashboard.php in Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) 1.8.11.3, at includes/admin/dashboard/class-charitable-dashboard.php

4,022 lines 164.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Charitable Dashboard class.
4 *
5 * @package Charitable/Classes/Charitable_Dashboard
6 * @author David Bisset
7 * @copyright Copyright (c) 2023, WP Charitable LLC
8 * @license http://opensource.org/licenses/gpl-2.0.php GNU Public License
9 * @since 1.8.1
10 * @version 1.8.9.1
11 */
12
13 // Exit if accessed directly.
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit;
16 }
17
18 if ( ! class_exists( 'Charitable_Dashboard' ) ) :
19
20 /**
21 * Charitable_Dashboard
22 *
23 * @final
24 * @since 1.8.8
25 */
26 final class Charitable_Dashboard {
27
28 /**
29 * ========================================
30 * TEMPORARY TESTING MODE - EASY REVERT
31 * ========================================
32 *
33 * To show empty dashboard state for testing:
34 * - Set SHOW_EMPTY_DASHBOARD = true
35 *
36 * To revert to normal behavior:
37 * - Set SHOW_EMPTY_DASHBOARD = false
38 * - OR delete this entire constant block
39 *
40 * This affects: stats, campaigns, donations, donors, comments
41 * ========================================
42 */
43 const SHOW_EMPTY_DASHBOARD = false;
44
45 /**
46 * The single instance of this class.
47 *
48 * @var Charitable_Dashboard|null
49 */
50 private static $instance = null;
51
52 /**
53 * Source of blog posts content.
54 *
55 * @since 1.8.8
56 *
57 * @var string
58 */
59 const BLOG_POSTS_SOURCE_URL = 'https://plugin.wpcharitable.com/wp-content/charitable-blog-posts.json';
60
61 /**
62 * Cached version information from the API.
63 *
64 * @since 1.8.8
65 *
66 * @var array|null
67 */
68 private $version_info = null;
69
70 /**
71 * Create object instance.
72 *
73 * @since 1.8.8
74 */
75 public function __construct() {
76 // Constructor - AJAX handlers are registered in charitable-admin-hooks.php
77
78 // Hook into Charitable's cache clearing mechanism
79 add_action( 'charitable_after_clear_expired_options', array( $this, 'clear_dashboard_stats_cache' ) );
80
81 // Refresh the "resume setup wizard" notification entry just before
82 // the dashboard rail renders.
83 add_action( 'charitable_before_admin_dashboard_v2', array( $this, 'sync_resume_wizard_notification' ) );
84 }
85
86 /**
87 * Register or refresh the "resume setup wizard" entry in the dashboard
88 * notification rail when an onboarding session is in progress; remove
89 * any stale entry when it isn't.
90 *
91 * Fires on `charitable_before_admin_dashboard_v2`, immediately before
92 * `render_dashboard_notifications()` reads the same option.
93 *
94 * @since 1.8.10.5
95 *
96 * @return void
97 */
98 public function sync_resume_wizard_notification() {
99
100 $notifications = (array) get_option( 'charitable_dashboard_notifications', array() );
101 $started = 1 === (int) get_option( 'charitable_started_onboarding', 0 );
102 $present = isset( $notifications['resume_setup_wizard'] );
103 $dismissed = $present && isset( $notifications['resume_setup_wizard']['dismissed'] );
104
105 if ( $started && ! $dismissed ) {
106
107 $notifications['resume_setup_wizard'] = array(
108 'type' => 'notice',
109 'priority' => 3,
110 'title' => __( 'Finish Setting Up Charitable', 'charitable' ),
111 'message' => '<p>' . esc_html__( "Looks like you started the setup wizard but didn't finish. Pick up where you left off and we'll have you ready to accept donations in just a couple more steps.", 'charitable' ) . '</p>',
112 'custom_css' => 'charitable-notification-type-notice',
113 'button_url' => add_query_arg( array( 'resume' => 'true' ), charitable_get_onboarding_url() ),
114 'button_text' => __( 'Resume Setup Wizard', 'charitable' ),
115 );
116
117 update_option( 'charitable_dashboard_notifications', $notifications );
118
119 return;
120 }
121
122 if ( $present && ! $started ) {
123 unset( $notifications['resume_setup_wizard'] );
124 update_option( 'charitable_dashboard_notifications', $notifications );
125 }
126 }
127
128
129 /**
130 * Render the dashboard new page.
131 *
132 * @since 1.8.8
133 * @version 1.8.8.1
134 */
135 public function render_dashboard_new() {
136 ?>
137 <div id="charitable-dashboard-v2" class="wrap">
138 <h1 class="screen-reader-text"><?php echo esc_html( get_admin_page_title() ); ?></h1>
139
140 <?php do_action( 'charitable_maybe_show_notification' ); ?>
141
142 <?php
143 /**
144 * Do or render something right before the dashboard new area.
145 *
146 * @since 1.8.8
147 */
148 do_action( 'charitable_before_admin_dashboard_v2' );
149 ?>
150
151 <?php $this->render_dashboard_header(); ?>
152
153 <?php $this->render_dashboard_notifications(); ?>
154
155 <div class="charitable-dashboard-v2-content">
156 <div class="charitable-dashboard-v2-layout">
157 <!-- Left Column -->
158 <div class="charitable-dashboard-v2-left-column">
159 <?php $this->render_stats_section(); ?>
160 <?php $this->render_tabs_section(); ?>
161 <?php $this->render_upgrade_section(); ?>
162 <?php
163 // Show Quick Access in left column for Pro users (when upgrade section is hidden)
164 if ( charitable_is_pro() ) {
165 $this->render_quick_access_section();
166 }
167 ?>
168 </div>
169
170 <!-- Right Column -->
171 <div class="charitable-dashboard-v2-right-column">
172 <?php $this->render_enhance_campaign_section(); ?>
173 <?php $this->render_latest_updates_section(); ?>
174 <?php
175 // Show Quick Access in right column for non-Pro users
176 if ( ! charitable_is_pro() ) {
177 $this->render_quick_access_section();
178 }
179 ?>
180 </div>
181 </div>
182 </div>
183
184 <?php
185 /**
186 * Do or render something right after the dashboard new area.
187 *
188 * @since 1.8.8
189 */
190 do_action( 'charitable_after_admin_dashboard_v2' );
191 ?>
192
193 <?php $this->render_dashboard_scripts(); ?>
194 </div>
195 <?php
196 }
197
198
199 /**
200 * Render the dashboard header section.
201 *
202 * @since 1.8.8
203 */
204 public function render_dashboard_header() {
205 ?>
206 <div class="charitable-dashboard-v2-header">
207 <div class="charitable-dashboard-v2-header-content">
208 <div class="charitable-dashboard-v2-header-left">
209 <h1><?php echo esc_html__( 'Overview', 'charitable' ); ?></h1>
210 </div>
211 <div class="charitable-dashboard-v2-header-right">
212 <?php if ( charitable_disable_legacy_campaigns() ) : ?>
213 <a href="<?php echo esc_url( admin_url( 'admin.php?page=charitable-campaign-builder&view=template' ) ); ?>" class="button button-primary charitable-button">
214 <?php echo esc_html__( '+ Add New Campaign', 'charitable' ); ?>
215 </a>
216 <?php else : ?>
217 <a href="<?php echo esc_url( admin_url( 'post-new.php?post_type=campaign' ) ); ?>" class="button button-primary charitable-button">
218 <?php echo esc_html__( '+ Add New Campaign', 'charitable' ); ?>
219 </a>
220 <?php endif; ?>
221 </div>
222 </div>
223 </div>
224 <?php
225 }
226
227 /**
228 * Render dashboard notifications.
229 *
230 * @since 1.8.8
231 */
232 public function render_dashboard_notifications() {
233 // Get dashboard notifications
234 $notifications = get_option( 'charitable_dashboard_notifications', array() );
235
236 if ( empty( $notifications ) || ! is_array( $notifications ) ) {
237 return;
238 }
239
240 // Remove any notifications that don't have a 'type' key
241 $notifications = array_filter(
242 $notifications,
243 function ( $notification ) {
244 return isset( $notification['type'] );
245 }
246 );
247
248 // Remove any notifications that have a 'dismissed' key
249 $notifications = array_filter(
250 $notifications,
251 function ( $notification ) {
252 return ! isset( $notification['dismissed'] );
253 }
254 );
255
256 if ( empty( $notifications ) ) {
257 return;
258 }
259
260 // Sort notifications by type = 'error' first, then 'warning', followed by 'notice'.
261 // Within the same type, sort by priority (lower number = shown first, default 10).
262 uasort(
263 $notifications,
264 function ( $a, $b ) {
265 $types = array( 'error', 'warning', 'notice' );
266 $pos_a = array_search( $a['type'], $types );
267 $pos_b = array_search( $b['type'], $types );
268
269 if ( $pos_a !== $pos_b ) {
270 return $pos_a - $pos_b;
271 }
272
273 $priority_a = isset( $a['priority'] ) ? (int) $a['priority'] : 10;
274 $priority_b = isset( $b['priority'] ) ? (int) $b['priority'] : 10;
275
276 return $priority_a - $priority_b;
277 }
278 );
279
280 $notifications_count = 1;
281 $notifications_total = count( $notifications );
282
283 ?>
284 <div class="charitable-container charitable-dashboard-notifications charitable-dashboard-v2-notifications">
285 <div class="charitable-dashboard-notification-bar">
286 <?php if ( (int) $notifications_total > 1 ) : ?>
287 <div class="charitable-dashboard-notification-navigation">
288 <a class="prev">
289 <span class="screen-reader-text"><?php esc_attr_e( 'Previous message', 'charitable' ); ?></span>
290 <span aria-hidden="true">&lsaquo;</span>
291 </a>
292 <span class="charitable-dashboard-notification-counter">
293 <?php
294 printf(
295 /* translators: 1: current notification number, 2: total notifications */
296 esc_html__( '%1$d of %2$d', 'charitable' ),
297 1,
298 (int) $notifications_total
299 );
300 ?>
301 </span>
302 <a class="next">
303 <span class="screen-reader-text"><?php esc_attr_e( 'Next message', 'charitable' ); ?></span>
304 <span aria-hidden="true">&rsaquo;</span>
305 </a>
306 </div>
307 <?php else : ?>
308 <div class="charitable-dashboard-notification-navigation"></div>
309 <?php endif; ?>
310
311 <a href="#" class="charitable-remove-dashboard-notification"></a>
312 </div>
313
314 <?php foreach ( $notifications as $notification_slug => $notification ) : ?>
315 <?php
316 $css_class = ! empty( $notification['custom_css'] ) ? $notification['custom_css'] : '';
317 $css_class .= $notifications_count === 1 ? '' : ' charitable-hidden';
318 $message_title = ! empty( $notification['title'] ) ? sanitize_text_field( $notification['title'] ) : esc_html__( 'Important', 'charitable' );
319 $message = ! empty( $notification['message'] ) ? $notification['message'] : '';
320 $message = wp_kses(
321 $message,
322 array(
323 'a' => array(
324 'href' => array(),
325 'title' => array(),
326 ),
327 'strong' => array(),
328 'p' => array(),
329 'ol' => array(),
330 'ul' => array(),
331 'li' => array(),
332 'br' => array(),
333 'h1' => array(),
334 'h2' => array(),
335 'h3' => array(),
336 'h4' => array(),
337 'h5' => array(),
338 )
339 );
340 $notification_type = ! empty( $notification['type'] ) ? $notification['type'] : 'notice';
341 $type_icons = array(
342 'notice' => 'dashicons-info',
343 'warning' => 'dashicons-warning',
344 'error' => 'dashicons-dismiss',
345 );
346 $icon_class = isset( $type_icons[ $notification_type ] ) ? $type_icons[ $notification_type ] : 'dashicons-info';
347 $button_url = ! empty( $notification['button_url'] ) ? esc_url( $notification['button_url'] ) : '';
348 $button_text = ! empty( $notification['button_text'] ) ? sanitize_text_field( $notification['button_text'] ) : '';
349 ?>
350
351 <div class="charitable-dashboard-notification <?php echo esc_attr( $css_class ); ?>" data-notification-number="<?php echo (int) $notifications_count; ?>" data-notification-id="<?php echo esc_attr( $notification_slug ); ?>" data-notification-type="<?php echo esc_attr( $notification_type ); ?>">
352 <div class="charitable-dashboard-notification-message">
353 <h4 class="charitable-dashboard-notification-headline">
354 <span class="dashicons <?php echo esc_attr( $icon_class ); ?> charitable-notification-type-icon"></span>
355 <?php echo esc_html( $message_title ); ?>
356 </h4>
357 <?php echo $message; // phpcs:ignore ?>
358 <?php if ( $button_url && $button_text ) : ?>
359 <a href="<?php echo esc_url( $button_url ); ?>" class="charitable-notification-cta-button"><?php echo esc_html( $button_text ); ?></a>
360 <?php endif; ?>
361 </div>
362 </div>
363
364 <?php ++$notifications_count; ?>
365 <?php endforeach; ?>
366 </div>
367 <?php
368 }
369
370
371 /**
372 * Render the stats section with time period filter and chart.
373 *
374 * @since 1.8.8
375 */
376 public function render_stats_section() {
377 ?>
378 <section class="charitable-dashboard-v2-section charitable-dashboard-v2-stats-row-section">
379 <div class="charitable-dashboard-v2-header-bar">
380 <div class="charitable-dashboard-v2-header-bar-left">
381 <select name="time-period" id="time-period-filter">
382 <option value="last-7-days" selected><?php esc_html_e( 'Last 7 Days', 'charitable' ); ?></option>
383 <option value="last-14-days"><?php esc_html_e( 'Last 14 Days', 'charitable' ); ?></option>
384 <option value="last-30-days"><?php esc_html_e( 'Last 30 Days', 'charitable' ); ?></option>
385 <?php /* <option value="this-month">This Month</option>
386 <option value="last-3-months">Last 3 Months</option>
387 <option value="last-6-months">Last 6 Months</option>
388 <option value="last-year">Last Year</option>
389 <option value="custom">Custom Range</option> */ ?>
390 </select>
391 </div>
392 <div class="charitable-dashboard-v2-header-bar-right">
393 <?php /* <button class="charitable-dashboard-v2-download-button">
394 <?php esc_html_e( 'Download Summary', 'charitable' ); ?>
395 <svg class="charitable-dashboard-v2-download-icon" width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
396 <path d="M7.85908 10.5L3.45604 6.125L4.68889 4.85625L6.97847 7.13125V0H8.73969V7.13125L11.0293 4.85625L12.2621 6.125L7.85908 10.5ZM2.57543 14C2.09109 14 1.67647 13.8286 1.33157 13.4859C0.986662 13.1432 0.814209 12.7312 0.814209 12.25V9.625H2.57543V12.25H13.1427V9.625H14.904V12.25C14.904 12.7312 14.7315 13.1432 14.3866 13.4859C14.0417 13.8286 13.6271 14 13.1427 14H2.57543Z" fill="#0E2121" fill-opacity="0.6"/>
397 </svg>
398 </button> */ ?>
399
400 <?php
401 // Get current date range for print functionality
402 // phpcs:disable WordPress.Security.NonceVerification.Recommended
403 $time_period = isset( $_GET['time-period'] ) ? sanitize_text_field( wp_unslash( $_GET['time-period'] ) ) : 'last-7-days';
404 // phpcs:enable WordPress.Security.NonceVerification.Recommended
405 $date_range = $this->get_date_range_for_period( $time_period );
406 ?>
407 <form action="" method="post" target="_blank" class="charitable-report-print" id="charitable-dashboard-v2-print">
408 <input name="charitable_report_action" type="hidden" value="charitable_report_print_dashboard" />
409 <input name="start_date" type="hidden" value="<?php echo esc_attr( $date_range['start_date'] ); ?>" />
410 <input name="end_date" type="hidden" value="<?php echo esc_attr( $date_range['end_date'] ); ?>" />
411 <input name="days" type="hidden" value="<?php echo esc_attr( $time_period ); ?>" />
412 <?php wp_nonce_field( 'charitable_export_report', 'charitable_export_report_nonce' ); ?>
413 <button type="submit" class="charitable-dashboard-v2-print-button" title="<?php echo esc_html__( 'Print Summary', 'charitable' ); ?>">
414 <svg class="charitable-dashboard-v2-print-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
415 <path d="M6 9V2H18V9M6 9H4C2.89543 9 2 9.89543 2 11V17C2 18.1046 2.89543 19 4 19H6V22H18V19H20C21.1046 19 22 18.1046 22 17V11C22 9.89543 21.1046 9 20 9H18M6 9V13H18V9M6 15H18V17H6V15Z" stroke="#0E2121" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
416 </svg>
417 </button>
418 </form>
419 </div>
420 </div>
421 <?php $this->render_stats_row(); ?>
422 <div class="charitable-dashboard-v2-header-bar-chart">
423 <div id="charitable-dashboard-v2-headline-graph" class="charitable-headline-graph"></div>
424 </div>
425 </section>
426 <?php
427 }
428
429 /**
430 * Render the stats row with donation statistics.
431 *
432 * @since 1.8.8
433 */
434 private function render_stats_row() {
435 // Get the selected time period from request, default to 'last-7-days'
436 // phpcs:disable WordPress.Security.NonceVerification.Recommended
437 $time_period = isset( $_GET['time-period'] ) ? sanitize_text_field( wp_unslash( $_GET['time-period'] ) ) : 'last-7-days';
438 // phpcs:enable WordPress.Security.NonceVerification.Recommended
439 $stats = $this->get_dashboard_stats( $time_period );
440 ?>
441 <div class="charitable-dashboard-v2-stats-row">
442 <div class="charitable-dashboard-v2-stat-item">
443 <div class="charitable-dashboard-v2-stat-title"><?php esc_html_e( 'Total Donations', 'charitable' ); ?></div>
444 <div class="charitable-dashboard-v2-stat-amount">
445 <?php echo esc_html( $stats['total_donations'] ); ?>
446 <?php if ( '0%' !== $stats['donations_change'] ) : ?>
447 <span class="charitable-dashboard-v2-stat-change">
448 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" xmlns="http://www.w3.org/2000/svg">
449 <mask id="mask0_1904_1526" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="13" height="13">
450 <rect y="0.25415" width="12.9062" height="12.3612" fill="#D9D9D9"/>
451 </mask>
452 <g mask="url(#mask0_1904_1526)">
453 <path d="M1.82849 9.52507L1.07562 8.804L5.05505 4.96689L7.20609 7.02708L10.0024 4.37458H8.60426V3.34448H11.8308V6.43477H10.7553V5.09565L7.20609 8.49497L5.05505 6.43477L1.82849 9.52507Z" fill="#31944D"/>
454 </g>
455 </svg>
456 <?php echo esc_html( $stats['donations_change'] ); ?>%
457 </span>
458 <?php endif; ?>
459 </div>
460 </div>
461 <div class="charitable-dashboard-v2-stat-item">
462 <div class="charitable-dashboard-v2-stat-title"><?php esc_html_e( 'Avg. Donations', 'charitable' ); ?></div>
463 <div class="charitable-dashboard-v2-stat-amount">
464 <?php echo esc_html( $stats['avg_donations'] ); ?>
465 <?php if ( '0%' !== $stats['avg_change'] ) : ?>
466 <span class="charitable-dashboard-v2-stat-change">
467 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" xmlns="http://www.w3.org/2000/svg">
468 <mask id="mask0_1904_1526" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="13" height="13">
469 <rect y="0.25415" width="12.9062" height="12.3612" fill="#D9D9D9"/>
470 </mask>
471 <g mask="url(#mask0_1904_1526)">
472 <path d="M1.82849 9.52507L1.07562 8.804L5.05505 4.96689L7.20609 7.02708L10.0024 4.37458H8.60426V3.34448H11.8308V6.43477H10.7553V5.09565L7.20609 8.49497L5.05505 6.43477L1.82849 9.52507Z" fill="#31944D"/>
473 </g>
474 </svg>
475 <?php echo esc_html( $stats['avg_change'] ); ?>%
476 </span>
477 <?php endif; ?>
478 </div>
479 </div>
480 <div class="charitable-dashboard-v2-stat-item">
481 <div class="charitable-dashboard-v2-stat-title"><?php esc_html_e( 'Total Donors', 'charitable' ); ?></div>
482 <div class="charitable-dashboard-v2-stat-amount"><?php echo esc_html( $stats['total_donors'] ); ?></div>
483 </div>
484 <div class="charitable-dashboard-v2-stat-item">
485 <div class="charitable-dashboard-v2-stat-title"><?php echo esc_html( $stats['total_refunds_count'] ); ?> <?php esc_html_e( 'Refunds', 'charitable' ); ?></div>
486 <div class="charitable-dashboard-v2-stat-amount"><?php echo esc_html( $stats['total_refunds'] ); ?></div>
487 </div>
488 </div>
489 <?php
490 }
491
492 /**
493 * Get dashboard statistics data.
494 *
495 * @since 1.8.8
496 * @param string $time_period The selected time period.
497 * @return array
498 */
499 public function get_dashboard_stats( $time_period = 'last-7-days' ) {
500 // Clear stats cache if requested via URL parameter
501 // phpcs:disable WordPress.Security.NonceVerification.Recommended
502 if ( isset( $_GET['charitable_clear_stats_cache'] ) && '1' === $_GET['charitable_clear_stats_cache'] ) {
503 // phpcs:enable WordPress.Security.NonceVerification.Recommended
504 $this->clear_dashboard_stats_cache();
505 }
506
507 // TEMPORARY TESTING: Return empty stats if testing mode is enabled
508 if ( self::SHOW_EMPTY_DASHBOARD ) {
509 // Decode HTML entities in currency-formatted strings to prevent double-encoding in JSON.
510 $empty_total = html_entity_decode( charitable_format_money( 0 ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
511 $empty_avg = html_entity_decode( charitable_format_money( 0 ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
512 $empty_refunds = html_entity_decode( charitable_format_money( 0 ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
513 return array(
514 'total_donations' => $empty_total,
515 'donations_change' => '0%',
516 'avg_donations' => $empty_avg,
517 'avg_change' => '0%',
518 'total_donors' => 0,
519 'total_refunds' => $empty_refunds,
520 'total_refunds_count' => 0
521 );
522 }
523
524 // Calculate dates based on time period
525 $date_range = $this->get_date_range_for_period( $time_period );
526 $start_date = $date_range['start_date'];
527 $end_date = $date_range['end_date'];
528 $days = $date_range['days'];
529
530 // Initialize Charitable Reports
531 $charitable_reports = Charitable_Reports::get_instance();
532 $args = array(
533 'start_date' => $start_date,
534 'end_date' => $end_date,
535 'days' => $days,
536 );
537 $charitable_reports->init_with_array( 'dashboard', $args );
538
539 // Get donations data
540 $donations = $charitable_reports->get_donations();
541 $total_count_donations = count( $donations );
542 $total_amount_donations = $charitable_reports->get_donations_total();
543 $donation_average = ( $total_amount_donations > 0 && $total_count_donations > 0 ) ? ( charitable_format_money( $total_amount_donations / $total_count_donations, 2, true ) ) : charitable_format_money( 0 );
544 $donation_total = charitable_format_money( $total_amount_donations );
545
546 // Get donors count
547 $total_count_donors = $charitable_reports->get_donors_count();
548
549 // Get refunds data
550 $refunds_data = $charitable_reports->get_refunds();
551 $total_amount_refunds = charitable_format_money( $refunds_data['total_amount'] );
552 $total_count_refunds = $refunds_data['total_count'];
553
554 // Decode HTML entities in currency-formatted strings to prevent double-encoding in JSON.
555 // charitable_format_money() returns HTML entities (e.g., &#36; for $), so we decode them
556 // before sending via AJAX to avoid double-encoding issues.
557 $donation_total = html_entity_decode( $donation_total, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
558 $donation_average = html_entity_decode( $donation_average, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
559 $total_amount_refunds = html_entity_decode( $total_amount_refunds, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
560
561 // Calculate percentage changes
562 $changes = $this->calculate_percentage_changes( $time_period, $total_amount_donations, $donation_average );
563 $donations_change = $changes['donations_change'];
564 $avg_change = $changes['avg_change'];
565
566 // Defensive guard: if current average is zero, hide avg change
567 if ( 0.0 === (float) $this->parse_money_amount( $donation_average ) ) {
568 $avg_change = '';
569 }
570
571 // Allow filtering to disable percentage displays completely
572 $show_percentage_changes = apply_filters( 'charitable_dashboard_show_percentage_changes', true );
573 if ( ! $show_percentage_changes ) {
574 $donations_change = '';
575 $avg_change = '';
576 }
577
578 return array(
579 'total_donations' => $donation_total,
580 'donations_change' => $donations_change,
581 'avg_donations' => $donation_average,
582 'avg_change' => $avg_change,
583 'total_donors' => $total_count_donors,
584 'total_refunds' => $total_amount_refunds,
585 'total_refunds_count' => $total_count_refunds
586 );
587 }
588
589 /**
590 * Get date range for a given time period.
591 *
592 * @since 1.8.8
593 * @param string $time_period The time period.
594 * @return array
595 */
596 public function get_date_range_for_period( $time_period ) {
597 $end_date = gmdate( 'Y/m/d' );
598
599 switch ( $time_period ) {
600 case 'last-7-days':
601 $start_date = gmdate( 'Y/m/d', strtotime( '-7 days' ) );
602 $days = 7;
603 break;
604 case 'last-14-days':
605 $start_date = gmdate( 'Y/m/d', strtotime( '-14 days' ) );
606 $days = 14;
607 break;
608 case 'last-30-days':
609 $start_date = gmdate( 'Y/m/d', strtotime( '-30 days' ) );
610 $days = 30;
611 break;
612 case 'this-month':
613 $start_date = gmdate( 'Y/m/d', strtotime( 'first day of this month' ) );
614 $days = gmdate( 'j' ); // Current day of month
615 break;
616 case 'last-3-months':
617 $start_date = gmdate( 'Y/m/d', strtotime( '-3 months' ) );
618 $days = 90;
619 break;
620 case 'last-6-months':
621 $start_date = gmdate( 'Y/m/d', strtotime( '-6 months' ) );
622 $days = 180;
623 break;
624 case 'last-year':
625 $start_date = gmdate( 'Y/m/d', strtotime( '-1 year' ) );
626 $days = 365;
627 break;
628 default:
629 $start_date = gmdate( 'Y/m/d', strtotime( '-7 days' ) );
630 $days = 7;
631 break;
632 }
633
634 return array(
635 'start_date' => $start_date,
636 'end_date' => $end_date,
637 'days' => $days
638 );
639 }
640
641 /**
642 * Calculate percentage changes for donations and average.
643 *
644 * @since 1.8.8
645 * @param string $time_period Current time period.
646 * @param float $current_total Current total donations.
647 * @param string $current_avg Current average donation.
648 * @return array
649 */
650 private function calculate_percentage_changes( $time_period, $current_total, $current_avg ) {
651
652 $debug_no_cache = ( defined( 'CHARITABLE_DEBUG' ) && true === constant( 'CHARITABLE_DEBUG' ) );
653
654 // Get cache duration from filter (default 4 hours)
655 $cache_duration = apply_filters( 'charitable_dashboard_stats_cache_duration', 4 * HOUR_IN_SECONDS );
656
657 // Create cache key
658 $cache_key = 'charitable_dashboard_stats_changes_' . $time_period . '_' . gmdate( 'Y-m-d' );
659
660 // Try to get from cache first (unless debugging)
661 if ( ! $debug_no_cache ) {
662 $cached_changes = get_transient( $cache_key );
663 if ( false !== $cached_changes ) {
664 return $cached_changes;
665 }
666 }
667
668 // Calculate previous period date range
669 $previous_period_range = $this->get_previous_period_date_range( $time_period );
670 if ( false === $previous_period_range ) {
671 // If we can't calculate previous period, return no change
672 $changes = array(
673 'donations_change' => '',
674 'avg_change' => ''
675 );
676 if ( ! $debug_no_cache ) {
677 set_transient( $cache_key, $changes, $cache_duration );
678 }
679 return $changes;
680 }
681
682 // Get previous period data
683 $previous_data = $this->get_previous_period_data( $previous_period_range );
684 if ( false === $previous_data ) {
685 // If we can't get previous data, return no change
686 $changes = array(
687 'donations_change' => '',
688 'avg_change' => ''
689 );
690 if ( ! $debug_no_cache ) {
691 set_transient( $cache_key, $changes, $cache_duration );
692 }
693 return $changes;
694 }
695
696 // Calculate percentage changes
697 $donations_change = $this->calculate_percentage_change( $previous_data['total'], $current_total );
698 $avg_change = $this->calculate_percentage_change( $previous_data['avg'], $this->parse_money_amount( $current_avg ) );
699
700 $changes = array(
701 'donations_change' => $donations_change,
702 'avg_change' => $avg_change
703 );
704
705 // Cache the results unless debugging
706 if ( ! $debug_no_cache ) {
707 set_transient( $cache_key, $changes, $cache_duration );
708 }
709
710 return $changes;
711 }
712
713 /**
714 * Get previous period date range for comparison.
715 *
716 * @since 1.8.8
717 * @param string $time_period Current time period.
718 * @return array|false
719 */
720 private function get_previous_period_date_range( $time_period ) {
721 $current_range = $this->get_date_range_for_period( $time_period );
722 $current_start = $current_range['start_date'];
723 $current_days = $current_range['days'];
724
725 // Calculate previous period dates
726 $previous_end = gmdate( 'Y/m/d', strtotime( $current_start . ' -1 day' ) );
727 $previous_start = gmdate( 'Y/m/d', strtotime( $previous_end . ' -' . ( $current_days - 1 ) . ' days' ) );
728
729 return array(
730 'start_date' => $previous_start,
731 'end_date' => $previous_end,
732 'days' => $current_days
733 );
734 }
735
736 /**
737 * Get previous period donation data.
738 *
739 * @since 1.8.8
740 * @param array $date_range Previous period date range.
741 * @return array|false
742 */
743 private function get_previous_period_data( $date_range ) {
744 try {
745 // Initialize Charitable Reports for previous period
746 $charitable_reports = Charitable_Reports::get_instance();
747 $args = array(
748 'start_date' => $date_range['start_date'],
749 'end_date' => $date_range['end_date'],
750 'days' => $date_range['days'],
751 );
752 $charitable_reports->init_with_array( 'dashboard', $args );
753
754 // Get donations data
755 $donations = $charitable_reports->get_donations();
756 $total_count_donations = count( $donations );
757 $total_amount_donations = $charitable_reports->get_donations_total();
758
759 // Calculate average
760 $donation_average = ( $total_amount_donations > 0 && $total_count_donations > 0 ) ? ( $total_amount_donations / $total_count_donations ) : 0;
761
762 return array(
763 'total' => $total_amount_donations,
764 'avg' => $donation_average
765 );
766 } catch ( Exception $e ) {
767 // Log error and return false
768 if ( charitable_is_debug() ) {
769 error_log( 'Charitable Dashboard: Error getting previous period data - ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
770 }
771 return false;
772 }
773 }
774
775 /**
776 * Calculate percentage change between two values.
777 *
778 * @since 1.8.8
779 * @param float $previous Previous value.
780 * @param float $current Current value.
781 * @return string
782 */
783 private function calculate_percentage_change( $previous, $current ) {
784 // Handle edge cases: if either value is zero or negative, do not show a percentage change
785 if ( $previous <= 0 || $current <= 0 ) {
786 return '';
787 }
788
789 $percentage = ( ( $current - $previous ) / $previous ) * 100;
790
791 // Round to 1 decimal place
792 $percentage = round( $percentage, 1 );
793
794 // Handle very large percentages
795 if ( $percentage > 99.9 ) {
796 return '+99.9%';
797 }
798 if ( $percentage < -99.9 ) {
799 return '-99.9%';
800 }
801
802 // Format with + or - sign
803 $sign = $percentage > 0 ? '+' : '';
804 return $sign . $percentage . '%';
805 }
806
807 /**
808 * Parse money amount from formatted string.
809 *
810 * @since 1.8.8
811 * @param string $money_formatted Formatted money string.
812 * @return float
813 */
814 private function parse_money_amount( $money_formatted ) {
815 // Decode HTML entities first (e.g., &#36; becomes $)
816 $money_formatted = html_entity_decode( $money_formatted, ENT_QUOTES, 'UTF-8' );
817
818 // Remove currency symbols and formatting
819 $amount = preg_replace( '/[^\d.,]/', '', $money_formatted );
820
821 // Handle different decimal separators
822 if ( strpos( $amount, ',' ) !== false && strpos( $amount, '.' ) !== false ) {
823 // Both comma and dot present - assume comma is thousands separator
824 $amount = str_replace( ',', '', $amount );
825 } elseif ( strpos( $amount, ',' ) !== false ) {
826 // Only comma - could be decimal separator
827 $amount = str_replace( ',', '.', $amount );
828 }
829
830 return (float) $amount;
831 }
832
833 /**
834 * Clear dashboard stats cache.
835 *
836 * @since 1.8.8
837 * @version 1.8.8.4 - added blog posts cache clearing
838 */
839 public function clear_dashboard_stats_cache() {
840 // Delete all dashboard stats cache transients
841 global $wpdb;
842
843 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
844 $wpdb->query(
845 $wpdb->prepare(
846 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
847 $wpdb->esc_like( '_transient_charitable_dashboard_stats_changes_' ) . '%'
848 )
849 );
850
851 // Also delete the transient timeout entries
852 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
853 $wpdb->query(
854 $wpdb->prepare(
855 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
856 $wpdb->esc_like( '_transient_timeout_charitable_dashboard_stats_changes_' ) . '%'
857 )
858 );
859
860 // Clear blog posts cache
861 delete_transient( 'charitable_blog_posts_cache' );
862 }
863
864 /**
865 * Get the default tab from URL parameter.
866 *
867 * @since 1.8.8
868 * @return string
869 */
870 private function get_default_tab() {
871 $valid_tabs = array( 'top-campaigns', 'latest-donations', 'top-donors', 'comments' );
872 // phpcs:disable WordPress.Security.NonceVerification.Recommended
873 $default_tab = isset( $_GET['tab'] ) ? sanitize_text_field( wp_unslash( $_GET['tab'] ) ) : 'top-campaigns';
874 // phpcs:enable WordPress.Security.NonceVerification.Recommended
875
876 // Validate the tab parameter
877 if ( ! in_array( $default_tab, $valid_tabs, true ) ) {
878 $default_tab = 'top-campaigns';
879 }
880
881 return $default_tab;
882 }
883
884 /**
885 * Check if a tab should be active.
886 *
887 * @since 1.8.8
888 * @param string $tab_name The tab name to check.
889 * @return bool
890 */
891 private function is_tab_active( $tab_name ) {
892 return $this->get_default_tab() === $tab_name;
893 }
894
895 /**
896 * Render the tabs section with campaigns, donations, donors, and comments.
897 *
898 * @since 1.8.8
899 */
900 public function render_tabs_section() {
901 ?>
902 <section class="charitable-dashboard-v2-section charitable-dashboard-top-campaigns">
903 <header class="charitable-dashboard-v2-section-header">
904 <nav class="charitable-dashboard-v2-tab-nav">
905 <a href="#" class="charitable-dashboard-v2-tab-nav-item<?php echo $this->is_tab_active( 'top-campaigns' ) ? ' charitable-dashboard-v2-tab-nav-active' : ''; ?>" data-tab="top-campaigns">
906 Top Campaigns
907 </a>
908 <a href="#" class="charitable-dashboard-v2-tab-nav-item<?php echo $this->is_tab_active( 'latest-donations' ) ? ' charitable-dashboard-v2-tab-nav-active' : ''; ?>" data-tab="latest-donations">
909 Latest Donations
910 </a>
911 <a href="#" class="charitable-dashboard-v2-tab-nav-item<?php echo $this->is_tab_active( 'top-donors' ) ? ' charitable-dashboard-v2-tab-nav-active' : ''; ?>" data-tab="top-donors">
912 Top Donors
913 </a>
914 <a href="#" class="charitable-dashboard-v2-tab-nav-item<?php echo $this->is_tab_active( 'comments' ) ? ' charitable-dashboard-v2-tab-nav-active' : ''; ?>" data-tab="comments">
915 Comments
916 </a>
917 </nav>
918 </header>
919 <div class="charitable-dashboard-v2-section-content">
920 <?php $this->render_top_campaigns_tab(); ?>
921 <?php $this->render_latest_donations_tab(); ?>
922 <?php $this->render_top_donors_tab(); ?>
923 <?php $this->render_comments_tab(); ?>
924 </div>
925 </section>
926 <?php
927 }
928
929 /**
930 * Render the top campaigns tab content.
931 *
932 * @since 1.8.8
933 */
934 private function render_top_campaigns_tab() {
935 $campaigns = $this->get_top_campaigns();
936 ?>
937 <div class="charitable-dashboard-v2-tab-content<?php echo $this->is_tab_active( 'top-campaigns' ) ? ' charitable-dashboard-v2-tab-content-active' : ''; ?>" data-tab="top-campaigns">
938 <?php if ( ! empty( $campaigns ) ) : ?>
939 <div class="charitable-dashboard-v2-campaigns-table">
940 <table class="charitable-dashboard-v2-table">
941 <thead>
942 <tr>
943 <th>Campaign Title</th>
944 <th>Goal</th>
945 <th>Raised</th>
946 <th>Status</th>
947 <th>Progress</th>
948 </tr>
949 </thead>
950 <tbody>
951 <?php foreach ( $campaigns as $campaign ) : ?>
952 <tr>
953 <td class="campaign-title">
954 <a href="<?php echo esc_url( $campaign['url'] ); ?>">
955 <?php echo esc_html( $campaign['title'] ); ?>
956 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" xmlns="http://www.w3.org/2000/svg">
957 <path d="M1.09091 11.0001C0.790908 11.0001 0.53409 10.8932 0.320454 10.6796C0.106818 10.466 0 10.2091 0 9.90915V2.27279C0 1.97279 0.106818 1.71598 0.320454 1.50234C0.53409 1.2887 0.790908 1.18188 1.09091 1.18188H4.36363V2.27279H1.09091V9.90915H8.72726V6.63642H9.81817V9.90915C9.81817 10.2091 9.71135 10.466 9.49772 10.6796C9.28408 10.8932 9.02726 11.0001 8.72726 11.0001H1.09091Z" fill="#191D2D" fill-opacity="0.6"/>
958 <path d="M4 6.16363L4.76364 6.92726L9.83636 1.85454V3.81818H10.9273V0H7.10909V1.09091H9.07272L4 6.16363Z" fill="#191D2D" fill-opacity="0.6"/>
959 </svg>
960 </a>
961 </td>
962 <td><?php echo esc_html( $campaign['goal'] ); ?></td>
963 <td><?php echo esc_html( $campaign['reach'] ); ?></td>
964 <td><span class="status-badge status-<?php echo esc_attr( $campaign['status'] ); ?>"><?php echo esc_html( $campaign['status_label'] ); ?></span></td>
965 <td>
966 <div class="progress-circle" data-progress="<?php echo esc_attr( $campaign['progress'] ); ?>">
967 <svg width="40" height="40" viewBox="0 0 40 40">
968 <circle cx="20" cy="20" r="18" fill="none" stroke="rgba(222, 234, 255, 1)" stroke-width="3"/>
969 <circle cx="20" cy="20" r="18" fill="none" stroke="rgba(76, 123, 207, 1)" stroke-width="3"/>
970 </svg>
971 <span class="progress-text"><?php echo esc_html( $campaign['progress'] ); ?>%</span>
972 </div>
973 </td>
974 </tr>
975 <?php endforeach; ?>
976 </tbody>
977 </table>
978 </div>
979 <?php else : ?>
980 <div class="charitable-dashboard-v2-empty-state">
981 <div class="charitable-dashboard-v2-empty-state-content">
982 <h3><?php esc_html_e( 'No Campaigns Yet!', 'charitable' ); ?></h3>
983 <p><?php esc_html_e( 'Create your first campaign to start receiving donations.', 'charitable' ); ?></p>
984 <?php if ( charitable_disable_legacy_campaigns() ) : ?>
985 <a href="<?php echo esc_url( admin_url( 'admin.php?page=charitable-campaign-builder&view=template' ) ); ?>" class="charitable-dashboard-v2-button charitable-dashboard-v2-button-primary">
986 <?php esc_html_e( 'Create Campaign', 'charitable' ); ?>
987 </a>
988 <?php else : ?>
989 <a href="<?php echo esc_url( admin_url( 'post-new.php?post_type=campaign' ) ); ?>" class="charitable-dashboard-v2-button charitable-dashboard-v2-button-primary">
990 <?php esc_html_e( 'Create Campaign', 'charitable' ); ?>
991 </a>
992 <?php endif; ?>
993 </div>
994 </div>
995 <?php endif; ?>
996 </div>
997 <?php
998 }
999
1000 /**
1001 * Get top campaigns data.
1002 *
1003 * @since 1.8.8
1004 * @return array
1005 */
1006 private function get_top_campaigns() {
1007 // TEMPORARY TESTING: Return empty campaigns if testing mode is enabled
1008 if ( self::SHOW_EMPTY_DASHBOARD ) {
1009 return array();
1010 }
1011
1012 // Get top campaigns using the same method as legacy dashboard
1013 $args = array(
1014 'posts_per_page' => apply_filters( 'charitable_dashboard_top_campaigns_amount', 5 )
1015 );
1016 $top_campaigns = Charitable_Campaigns::ordered_by_amount( $args );
1017
1018 $campaigns = array();
1019
1020 if ( ! empty( $top_campaigns->posts ) ) {
1021 foreach ( $top_campaigns->posts as $campaign ) {
1022 $_campaign = charitable_get_campaign( $campaign->ID );
1023
1024 if ( ! $_campaign ) {
1025 continue;
1026 }
1027
1028 // Get campaign data (same as legacy)
1029 $donated_amount = $_campaign->get_donated_amount();
1030 $donation_goal = $_campaign->get_goal();
1031 $status = $_campaign->get_status();
1032 $percent_donated = $_campaign->get_percent_donated_raw();
1033
1034 // Filter out draft campaigns
1035 if ( 'draft' === $status ) {
1036 continue;
1037 }
1038
1039 // Cap percentage at 100%
1040 if ( $percent_donated > 100 ) {
1041 $percent_donated = 100;
1042 }
1043
1044 // Map status to display format (same as legacy)
1045 $status_labels = array(
1046 'active' => __( 'Active', 'charitable' ),
1047 'completed' => __( 'Finished', 'charitable' ),
1048 'failed' => __( 'Failed', 'charitable' )
1049 );
1050 $status_label = isset( $status_labels[ $status ] ) ? $status_labels[ $status ] : ucwords( $status );
1051
1052 // Map status for CSS class
1053 $status_class = $status;
1054 if ( 'completed' === $status ) {
1055 $status_class = 'finished';
1056 }
1057
1058 // Build campaign URL
1059 $url = charitable_disable_legacy_campaigns()
1060 ? admin_url( 'admin.php?page=charitable-campaign-builder&view=template&campaign_id=' . $campaign->ID )
1061 : admin_url( 'post.php?post=' . $campaign->ID . '&action=edit' );
1062
1063 // Handle goal display for finished campaigns
1064 $goal_display = '-';
1065 if ( $donation_goal ) {
1066 $goal_display = charitable_format_money( $donation_goal );
1067 } elseif ( 'completed' === $status ) {
1068 // For finished campaigns without goals, show the total raised
1069 $goal_display = charitable_format_money( $donated_amount );
1070 }
1071
1072 $campaigns[] = array(
1073 'title' => $campaign->post_title,
1074 'url' => $url,
1075 'goal' => $goal_display,
1076 'reach' => charitable_format_money( $donated_amount ),
1077 'status' => $status_class,
1078 'status_label' => $status_label,
1079 'progress' => round( $percent_donated )
1080 );
1081 }
1082 }
1083
1084 // If no campaigns, return empty array
1085 return $campaigns;
1086 }
1087
1088 /**
1089 * Render the latest donations tab content.
1090 *
1091 * @since 1.8.8
1092 */
1093 private function render_latest_donations_tab() {
1094 $donations = $this->get_latest_donations();
1095 ?>
1096 <div class="charitable-dashboard-v2-tab-content<?php echo $this->is_tab_active( 'latest-donations' ) ? ' charitable-dashboard-v2-tab-content-active' : ''; ?>" data-tab="latest-donations">
1097 <?php if ( ! empty( $donations ) ) : ?>
1098 <div class="charitable-dashboard-v2-table-container">
1099 <table class="charitable-dashboard-v2-table charitable-dashboard-v2-table-donations">
1100 <thead>
1101 <tr>
1102 <th>Donation Details</th>
1103 <th>Status</th>
1104 </tr>
1105 </thead>
1106 <tbody>
1107 <?php foreach ( $donations as $donation ) : ?>
1108 <tr>
1109 <td>
1110 <div class="donation-details">
1111 <div class="donation-header">
1112 <span class="donation-name">
1113 <a href="<?php echo esc_url( $donation['url'] ); ?>">
1114 <span class="donation-id">#<?php echo esc_html( $donation['id'] ); ?></span>
1115 <span class="donor-name"><?php echo esc_html( $donation['donor_name'] ); ?></span>
1116 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" xmlns="http://www.w3.org/2000/svg">
1117 <path d="M1.09091 11.0001C0.790908 11.0001 0.53409 10.8932 0.320454 10.6796C0.106818 10.466 0 10.2091 0 9.90915V2.27279C0 1.97279 0.106818 1.71598 0.320454 1.50234C0.53409 1.2887 0.790908 1.18188 1.09091 1.18188H4.36363V2.27279H1.09091V9.90915H8.72726V6.63642H9.81817V9.90915C9.81817 10.2091 9.71135 10.466 9.49772 10.6796C9.28408 10.8932 9.02726 11.0001 8.72726 11.0001H1.09091Z" fill="#191D2D" fill-opacity="0.6"/>
1118 <path d="M4 6.16363L4.76364 6.92726L9.83636 1.85454V3.81818H10.9273V0H7.10909V1.09091H9.07272L4 6.16363Z" fill="#191D2D" fill-opacity="0.6"/>
1119 </svg>
1120 </a>
1121 </span>
1122 <span class="donation-timestamp"><?php echo esc_html( $donation['timestamp'] ); ?></span>
1123 <span class="donation-amount">
1124 <strong>Amount Donated:</strong> <?php echo esc_html( $donation['amount'] ); ?>
1125 </span>
1126 <span class="donation-method <?php echo esc_attr( $donation['method_class'] ); ?>">
1127 <strong>Method:</strong> <?php echo esc_html( $donation['method'] ); ?>
1128 </span>
1129 </div>
1130 </div>
1131 </td>
1132 <td><span class="status-badge status-<?php echo esc_attr( $donation['status'] ); ?>"><?php echo esc_html( $donation['status_label'] ); ?></span></td>
1133 </tr>
1134 <?php endforeach; ?>
1135 </tbody>
1136 </table>
1137 </div>
1138 <?php else : ?>
1139 <div class="charitable-dashboard-v2-empty-state">
1140 <div class="charitable-dashboard-v2-empty-state-content">
1141 <h3><?php esc_html_e( 'No Donations Yet!', 'charitable' ); ?></h3>
1142 <p><?php esc_html_e( 'Start receiving donations to see them here.', 'charitable' ); ?></p>
1143 <a href="<?php echo esc_url( admin_url( 'post-new.php?post_type=donation' ) ); ?>" class="charitable-dashboard-v2-button charitable-dashboard-v2-button-primary">
1144 <?php esc_html_e( 'Add Donation', 'charitable' ); ?>
1145 </a>
1146 </div>
1147 </div>
1148 <?php endif; ?>
1149 </div>
1150 <?php
1151 }
1152
1153 /**
1154 * Get latest donations data.
1155 *
1156 * @since 1.8.8
1157 * @return array
1158 */
1159 private function get_latest_donations() {
1160 // TEMPORARY TESTING: Return empty donations if testing mode is enabled
1161 if ( self::SHOW_EMPTY_DASHBOARD ) {
1162 return array();
1163 }
1164
1165 // Check for test parameter to simulate empty state
1166 // phpcs:disable WordPress.Security.NonceVerification.Recommended
1167 if ( isset( $_GET['charitable_test_no_donations'] ) && '1' === $_GET['charitable_test_no_donations'] ) {
1168 // phpcs:enable WordPress.Security.NonceVerification.Recommended
1169 return array();
1170 }
1171
1172 // Get latest donations using Charitable_Reports
1173 $charitable_reports = Charitable_Reports::get_instance();
1174 $args = array(
1175 'start_date' => gmdate( 'Y/m/d', strtotime( '-30 days' ) ),
1176 'end_date' => gmdate( 'Y/m/d' ),
1177 'days' => 30,
1178 );
1179 $charitable_reports->init_with_array( 'dashboard', $args );
1180 $donations = $charitable_reports->get_donations();
1181
1182 $latest_donations = array();
1183 $limit = apply_filters( 'charitable_dashboard_latest_donations_amount', 3 );
1184
1185 if ( ! empty( $donations ) ) {
1186 // Sort donations by date (newest first)
1187 usort( $donations, function( $a, $b ) {
1188 return strtotime( $b->post_date ) - strtotime( $a->post_date );
1189 });
1190
1191 // Get valid donation statuses for mapping
1192 $statuses = charitable_get_valid_donation_statuses();
1193
1194 $count = 0;
1195 foreach ( $donations as $donation ) {
1196 if ( $count >= $limit ) {
1197 break;
1198 }
1199
1200 $donation_id = intval( $donation->donation_id );
1201 $donor_name = trim( esc_html( $donation->first_name ) . ' ' . esc_html( $donation->last_name ) );
1202 $payment_method = strtolower( $donation->payment_gateway );
1203 $donation_status = $donation->donation_status;
1204 $amount = charitable_format_money( $donation->amount );
1205 $timestamp = human_time_diff( strtotime( $donation->post_date ), current_time( 'timestamp' ) ) . ' ago';
1206
1207 // Map status to display format
1208 $status_label = array_key_exists( $donation_status, $statuses ) ? $statuses[ $donation_status ] : ucwords( $donation_status );
1209
1210 // Map status for CSS class
1211 $status_class = $donation_status;
1212 if ( 'charitable-completed' === $donation_status ) {
1213 $status_class = 'paid';
1214 } elseif ( strpos( $donation_status, 'charitable-' ) === 0 ) {
1215 $status_class = substr( $donation_status, 11 ); // Remove 'charitable-' prefix
1216 }
1217
1218 // Determine payment method CSS class
1219 $method_class = '';
1220 if ( 'stripe' === $payment_method ) {
1221 $method_class = 'donation-method-stripe';
1222 } elseif ( 'paypal' === $payment_method ) {
1223 $method_class = 'donation-method-paypal';
1224 }
1225
1226 // Build donation URL
1227 $url = admin_url( 'post.php?post=' . $donation_id . '&action=edit' );
1228
1229 $latest_donations[] = array(
1230 'id' => $donation_id,
1231 'url' => $url,
1232 'donor_name' => $donor_name,
1233 'timestamp' => $timestamp,
1234 'amount' => $amount,
1235 'method' => ucwords( $payment_method ),
1236 'method_class' => $method_class,
1237 'status' => $status_class,
1238 'status_label' => $status_label
1239 );
1240
1241 $count++;
1242 }
1243 }
1244
1245 return $latest_donations;
1246 }
1247
1248 /**
1249 * Render the top donors tab content.
1250 *
1251 * @since 1.8.8
1252 */
1253 private function render_top_donors_tab() {
1254 $donors = $this->get_top_donors();
1255 ?>
1256 <div class="charitable-dashboard-v2-tab-content<?php echo $this->is_tab_active( 'top-donors' ) ? ' charitable-dashboard-v2-tab-content-active' : ''; ?>" data-tab="top-donors">
1257 <?php if ( ! empty( $donors ) ) : ?>
1258 <div class="charitable-dashboard-v2-table-container">
1259 <table class="charitable-dashboard-v2-table charitable-dashboard-v2-table-donors">
1260 <thead>
1261 <tr>
1262 <th style="width: 60px;"></th>
1263 <th style="width: 200px;">Name</th>
1264 <th style="width: 150px;">Total Donations</th>
1265 <th style="width: 120px;">Status</th>
1266 </tr>
1267 </thead>
1268 <tbody>
1269 <?php foreach ( $donors as $donor ) : ?>
1270 <tr>
1271 <td>
1272 <div class="donor-avatar">
1273 <img src="<?php echo esc_url( $donor['avatar'] ); ?>" alt="<?php echo esc_attr( $donor['name'] ); ?>" width="40" height="40">
1274 </div>
1275 </td>
1276 <td>
1277 <div class="donor-details">
1278 <div class="donor-name">
1279 <a href="<?php echo esc_url( $donor['url'] ); ?>" class="donor-name-link"><?php echo esc_html( $donor['name'] ); ?></a>
1280 </div>
1281 <div class="donor-email">
1282 <?php if ( ! empty( $donor['email'] ) ) : ?>
1283 <a href="mailto:<?php echo esc_attr( $donor['email'] ); ?>" class="donor-email-link" title="<?php echo esc_attr( $donor['email'] ); ?>"><?php echo esc_html( $donor['email_display'] ); ?></a>
1284 <?php else : ?>
1285 <span class="no-email"><?php esc_html_e( 'No email', 'charitable' ); ?></span>
1286 <?php endif; ?>
1287 </div>
1288 </div>
1289 </td>
1290 <td>
1291 <div class="donor-amount"><?php echo esc_html( $donor['total'] ); ?></div>
1292 </td>
1293 <td>
1294 <span class="status-badge status-<?php echo esc_attr( $donor['type'] ); ?>"><?php echo esc_html( $donor['type_label'] ); ?></span>
1295 </td>
1296 </tr>
1297 <?php endforeach; ?>
1298 </tbody>
1299 </table>
1300 </div>
1301 <?php else : ?>
1302 <?php
1303 // Determine if there is at least one non-trashed campaign.
1304 $has_non_trashed_campaign = false;
1305 foreach ( array( 'campaign', 'charitable_campaign' ) as $campaign_cpt ) {
1306 $counts = wp_count_posts( $campaign_cpt );
1307 if ( $counts && is_object( $counts ) ) {
1308 foreach ( $counts as $status => $num_posts ) {
1309 if ( 'trash' === $status ) {
1310 continue;
1311 }
1312 if ( (int) $num_posts > 0 ) {
1313 $has_non_trashed_campaign = true;
1314 break 2;
1315 }
1316 }
1317 }
1318 }
1319 ?>
1320 <div class="charitable-dashboard-v2-empty-state">
1321 <div class="charitable-dashboard-v2-empty-state-content">
1322 <h3><?php esc_html_e( 'No Donors Yet!', 'charitable' ); ?></h3>
1323 <p><?php esc_html_e( 'Start receiving donations to see your top donors here.', 'charitable' ); ?></p>
1324 <?php if ( $has_non_trashed_campaign ) : ?>
1325 <a href="<?php echo esc_url( admin_url( 'edit.php?post_type=donation' ) ); ?>" class="charitable-dashboard-v2-button charitable-dashboard-v2-button-primary">
1326 <?php esc_html_e( 'Add Donation', 'charitable' ); ?>
1327 </a>
1328 <?php else : ?>
1329 <a href="<?php echo esc_url( admin_url( 'post-new.php?post_type=charitable_campaign' ) ); ?>" class="charitable-dashboard-v2-button charitable-dashboard-v2-button-primary">
1330 <?php esc_html_e( 'Create Campaign', 'charitable' ); ?>
1331 </a>
1332 <?php endif; ?>
1333 </div>
1334 </div>
1335 <?php endif; ?>
1336 </div>
1337 <?php
1338 }
1339
1340 /**
1341 * Get top donors data.
1342 *
1343 * @since 1.8.8
1344 * @return array
1345 */
1346 private function get_top_donors() {
1347 // TEMPORARY TESTING: Return empty donors if testing mode is enabled
1348 if ( self::SHOW_EMPTY_DASHBOARD ) {
1349 return array();
1350 }
1351
1352 // Get configurable limit (default 10)
1353 $limit = apply_filters( 'charitable_dashboard_top_donors_limit', 10 );
1354
1355 // Initialize Charitable_Reports for all-time data (no date filtering)
1356 $charitable_reports = Charitable_Reports::get_instance();
1357 $args = array(
1358 'start_date' => false,
1359 'end_date' => false,
1360 'days' => false,
1361 );
1362 $charitable_reports->init_with_array( 'dashboard', $args );
1363
1364 // Get donations data first
1365 $charitable_reports->get_donations();
1366
1367 // Get top donors data (get more than needed to account for filtering)
1368 $top_donors = $charitable_reports->get_top_donors_overview( $limit * 2 );
1369
1370 if ( empty( $top_donors ) || ! is_array( $top_donors ) ) {
1371 return array();
1372 }
1373
1374 $donors = array();
1375 $unique_donors = array();
1376 $count = 0;
1377
1378 foreach ( $top_donors as $donor_data ) {
1379 // Skip if we've already processed this donor
1380 if ( in_array( $donor_data->donor_id, $unique_donors, true ) ) {
1381 continue;
1382 }
1383
1384 // Stop if we've reached the limit
1385 if ( $count >= $limit ) {
1386 break;
1387 }
1388
1389 $unique_donors[] = $donor_data->donor_id;
1390 $count++;
1391
1392 // Get donor name
1393 $donor_name = ! empty( $donor_data->donor_name ) ? $donor_data->donor_name : __( 'Unknown', 'charitable' );
1394
1395 // Get donor email and truncate if needed
1396 $donor_email = ! empty( $donor_data->donor_email ) ? $donor_data->donor_email : '';
1397 $donor_email_display = $donor_email;
1398 if ( strlen( $donor_email ) > 30 ) {
1399 $donor_email_display = substr( $donor_email, 0, 30 ) . '...';
1400 }
1401
1402 // Get avatar (WordPress default)
1403 $donor_avatar = ! empty( $donor_data->donor_avatar_url ) ? $donor_data->donor_avatar_url : false;
1404 if ( false === $donor_avatar && function_exists( 'get_avatar_url' ) && ! empty( $donor_data->donor_email ) ) {
1405 $donor_avatar = get_avatar_url(
1406 $donor_data->donor_email,
1407 array(
1408 'size' => 40,
1409 )
1410 );
1411 }
1412
1413 // Determine donor type and badge
1414 $donation_parent = false;
1415 if ( ! empty( $donor_data->donation_parent_id ) ) {
1416 $donation = charitable_get_donation( $donor_data->donation_parent_id );
1417 $donation_parent = ( $donation && is_object( $donation ) ) ? $donation->get( 'donation_type' ) : false;
1418 }
1419 $display_count = ! empty( $donor_data->total_donation_count ) ? intval( $donor_data->total_donation_count ) : 1;
1420
1421 $badge_css = strtolower( $donation_parent ) === 'recurring' ? 'recurring' : 'one-time';
1422 $badge_label = strtolower( $donation_parent ) === 'recurring' ? esc_html__( 'Recurring', 'charitable' ) : esc_html__( 'One-Time', 'charitable' );
1423
1424 // Override for multiple donations
1425 if ( $display_count > 1 ) {
1426 $badge_css = 'multiple';
1427 $badge_label = esc_html__( 'Multiple', 'charitable' );
1428 }
1429
1430 // Format total amount
1431 $display_amount = ! empty( $donor_data->total_donation_amount ) ? charitable_format_money( $donor_data->total_donation_amount, 2, true ) : charitable_format_money( $donor_data->amount, 2, true );
1432
1433 $donors[] = array(
1434 'name' => $donor_name,
1435 'email' => $donor_email,
1436 'email_display' => $donor_email_display,
1437 'avatar' => $donor_avatar,
1438 'total' => $display_amount,
1439 'type' => $badge_css,
1440 'type_label' => $badge_label,
1441 'url' => admin_url( 'admin.php?page=charitable-donors' )
1442 );
1443 }
1444
1445 return $donors;
1446 }
1447
1448 /**
1449 * Render the comments tab content.
1450 *
1451 * @since 1.8.8
1452 */
1453 private function render_comments_tab() {
1454 ?>
1455 <div class="charitable-dashboard-v2-tab-content<?php echo $this->is_tab_active( 'comments' ) ? ' charitable-dashboard-v2-tab-content-active' : ''; ?>" data-tab="comments">
1456 <?php $this->render_comments_content(); ?>
1457 </div>
1458 <?php
1459 }
1460
1461 /**
1462 * Render comments content based on addon status.
1463 *
1464 * @since 1.8.8
1465 */
1466 private function render_comments_content() {
1467 $addon_status = $this->get_donor_comments_addon_status();
1468
1469 switch ( $addon_status ) {
1470 case 'active':
1471 $this->render_comments_table();
1472 break;
1473 case 'installed_inactive':
1474 $this->render_addon_inactive_message();
1475 break;
1476 case 'not_installed_pro':
1477 $this->render_addon_not_installed_pro_message();
1478 break;
1479 case 'not_installed_lite':
1480 default:
1481 $this->render_addon_not_installed_lite_message();
1482 break;
1483 }
1484 }
1485
1486 /**
1487 * Get donor comments addon status.
1488 *
1489 * @since 1.8.8
1490 * @return string Status: 'active', 'installed_inactive', 'not_installed_pro', 'not_installed_lite'
1491 */
1492 private function get_donor_comments_addon_status() {
1493 // Check if addon is active
1494 if ( class_exists( 'Charitable_Donor_Comments' ) ) {
1495 return 'active';
1496 }
1497
1498 // Check if addon is installed but not active
1499 $plugin_file = 'charitable-donor-comments/charitable-donor-comments.php';
1500 if ( file_exists( WP_PLUGIN_DIR . '/' . $plugin_file ) ) {
1501 return charitable_is_pro() ? 'installed_inactive' : 'not_installed_lite';
1502 }
1503
1504 // Addon not installed
1505 return charitable_is_pro() ? 'not_installed_pro' : 'not_installed_lite';
1506 }
1507
1508 /**
1509 * Render comments table with actual data.
1510 *
1511 * @since 1.8.8
1512 */
1513 private function render_comments_table() {
1514 $comments = $this->get_comments();
1515 ?>
1516 <div class="charitable-dashboard-v2-table-container">
1517 <table class="charitable-dashboard-v2-table charitable-dashboard-v2-table-comments">
1518 <thead>
1519 <tr>
1520 <th>Comment Details</th>
1521 <th>Status</th>
1522 </tr>
1523 </thead>
1524 <tbody>
1525 <?php if ( ! empty( $comments ) ) : ?>
1526 <?php foreach ( $comments as $comment ) : ?>
1527 <tr data-comment-id="<?php echo esc_attr( $comment['id'] ); ?>">
1528 <td>
1529 <div class="comment-details">
1530 <div class="comment-header">
1531 <span class="comment-name"><?php echo esc_html( $comment['name'] ); ?></span>
1532 <span class="comment-text">"<?php echo esc_html( $comment['text'] ); ?>"</span>
1533 </div>
1534 </div>
1535 </td>
1536 <td>
1537 <?php if ( 'approved' === $comment['status'] ) : ?>
1538 <span class="comment-status-approved">Approved</span>
1539 <?php else : ?>
1540 <div class="comment-actions">
1541 <a href="#" class="comment-action approve" data-comment-id="<?php echo esc_attr( $comment['id'] ); ?>">
1542 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
1543 <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" fill="#10B981"/>
1544 </svg>
1545 <span class="action-text">Approve</span>
1546 <span class="action-loading" style="display: none;">
1547 <svg class="charitable-spinner" width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
1548 <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-dasharray="31.416" stroke-dashoffset="31.416">
1549 <animate attributeName="stroke-dasharray" dur="2s" values="0 31.416;15.708 15.708;0 31.416" repeatCount="indefinite"/>
1550 <animate attributeName="stroke-dashoffset" dur="2s" values="0;-15.708;-31.416" repeatCount="indefinite"/>
1551 </circle>
1552 </svg>
1553 Processing...
1554 </span>
1555 </a>
1556 <span class="comment-action-separator">|</span>
1557 <a href="#" class="comment-action delete" data-comment-id="<?php echo esc_attr( $comment['id'] ); ?>">
1558 <img src="/wp-content/plugins/charitable/assets/images/icons/trash.svg" width="12" height="12" alt="Delete">
1559 <span class="action-text"><?php esc_html_e( 'Delete', 'charitable' ); ?></span>
1560 <span class="action-loading" style="display: none;">
1561 <svg class="charitable-spinner" width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
1562 <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-dasharray="31.416" stroke-dashoffset="31.416">
1563 <animate attributeName="stroke-dasharray" dur="2s" values="0 31.416;15.708 15.708;0 31.416" repeatCount="indefinite"/>
1564 <animate attributeName="stroke-dashoffset" dur="2s" values="0;-15.708;-31.416" repeatCount="indefinite"/>
1565 </circle>
1566 </svg>
1567 Processing...
1568 </span>
1569 </a>
1570 </div>
1571 <?php endif; ?>
1572 </td>
1573 </tr>
1574 <?php endforeach; ?>
1575 <?php else : ?>
1576 <tr>
1577 <td colspan="2" class="charitable-dashboard-v2-no-comments">
1578 <p><strong><?php echo esc_html__( 'No donor comments yet.', 'charitable' ); ?></strong></p>
1579 <p><?php echo esc_html__( 'Donor comments will appear here once donors start leaving messages with their donations.', 'charitable' ); ?></p>
1580 </td>
1581 </tr>
1582 <?php endif; ?>
1583 </tbody>
1584 </table>
1585 </div>
1586 <?php
1587 }
1588
1589 /**
1590 * Render message for addon not installed (Lite users).
1591 *
1592 * @since 1.8.8
1593 */
1594 private function render_addon_not_installed_lite_message() {
1595 ?>
1596 <div class="charitable-dashboard-v2-empty-state">
1597 <div class="charitable-dashboard-v2-empty-state-content">
1598 <h3><?php esc_html_e( 'Donor Comments Not Available', 'charitable' ); ?></h3>
1599 <p><?php echo wp_kses( __( 'The Donor Comments addon is not installed. Upgrade to <strong>Charitable Pro</strong> to access this feature.', 'charitable' ), array( 'strong' => array() ) ); ?></p>
1600 <a href="https://wpcharitable.com/pricing" target="_blank" rel="noopener noreferrer" class="charitable-dashboard-v2-button charitable-dashboard-v2-button-primary">
1601 <?php esc_html_e( 'Upgrade to Pro', 'charitable' ); ?>
1602 </a>
1603 </div>
1604 </div>
1605 <?php
1606 }
1607
1608 /**
1609 * Render message for addon not installed (Pro users).
1610 *
1611 * @since 1.8.8
1612 */
1613 private function render_addon_not_installed_pro_message() {
1614 ?>
1615 <div class="charitable-dashboard-v2-empty-state">
1616 <div class="charitable-dashboard-v2-empty-state-content">
1617 <h3><?php esc_html_e( 'Donor Comments Addon Not Installed', 'charitable' ); ?></h3>
1618 <p><?php esc_html_e( 'Download and activate the Donor Comments addon to view donor comments.', 'charitable' ); ?></p>
1619 <a href="https://wpcharitable.com/addons" target="_blank" rel="noopener noreferrer" class="charitable-dashboard-v2-button charitable-dashboard-v2-button-primary">
1620 <?php esc_html_e( 'Download Addon', 'charitable' ); ?>
1621 </a>
1622 </div>
1623 </div>
1624 <?php
1625 }
1626
1627 /**
1628 * Render message for addon installed but not activated.
1629 *
1630 * @since 1.8.8
1631 */
1632 private function render_addon_inactive_message() {
1633 ?>
1634 <div class="charitable-dashboard-v2-empty-state">
1635 <div class="charitable-dashboard-v2-empty-state-content">
1636 <h3><?php esc_html_e( 'Donor Comments Addon Not Activated', 'charitable' ); ?></h3>
1637 <p><?php esc_html_e( 'The Donor Comments addon is installed but not activated. Click the button below to activate it.', 'charitable' ); ?></p>
1638 <button id="charitable-activate-donor-comments" class="charitable-dashboard-v2-button charitable-dashboard-v2-button-primary" data-plugin="charitable-donor-comments/charitable-donor-comments.php">
1639 <span class="button-text"><?php esc_html_e( 'Activate Addon', 'charitable' ); ?></span>
1640 <span class="button-loading" style="display: none;">
1641 <svg class="charitable-spinner" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
1642 <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-dasharray="31.416" stroke-dashoffset="31.416">
1643 <animate attributeName="stroke-dasharray" dur="2s" values="0 31.416;15.708 15.708;0 31.416" repeatCount="indefinite"/>
1644 <animate attributeName="stroke-dashoffset" dur="2s" values="0;-15.708;-31.416" repeatCount="indefinite"/>
1645 </circle>
1646 </svg>
1647 <?php esc_html_e( 'Activating...', 'charitable' ); ?>
1648 </span>
1649 </button>
1650 </div>
1651 </div>
1652 <?php
1653 }
1654
1655 /**
1656 * Get comments data.
1657 *
1658 * @since 1.8.8
1659 * @return array
1660 */
1661 private function get_comments() {
1662 // TEMPORARY TESTING: Return empty comments if testing mode is enabled
1663 if ( self::SHOW_EMPTY_DASHBOARD ) {
1664 return array();
1665 }
1666
1667 // Check if donor comments addon is active
1668 if ( ! class_exists( 'Charitable_Donor_Comments' ) ) {
1669 return array();
1670 }
1671
1672 // Get comments limit (configurable)
1673 $limit = apply_filters( 'charitable_dashboard_comments_limit', 10 );
1674
1675 // Get charitable comments
1676 $comments = get_comments( array(
1677 'type' => 'charitable_comment',
1678 'status' => 'all', // Include pending and approved
1679 'number' => $limit,
1680 'orderby' => 'comment_date',
1681 'order' => 'DESC',
1682 'post_type' => Charitable::CAMPAIGN_POST_TYPE,
1683 ) );
1684
1685 $formatted_comments = array();
1686
1687 if ( ! empty( $comments ) ) {
1688 foreach ( $comments as $comment ) {
1689 // Get comment author (handle anonymous)
1690 $author = get_comment_author( $comment );
1691 if ( empty( $author ) ) {
1692 $author = __( 'Anonymous', 'charitable' );
1693 }
1694
1695 // Get comment text and sanitize it
1696 $text = get_comment_text( $comment );
1697 if ( empty( $text ) ) {
1698 continue; // Skip comments without text
1699 }
1700
1701 // Strip HTML tags except for bold and italic, then convert to proper formatting
1702 $allowed_tags = array(
1703 'strong' => array(),
1704 'b' => array(),
1705 'em' => array(),
1706 'i' => array(),
1707 );
1708 $text = wp_kses( $text, $allowed_tags );
1709
1710 // Convert HTML tags to proper bold/italic formatting
1711 $text = str_replace( array( '<strong>', '</strong>', '<b>', '</b>' ), array( '<strong>', '</strong>', '<strong>', '</strong>' ), $text );
1712 $text = str_replace( array( '<em>', '</em>', '<i>', '</i>' ), array( '<em>', '</em>', '<em>', '</em>' ), $text );
1713
1714 // Determine status
1715 $status = $comment->comment_approved ? 'approved' : 'pending';
1716
1717 $formatted_comments[] = array(
1718 'id' => $comment->comment_ID,
1719 'name' => $author,
1720 'text' => $text,
1721 'status' => $status,
1722 'date' => $comment->comment_date,
1723 );
1724 }
1725 }
1726
1727 return $formatted_comments;
1728 }
1729
1730 /**
1731 * AJAX handler for activating addons.
1732 *
1733 * @since 1.8.8
1734 */
1735 public function ajax_activate_addon() {
1736 // Verify nonce
1737 // phpcs:disable WordPress.Security.NonceVerification.Missing
1738 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1739 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( wp_unslash( $_POST['nonce'] ), 'charitable-admin' ) ) {
1740 wp_die( esc_html( __( 'Security check failed', 'charitable' ) ) );
1741 }
1742
1743 // Check user capabilities
1744 if ( ! current_user_can( 'activate_plugins' ) ) {
1745 wp_send_json_error( array( 'message' => __( 'You do not have permission to activate plugins.', 'charitable' ) ) );
1746 }
1747
1748 $plugin = isset( $_POST['plugin'] ) ? sanitize_text_field( wp_unslash( $_POST['plugin'] ) ) : '';
1749 // phpcs:enable WordPress.Security.NonceVerification.Missing
1750
1751 if ( empty( $plugin ) ) {
1752 wp_send_json_error( array( 'message' => __( 'Plugin not specified.', 'charitable' ) ) );
1753 }
1754
1755 // Check if plugin exists
1756 if ( ! file_exists( WP_PLUGIN_DIR . '/' . $plugin ) ) {
1757 wp_send_json_error( array( 'message' => __( 'Plugin file not found.', 'charitable' ) ) );
1758 }
1759
1760 // Activate the plugin
1761 $result = activate_plugin( $plugin );
1762
1763 if ( is_wp_error( $result ) ) {
1764 wp_send_json_error( array( 'message' => $result->get_error_message() ) );
1765 }
1766
1767 wp_send_json_success( array( 'message' => __( 'Addon activated successfully!', 'charitable' ) ) );
1768 }
1769
1770
1771 /**
1772 * AJAX handler for approving comments.
1773 *
1774 * @since 1.8.8
1775 */
1776 public function ajax_approve_comment() {
1777 // Verify nonce
1778 // phpcs:disable WordPress.Security.NonceVerification.Missing
1779 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1780 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( wp_unslash( $_POST['nonce'] ), 'charitable-admin' ) ) {
1781 wp_die( esc_html( __( 'Security check failed', 'charitable' ) ) );
1782 }
1783
1784 // Check user capabilities
1785 if ( ! current_user_can( 'moderate_comments' ) ) {
1786 wp_send_json_error( array( 'message' => __( 'You do not have permission to moderate comments.', 'charitable' ) ) );
1787 }
1788
1789 $comment_id = isset( $_POST['comment_id'] ) ? intval( wp_unslash( $_POST['comment_id'] ) ) : 0;
1790 // phpcs:enable WordPress.Security.NonceVerification.Missing
1791
1792 if ( empty( $comment_id ) ) {
1793 wp_send_json_error( array( 'message' => __( 'Comment ID not specified.', 'charitable' ) ) );
1794 }
1795
1796 // Check if comment exists
1797 $comment = get_comment( $comment_id );
1798 if ( ! $comment ) {
1799 wp_send_json_error( array( 'message' => __( 'Comment not found.', 'charitable' ) ) );
1800 }
1801
1802 // Check if it's a charitable comment
1803 if ( 'charitable_comment' !== $comment->comment_type ) {
1804 wp_send_json_error( array( 'message' => __( 'Invalid comment type.', 'charitable' ) ) );
1805 }
1806
1807 // Approve the comment
1808 $result = wp_set_comment_status( $comment_id, 'approve' );
1809
1810 if ( is_wp_error( $result ) ) {
1811 wp_send_json_error( array( 'message' => $result->get_error_message() ) );
1812 }
1813
1814 wp_send_json_success( array( 'message' => __( 'Comment approved successfully!', 'charitable' ) ) );
1815 }
1816
1817 /**
1818 * AJAX handler for deleting comments.
1819 *
1820 * @since 1.8.8
1821 */
1822 public function ajax_delete_comment() {
1823 // Verify nonce
1824 // phpcs:disable WordPress.Security.NonceVerification.Missing
1825 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1826 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( wp_unslash( $_POST['nonce'] ), 'charitable-admin' ) ) {
1827 wp_die( esc_html( __( 'Security check failed', 'charitable' ) ) );
1828 }
1829
1830 // Check user capabilities
1831 if ( ! current_user_can( 'moderate_comments' ) ) {
1832 wp_send_json_error( array( 'message' => __( 'You do not have permission to moderate comments.', 'charitable' ) ) );
1833 }
1834
1835 $comment_id = isset( $_POST['comment_id'] ) ? intval( wp_unslash( $_POST['comment_id'] ) ) : 0;
1836 // phpcs:enable WordPress.Security.NonceVerification.Missing
1837
1838 if ( empty( $comment_id ) ) {
1839 wp_send_json_error( array( 'message' => __( 'Comment ID not specified.', 'charitable' ) ) );
1840 }
1841
1842 // Check if comment exists
1843 $comment = get_comment( $comment_id );
1844 if ( ! $comment ) {
1845 wp_send_json_error( array( 'message' => __( 'Comment not found.', 'charitable' ) ) );
1846 }
1847
1848 // Check if it's a charitable comment
1849 if ( 'charitable_comment' !== $comment->comment_type ) {
1850 wp_send_json_error( array( 'message' => __( 'Invalid comment type.', 'charitable' ) ) );
1851 }
1852
1853 // Delete the comment (force delete)
1854 $result = wp_delete_comment( $comment_id, true );
1855
1856 if ( ! $result ) {
1857 wp_send_json_error( array( 'message' => __( 'Failed to delete comment.', 'charitable' ) ) );
1858 }
1859
1860 wp_send_json_success( array( 'message' => __( 'Comment deleted successfully!', 'charitable' ) ) );
1861 }
1862
1863 /**
1864 * Render the upgrade section.
1865 *
1866 * @since 1.8.8
1867 */
1868 public function render_upgrade_section() {
1869 // Don't show upgrade section if user already has Pro license
1870 if ( charitable_is_pro() ) {
1871 return;
1872 }
1873 ?>
1874 <section class="charitable-dashboard-v2-section">
1875 <h4 class="charitable-dashboard-v2-upgrade-title"><?php esc_html_e( 'Upgrade to Pro to Unlock Powerful Donation Features', 'charitable' ); ?></h4>
1876 <div class="charitable-dashboard-v2-upgrade-features">
1877 <div class="charitable-dashboard-v2-upgrade-column">
1878 <?php $this->render_upgrade_features_left(); ?>
1879 </div>
1880 <div class="charitable-dashboard-v2-upgrade-column">
1881 <?php $this->render_upgrade_features_right(); ?>
1882 </div>
1883 </div>
1884 <div class="charitable-dashboard-v2-upgrade-actions">
1885 <a href="<?php echo esc_url( charitable_utm_link( 'https://wpcharitable.com/pricing', 'Dashboard Upgrade Section', 'Upgrade To Pro Button' ) ); ?>" target="_blank" rel="noopener noreferrer" class="charitable-dashboard-v2-upgrade-button"><?php esc_html_e( 'Upgrade To Pro', 'charitable' ); ?></a>
1886 <a href="<?php echo esc_url( charitable_utm_link( 'https://wpcharitable.com/pricing', 'Dashboard Upgrade Section', 'Learn More About Features Link' ) ); ?>" target="_blank" rel="noopener noreferrer" class="charitable-dashboard-v2-learn-more-link"><?php esc_html_e( 'Learn more about all features →', 'charitable' ); ?></a>
1887 </div>
1888 </section>
1889 <?php
1890 }
1891
1892 /**
1893 * Render left column upgrade features.
1894 *
1895 * @since 1.8.8
1896 */
1897 private function render_upgrade_features_left() {
1898 $features = array(
1899 __( 'Recurring Donations', 'charitable' ),
1900 __( 'Peer-to-Peer Fundraising', 'charitable' ),
1901 __( 'Donor Database', 'charitable' ),
1902 __( 'Donor Dashboard', 'charitable' ),
1903 __( 'PDF & Annual Receipts', 'charitable' )
1904 );
1905
1906 foreach ( $features as $feature ) :
1907 ?>
1908 <div class="charitable-dashboard-v2-upgrade-feature">
1909 <svg class="charitable-dashboard-v2-upgrade-checkmark" width="18" height="18" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg">
1910 <path fill-rule="evenodd" clip-rule="evenodd" d="M9.0688 1.3573C13.798 1.3573 17.6374 5.19676 17.6374 9.92591C17.6374 14.6551 13.798 18.4945 9.0688 18.4945C4.33966 18.4945 0.500198 14.6551 0.500198 9.92591C0.500198 5.19676 4.33966 1.3573 9.0688 1.3573ZM13.5778 7.82078C13.5778 7.67488 13.5197 7.52981 13.4146 7.42562L12.6234 6.63444C12.5192 6.52938 12.3733 6.47118 12.2283 6.47118C12.0824 6.47118 11.9373 6.52938 11.8322 6.63444L8.01537 10.4548L6.30536 8.7396C6.20026 8.6345 6.05523 8.57631 5.90933 8.57631C5.7643 8.57631 5.6184 8.6345 5.51421 8.7396L4.72303 9.53075C4.61793 9.63498 4.55978 9.78088 4.55978 9.92591C4.55978 10.0709 4.61793 10.2168 4.72303 10.3211L7.62021 13.2174C7.72444 13.3225 7.87034 13.3806 8.01537 13.3806C8.16127 13.3806 8.3063 13.3225 8.4114 13.2174L13.4146 8.2159C13.5197 8.11171 13.5778 7.96581 13.5778 7.82078Z" fill="#59A56D"/>
1911 </svg>
1912 <span class="charitable-dashboard-v2-upgrade-feature-text"><?php echo esc_html( $feature ); ?></span>
1913 </div>
1914 <?php
1915 endforeach;
1916 }
1917
1918 /**
1919 * Render right column upgrade features.
1920 *
1921 * @since 1.8.8
1922 */
1923 private function render_upgrade_features_right() {
1924 $features = array(
1925 __( 'Anonymous Giving', 'charitable' ),
1926 __( 'Fee Coverage Option', 'charitable' ),
1927 __( 'Email Marketing Integration', 'charitable' ),
1928 __( 'Built-In Analytics & Reports', 'charitable' ),
1929 __( 'Flexible Payment Methods', 'charitable' )
1930 );
1931
1932 foreach ( $features as $feature ) :
1933 ?>
1934 <div class="charitable-dashboard-v2-upgrade-feature">
1935 <svg class="charitable-dashboard-v2-upgrade-checkmark" width="18" height="18" viewBox="0 0 19 19" fill="none" xmlns="http://www.w3.org/2000/svg">
1936 <path fill-rule="evenodd" clip-rule="evenodd" d="M9.0688 1.3573C13.798 1.3573 17.6374 5.19676 17.6374 9.92591C17.6374 14.6551 13.798 18.4945 9.0688 18.4945C4.33966 18.4945 0.500198 14.6551 0.500198 9.92591C0.500198 5.19676 4.33966 1.3573 9.0688 1.3573ZM13.5778 7.82078C13.5778 7.67488 13.5197 7.52981 13.4146 7.42562L12.6234 6.63444C12.5192 6.52938 12.3733 6.47118 12.2283 6.47118C12.0824 6.47118 11.9373 6.52938 11.8322 6.63444L8.01537 10.4548L6.30536 8.7396C6.20026 8.6345 6.05523 8.57631 5.90933 8.57631C5.7643 8.57631 5.6184 8.6345 5.51421 8.7396L4.72303 9.53075C4.61793 9.63498 4.55978 9.78088 4.55978 9.92591C4.55978 10.0709 4.61793 10.2168 4.72303 10.3211L7.62021 13.2174C7.72444 13.3225 7.87034 13.3806 8.01537 13.3806C8.16127 13.3806 8.3063 13.3225 8.4114 13.2174L13.4146 8.2159C13.5197 8.11171 13.5778 7.96581 13.5778 7.82078Z" fill="#59A56D"/>
1937 </svg>
1938 <span class="charitable-dashboard-v2-upgrade-feature-text"><?php echo esc_html( $feature ); ?></span>
1939 </div>
1940 <?php
1941 endforeach;
1942 }
1943
1944 /**
1945 * Render the enhance campaign section.
1946 *
1947 * @since 1.8.8
1948 */
1949 public function render_enhance_campaign_section() {
1950 // Hide section if no items to show
1951 if ( ! $this->should_show_enhance_section() ) {
1952 return;
1953 }
1954
1955 ?>
1956
1957 <style>
1958 .charitable-dashboard-v2-setup-button {
1959 background-color: #28a745 !important;
1960 color: white !important;
1961 border-color: #28a745 !important;
1962 }
1963 .charitable-dashboard-v2-setup-button:hover {
1964 background-color: #218838 !important;
1965 border-color: #1e7e34 !important;
1966 }
1967 .charitable-dashboard-v2-activate-button:disabled {
1968 background-color: #6c757d !important;
1969 color: #fff !important;
1970 border-color: #6c757d !important;
1971 opacity: 0.65 !important;
1972 cursor: not-allowed !important;
1973 }
1974 .charitable-dashboard-v2-upgrade-button {
1975 background-color: #E38632 !important;
1976 color: white !important;
1977 border-color: #E38632 !important;
1978 }
1979 .charitable-dashboard-v2-upgrade-button:hover {
1980 background-color: #E38632 !important;
1981 border-color: #E38632 !important;
1982 }
1983 .charitable-dashboard-v2-installed-button {
1984 background-color: #6c757d !important;
1985 color: #fff !important;
1986 border-color: #6c757d !important;
1987 cursor: not-allowed !important;
1988 }
1989
1990 /* Hide all text in stats row initially */
1991 .charitable-dashboard-v2-stats-row .charitable-dashboard-v2-stat-title,
1992 .charitable-dashboard-v2-stats-row .charitable-dashboard-v2-stat-amount {
1993 visibility: hidden;
1994 }
1995
1996 /* Show text when loading is complete */
1997 .charitable-dashboard-v2-stats-row.charitable-dashboard-stats-loaded .charitable-dashboard-v2-stat-title,
1998 .charitable-dashboard-v2-stats-row.charitable-dashboard-stats-loaded .charitable-dashboard-v2-stat-amount {
1999 visibility: visible;
2000 }
2001 </style>
2002
2003 <section id="charitable-dashboard-v2-enhance-campaign" class="charitable-dashboard-v2-section">
2004 <header class="charitable-dashboard-v2-section-header">
2005 <h3><?php esc_html_e( 'Enhance Your Campaign', 'charitable' ); ?></h3>
2006 </header>
2007 <div class="charitable-dashboard-v2-section-content">
2008 <div class="charitable-dashboard-v2-enhance-grid">
2009 <?php $this->render_enhance_grid_items(); ?>
2010 </div>
2011 <footer class="charitable-dashboard-v2-enhance-campaign-footer">
2012 <div class="charitable-dashboard-v2-enhance-campaign-footer-content">
2013 <div class="charitable-dashboard-v2-enhance-campaign-footer-left">
2014 <a href="<?php echo esc_url( admin_url( 'admin.php?page=charitable-addons' ) ); ?>" class="charitable-dashboard-v2-addons-link">
2015 <span class="charitable-dashboard-v2-addons-text"><?php esc_html_e( 'View All Addons', 'charitable' ); ?></span>
2016 <svg class="charitable-dashboard-v2-arrow-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
2017 <path d="M6 12L10 8L6 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
2018 </svg>
2019 </a>
2020 </div>
2021 </div>
2022 </footer>
2023 </div>
2024 </section>
2025 <?php
2026 }
2027
2028 /**
2029 * Render enhance grid items.
2030 *
2031 * @since 1.8.8
2032 */
2033 private function render_enhance_grid_items() {
2034 $items = $this->get_enhance_items();
2035
2036 foreach ( $items as $item ) :
2037 $button_attributes = '';
2038 if ( isset( $item['button_action'] ) ) {
2039 $button_attributes = 'data-action="' . esc_attr( $item['button_action'] ) . '"';
2040 $button_attributes .= ' data-slug="' . esc_attr( $item['slug'] ) . '"';
2041 $button_attributes .= ' data-type="' . esc_attr( $item['type'] ) . '"';
2042
2043 // Add basename for third-party plugins and charitable addons
2044 if ( isset( $item['basename'] ) ) {
2045 $button_attributes .= ' data-basename="' . esc_attr( $item['basename'] ) . '"';
2046 }
2047
2048 // Add setup URL for third-party plugins (always available for setup state)
2049 if ( $item['type'] === 'third_party' && isset( $item['setup_url'] ) ) {
2050 $button_attributes .= ' data-setup-url="' . esc_url( $item['setup_url'] ) . '"';
2051 }
2052
2053 // Add upgrade URL for charitable addons
2054 if ( $item['type'] === 'charitable_addon' && isset( $item['upgrade_url'] ) ) {
2055 $button_attributes .= ' data-upgrade-url="' . esc_url( $item['upgrade_url'] ) . '"';
2056 }
2057
2058 // Add disabled state
2059 if ( isset( $item['button_disabled'] ) && $item['button_disabled'] ) {
2060 $button_attributes .= ' disabled';
2061 }
2062 }
2063 ?>
2064 <div class="charitable-dashboard-v2-enhance-grid-item">
2065 <div class="charitable-dashboard-v2-enhance-grid-content">
2066 <div class="charitable-dashboard-v2-enhance-grid-header">
2067 <div class="charitable-dashboard-v2-enhance-grid-icon">
2068 <?php
2069 // Allow SVG icons through as trusted raw output.
2070 if ( isset( $item['icon'] ) && is_string( $item['icon'] ) ) {
2071 echo $item['icon']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- SVG markup is trusted as curated/provided internally.
2072 }
2073 ?>
2074 </div>
2075 <h4 class="charitable-dashboard-v2-enhance-grid-title"><?php echo esc_html( $item['title'] ); ?></h4>
2076 </div>
2077 <p class="charitable-dashboard-v2-enhance-grid-description"><?php echo esc_html( $item['description'] ); ?></p>
2078 <button class="charitable-dashboard-v2-enhance-grid-button <?php echo esc_attr( $item['button_class'] ); ?>" <?php echo wp_kses_post( $button_attributes ); ?>><?php echo esc_html( $item['button_text'] ); ?></button>
2079 </div>
2080 </div>
2081 <?php
2082 endforeach;
2083 }
2084
2085 /**
2086 * Get enhance items data.
2087 *
2088 * @since 1.8.8
2089 * @return array
2090 */
2091 private function get_enhance_items() {
2092 // TODO: Future caching implementation
2093 // Consider caching plugin states to avoid repeated get_plugins() calls
2094 // Cache key: 'charitable_dashboard_plugin_states'
2095 // Cache duration: 1 hour or until plugin state changes
2096 // Invalidate cache when: plugins installed/activated/deactivated
2097
2098 $items = array();
2099 $third_party_plugins = $this->get_curated_third_party_plugins();
2100 $charitable_addons = $this->get_curated_charitable_addons();
2101
2102 // Get third-party plugin data
2103 $all_plugins = $this->get_third_party_plugin_data();
2104
2105 // Get first 3 available third-party plugins
2106 $available_third_party = 0;
2107 foreach ( $third_party_plugins as $slug ) {
2108 if ( $available_third_party >= 3 ) {
2109 break;
2110 }
2111
2112 if ( isset( $all_plugins[ $slug ] ) ) {
2113 $plugin_data = $all_plugins[ $slug ];
2114 if ( ! $plugin_data['active'] ) { // Show if not active (installed or not)
2115 $items[] = $this->format_plugin_item( $plugin_data, 'third_party' );
2116 $available_third_party++;
2117 }
2118 }
2119 }
2120
2121 // Fill remaining slots with Charitable addons
2122 $remaining_slots = 4 - count( $items );
2123 $available_charitable = 0;
2124
2125 foreach ( $charitable_addons as $slug ) {
2126 if ( $available_charitable >= $remaining_slots ) {
2127 break;
2128 }
2129
2130 $addon_data = $this->get_charitable_addon_data( $slug );
2131
2132 if ( $addon_data && ! ( $addon_data['installed'] && $addon_data['active'] ) ) {
2133 $items[] = $this->format_addon_item( $addon_data );
2134 $available_charitable++;
2135 }
2136 }
2137
2138 return $items;
2139 }
2140
2141 /**
2142 * AJAX handler for installing Charitable addons.
2143 *
2144 * @since 1.8.8
2145 */
2146 public function ajax_install_charitable_addon() {
2147 // Security check
2148 // phpcs:disable WordPress.Security.NonceVerification.Missing
2149 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
2150 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( wp_unslash( $_POST['nonce'] ), 'charitable-admin' ) ) {
2151 wp_send_json_error( 'Invalid nonce.' );
2152 return;
2153 }
2154
2155 // Permission check
2156 if ( ! current_user_can( 'install_plugins' ) ) {
2157 wp_send_json_error( 'Insufficient permissions.' );
2158 return;
2159 }
2160
2161 $slug = isset( $_POST['slug'] ) ? sanitize_text_field( wp_unslash( $_POST['slug'] ) ) : '';
2162 // phpcs:enable WordPress.Security.NonceVerification.Missing
2163 if ( empty( $slug ) ) {
2164 wp_send_json_error( 'Addon slug is required.' );
2165 return;
2166 }
2167
2168 // Get addon data
2169 $addon_data = $this->get_charitable_addon_data( $slug );
2170 if ( ! $addon_data ) {
2171 wp_send_json_error( 'Addon not found.' );
2172 return;
2173 }
2174
2175 // Check if already installed
2176 if ( $addon_data['installed'] ) {
2177 wp_send_json_error( 'Addon is already installed.' );
2178 return;
2179 }
2180
2181 // Check if download URL is available
2182 if ( ! isset( $addon_data['download_url'] ) || empty( $addon_data['download_url'] ) ) {
2183 wp_send_json_error( 'Download URL not available for this addon.' );
2184 return;
2185 }
2186
2187 // Download and install the addon
2188 $result = $this->install_charitable_addon_from_url( $addon_data['download_url'], $slug );
2189
2190 if ( is_wp_error( $result ) ) {
2191 wp_send_json_error( $result->get_error_message() );
2192 return;
2193 }
2194
2195 wp_send_json_success( array(
2196 'message' => __( 'Addon installed successfully.', 'charitable' ),
2197 'button_text' => __( 'Activate', 'charitable' ),
2198 'button_class' => 'charitable-dashboard-v2-activate-button',
2199 'button_action' => 'activate_addon'
2200 ) );
2201 }
2202
2203 /**
2204 * AJAX handler for activating Charitable addons.
2205 *
2206 * @since 1.8.8
2207 */
2208 public function ajax_activate_charitable_addon() {
2209 // Security check
2210 // phpcs:disable WordPress.Security.NonceVerification.Missing
2211 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
2212 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( wp_unslash( $_POST['nonce'] ), 'charitable-admin' ) ) {
2213 wp_send_json_error( 'Invalid nonce.' );
2214 return;
2215 }
2216
2217 // Permission check
2218 if ( ! current_user_can( 'activate_plugins' ) ) {
2219 wp_send_json_error( 'Insufficient permissions.' );
2220 return;
2221 }
2222
2223 $slug = isset( $_POST['slug'] ) ? sanitize_text_field( wp_unslash( $_POST['slug'] ) ) : '';
2224 // phpcs:enable WordPress.Security.NonceVerification.Missing
2225 if ( empty( $slug ) ) {
2226 wp_send_json_error( 'Addon slug is required.' );
2227 return;
2228 }
2229
2230 // Get addon data
2231 $addon_data = $this->get_charitable_addon_data( $slug );
2232 if ( ! $addon_data ) {
2233 wp_send_json_error( 'Addon not found.' );
2234 return;
2235 }
2236
2237 // Check if installed
2238 if ( ! $addon_data['installed'] ) {
2239 wp_send_json_error( 'Addon is not installed.' );
2240 return;
2241 }
2242
2243 // Check if already active
2244 if ( $addon_data['active'] ) {
2245 wp_send_json_error( 'Addon is already active.' );
2246 return;
2247 }
2248
2249 // Activate the plugin
2250 $result = activate_plugin( $addon_data['basename'] );
2251
2252 if ( is_wp_error( $result ) ) {
2253 wp_send_json_error( 'Failed to activate addon: ' . $result->get_error_message() );
2254 return;
2255 }
2256
2257 wp_send_json_success( array(
2258 'message' => __( 'Addon activated successfully.', 'charitable' ),
2259 'button_text' => __( 'Installed', 'charitable' ),
2260 'button_class' => 'charitable-dashboard-v2-installed-button',
2261 'button_action' => 'installed',
2262 'button_disabled' => true
2263 ) );
2264 }
2265
2266 /**
2267 * Get curated third-party plugins list.
2268 *
2269 * @since 1.8.8
2270 * @return array
2271 */
2272 private function get_curated_third_party_plugins() {
2273 return array(
2274 'monsterinsights', // Analytics
2275 'wpforms-lite', // Forms
2276 'aioseo', // SEO
2277 'duplicator', // Backup
2278 'pushengage', // Notifications
2279 'uncanny-automator', // Automation
2280 'envira-gallery', // Gallery
2281 // Add more as you review
2282 );
2283 }
2284
2285 /**
2286 * Get custom SVG icons for plugins.
2287 *
2288 * @since 1.8.8
2289 * @return array
2290 */
2291 private function get_custom_plugin_icons() {
2292 return array(
2293 'pushengage' => '<svg width="106" height="83" viewBox="0 0 106 83" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M102 40.4611C101.856 54.0439 97.8267 64.8812 89.768 74.1291C88.3289 75.863 86.6021 77.4525 84.8752 78.8975C83.2922 80.3425 80.9897 80.3425 79.6946 78.8975C78.3994 77.308 78.6872 75.1405 80.4141 73.6956C84.0117 70.6611 86.8899 67.1932 89.1924 63.1472C92.6461 57.2228 94.6608 50.8649 94.9486 43.929C95.5242 31.9357 91.7827 21.6763 83.8678 12.862C82.7166 11.706 81.4214 10.55 80.2702 9.39403C78.6872 7.94905 78.3994 5.63709 79.6946 4.19211C80.9897 2.60263 83.1483 2.60263 84.8752 4.19211C94.0852 12.1395 99.5536 22.2543 101.568 34.2477C101.856 36.7041 101.856 39.1606 102 40.4611Z" fill="#3B43FF"/><path d="M4 41.617C4.28781 26.5893 9.90015 14.018 21.2687 4.04761C22.8517 2.60263 25.1542 2.74713 26.3054 4.19211C27.6006 5.63709 27.3128 7.94905 25.7298 9.24953C17.9589 16.0409 13.0661 24.4218 11.4831 34.6811C9.32452 48.1194 12.9222 59.9683 21.9883 70.0831C23.1395 71.3836 24.5786 72.6841 25.8737 73.8401C27.4567 75.285 27.6006 77.4525 26.3054 78.8975C25.0103 80.3425 22.8517 80.3425 21.2687 79.042C11.1953 70.3721 5.43906 59.2458 4.28781 45.952C4.28781 45.2295 4.14391 44.507 4.14391 43.7845C4 43.062 4 42.3395 4 41.617Z" fill="#3B43FF"/><path d="M17.9589 41.4725C18.2467 30.7797 22.1322 21.8208 30.3348 14.7404C32.0617 13.2955 33.9325 13.44 35.3715 15.0294C36.6667 16.4744 36.5228 18.3529 34.7959 19.9424C30.9104 23.5548 27.7445 27.7453 26.1615 32.9472C23.1395 43.4955 25.2981 52.8879 32.7812 60.9798C33.5007 61.8467 34.3642 62.5692 35.0837 63.4362C36.3789 64.7367 36.5228 66.7597 35.3715 68.0602C34.2203 69.5051 31.9178 69.7941 30.6226 68.4936C28.464 66.4707 26.4493 64.4477 24.5786 62.1357C20.6931 56.9338 18.5345 51.0094 18.1028 44.3625C18.1028 43.929 17.9589 43.4955 17.9589 42.9175C17.9589 42.484 17.9589 42.0505 17.9589 41.4725Z" fill="#3B43FF"/><path d="M88.0411 41.617C87.7533 52.0209 83.8678 60.8353 76.0969 67.9157C74.9457 69.0716 73.5066 69.6496 71.9236 69.0716C69.6211 68.0602 69.0455 65.4592 70.7724 63.4362C72.7871 61.1243 75.0896 58.9568 76.8164 56.3558C83.7239 45.663 82.141 31.0687 73.0749 21.9653C72.3554 21.2429 71.6358 20.5204 70.9163 19.7979C69.6211 18.4974 69.4772 16.4744 70.6285 15.1739C71.9236 13.729 73.9383 13.44 75.5213 14.596C78.3994 16.9079 80.8458 19.6534 82.7166 22.8323C85.7386 27.7453 87.6094 33.0917 87.8972 38.8716C87.8972 39.7386 87.8972 40.6056 88.0411 41.617Z" fill="#3B43FF"/><path d="M73.7944 40.7501C73.7944 47.975 71.348 53.0324 66.743 57.3673C65.1601 58.9568 62.8576 58.9568 61.5624 57.3673C60.2673 55.9223 60.2673 53.7549 61.9941 52.3099C64.8722 49.5644 66.743 46.241 66.8869 42.195C67.0308 38.2936 65.8796 34.9701 63.2893 32.0802C62.8576 31.5022 62.2819 31.0687 61.8502 30.6352C60.4112 29.1902 60.2673 27.1673 61.5624 25.7223C62.8576 24.2773 65.0162 24.1328 66.5991 25.5778C69.0455 27.7453 70.9163 30.3462 72.2115 33.3807C73.3627 35.9816 73.9383 38.7271 73.7944 40.7501Z" fill="#3B43FF"/><path d="M41.2717 24.5663C43.2863 24.5663 44.2937 25.2888 45.0132 26.7338C45.7327 28.1787 45.301 29.6237 44.1498 30.6352C42.279 32.5137 40.696 34.3922 39.8326 36.9931C38.1057 42.484 39.257 47.2525 42.9985 51.4429C43.2863 51.8764 43.7181 52.1654 44.1498 52.5989C45.5888 54.1884 45.7327 56.2113 44.2937 57.6563C42.9985 58.9568 40.8399 59.1013 39.4009 57.8008C35.8032 54.6219 33.3568 50.8649 32.6373 46.0965C31.1982 38.2936 33.3568 31.5022 39.1131 26.1558C39.8326 25.1443 40.8399 24.8553 41.2717 24.5663Z" fill="#3B43FF"/><path d="M52.7841 48.4084C48.8987 48.4084 45.8767 45.374 45.8767 41.4725C45.8767 37.5711 49.0426 34.5367 52.928 34.5367C56.8135 34.5367 59.8355 37.5711 59.8355 41.4725C59.8355 45.5185 56.8135 48.4084 52.7841 48.4084Z" fill="#3B43FF"/></svg>',
2294 'all-in-one-seo-pack' => '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
2295 <path fill-rule="evenodd" clip-rule="evenodd" d="M10 20C15.524 20 20 15.524 20 10C20 4.47599 15.524 0 10 0C4.47599 0 0 4.47599 0 10C0 15.524 4.47599 20 10 20ZM8.40919 3.6618C8.27557 3.4572 8.02088 3.35699 7.79123 3.4405C7.52818 3.53236 7.27349 3.64092 7.02296 3.76618C6.80585 3.87474 6.69311 4.12526 6.74322 4.36743L6.91441 5.23591C6.96033 5.4572 6.86848 5.68685 6.69311 5.82881C6.41754 6.05846 6.16701 6.31315 5.94155 6.59708C5.79958 6.77244 5.57829 6.86848 5.36117 6.82255L4.50939 6.65136C4.2714 6.60125 4.02505 6.71399 3.92067 6.93946C3.86221 7.06472 3.80376 7.19415 3.74948 7.32777C3.6952 7.46138 3.64927 7.59081 3.60334 7.72443C3.52401 7.95825 3.62004 8.21712 3.82463 8.35491L4.54697 8.84342C4.73486 8.96869 4.82672 9.19415 4.80167 9.4238C4.76409 9.78706 4.76827 10.1545 4.80585 10.5094C4.8309 10.7349 4.73904 10.9603 4.55115 11.0898L3.82881 11.5825C3.62839 11.7203 3.53236 11.9749 3.61169 12.2129C3.70355 12.4802 3.80793 12.7432 3.93319 12.9979C4.04175 13.2192 4.28393 13.3319 4.52192 13.2818L5.37369 13.1065C5.59081 13.0605 5.81628 13.1524 5.95407 13.3319C6.17537 13.6117 6.43006 13.8706 6.70981 14.1002C6.88518 14.2422 6.97704 14.4718 6.93111 14.6931L6.76409 15.5616C6.71816 15.8038 6.82672 16.0543 7.04802 16.1628C7.17328 16.2255 7.29854 16.2839 7.42797 16.3382C7.55741 16.3925 7.68685 16.4426 7.81628 16.4885C8.19624 16.6221 8.73069 16.1461 9.12317 15.7912C9.31942 15.6159 9.43633 15.3695 9.4405 15.1065V15.1023V13.6493C9.4405 13.6326 9.4405 13.62 9.4405 13.6033C8.27557 13.3194 7.41127 12.2505 7.41127 10.977V9.43633C7.41127 9.31942 7.50313 9.22338 7.62004 9.22338H8.34238V7.70355C8.34238 7.49478 8.50939 7.32359 8.71399 7.32359C8.91858 7.32359 9.0856 7.49478 9.0856 7.70355V9.21921H11.0355V7.70355C11.0355 7.49478 11.2025 7.32359 11.4071 7.32359C11.6117 7.32359 11.7787 7.49478 11.7787 7.70355V9.21921H12.501C12.6138 9.21921 12.7098 9.31524 12.7098 9.43215V10.9729C12.7098 12.2881 11.7871 13.382 10.5679 13.6242C10.5679 13.6326 10.5679 13.6367 10.5679 13.6451V15.0898C10.5679 15.357 10.6931 15.6075 10.8935 15.7829C11.2944 16.1336 11.8372 16.6054 12.2171 16.4718C12.4802 16.38 12.7349 16.2714 12.9854 16.1461C13.2025 16.0376 13.3152 15.7871 13.2651 15.5449L13.0939 14.6764C13.048 14.4551 13.1399 14.2255 13.3152 14.0835C13.5866 13.8539 13.8413 13.5992 14.0668 13.3152C14.2088 13.1399 14.4301 13.0438 14.6472 13.0898L15.499 13.261C15.737 13.3111 15.9833 13.1983 16.0877 12.9729C16.1461 12.8476 16.2046 12.7182 16.2589 12.5846C16.3132 12.4509 16.3591 12.3215 16.405 12.1879C16.4843 11.9541 16.3883 11.6952 16.1837 11.5574L15.4614 11.0689C15.2735 10.9436 15.1816 10.7182 15.2067 10.4885C15.2443 10.1253 15.2401 9.75783 15.2025 9.40292C15.1775 9.17745 15.2693 8.95198 15.4572 8.82255L16.1795 8.32985C16.38 8.19207 16.476 7.93737 16.3967 7.69937C16.3048 7.43215 16.2004 7.1691 16.0752 6.91441C15.9666 6.69311 15.7244 6.58038 15.4864 6.63048L14.6347 6.80585C14.4175 6.85177 14.1921 6.75992 14.0543 6.58038C13.833 6.30063 13.5783 6.04175 13.2985 5.81211C13.1232 5.67015 13.0313 5.4405 13.0772 5.21921L13.2443 4.35073C13.2902 4.10856 13.1816 3.85804 12.9603 3.74948C12.8351 3.68685 12.7098 3.62839 12.5804 3.57411C12.4509 3.51983 12.3215 3.46973 12.1921 3.4238C11.9624 3.34447 11.7119 3.4405 11.5741 3.64927L11.0939 4.38831C10.9687 4.5762 10.7474 4.67223 10.5261 4.65136C10.167 4.61378 9.81211 4.61795 9.46138 4.65553C9.24008 4.68058 9.01879 4.58455 8.89353 4.39666L8.40919 3.6618Z" fill="#005AE0"/>
2296 </svg>',
2297 'duplicator' => '<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
2298 <path d="M46.2041 20.0816H44.8571C44.7347 20.0816 44.6122 20.0408 44.449 20.0408C43.9184 19.9184 43.4286 19.7143 42.9388 19.4694C42.8163 19.3878 42.6939 19.3469 42.6122 19.2653C42.5714 19.2245 42.4898 19.1837 42.449 19.1429C42.3673 19.102 42.3265 19.0612 42.2449 18.9796C42.0408 18.8163 41.8775 18.6531 41.6735 18.449C41.6735 18.449 41.6735 18.449 41.6327 18.4082C41.5102 18.2857 41.4286 18.1633 41.3469 18.0408C41.2245 17.8776 41.1429 17.7143 41.0204 17.551C40.898 17.3061 40.7755 17.0612 40.6531 16.7755C40.6122 16.6122 40.5306 16.449 40.4898 16.2857C40.449 16.1633 40.449 16.0408 40.4082 15.9592C40.449 15.8367 40.4082 15.6735 40.4082 15.5102C40.4082 15.4694 40.4082 15.4286 40.4082 15.3878C40.4082 14.898 40.449 14.449 40.5714 14C40.7755 13.3061 41.102 12.6531 41.4694 12.0408C41.551 11.9592 41.6327 11.8776 41.6735 11.7551L42.4082 10.9796C43.0204 10.3673 43.0612 9.34694 42.4898 8.69388C41.7551 7.79592 40.9388 6.93878 40.0408 6.16327L15.3061 30.5714C14.898 30.9388 14.2449 30.6939 14.2449 30.1224C14.2857 25.8367 14.3673 18.5306 14.4082 14.8571C14.4082 14.2449 15.2245 14.0408 15.551 14.5714L18.1224 19.0612C18.3265 19.4286 18.8163 19.4694 19.102 19.1837L30.5714 7.71429C30.5714 7.71429 30.5306 7.71428 30.5306 7.67347C30.3673 7.59184 30.2449 7.5102 30.0816 7.42857C30 7.34694 29.8776 7.30612 29.7959 7.22449C29.6735 7.14286 29.551 7.02041 29.4286 6.93878C29.3878 6.89796 29.3878 6.89796 29.3469 6.85714C28.9796 6.4898 28.7347 6.12245 28.4898 5.71429C28.1224 5.02041 27.8776 4.28571 27.7551 3.55102C27.7143 3.42857 27.7143 3.26531 27.6735 3.14286C27.6735 3.06122 27.6735 2.97959 27.6735 2.89796V1.83673C27.6735 0.897959 26.9796 0.163265 26.0816 0.0816327C25.3878 0.0408163 24.6939 0 24 0C23.3469 0 22.6531 0.0408163 22 0.0816327C21.102 0.163265 20.4082 0.897959 20.4082 1.79592C20.4082 2.32653 20.3673 2.93878 20.2449 3.55102C20.1224 4.28571 19.8776 5.02041 19.5102 5.71429C19.3061 6.12245 19.0204 6.4898 18.6531 6.81633C18.6122 6.85714 18.6122 6.85714 18.5714 6.89796C18.449 7.02041 18.3265 7.10204 18.2041 7.18367C18.1224 7.26531 18 7.30612 17.9184 7.38775C17.7551 7.46939 17.6327 7.55102 17.4694 7.63265C17.2245 7.7551 16.9388 7.87755 16.6939 7.91837C16.4898 8 16.3265 8.04082 16.1224 8.04082C15.9592 8.08163 15.8367 8.08163 15.6735 8.08163H15.6327C15.3878 8.08163 15.102 8.08163 14.8571 8.08163C14.7755 8.08163 14.6939 8.08163 14.6122 8.04082C14.5306 8.04082 14.449 8 14.4082 8C14.2857 7.95918 14.1633 7.95918 14.0408 7.91837C13.5102 7.7551 13.0204 7.55102 12.5714 7.30612C12.3265 7.18367 12.1224 7.02041 11.9184 6.85714C11.8367 6.77551 11.7551 6.73469 11.6735 6.65306L10.8571 5.79592C10.2449 5.14286 9.18367 5.06122 8.4898 5.63265C7.38775 6.57143 6.36735 7.59184 5.46939 8.69388C4.89796 9.38775 4.97959 10.3673 5.55102 10.9796L6.28571 11.7551C6.36735 11.8367 6.44898 11.9184 6.4898 12.0408C6.85714 12.6122 7.22449 13.3061 7.38775 14C7.5102 14.449 7.55102 14.898 7.55102 15.3878C7.55102 15.4286 7.55102 15.4694 7.55102 15.5102C7.55102 15.6735 7.5102 15.7959 7.5102 15.9592C7.5102 16.0816 7.46939 16.2041 7.42857 16.2857C7.38775 16.449 7.34694 16.6122 7.26531 16.7755C7.18367 17.0612 7.06122 17.3061 6.89796 17.551C6.81633 17.7143 6.69388 17.8776 6.57143 18.0408C6.4898 18.1633 6.36735 18.2857 6.28571 18.4082C6.28571 18.4082 6.28571 18.4082 6.2449 18.449C6.08163 18.6531 5.87755 18.8163 5.67347 18.9796C5.59184 19.0204 5.55102 19.102 5.46939 19.1429C5.42857 19.1837 5.34694 19.2245 5.30612 19.2653C5.18367 19.3469 5.06122 19.3878 4.97959 19.4694C4.4898 19.7143 4 19.9184 3.46939 20.0408C3.34694 20.0408 3.22449 20.0816 3.06122 20.0816H1.71429C0.857143 20.0816 0.0816327 20.7347 0 21.6327C0.0408163 22.4082 0 23.2245 0 24C0 24.6531 0.0408163 25.3061 0.0816327 25.9592C0.163265 26.8571 0.857143 27.551 1.7551 27.551L2.97959 27.5918C3.10204 27.5918 3.22449 27.5918 3.34694 27.6327C3.83673 27.7551 4.28571 27.9184 4.73469 28.1224C5.14286 28.3265 5.5102 28.6122 5.87755 28.9388C5.91837 28.9796 5.91837 28.9796 5.95918 29.0204C6.08163 29.1429 6.16327 29.2653 6.2449 29.3469C6.32653 29.4286 6.40816 29.5102 6.44898 29.6327C6.53061 29.7959 6.65306 29.9184 6.73469 30.0816C6.85714 30.3265 6.97959 30.5714 7.06122 30.8571C7.14286 31.0612 7.18367 31.2245 7.22449 31.4286C7.26531 31.5918 7.26531 31.7143 7.30612 31.8775V31.9184C7.34694 32.1633 7.34694 32.449 7.30612 32.6939C7.30612 32.7755 7.30612 32.8571 7.30612 32.9388C7.30612 33.0204 7.30612 33.102 7.26531 33.1429C7.22449 33.2653 7.22449 33.3878 7.18367 33.5102C7.06122 34.0408 6.85714 34.5306 6.61225 34.9796C6.53061 35.1429 6.44898 35.3061 6.32653 35.4286C6.2449 35.5102 6.20408 35.6327 6.08163 35.7143L5.30612 36.5714C4.69388 37.1837 4.61225 38.1633 5.14286 38.8571C6 39.9592 6.97959 40.9796 8 41.8775L33.1429 17.1837C33.551 16.8163 34.2041 17.0612 34.2041 17.6327C34.1633 21.9184 34.0816 29.2245 34.0408 32.898C34.0408 33.5102 33.2245 33.7143 32.898 33.1837L30.3265 28.6939C30.1224 28.3265 29.6327 28.2857 29.3469 28.5714L17.3878 40.5306C17.3878 40.5306 17.4286 40.5306 17.4286 40.5714C17.551 40.6531 17.6735 40.7347 17.7959 40.8571C17.7959 40.8571 17.7959 40.8571 17.8367 40.898C18.0408 41.0612 18.2041 41.2245 18.3673 41.4286C18.4082 41.5102 18.4898 41.551 18.5306 41.6327C18.5714 41.6735 18.6122 41.7551 18.6531 41.7959C18.7347 41.9184 18.7755 42 18.8571 42.1224C19.1429 42.5714 19.3469 43.0612 19.4694 43.5918C19.7143 44.4898 19.7959 45.3878 19.7959 46.1633C19.7959 47.0612 20.4898 47.7959 21.3469 47.8776C22.2041 47.9592 23.102 48.0408 24 48.0408C24.898 48.0408 25.7551 48 26.6531 47.8776C27.5102 47.7959 28.2041 47.0612 28.2041 46.1633C28.2041 45.3878 28.2857 44.449 28.5306 43.5918C28.6531 43.102 28.8571 42.6122 29.1429 42.1224C29.2245 42 29.2653 41.9184 29.3469 41.7959C29.3878 41.7551 29.4286 41.6735 29.4694 41.6327C29.5102 41.551 29.5918 41.5102 29.6327 41.4286C29.7959 41.2245 29.9592 41.0612 30.1633 40.898C30.1633 40.898 30.1633 40.898 30.2041 40.8571C30.3265 40.7755 30.449 40.6531 30.5714 40.5714C30.7347 40.449 30.898 40.3673 31.102 40.2857C31.3469 40.1633 31.5918 40.0408 31.8775 39.9592C32.0408 39.9184 32.2041 39.8775 32.3673 39.8367C32.4898 39.7959 32.6122 39.7959 32.7347 39.7959C32.898 39.7959 33.0204 39.7551 33.1837 39.7551C33.2245 39.7551 33.2653 39.7551 33.3061 39.7551C33.7959 39.7551 34.2449 39.8367 34.6939 39.9592C35.3061 40.1633 35.8775 40.449 36.4082 40.8163C36.4898 40.898 36.6122 40.9796 36.6939 41.0612L37.551 41.9592C38.2041 42.6122 39.2245 42.6531 39.9184 42.0816C41.0204 41.102 42 40.0816 42.898 38.9388C43.4286 38.2449 43.3469 37.2653 42.7347 36.6531L41.9592 35.8776C41.8776 35.7959 41.7959 35.7143 41.7143 35.5918C41.6326 35.4286 41.5102 35.3061 41.4286 35.1429C41.1837 34.6939 40.9796 34.2041 40.8571 33.6735C40.8163 33.551 40.7755 33.4286 40.7755 33.3061C40.7755 33.2245 40.7347 33.1429 40.7347 33.102C40.7347 33.0204 40.7347 32.9388 40.7347 32.8571C40.7347 32.6122 40.7347 32.3673 40.7347 32.0816V32.0408C40.7347 31.8776 40.7755 31.7551 40.8163 31.5918C40.8571 31.3878 40.898 31.2245 40.9796 31.0204C41.0612 30.7755 41.1837 30.4898 41.3061 30.2449C41.3878 30.0816 41.4694 29.9592 41.5918 29.7959C41.6735 29.7143 41.7143 29.5918 41.7959 29.5102C41.8775 29.3878 42 29.2653 42.0816 29.1837C42.1224 29.1429 42.1224 29.1429 42.1633 29.102C42.4898 28.7755 42.898 28.4898 43.3061 28.2857C43.7551 28.0408 44.2041 27.8776 44.6939 27.7959C44.8163 27.7551 44.9388 27.7551 45.0612 27.7551L46.2857 27.7143C47.1837 27.6735 47.8775 26.9796 47.9592 26.1224C48 25.4694 48.0408 24.8163 48.0408 24.1633C48.0408 23.3469 48 22.5714 47.9184 21.7959C47.7959 20.7755 47.0612 20.1224 46.2041 20.0816Z" fill="#FE4715"/>
2299 </svg>',
2300 // Add more custom SVG icons as needed
2301 );
2302 }
2303
2304 /**
2305 * Get curated Charitable addons list.
2306 *
2307 * @since 1.8.8
2308 * @return array
2309 */
2310 private function get_curated_charitable_addons() {
2311 return array(
2312 'charitable-recurring',
2313 'charitable-donortrust',
2314 'charitable-gift-aid',
2315 // Add more as you review
2316 );
2317 }
2318
2319 /**
2320 * Get custom title overrides for Charitable addons.
2321 *
2322 * @since 1.8.8
2323 * @return array
2324 */
2325 private function get_charitable_addon_title_overrides() {
2326 return array(
2327 'charitable-recurring' => __( 'Recurring Donations', 'charitable' ),
2328 'charitable-donortrust' => __( 'DonorTrust', 'charitable' ),
2329 'charitable-gift-aid' => __( 'Gift Aid', 'charitable' ),
2330 'charitable-ambassadors' => __( 'Ambassadors', 'charitable' ),
2331 'charitable-anonymous-donations' => __( 'Anonymous Donations', 'charitable' ),
2332 'charitable-fee-relief' => __( 'Fee Relief', 'charitable' ),
2333 // Add more overrides as needed
2334 );
2335 }
2336
2337 /**
2338 * Get third-party plugin data.
2339 *
2340 * @since 1.8.8
2341 * @return array
2342 */
2343 private function get_third_party_plugin_data() {
2344 $plugins_class = Charitable_Admin_Plugins_Third_Party::get_instance();
2345 return $plugins_class->get_plugins( false );
2346 }
2347
2348 /**
2349 * Get Charitable addon data.
2350 *
2351 * @since 1.8.8
2352 * @param string $slug Addon slug.
2353 * @return array|false
2354 */
2355 private function get_charitable_addon_data( $slug ) {
2356 // Debug: Output current plan and license info
2357 $addons_directory = Charitable_Addons_Directory::get_instance();
2358 $current_plan = $addons_directory->get_current_plan_slug();
2359 $licenses = charitable_get_licenses();
2360 $is_pro = charitable_is_pro();
2361
2362 // First check if the addon is installed as a WordPress plugin
2363 $plugin_file = $slug . '/' . $slug . '.php';
2364 $is_installed = file_exists( WP_PLUGIN_DIR . '/' . $plugin_file );
2365 $is_active = is_plugin_active( $plugin_file );
2366
2367 // If not installed, try to get addon data from directory
2368 if ( ! $is_installed ) {
2369 $addons_directory = Charitable_Addons_Directory::get_instance();
2370 $addon = $addons_directory->get_addon( $slug );
2371
2372 if ( ! $addon ) {
2373 // Check if user has access to this addon by checking if it's in the recommended addons
2374 $all_addons = $addons_directory->get_addons();
2375 $has_access = false;
2376
2377 // Check if addon is in recommended, licensed, or unlicensed categories
2378 foreach ( $all_addons as $category => $addons ) {
2379 foreach ( $addons as $addon_data ) {
2380 if ( isset( $addon_data['slug'] ) && $addon_data['slug'] === $slug ) {
2381 $has_access = true;
2382 $addon = $addon_data;
2383 break 2;
2384 }
2385 }
2386 }
2387
2388 if ( ! $has_access ) {
2389 return false;
2390 }
2391
2392 // Process the raw addon data to add status, action, and plugin_allow fields
2393 // This mimics what get_addon() does but for raw data from get_addons()
2394 $addon = $this->process_raw_addon_data( $addon, $slug );
2395 }
2396 } else {
2397 // Create addon data for installed plugin
2398 $addon = array(
2399 'name' => ucwords( str_replace( '-', ' ', $slug ) ),
2400 'status' => $is_active ? 'active' : 'installed',
2401 'action' => $is_active ? 'deactivate' : 'activate',
2402 'path' => $plugin_file,
2403 'plugin_allow' => true,
2404 'upgrade_url' => '',
2405 // Installed plugin may not have directory metadata; default license to empty
2406 'license' => array(),
2407 );
2408 }
2409
2410 // Get title with overrides
2411 $title_overrides = $this->get_charitable_addon_title_overrides();
2412 if ( isset( $title_overrides[ $slug ] ) ) {
2413 $title = $title_overrides[ $slug ];
2414 } else {
2415 $title = isset( $addon['name'] ) ? $addon['name'] : ucwords( str_replace( '-', ' ', $slug ) );
2416 $title = preg_replace( '/^Charitable\s+/i', '', $title );
2417 }
2418
2419 // Get local icon
2420 $icon = $this->get_charitable_addon_icon( $slug );
2421
2422 $final_data = array(
2423 'title' => $title,
2424 'description' => $this->get_charitable_addon_description( $slug ),
2425 'icon' => $icon,
2426 'installed' => $addon['status'] === 'installed' || $addon['status'] === 'active',
2427 'active' => $addon['status'] === 'active',
2428 'download_url' => isset( $addon['download_link'] ) ? $addon['download_link'] : '',
2429 'slug' => $slug,
2430 'type' => 'charitable_addon',
2431 'basename' => $addon['path'],
2432 'status' => $addon['status'],
2433 'action' => $addon['action'],
2434 'plugin_allow' => $addon['plugin_allow'],
2435 'upgrade_url' => $addon['upgrade_url'] ?? '',
2436 'license' => isset( $addon['license'] ) ? (array) $addon['license'] : array(),
2437 );
2438
2439 return $final_data;
2440 }
2441
2442 /**
2443 * Get Charitable addon description.
2444 *
2445 * @since 1.8.8.4
2446 * @param string $slug Addon slug.
2447 * @return string Description.
2448 */
2449 public function get_charitable_addon_description( $slug ) {
2450
2451 $descriptions = array(
2452 // Charitable official addons
2453 'charitable-recurring' => __( 'Let donors give automatically on a daily, weekly, monthly, or yearly schedule.', 'charitable' ),
2454 'charitable-donortrust' => __( 'Build Trust and Boost Donations with Real-Time Social Proof.', 'charitable' ),
2455 'charitable-gift-aid' => __( 'Allow UK donors to add Gift Aid and boost eligible donations by 25%.', 'charitable' ),
2456 'charitable-anonymous-donations' => __( 'Give supporters the option to hide their name from public donations.', 'charitable' ),
2457 'charitable-fee-relief' => __( 'Let donors optionally cover processing fees.', 'charitable' ),
2458
2459 // Add more addon descriptions here as they are surfaced in the dashboard
2460 'google-analytics-for-wordpress' => __( 'Get detailed insights into your donation traffic and campaign performance.', 'charitable' ),
2461 'wpforms-lite' => __( 'Create custom donation forms and surveys to engage with supporters.', 'charitable' ),
2462 'all-in-one-seo-pack' => __( 'Optimize your campaigns for search engines to reach more potential donors.', 'charitable' ),
2463 'duplicator' => __( 'Safely backup and migrate your donation data and campaign content.', 'charitable' ),
2464 'pushengage' => __( 'Send targeted push notifications to re-engage donors and drive contributions.', 'charitable' ),
2465 'uncanny-automator' => __( 'Automate your donation workflows and connect with other fundraising tools.', 'charitable' ),
2466 'envira-gallery' => __( 'Create beautiful photo galleries to showcase your charitable impact.', 'charitable' ),
2467 );
2468
2469 if ( isset( $descriptions[ $slug ] ) ) {
2470 return $descriptions[ $slug ];
2471 }
2472
2473 // Sensible default if an addon is missing a curated description
2474 return __( 'Enhance your campaign with powerful fundraising features.', 'charitable' );
2475 }
2476
2477 /**
2478 * Get Charitable addon icon from local assets.
2479 *
2480 * @since 1.8.8
2481 * @param string $slug Addon slug.
2482 * @return string Icon URL or generic icon.
2483 */
2484 private function get_charitable_addon_icon( $slug ) {
2485 // Map addon slugs to their base filenames (without extension)
2486 $icon_mapping = array(
2487 'charitable-donortrust' => 'donortrust',
2488 'charitable-gift-aid' => 'gift-aid',
2489 'charitable-recurring' => 'recurring-donations',
2490 'charitable-anonymous-donations' => 'anonymous-donations',
2491 'charitable-fee-relief' => 'fee-relief',
2492 );
2493
2494 // Check if we have a local icon for this addon
2495 if ( isset( $icon_mapping[ $slug ] ) ) {
2496 $base_filename = $icon_mapping[ $slug ];
2497 $base_path = charitable()->get_path( 'assets' ) . 'images/plugins/charitable/' . $base_filename;
2498
2499 // First try SVG
2500 $svg_path = $base_path . '.svg';
2501 if ( file_exists( $svg_path ) ) {
2502 $svg_content = file_get_contents( $svg_path );
2503 return $svg_content ? $svg_content : $this->get_generic_plugin_icon();
2504 }
2505
2506 // Fallback to PNG
2507 $png_path = $base_path . '.png';
2508 if ( file_exists( $png_path ) ) {
2509 $png_url = charitable()->get_path( 'directory', false ) . 'assets/images/plugins/charitable/' . $base_filename . '.png';
2510 return '<img src="' . esc_url( $png_url ) . '" alt="' . esc_attr( $base_filename ) . '" width="20" height="20" />';
2511 }
2512 }
2513
2514 // Fall back to generic puzzle piece icon
2515 return $this->get_generic_plugin_icon();
2516 }
2517
2518 /**
2519 * Get generic plugin icon SVG.
2520 *
2521 * @since 1.8.8
2522 * @return string Generic icon SVG.
2523 */
2524 private function get_generic_plugin_icon() {
2525 return '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
2526 <path d="M8 2L12 6L8 10L4 6L8 2Z" fill="#6B7280"/>
2527 <path d="M8 10L12 14L8 18L4 14L8 10Z" fill="#6B7280"/>
2528 <path d="M12 6L16 10L12 14L8 10L12 6Z" fill="#6B7280"/>
2529 </svg>';
2530 }
2531
2532 /**
2533 * Format plugin item for display.
2534 *
2535 * @since 1.8.8
2536 * @param array $plugin_data Plugin data.
2537 * @param string $type Plugin type.
2538 * @return array
2539 */
2540 private function format_plugin_item( $plugin_data, $type ) {
2541 $button_state = $this->get_button_state( $plugin_data, $type );
2542
2543
2544 $item = array(
2545 'title' => $plugin_data['title'],
2546 'description' => $this->get_charitable_addon_description( $plugin_data['slug'] ),
2547 'button_text' => $button_state['text'],
2548 'button_class' => $button_state['class'],
2549 'button_action' => $button_state['action'],
2550 'icon' => $this->get_plugin_icon( $plugin_data ),
2551 'slug' => $plugin_data['slug'],
2552 'basename' => $plugin_data['basename'],
2553 'type' => $type
2554 );
2555
2556 // Always add setup URL if available (for when button changes to setup state)
2557 if ( ! empty( $plugin_data['setup'] ) ) {
2558 $item['setup_url'] = $plugin_data['setup'];
2559 }
2560
2561 return $item;
2562 }
2563
2564 /**
2565 * Format addon item for display.
2566 *
2567 * @since 1.8.8
2568 * @param array $addon_data Addon data.
2569 * @return array
2570 */
2571 private function format_addon_item( $addon_data ) {
2572 $button_state = $this->get_button_state( $addon_data, 'charitable_addon' );
2573
2574 $item = array(
2575 'title' => $addon_data['title'],
2576 'description' => isset( $addon_data['description'] ) ? $addon_data['description'] : $this->get_charitable_addon_description( $addon_data['slug'] ),
2577 'button_text' => $button_state['text'],
2578 'button_class' => $button_state['class'],
2579 'button_action' => $button_state['action'],
2580 'icon' => $addon_data['icon'],
2581 'slug' => $addon_data['slug'],
2582 'type' => 'charitable_addon'
2583 );
2584
2585 // Add upgrade URL for upgrade buttons
2586 if ( $button_state['action'] === 'upgrade' && isset( $button_state['upgrade_url'] ) ) {
2587 $item['upgrade_url'] = $button_state['upgrade_url'];
2588 }
2589
2590 // Add disabled state for installed buttons
2591 if ( isset( $button_state['disabled'] ) && $button_state['disabled'] ) {
2592 $item['button_disabled'] = true;
2593 }
2594
2595 return $item;
2596 }
2597
2598 /**
2599 * Install a Charitable addon from download URL.
2600 *
2601 * @param string $download_url The download URL for the addon.
2602 * @param string $slug The addon slug.
2603 * @return bool|WP_Error True on success, WP_Error on failure.
2604 */
2605 private function install_charitable_addon_from_url( $download_url, $slug ) {
2606 // Include WordPress filesystem API
2607 if ( ! function_exists( 'download_url' ) ) {
2608 require_once ABSPATH . 'wp-admin/includes/file.php';
2609 }
2610 if ( ! function_exists( 'unzip_file' ) ) {
2611 require_once ABSPATH . 'wp-admin/includes/file.php';
2612 }
2613
2614 // Download the addon zip file
2615 $temp_file = download_url( $download_url );
2616
2617 if ( is_wp_error( $temp_file ) ) {
2618 return new WP_Error( 'download_failed', 'Failed to download addon: ' . $temp_file->get_error_message() );
2619 }
2620
2621 // Get the WordPress filesystem
2622 $wp_filesystem = $this->get_wp_filesystem();
2623 if ( ! $wp_filesystem ) {
2624 unlink( $temp_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
2625 return new WP_Error( 'filesystem_error', 'Unable to access WordPress filesystem.' );
2626 }
2627
2628 // Create the plugin directory
2629 $plugin_dir = WP_PLUGIN_DIR . '/' . $slug;
2630 if ( ! $wp_filesystem->is_dir( $plugin_dir ) ) {
2631 $wp_filesystem->mkdir( $plugin_dir );
2632 }
2633
2634 // Unzip the file
2635 $result = unzip_file( $temp_file, WP_PLUGIN_DIR );
2636
2637 // Clean up temp file
2638 unlink( $temp_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
2639
2640 if ( is_wp_error( $result ) ) {
2641 return new WP_Error( 'unzip_failed', 'Failed to extract addon: ' . $result->get_error_message() );
2642 }
2643
2644 return true;
2645 }
2646
2647 /**
2648 * Get WordPress filesystem instance.
2649 *
2650 * @return WP_Filesystem_Base|false
2651 */
2652 private function get_wp_filesystem() {
2653 global $wp_filesystem;
2654
2655 if ( empty( $wp_filesystem ) ) {
2656 require_once ABSPATH . '/wp-admin/includes/file.php';
2657 WP_Filesystem();
2658 }
2659
2660 return $wp_filesystem;
2661 }
2662
2663 /**
2664 * Process raw addon data to add status, action, and plugin_allow fields.
2665 * This mimics what get_addon() does but for raw data from get_addons().
2666 *
2667 * @version 1.8.8.4
2668 *
2669 * @param array $raw_addon The raw addon data from get_addons().
2670 * @param string $slug The addon slug.
2671 * @return array The processed addon data.
2672 */
2673 private function process_raw_addon_data( $raw_addon, $slug ) {
2674 // Check if plugin is installed
2675 $plugin_file = $slug . '/' . $slug . '.php';
2676 $is_installed = file_exists( WP_PLUGIN_DIR . '/' . $plugin_file );
2677 $is_active = $is_installed ? is_plugin_active( $plugin_file ) : false;
2678 $is_pro = charitable_is_pro();
2679 $current_plan = Charitable_Addons_Directory::get_current_plan_slug();
2680 $required_plans = isset( $raw_addon['license'] ) ? (array) $raw_addon['license'] : array();
2681 $plan_allows = empty( $required_plans ) ? false : in_array( $current_plan, $required_plans, true );
2682
2683 // Determine status and action based on installation state and license
2684 if ( $is_active ) {
2685 $status = 'active';
2686 $action = 'deactivate';
2687 $plugin_allow = true;
2688 } elseif ( $is_installed ) {
2689 $status = 'installed';
2690 $action = 'activate';
2691 $plugin_allow = true;
2692 } else {
2693 $status = 'uninstalled';
2694 // Check if user has a valid license and required plan before allowing install
2695 if ( $is_pro && $plan_allows ) {
2696 $action = 'install';
2697 $plugin_allow = true;
2698 } else {
2699 $action = 'upgrade';
2700 $plugin_allow = false;
2701 // Provide upgrade URL so the button can link to pricing
2702 $addons_directory = Charitable_Addons_Directory::get_instance();
2703 if ( method_exists( $addons_directory, 'charitable_get_upgrade_link' ) ) {
2704 $raw_addon['upgrade_url'] = $addons_directory->charitable_get_upgrade_link();
2705 }
2706 }
2707 }
2708
2709 // Add the processed fields to the raw addon data
2710 $raw_addon['status'] = $status;
2711 $raw_addon['action'] = $action;
2712 $raw_addon['plugin_allow'] = $plugin_allow;
2713 $raw_addon['path'] = $plugin_file;
2714
2715 return $raw_addon;
2716 }
2717
2718 /**
2719 * Get button state for plugin/addon.
2720 *
2721 * @since 1.8.8
2722 * @param array $item_data Item data.
2723 * @param string $type Item type.
2724 * @return array
2725 */
2726 private function get_button_state( $item_data, $type = 'third_party' ) {
2727 if ( $type === 'third_party' ) {
2728 if ( ! $item_data['installed'] ) {
2729 return array(
2730 'text' => __( 'Install & Activate', 'charitable' ),
2731 'action' => 'install',
2732 'class' => '' // Same as hardcoded
2733 );
2734 } elseif ( ! $item_data['active'] ) {
2735 return array(
2736 'text' => __( 'Activate', 'charitable' ),
2737 'action' => 'activate',
2738 'class' => 'charitable-dashboard-v2-activate-button' // Same as hardcoded
2739 );
2740 } else {
2741 return array(
2742 'text' => __( 'Setup', 'charitable' ),
2743 'action' => 'setup',
2744 'class' => 'charitable-dashboard-v2-setup-button' // New class for setup
2745 );
2746 }
2747 } else { // Charitable addon
2748 // Use the action from addons directory
2749 switch ( $item_data['action'] ) {
2750 case 'upgrade':
2751 case 'license':
2752 return array(
2753 'text' => __( 'Upgrade', 'charitable' ),
2754 'action' => 'upgrade',
2755 'class' => 'charitable-dashboard-v2-upgrade-button',
2756 'upgrade_url' => $item_data['upgrade_url']
2757 );
2758 case 'install':
2759 return array(
2760 'text' => 'Install & Activate',
2761 'action' => 'install_addon',
2762 'class' => ''
2763 );
2764 case 'activate':
2765 return array(
2766 'text' => __( 'Activate', 'charitable' ),
2767 'action' => 'activate_addon',
2768 'class' => 'charitable-dashboard-v2-activate-button'
2769 );
2770 case 'deactivate':
2771 return array(
2772 'text' => __( 'Installed', 'charitable' ),
2773 'action' => 'installed',
2774 'class' => 'charitable-dashboard-v2-installed-button',
2775 'disabled' => true
2776 );
2777 default:
2778 return array(
2779 'text' => __( 'Upgrade', 'charitable' ),
2780 'action' => 'upgrade',
2781 'class' => 'charitable-dashboard-v2-upgrade-button',
2782 'upgrade_url' => $item_data['upgrade_url']
2783 );
2784 }
2785 }
2786 }
2787
2788 /**
2789 * Get plugin icon.
2790 *
2791 * @since 1.8.8
2792 * @param array $plugin_data Plugin data.
2793 * @return string
2794 */
2795 private function get_plugin_icon( $plugin_data ) {
2796 // Step 1: Try PNG icon from plugin data (check if file exists)
2797 if ( ! empty( $plugin_data['icon'] ) ) {
2798 $icon_url = $plugin_data['icon'];
2799 if ( filter_var( $icon_url, FILTER_VALIDATE_URL ) ) {
2800 // Convert URL to server path for file existence check
2801 $icon_path = $this->url_to_server_path( $icon_url );
2802 if ( $icon_path && file_exists( $icon_path ) ) {
2803 return '<img src="' . esc_url( $icon_url ) . '" alt="' . esc_attr( $plugin_data['title'] ) . '" width="20" height="20" />';
2804 }
2805 }
2806 }
2807
2808 // Step 2: Try custom SVG icon
2809 $custom_icons = $this->get_custom_plugin_icons();
2810 if ( isset( $custom_icons[ $plugin_data['slug'] ] ) ) {
2811 return $custom_icons[ $plugin_data['slug'] ];
2812 }
2813
2814 // Step 3: Try the 'icon' from plugin data even if file doesn't exist
2815 // (might be a different format or external URL)
2816 if ( ! empty( $plugin_data['icon'] ) ) {
2817 $icon_url = $plugin_data['icon'];
2818 if ( filter_var( $icon_url, FILTER_VALIDATE_URL ) ) {
2819 // Check if the URL actually exists
2820 $response = wp_remote_head( $icon_url, array( 'timeout' => 5 ) );
2821 if ( ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) === 200 ) {
2822 return '<img src="' . esc_url( $icon_url ) . '" alt="' . esc_attr( $plugin_data['title'] ) . '" width="20" height="20" />';
2823 }
2824 }
2825 }
2826
2827 // Step 4: Default puzzle piece icon
2828 return '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
2829 <path d="M8 2a2 2 0 00-2 2v2H4a2 2 0 00-2 2v2a2 2 0 002 2h2v2a2 2 0 002 2h2a2 2 0 002-2v-2h2a2 2 0 002-2V8a2 2 0 00-2-2h-2V4a2 2 0 00-2-2H8z" fill="#9CA3AF"/>
2830 <path d="M10 6a1 1 0 100 2 1 1 0 000-2zM6 10a1 1 0 100 2 1 1 0 000-2zM14 10a1 1 0 100 2 1 1 0 000-2zM10 14a1 1 0 100 2 1 1 0 000-2z" fill="#6B7280"/>
2831 </svg>';
2832 }
2833
2834 /**
2835 * Convert URL to server file path.
2836 *
2837 * @since 1.8.8
2838 * @param string $url The URL to convert.
2839 * @return string|false The server path or false if conversion fails.
2840 */
2841 private function url_to_server_path( $url ) {
2842 // Get the site URL and uploads directory
2843 $site_url = get_site_url();
2844 $upload_dir = wp_upload_dir();
2845
2846 // Check if it's a local URL
2847 if ( strpos( $url, $site_url ) === 0 ) {
2848 // Remove site URL from the beginning
2849 $relative_path = str_replace( $site_url, '', $url );
2850 // Get the absolute path
2851 $absolute_path = ABSPATH . ltrim( $relative_path, '/' );
2852 return $absolute_path;
2853 }
2854
2855 // Check if it's a plugin URL
2856 $plugin_url = plugins_url();
2857 if ( strpos( $url, $plugin_url ) === 0 ) {
2858 // Remove plugin URL from the beginning
2859 $relative_path = str_replace( $plugin_url, '', $url );
2860 // Get the absolute path
2861 $absolute_path = WP_PLUGIN_DIR . ltrim( $relative_path, '/' );
2862 return $absolute_path;
2863 }
2864
2865 // Check if it's a theme URL
2866 $theme_url = get_template_directory_uri();
2867 if ( strpos( $url, $theme_url ) === 0 ) {
2868 // Remove theme URL from the beginning
2869 $relative_path = str_replace( $theme_url, '', $url );
2870 // Get the absolute path
2871 $absolute_path = get_template_directory() . ltrim( $relative_path, '/' );
2872 return $absolute_path;
2873 }
2874
2875 // Check if it's a content URL
2876 $content_url = content_url();
2877 if ( strpos( $url, $content_url ) === 0 ) {
2878 // Remove content URL from the beginning
2879 $relative_path = str_replace( $content_url, '', $url );
2880 // Get the absolute path
2881 $absolute_path = WP_CONTENT_DIR . ltrim( $relative_path, '/' );
2882 return $absolute_path;
2883 }
2884
2885 return false;
2886 }
2887
2888 /**
2889 * Track recommendation interaction.
2890 *
2891 * @since 1.8.8
2892 * @param string $action Action performed.
2893 * @param string $plugin_slug Plugin slug.
2894 * @param string $plugin_type Plugin type.
2895 * @param string $context Context of interaction.
2896 * @return void
2897 */
2898 private function track_recommendation_click( $action, $plugin_slug, $plugin_type, $context = 'dashboard_enhance' ) {
2899 if ( ! charitable_get_usage_tracking_setting() ) {
2900 return;
2901 }
2902
2903 $tracked_data = get_option( 'charitable_recommended_plugins_' . $action, array() );
2904 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
2905 $tracked_data[] = array(
2906 'plugin' => $plugin_slug,
2907 'type' => $plugin_type,
2908 'context' => $context,
2909 'timestamp' => time(),
2910 'user_agent' => $user_agent,
2911 'referrer' => wp_get_referer() ?: 'direct'
2912 );
2913
2914 // Keep only last 100 entries
2915 if ( count( $tracked_data ) > 100 ) {
2916 $tracked_data = array_slice( $tracked_data, -100 );
2917 }
2918
2919 update_option( 'charitable_recommended_plugins_' . $action, $tracked_data );
2920 }
2921
2922 /**
2923 * Check if enhance section should be shown.
2924 *
2925 * @since 1.8.8
2926 * @return bool
2927 */
2928 private function should_show_enhance_section() {
2929 $items = $this->get_enhance_items();
2930 return count( $items ) > 0; // Hide if no items to show
2931 }
2932
2933 /**
2934 * Render the latest updates section.
2935 *
2936 * @since 1.8.8
2937 */
2938 public function render_latest_updates_section() {
2939 // Check if there are blog posts to determine footer link text
2940 $posts = $this->get_blog_posts();
2941 $has_posts = ! empty( $posts ) && ! $this->has_blog_posts_error( $posts );
2942 $footer_link_text = $has_posts ? __( 'Read More', 'charitable' ) : __( 'Read Blog', 'charitable' );
2943
2944 // Check if user is more than 3 updates behind
2945 $updates_behind = $this->get_updates_behind();
2946 $show_version_update = $updates_behind > 3;
2947 $section_class = 'charitable-dashboard-v2-section';
2948 if ( ! $show_version_update ) {
2949 $section_class .= ' no-update-alert';
2950 }
2951 ?>
2952 <section id="charitable-dashboard-v2-latest-updates" class="<?php echo esc_attr( $section_class ); ?>">
2953 <header class="charitable-dashboard-v2-section-header">
2954 <h3>Latest Updates</h3>
2955 </header>
2956 <div class="charitable-dashboard-v2-section-content">
2957 <?php if ( $show_version_update ) : ?>
2958 <div class="charitable-dashboard-v2-latest-updates-background">
2959 <div class="charitable-dashboard-v2-latest-updates-background-content">
2960 <div class="charitable-dashboard-v2-latest-updates-flex">
2961 <div class="charitable-dashboard-v2-latest-updates-icon">
2962 <svg class="charitable-dashboard-v2-latest-updates-icon-placeholder" width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
2963 <circle cx="13" cy="13" r="13" fill="#eaa251"/>
2964 <path d="M13 6.5L19.5 17.5H6.5L13 6.5Z" fill="white"/>
2965 <path d="M13 10.5V14.5" stroke="#eaa251" stroke-width="2" stroke-linecap="round"/>
2966 <circle cx="13" cy="17" r="1" fill="#eaa251"/>
2967 </svg>
2968 </div>
2969 <div class="charitable-dashboard-v2-latest-updates-content">
2970 <h4 class="charitable-dashboard-v2-latest-updates-headline">You are currently <?php echo esc_html( $updates_behind ); ?> updates behind. Please upgrade Charitable to take advantage of:</h4>
2971 <div class="charitable-dashboard-v2-latest-updates-features">
2972 <?php $this->render_update_features(); ?>
2973 </div>
2974 </div>
2975 </div>
2976 </div>
2977 </div>
2978 <?php endif; ?>
2979 <?php $this->render_blog_posts(); ?>
2980 <footer class="charitable-dashboard-v2-latest-updates-footer">
2981 <div class="charitable-dashboard-v2-latest-updates-footer-content">
2982 <div class="charitable-dashboard-v2-latest-updates-footer-left">
2983 <a href="https://wpcharitable.com/blog" target="_blank" rel="noopener noreferrer" class="charitable-dashboard-v2-blog-link">
2984 <span class="charitable-dashboard-v2-blog-text"><?php echo esc_html( $footer_link_text ); ?></span>
2985 <svg class="charitable-dashboard-v2-arrow-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
2986 <path d="M6 12L10 8L6 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
2987 </svg>
2988 </a>
2989 </div>
2990 </div>
2991 </footer>
2992 </div>
2993 </section>
2994 <?php
2995 }
2996
2997 /**
2998 * Render update features.
2999 *
3000 * @since 1.8.8
3001 */
3002 private function render_update_features() {
3003 $features = array(
3004 __( 'Enhancement Payments', 'charitable' ),
3005 __( 'Reply-To Donors', 'charitable' ),
3006 __( 'Square Gateway', 'charitable' ),
3007 __( 'DonorTrust', 'charitable' )
3008 );
3009
3010 foreach ( $features as $feature ) :
3011 ?>
3012 <span class="charitable-dashboard-v2-latest-updates-feature">
3013 <svg class="charitable-dashboard-v2-latest-updates-checkmark" width="9" height="7" viewBox="0 0 10 8" fill="none" xmlns="http://www.w3.org/2000/svg">
3014 <path fill-rule="evenodd" clip-rule="evenodd" d="M9.03612 1.89488C9.03612 1.74898 8.97797 1.60391 8.87287 1.49973L8.08169 0.708539C7.9775 0.603482 7.8316 0.545288 7.68657 0.545288C7.54067 0.545288 7.39564 0.603482 7.29054 0.708539L3.47366 4.52891L1.76365 2.81371C1.65855 2.70861 1.51352 2.65041 1.36762 2.65041C1.22259 2.65041 1.07669 2.70861 0.972505 2.81371L0.181318 3.60485C0.0762192 3.70908 0.0180664 3.85498 0.0180664 4.00001C0.0180664 4.14504 0.0762192 4.29094 0.181318 4.39517L3.0785 7.29148C3.18273 7.39658 3.32863 7.45473 3.47366 7.45473C3.61956 7.45473 3.76459 7.39658 3.86969 7.29148L8.87287 2.29C8.97797 2.18581 9.03612 2.03991 9.03612 1.89488Z" fill="#59A56D"/>
3015 </svg>
3016 <?php echo esc_html( $feature ); ?>
3017 </span>
3018 <?php
3019 endforeach;
3020 }
3021
3022 /**
3023 * Render blog posts.
3024 *
3025 * @since 1.8.8
3026 */
3027 private function render_blog_posts() {
3028 $posts = $this->get_blog_posts();
3029
3030 // phpcs:disable WordPress.Security.NonceVerification.Recommended
3031 $is_simulating = isset( $_GET['charitable_simulate_api_failure'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['charitable_simulate_api_failure'] ) );
3032 $clear_cache = isset( $_GET['charitable_clear_cache'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['charitable_clear_cache'] ) );
3033 // phpcs:enable WordPress.Security.NonceVerification.Recommended
3034
3035
3036 ?>
3037 <div class="charitable-dashboard-v2-blog-posts" id="charitable-blog-posts-container">
3038
3039 <?php if ( $is_simulating || $clear_cache || ! $posts ) : ?>
3040 <div class="charitable-dashboard-v2-blog-post-row error-display">
3041 <h5 class="charitable-dashboard-v2-blog-post-title">Unable to load blog posts. Please check back later.</h5>
3042 </div>
3043 <?php else : ?>
3044 <?php foreach ( $posts as $post ) : ?>
3045 <div class="charitable-dashboard-v2-blog-post-row<?php echo ! empty( $post['featured'] ) ? ' featured' : ''; ?><?php echo ! empty( $post['error'] ) ? ' error' : ''; ?>">
3046 <div class="charitable-dashboard-v2-blog-post-left">
3047 <?php if ( ! empty( $post['timestamp'] ) ) : ?>
3048 <div class="charitable-dashboard-v2-blog-post-timestamp"><?php echo esc_html( $post['timestamp'] ); ?></div>
3049 <?php endif; ?>
3050 <h5 class="charitable-dashboard-v2-blog-post-title">
3051 <?php if ( ! empty( $post['error'] ) ) : ?>
3052 <span class="charitable-dashboard-v2-blog-post-error"><?php echo esc_html( $post['title'] ); ?></span>
3053 <?php else : ?>
3054 <a href="<?php echo esc_url( $post['url'] ); ?>" target="_blank" rel="noopener noreferrer"><?php echo esc_html( $post['title'] ); ?></a>
3055 <?php endif; ?>
3056 </h5>
3057 </div>
3058 <div class="charitable-dashboard-v2-blog-post-right">
3059 <?php if ( ! empty( $post['error'] ) ) : ?>
3060 <div class="charitable-dashboard-v2-blog-post-error-icon">
3061 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
3062 <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill="#f56565"/>
3063 </svg>
3064 </div>
3065 <?php else : ?>
3066 <?php if ( ! empty( $post['image'] ) && is_array( $post['image'] ) ) : ?>
3067 <?php if ( ! empty( $post['image']['img'] ) ) : ?>
3068 <!-- Actual image -->
3069 <img src="<?php echo esc_url( $post['image']['img'] ); ?>" alt="<?php echo esc_attr( $post['title'] ); ?>" class="charitable-dashboard-v2-blog-post-image" width="106" height="56" style="object-fit: cover; border-radius: 4px;" />
3070 <?php elseif ( ! empty( $post['image']['svg'] ) ) : ?>
3071 <!-- Custom SVG placeholder -->
3072 <?php echo wp_kses_post( $post['image']['svg'] ); ?>
3073 <?php else : ?>
3074 <!-- Default SVG placeholder -->
3075 <svg class="charitable-dashboard-v2-blog-post-image-placeholder" width="106" height="56" viewBox="0 0 106 56" fill="none" xmlns="http://www.w3.org/2000/svg">
3076 <rect width="106" height="56" rx="4" fill="#E5E7EB"/>
3077 <path d="M20 40L35 25L50 35L65 20L80 30L95 15" stroke="#9CA3AF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
3078 <circle cx="35" cy="25" r="3" fill="#9CA3AF"/>
3079 <circle cx="50" cy="35" r="3" fill="#9CA3AF"/>
3080 <circle cx="65" cy="20" r="3" fill="#9CA3AF"/>
3081 <circle cx="80" cy="30" r="3" fill="#9CA3AF"/>
3082 <circle cx="95" cy="15" r="3" fill="#9CA3AF"/>
3083 </svg>
3084 <?php endif; ?>
3085 <?php else : ?>
3086 <!-- Fallback default SVG placeholder -->
3087 <svg class="charitable-dashboard-v2-blog-post-image-placeholder" width="106" height="56" viewBox="0 0 106 56" fill="none" xmlns="http://www.w3.org/2000/svg">
3088 <rect width="106" height="56" rx="4" fill="#E5E7EB"/>
3089 <path d="M20 40L35 25L50 35L65 20L80 30L95 15" stroke="#9CA3AF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
3090 <circle cx="35" cy="25" r="3" fill="#9CA3AF"/>
3091 <circle cx="50" cy="35" r="3" fill="#9CA3AF"/>
3092 <circle cx="65" cy="20" r="3" fill="#9CA3AF"/>
3093 <circle cx="80" cy="30" r="3" fill="#9CA3AF"/>
3094 <circle cx="95" cy="15" r="3" fill="#9CA3AF"/>
3095 </svg>
3096 <?php endif; ?>
3097 <?php endif; ?>
3098 </div>
3099 </div>
3100 <?php endforeach; ?>
3101 <?php endif; ?>
3102 </div>
3103
3104 <?php
3105
3106
3107 }
3108
3109 /**
3110 * Fetch blog posts from feed.
3111 *
3112 * @since 1.8.8
3113 *
3114 * @return array|false Returns array on success, false on failure
3115 */
3116 private function fetch_blog_posts_feed() {
3117
3118 // Allow simulating API failure for testing
3119 // phpcs:disable WordPress.Security.NonceVerification.Recommended
3120 if ( isset( $_GET['charitable_simulate_api_failure'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['charitable_simulate_api_failure'] ) ) ) {
3121 // phpcs:enable WordPress.Security.NonceVerification.Recommended
3122 return false;
3123 }
3124
3125 $args = [
3126 'timeout' => 10,
3127 'sslverify' => false,
3128 'user-agent' => charitable_get_default_user_agent(),
3129 ];
3130
3131 $response = wp_remote_get(
3132 self::BLOG_POSTS_SOURCE_URL,
3133 $args
3134 );
3135
3136 if ( is_wp_error( $response ) ) {
3137 return false;
3138 }
3139
3140 $body = wp_remote_retrieve_body( $response );
3141
3142 if ( empty( $body ) ) {
3143 return false;
3144 }
3145
3146 $decoded = json_decode( $body, true );
3147 if ( json_last_error() !== JSON_ERROR_NONE ) {
3148 return false;
3149 }
3150
3151
3152 return $this->verify_blog_posts( $decoded );
3153 }
3154
3155 /**
3156 * Calculate relative time from a date string.
3157 *
3158 * @since 1.8.8.4
3159 *
3160 * @param string $date_string Date string in format 'Y-m-d H:i:s'.
3161 *
3162 * @return string Relative time string (e.g., '2 days ago', '1 week ago').
3163 */
3164 private function calculate_relative_time( $date_string ) {
3165 if ( empty( $date_string ) ) {
3166 return '';
3167 }
3168
3169 $post_date = strtotime( $date_string );
3170 $current_time = current_time( 'timestamp' );
3171 $time_diff = $current_time - $post_date;
3172
3173 // If the date is in the future, return empty string
3174 if ( $time_diff < 0 ) {
3175 return '';
3176 }
3177
3178 // Calculate relative time
3179 $minutes = floor( $time_diff / 60 );
3180 $hours = floor( $time_diff / 3600 );
3181 $days = floor( $time_diff / 86400 );
3182 $weeks = floor( $time_diff / 604800 );
3183 $months = floor( $time_diff / 2592000 );
3184 $years = floor( $time_diff / 31536000 );
3185
3186 if ( $years > 0 ) {
3187 /* translators: %s: number of years */
3188 return $years === 1 ? _x( '1 year ago', 'relative time', 'charitable' ) : sprintf( _nx( '%s year ago', '%s years ago', $years, 'relative time', 'charitable' ), number_format_i18n( $years ) );
3189 } elseif ( $months > 0 ) {
3190 /* translators: %s: number of months */
3191 return $months === 1 ? _x( '1 month ago', 'relative time', 'charitable' ) : sprintf( _nx( '%s month ago', '%s months ago', $months, 'relative time', 'charitable' ), number_format_i18n( $months ) );
3192 } elseif ( $weeks > 0 ) {
3193 /* translators: %s: number of weeks */
3194 return $weeks === 1 ? _x( '1 week ago', 'relative time', 'charitable' ) : sprintf( _nx( '%s week ago', '%s weeks ago', $weeks, 'relative time', 'charitable' ), number_format_i18n( $weeks ) );
3195 } elseif ( $days > 0 ) {
3196 /* translators: %s: number of days */
3197 return $days === 1 ? _x( '1 day ago', 'relative time', 'charitable' ) : sprintf( _nx( '%s day ago', '%s days ago', $days, 'relative time', 'charitable' ), number_format_i18n( $days ) );
3198 } elseif ( $hours > 0 ) {
3199 /* translators: %s: number of hours */
3200 return $hours === 1 ? _x( '1 hour ago', 'relative time', 'charitable' ) : sprintf( _nx( '%s hour ago', '%s hours ago', $hours, 'relative time', 'charitable' ), number_format_i18n( $hours ) );
3201 } elseif ( $minutes > 0 ) {
3202 /* translators: %s: number of minutes */
3203 return $minutes === 1 ? _x( '1 minute ago', 'relative time', 'charitable' ) : sprintf( _nx( '%s minute ago', '%s minutes ago', $minutes, 'relative time', 'charitable' ), number_format_i18n( $minutes ) );
3204 } else {
3205 return _x( 'Just now', 'relative time', 'charitable' );
3206 }
3207 }
3208
3209 /**
3210 * Verify blog posts data before it is saved.
3211 *
3212 * @since 1.8.8
3213 *
3214 * @param array $blog_posts Array of blog posts items to verify.
3215 *
3216 * @return array
3217 */
3218 private function verify_blog_posts( $blog_posts ) {
3219
3220 $data = [];
3221
3222 if ( ! is_array( $blog_posts ) || empty( $blog_posts ) ) {
3223 return $data;
3224 }
3225
3226 // Check if we have the expected structure
3227 if ( ! isset( $blog_posts['blog_posts'] ) || ! is_array( $blog_posts['blog_posts'] ) ) {
3228 return $data;
3229 }
3230
3231 // Store version information if available
3232 if ( isset( $blog_posts['meta'] ) && is_array( $blog_posts['meta'] ) ) {
3233 $this->version_info = array(
3234 'current_charitable_version_lite' => ! empty( $blog_posts['meta']['current_charitable_version_lite'] ) ? sanitize_text_field( $blog_posts['meta']['current_charitable_version_lite'] ) : '',
3235 'current_charitable_version_pro' => ! empty( $blog_posts['meta']['current_charitable_version_pro'] ) ? sanitize_text_field( $blog_posts['meta']['current_charitable_version_pro'] ) : '',
3236 );
3237 }
3238
3239 foreach ( $blog_posts['blog_posts'] as $post ) {
3240
3241 // Verify required fields
3242 if ( empty( $post['title'] ) || empty( $post['url'] ) ) {
3243 continue;
3244 }
3245
3246 // Handle image data
3247 $image_data = array(
3248 'placeholder' => true,
3249 'svg' => ''
3250 );
3251
3252 if ( ! empty( $post['image'] ) && is_array( $post['image'] ) ) {
3253 // Handle placeholder as both boolean and string
3254 $placeholder_value = $post['image']['placeholder'] ?? true;
3255 if ( is_string( $placeholder_value ) ) {
3256 // Handle string values: 'false', '0', 'no' should be false
3257 $placeholder_value = in_array( strtolower( $placeholder_value ), ['false', '0', 'no', ''], true ) ? false : true;
3258 }
3259 $image_data['placeholder'] = (bool) $placeholder_value;
3260
3261 if ( ! empty( $post['image']['img'] ) ) {
3262 $image_data['img'] = esc_url_raw( $post['image']['img'] );
3263 }
3264
3265 if ( ! empty( $post['image']['svg'] ) ) {
3266 $image_data['svg'] = $post['image']['svg']; // SVG is already sanitized HTML
3267 }
3268 }
3269
3270 // Calculate timestamp from date field if available, otherwise use provided timestamp
3271 $calculated_timestamp = '';
3272 if ( ! empty( $post['date'] ) ) {
3273 $calculated_timestamp = $this->calculate_relative_time( $post['date'] );
3274 } elseif ( ! empty( $post['timestamp'] ) ) {
3275 $calculated_timestamp = sanitize_text_field( $post['timestamp'] );
3276 }
3277
3278 $data[] = array(
3279 'title' => sanitize_text_field( $post['title'] ),
3280 'url' => esc_url_raw( $post['url'] ),
3281 'timestamp' => $calculated_timestamp,
3282 'featured' => ! empty( $post['featured'] ) ? (bool) $post['featured'] : false,
3283 'image' => $image_data,
3284 );
3285
3286 }
3287
3288 return $data;
3289 }
3290
3291 /**
3292 * Get blog posts from transient cache.
3293 *
3294 * @since 1.8.8
3295 *
3296 * @return array
3297 */
3298 private function get_blog_posts_from_cache() {
3299
3300 $cached_data = get_transient( 'charitable_blog_posts_cache' );
3301
3302 if ( false === $cached_data ) {
3303 return [];
3304 }
3305
3306 return (array) $cached_data;
3307 }
3308
3309 /**
3310 * Cache blog posts data using transients.
3311 *
3312 * @since 1.8.8
3313 *
3314 * @param array $blog_posts Blog posts data to cache.
3315 */
3316 private function cache_blog_posts( $blog_posts ) {
3317
3318 $data = [
3319 'update' => time(),
3320 'blog_posts' => $blog_posts,
3321 'version_info' => $this->version_info,
3322 ];
3323
3324 // Cache for 24 hours
3325 set_transient( 'charitable_blog_posts_cache', $data, DAY_IN_SECONDS );
3326 }
3327
3328 /**
3329 * Get blog posts data.
3330 *
3331 * @since 1.8.8
3332 * @return array
3333 */
3334 private function get_blog_posts() {
3335
3336 // phpcs:disable WordPress.Security.NonceVerification.Recommended
3337 $is_simulating = isset( $_GET['charitable_simulate_api_failure'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['charitable_simulate_api_failure'] ) );
3338 $clear_cache = isset( $_GET['charitable_clear_cache'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['charitable_clear_cache'] ) );
3339 // phpcs:enable WordPress.Security.NonceVerification.Recommended
3340
3341 // If simulating API failure, always return error
3342 if ( $is_simulating ) {
3343 return $this->get_blog_posts_error();
3344 }
3345
3346 // Clear cache if requested
3347 if ( $clear_cache ) {
3348 $this->clear_blog_posts_cache();
3349 }
3350
3351 // Try to get from cache first
3352 $cached_data = $this->get_blog_posts_from_cache();
3353 if ( ! empty( $cached_data ) && ! $clear_cache ) {
3354 // Restore version info from cache
3355 if ( isset( $cached_data['version_info'] ) ) {
3356 $this->version_info = $cached_data['version_info'];
3357 }
3358 return $cached_data['blog_posts'] ?? [];
3359 }
3360
3361 // Fetch fresh data from API
3362 $blog_posts = $this->fetch_blog_posts_feed();
3363
3364 // If API fetch was successful, cache and return the data
3365 if ( false !== $blog_posts && ! empty( $blog_posts ) ) {
3366 $this->cache_blog_posts( $blog_posts );
3367 return $blog_posts;
3368 } else {
3369 // API failed, show error
3370 return $this->get_blog_posts_error();
3371 }
3372 }
3373
3374 /**
3375 * Clear blog posts cache (for testing purposes).
3376 *
3377 * @since 1.8.8
3378 */
3379 private function clear_blog_posts_cache() {
3380 delete_transient( 'charitable_blog_posts_cache' );
3381 }
3382
3383 /**
3384 * Get blog posts error data when API fails and no cache is available.
3385 *
3386 * @since 1.8.8
3387 * @return array
3388 */
3389 private function get_blog_posts_error() {
3390 // phpcs:disable WordPress.Security.NonceVerification.Recommended
3391 $is_simulating = isset( $_GET['charitable_simulate_api_failure'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['charitable_simulate_api_failure'] ) );
3392 // phpcs:enable WordPress.Security.NonceVerification.Recommended
3393 $error_title = $is_simulating ? __( 'Unable to load blog posts (Simulated API Failure)', 'charitable' ) : __( 'Unable to load blog posts', 'charitable' );
3394
3395 return array(
3396 array(
3397 'title' => $error_title,
3398 'url' => '#',
3399 'timestamp' => '',
3400 'featured' => false,
3401 'error' => true
3402 )
3403 );
3404 }
3405
3406 /**
3407 * Check if blog posts contain an error.
3408 *
3409 * @since 1.8.8
3410 * @param array $posts Blog posts array.
3411 * @return bool True if posts contain an error.
3412 */
3413 private function has_blog_posts_error( $posts ) {
3414 if ( empty( $posts ) ) {
3415 return true;
3416 }
3417
3418 foreach ( $posts as $post ) {
3419 if ( ! empty( $post['error'] ) ) {
3420 return true;
3421 }
3422 }
3423
3424 return false;
3425 }
3426
3427 /**
3428 * Calculate how many updates behind the user is.
3429 *
3430 * @since 1.8.8
3431 * @return int Number of updates behind, or 0 if up to date or unable to determine.
3432 */
3433 private function get_updates_behind() {
3434 // Get current installed version
3435 $current_version = charitable()->get_version();
3436
3437 // Get latest version from API
3438 $latest_version = $this->get_latest_version();
3439
3440 if ( empty( $latest_version ) ) {
3441 return 0;
3442 }
3443
3444 return $this->compare_versions( $current_version, $latest_version );
3445 }
3446
3447 /**
3448 * Get the latest version from the API data.
3449 *
3450 * @since 1.8.8
3451 * @return string Latest version or empty string if not available.
3452 */
3453 private function get_latest_version() {
3454 if ( null === $this->version_info ) {
3455 // Try to get version info from cached data
3456 $cached_data = get_transient( 'charitable_blog_posts_cache' );
3457 if ( false !== $cached_data && isset( $cached_data['version_info'] ) ) {
3458 $this->version_info = $cached_data['version_info'];
3459 }
3460 }
3461
3462 if ( null === $this->version_info ) {
3463 return '';
3464 }
3465
3466 // For now, we're working with Lite version
3467 return ! empty( $this->version_info['current_charitable_version_lite'] ) ? $this->version_info['current_charitable_version_lite'] : '';
3468 }
3469
3470 /**
3471 * Compare two version strings and return how many updates behind.
3472 *
3473 * @since 1.8.8
3474 * @param string $current Current version (e.g., "1.8.4").
3475 * @param string $latest Latest version (e.g., "1.8.8").
3476 * @return int Number of updates behind.
3477 */
3478 private function compare_versions( $current, $latest ) {
3479 // Normalize versions by removing any non-numeric characters except dots
3480 $current = preg_replace( '/[^0-9.]/', '', $current );
3481 $latest = preg_replace( '/[^0-9.]/', '', $latest );
3482
3483 // Split into parts
3484 $current_parts = array_map( 'intval', explode( '.', $current ) );
3485 $latest_parts = array_map( 'intval', explode( '.', $latest ) );
3486
3487 // Ensure both arrays have the same length by padding with zeros
3488 $max_length = max( count( $current_parts ), count( $latest_parts ) );
3489 $current_parts = array_pad( $current_parts, $max_length, 0 );
3490 $latest_parts = array_pad( $latest_parts, $max_length, 0 );
3491
3492 // Compare each part
3493 for ( $i = 0; $i < $max_length; $i++ ) {
3494 if ( $latest_parts[ $i ] > $current_parts[ $i ] ) {
3495 // Calculate the difference
3496 $updates_behind = 0;
3497 for ( $j = $i; $j < $max_length; $j++ ) {
3498 $updates_behind += $latest_parts[ $j ] - $current_parts[ $j ];
3499 }
3500 return $updates_behind;
3501 } elseif ( $latest_parts[ $i ] < $current_parts[ $i ] ) {
3502 // Current version is newer than latest (shouldn't happen)
3503 return 0;
3504 }
3505 }
3506
3507 // Versions are equal
3508 return 0;
3509 }
3510
3511 /**
3512 * Test version comparison logic (for debugging).
3513 *
3514 * @since 1.8.8
3515 * @return void
3516 */
3517 public function test_version_comparison() {
3518 // Test cases
3519 $test_cases = array(
3520 array( '1.8.4', '1.8.8', 4 ),
3521 array( '1.8.4.2', '1.8.8', 4 ),
3522 array( '1.8.7.5', '1.8.8', 1 ),
3523 array( '1.8.8', '1.8.8', 0 ),
3524 array( '1.8.9', '1.8.8', 0 ),
3525 array( '1.7.5', '1.8.2', 5 ),
3526 );
3527
3528 foreach ( $test_cases as $test ) {
3529 $result = $this->compare_versions( $test[0], $test[1] );
3530 $status = ( $result === $test[2] ) ? 'PASS' : 'FAIL';
3531 }
3532 }
3533
3534 /**
3535 * Render the quick access section.
3536 *
3537 * @since 1.8.8
3538 * @version 1.8.8.6
3539 */
3540 public function render_quick_access_section() {
3541 ?>
3542 <section id="charitable-dashboard-v2-quick-access" class="charitable-dashboard-v2-section charitable-dashboard-v2-collapsible">
3543 <header class="charitable-dashboard-v2-section-header">
3544 <h3>Quick Access</h3>
3545 <a href="#" class="charitable-dashboard-v2-toggle">
3546 <i class="fa fa-angle-down charitable-dashboard-v2-angle-down"></i>
3547 </a>
3548 </header>
3549 <div class="charitable-dashboard-v2-section-content">
3550 <div class="charitable-dashboard-v2-quick-access-content">
3551 <div class="charitable-dashboard-v2-quick-access-items">
3552 <?php $this->render_quick_access_items(); ?>
3553 </div>
3554 </div>
3555 <footer class="charitable-dashboard-v2-quick-access-footer">
3556 <div class="charitable-dashboard-v2-quick-access-footer-content">
3557 <div class="charitable-dashboard-v2-quick-access-footer-left">
3558 <a href="https://wpcharitable.com/charitable-admin-review/" class="charitable-dashboard-v2-rate-link" target="_blank" rel="noopener noreferrer">
3559 <span class="charitable-dashboard-v2-rate-text">Rate us 5 stars</span>
3560 <svg class="charitable-dashboard-v2-arrow-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
3561 <path d="M6 12L10 8L6 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
3562 </svg>
3563 </a>
3564 </div>
3565 <div class="charitable-dashboard-v2-quick-access-footer-right">
3566 <div class="charitable-dashboard-v2-stars">
3567 <span class="charitable-dashboard-v2-star">�
3568 </span>
3569 <span class="charitable-dashboard-v2-star">�
3570 </span>
3571 <span class="charitable-dashboard-v2-star">�
3572 </span>
3573 <span class="charitable-dashboard-v2-star">�
3574 </span>
3575 <span class="charitable-dashboard-v2-star">�
3576 </span>
3577 </div>
3578 </div>
3579 </div>
3580 </footer>
3581 </div>
3582 </section>
3583 <?php
3584 }
3585
3586 /**
3587 * Render quick access items.
3588 *
3589 * @since 1.8.8
3590 */
3591 private function render_quick_access_items() {
3592 $items = $this->get_quick_access_items();
3593
3594 foreach ( $items as $item ) :
3595 ?>
3596 <div class="charitable-dashboard-v2-quick-access-item">
3597 <div class="charitable-dashboard-v2-quick-access-icon">
3598 <?php
3599 // Only allow specific SVG-related tags and attributes for safety.
3600 echo wp_kses(
3601 $item['icon'],
3602 array(
3603 'svg' => array(
3604 'xmlns' => true,
3605 'width' => true,
3606 'height' => true,
3607 'viewbox' => true,
3608 'fill' => true,
3609 'class' => true,
3610 'aria-hidden' => true,
3611 'role' => true,
3612 ),
3613 'path' => array(
3614 'd' => true,
3615 'fill' => true,
3616 'stroke' => true,
3617 'stroke-width' => true,
3618 'stroke-linecap' => true,
3619 'stroke-linejoin'=> true,
3620 ),
3621 'rect' => array(
3622 'x' => true,
3623 'y' => true,
3624 'width' => true,
3625 'height' => true,
3626 'fill' => true,
3627 ),
3628 'mask' => array(
3629 'id' => true,
3630 'style' => true,
3631 'maskunits' => true,
3632 'x' => true,
3633 'y' => true,
3634 'width' => true,
3635 'height' => true,
3636 ),
3637 'g' => array(
3638 'mask' => true,
3639 ),
3640 )
3641 );
3642 ?>
3643 </div>
3644 <div class="charitable-dashboard-v2-quick-access-title">
3645 <a href="<?php echo esc_url( $item['url'] ); ?>" target="_blank"><?php echo esc_html( $item['title'] ); ?></a>
3646 </div>
3647 </div>
3648 <?php
3649 endforeach;
3650 }
3651
3652 /**
3653 * Get quick access items data.
3654 *
3655 * @since 1.8.8
3656 * @return array
3657 */
3658 private function get_quick_access_items() {
3659 return array(
3660 array(
3661 'title' => __( 'Getting Started', 'charitable' ),
3662 'url' => 'https://wpcharitable.com/start-here/?referrer=chariable-dashboard',
3663 'icon' => '<svg width="22" height="20" viewBox="0 0 22 20" fill="none" xmlns="http://www.w3.org/2000/svg">
3664 <path d="M11 20C10.2 19.3667 9.33333 18.875 8.4 18.525C7.46667 18.175 6.5 18 5.5 18C4.8 18 4.11267 18.0917 3.438 18.275C2.76267 18.4583 2.11667 18.7167 1.5 19.05C1.15 19.2333 0.812667 19.225 0.488 19.025C0.162667 18.825 0 18.5333 0 18.15V6.1C0 5.91667 0.046 5.74167 0.138 5.575C0.229333 5.40833 0.366667 5.28333 0.55 5.2C1.31667 4.8 2.11667 4.5 2.95 4.3C3.78333 4.1 4.63333 4 5.5 4C6.46667 4 7.41267 4.125 8.338 4.375C9.26267 4.625 10.15 5 11 5.5V17.6C11.85 17.0667 12.7417 16.6667 13.675 16.4C14.6083 16.1333 15.55 16 16.5 16C17.1 16 17.6877 16.05 18.263 16.15C18.8377 16.25 19.4167 16.4 20 16.6V4.6C20.25 4.68333 20.496 4.77067 20.738 4.862C20.9793 4.954 21.2167 5.06667 21.45 5.2C21.6333 5.28333 21.771 5.40833 21.863 5.575C21.9543 5.74167 22 5.91667 22 6.1V18.15C22 18.5333 21.8377 18.825 21.513 19.025C21.1877 19.225 20.85 19.2333 20.5 19.05C19.8833 18.7167 19.2373 18.4583 18.562 18.275C17.8873 18.0917 17.2 18 16.5 18C15.5 18 14.5333 18.175 13.6 18.525C12.6667 18.875 11.8 19.3667 11 20ZM13 15V5.5L18 0.5V10.5L13 15ZM9 16.625V6.725C8.45 6.49167 7.87933 6.31267 7.288 6.188C6.696 6.06267 6.1 6 5.5 6C4.88333 6 4.28333 6.05833 3.7 6.175C3.11667 6.29167 2.55 6.46667 2 6.7V16.625C2.58333 16.4083 3.16267 16.25 3.738 16.15C4.31267 16.05 4.9 16 5.5 16C6.1 16 6.68733 16.05 7.262 16.15C7.83733 16.25 8.41667 16.4083 9 16.625Z" fill="#5AA152"/>
3665 </svg>'
3666 ),
3667 array(
3668 'title' => __( 'Support Ticket', 'charitable' ),
3669 'url' => 'https://wpcharitable.com/support?referrer=chariable-dashboard',
3670 'icon' => '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
3671 <path d="M6 9C6.28333 9 6.521 8.904 6.713 8.712C6.90433 8.52067 7 8.28333 7 8C7 7.71667 6.90433 7.479 6.713 7.287C6.521 7.09567 6.28333 7 6 7C5.71667 7 5.479 7.09567 5.287 7.287C5.09567 7.479 5 7.71667 5 8C5 8.28333 5.09567 8.52067 5.287 8.712C5.479 8.904 5.71667 9 6 9ZM10 9C10.2833 9 10.521 8.904 10.713 8.712C10.9043 8.52067 11 8.28333 11 8C11 7.71667 10.9043 7.479 10.713 7.287C10.521 7.09567 10.2833 7 10 7C9.71667 7 9.47933 7.09567 9.288 7.287C9.096 7.479 9 7.71667 9 8C9 8.28333 9.096 8.52067 9.288 8.712C9.47933 8.904 9.71667 9 10 9ZM14 9C14.2833 9 14.5207 8.904 14.712 8.712C14.904 8.52067 15 8.28333 15 8C15 7.71667 14.904 7.479 14.712 7.287C14.5207 7.09567 14.2833 7 14 7C13.7167 7 13.4793 7.09567 13.288 7.287C13.096 7.479 13 7.71667 13 8C13 8.28333 13.096 8.52067 13.288 8.712C13.4793 8.904 13.7167 9 14 9ZM0 20V2C0 1.45 0.196 0.979 0.588 0.587C0.979333 0.195667 1.45 0 2 0H18C18.55 0 19.021 0.195667 19.413 0.587C19.8043 0.979 20 1.45 20 2V14C20 14.55 19.8043 15.021 19.413 15.413C19.021 15.8043 18.55 16 18 16H4L0 20ZM2 15.175L3.175 14H18V2H2V15.175Z" fill="#5AA152"/>
3672 </svg>'
3673 ),
3674 array(
3675 'title' => __( 'Documentation', 'charitable' ),
3676 'url' => 'https://wpcharitable.com/documentation?referrer=chariable-dashboard',
3677 'icon' => '<svg width="22" height="16" viewBox="0 0 22 16" fill="none" xmlns="http://www.w3.org/2000/svg">
3678 <path d="M13 5.9V4.2C13.55 3.96667 14.1127 3.79167 14.688 3.675C15.2627 3.55833 15.8667 3.5 16.5 3.5C16.9333 3.5 17.3583 3.53333 17.775 3.6C18.1917 3.66667 18.6 3.75 19 3.85V5.45C18.6 5.3 18.196 5.18767 17.788 5.113C17.3793 5.03767 16.95 5 16.5 5C15.8667 5 15.2583 5.07933 14.675 5.238C14.0917 5.396 13.5333 5.61667 13 5.9ZM13 11.4V9.7C13.55 9.46667 14.1127 9.29167 14.688 9.175C15.2627 9.05833 15.8667 9 16.5 9C16.9333 9 17.3583 9.03333 17.775 9.1C18.1917 9.16667 18.6 9.25 19 9.35V10.95C18.6 10.8 18.196 10.6877 17.788 10.613C17.3793 10.5377 16.95 10.5 16.5 10.5C15.8667 10.5 15.2583 10.575 14.675 10.725C14.0917 10.875 13.5333 11.1 13 11.4ZM13 8.65V6.95C13.55 6.71667 14.1127 6.54167 14.688 6.425C15.2627 6.30833 15.8667 6.25 16.5 6.25C16.9333 6.25 17.3583 6.28333 17.775 6.35C18.1917 6.41667 18.6 6.5 19 6.6V8.2C18.6 8.05 18.196 7.93767 17.788 7.863C17.3793 7.78767 16.95 7.75 16.5 7.75C15.8667 7.75 15.2583 7.82933 14.675 7.988C14.0917 8.146 13.5333 8.36667 13 8.65ZM5.5 12C6.28333 12 7.046 12.0873 7.788 12.262C8.52933 12.4373 9.26667 12.7 10 13.05V3.2C9.31667 2.8 8.59167 2.5 7.825 2.3C7.05833 2.1 6.28333 2 5.5 2C4.9 2 4.30433 2.05833 3.713 2.175C3.121 2.29167 2.55 2.46667 2 2.7V12.6C2.58333 12.4 3.16267 12.25 3.738 12.15C4.31267 12.05 4.9 12 5.5 12ZM12 13.05C12.7333 12.7 13.471 12.4373 14.213 12.262C14.9543 12.0873 15.7167 12 16.5 12C17.1 12 17.6877 12.05 18.263 12.15C18.8377 12.25 19.4167 12.4 20 12.6V2.7C19.45 2.46667 18.8793 2.29167 18.288 2.175C17.696 2.05833 17.1 2 16.5 2C15.7167 2 14.9417 2.1 14.175 2.3C13.4083 2.5 12.6833 2.8 12 3.2V13.05ZM11 16C10.2 15.3667 9.33333 14.875 8.4 14.525C7.46667 14.175 6.5 14 5.5 14C4.8 14 4.11267 14.0917 3.438 14.275C2.76267 14.4583 2.11667 14.7167 1.5 15.05C1.15 15.2333 0.812667 15.225 0.488 15.025C0.162667 14.825 0 14.5333 0 14.15V2.1C0 1.91667 0.046 1.74167 0.138 1.575C0.229333 1.40833 0.366667 1.28333 0.55 1.2C1.31667 0.8 2.11667 0.5 2.95 0.3C3.78333 0.1 4.63333 0 5.5 0C6.46667 0 7.41267 0.125 8.338 0.375C9.26267 0.625 10.15 1 11 1.5C11.85 1 12.7377 0.625 13.663 0.375C14.5877 0.125 15.5333 0 16.5 0C17.3667 0 18.2167 0.1 19.05 0.3C19.8833 0.5 20.6833 0.8 21.45 1.2C21.6333 1.28333 21.771 1.40833 21.863 1.575C21.9543 1.74167 22 1.91667 22 2.1V14.15C22 14.5333 21.8377 14.825 21.513 15.025C21.1877 15.225 20.85 15.2333 20.5 15.05C19.8833 14.7167 19.2373 14.4583 18.562 14.275C17.8873 14.0917 17.2 14 16.5 14C15.5 14 14.5333 14.175 13.6 14.525C12.6667 14.875 11.8 15.3667 11 16Z" fill="#5AA152"/>
3679 </svg>'
3680 ),
3681 array(
3682 'title' => __( 'Upgrade To Pro', 'charitable' ),
3683 'url' => 'https://wpcharitable.com/lite-vs-pro?referrer=chariable-dashboard',
3684 'icon' => '<svg width="16" height="21" viewBox="0 0 16 21" fill="none" xmlns="http://www.w3.org/2000/svg">
3685 <path d="M5.675 11.7L6.55 8.85L4.25 7H7.1L8 4.2L8.9 7H11.75L9.425 8.85L10.3 11.7L8 9.925L5.675 11.7ZM2 21V13.275C1.36667 12.575 0.875 11.775 0.525 10.875C0.175 9.975 0 9.01667 0 8C0 5.76667 0.775 3.875 2.325 2.325C3.875 0.775 5.76667 0 8 0C10.2333 0 12.125 0.775 13.675 2.325C15.225 3.875 16 5.76667 16 8C16 9.01667 15.825 9.975 15.475 10.875C15.125 11.775 14.6333 12.575 14 13.275V21L8 19L2 21ZM8 14C9.66667 14 11.0833 13.4167 12.25 12.25C13.4167 11.0833 14 9.66667 14 8C14 6.33333 13.4167 4.91667 12.25 3.75C11.0833 2.58333 9.66667 2 8 2C6.33333 2 4.91667 2.58333 3.75 2.325C2.58333 3.875 2 5.76667 2 8C2 9.66667 2.58333 11.0833 3.75 12.25C4.91667 13.4167 6.33333 14 8 14ZM4 18.025L8 17L12 18.025V14.925C11.4167 15.2583 10.7875 15.5208 10.1125 15.7125C9.4375 15.9042 8.73333 16 8 16C7.26667 16 6.5625 15.9042 5.8875 15.7125C5.2125 15.5208 4.58333 15.2583 4 14.925V18.025Z" fill="#5AA152"/>
3686 </svg>'
3687 )
3688 );
3689 }
3690
3691 /**
3692 * Render dashboard scripts.
3693 *
3694 * @since 1.8.8
3695 */
3696 public function render_dashboard_scripts() {
3697 $charitable_dashboard_legacy = Charitable_Dashboard_Legacy::get_instance();
3698 $charitable_dashboard_legacy->generate_dashboard_report_html();
3699 $donation_axis = $charitable_dashboard_legacy->get_donation_axis();
3700 $date_axis = $charitable_dashboard_legacy->get_date_axis();
3701
3702 // Decode HTML entities in the donation axis data and extract numeric values
3703 if ( is_array( $donation_axis ) ) {
3704 $donation_axis = array_map( function( $value ) {
3705 $decoded = html_entity_decode( $value, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
3706 if ( preg_match( '/(\d+\.?\d*)/', $decoded, $matches ) ) {
3707 return floatval( $matches[1] );
3708 }
3709 return 0;
3710 }, $donation_axis );
3711 }
3712 ?>
3713 <?php
3714 // Decode HTML entities in currency symbol before passing to JavaScript.
3715 // The currency symbol comes as HTML entities (e.g., &#36; for $), so we decode it
3716 // before using esc_js() to avoid double-encoding issues.
3717 $currency_helper = charitable_get_currency_helper();
3718 $currency_symbol = html_entity_decode( $currency_helper->get_currency_symbol(), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
3719 $decimal_count = $currency_helper->get_decimals();
3720 ?>
3721 <script id="charitable-dashboard-report-data-js">
3722 var charitable_dashboard_reporting = {
3723 version: '<?php echo esc_js( Charitable::VERSION ); ?>',
3724 user_id: '<?php echo esc_js( get_current_user_id() ); ?>',
3725 nonce: '<?php echo esc_js( wp_create_nonce( 'charitable-reporting' ) ); ?>',
3726 dashboard_nonce: '<?php echo esc_js( wp_create_nonce( 'charitable-dashboard' ) ); ?>',
3727 admin_nonce: '<?php echo esc_js( wp_create_nonce( 'charitable-admin' ) ); ?>',
3728 currency_symbol: '<?php echo esc_js( $currency_symbol ); ?>',
3729 decimal_count: <?php echo (int) $decimal_count; ?>,
3730 ajax_url: '<?php echo esc_js( admin_url( 'admin-ajax.php' ) ); ?>',
3731 date_select_day: 'DD',
3732 date_select_month: 'MM',
3733 strings: {
3734 activation_failed: '<?php echo esc_js( __( 'Activation failed. Please try again.', 'charitable' ) ); ?>',
3735 error_occurred: '<?php echo esc_js( __( 'An error occurred. Please try again.', 'charitable' ) ); ?>',
3736 approved: '<?php echo esc_js( __( 'Approved', 'charitable' ) ); ?>',
3737 no_comments_title: '<?php echo esc_js( __( 'No donor comments yet.', 'charitable' ) ); ?>',
3738 no_comments_message: '<?php echo esc_js( __( 'Donor comments will appear here once donors start leaving messages with their donations.', 'charitable' ) ); ?>'
3739 },
3740 headline_chart_options: {
3741 donation_axis: <?php echo wp_json_encode( (array) $donation_axis ); ?>,
3742 date_axis: <?php echo wp_json_encode( (array) $date_axis ); ?>,
3743 enable_tooltips: <?php echo $charitable_dashboard_legacy->enable_tooltips() ? 'true' : 'false'; ?>
3744 },
3745 payment_methods_chart_options: {
3746 payment_percentages: [],
3747 payment_labels: []
3748 }
3749 };
3750 </script>
3751
3752 <script id="charitable-dashboard-tabs-js">
3753 document.addEventListener('DOMContentLoaded', function() {
3754 // Tab switching functionality
3755 const tabNavItems = document.querySelectorAll('.charitable-dashboard-v2-tab-nav-item');
3756 const tabContents = document.querySelectorAll('.charitable-dashboard-v2-tab-content');
3757
3758 // Function to switch tabs
3759 function switchTab(targetTab) {
3760 // Remove active classes from all tabs and contents
3761 tabNavItems.forEach(item => item.classList.remove('charitable-dashboard-v2-tab-nav-active'));
3762 tabContents.forEach(content => content.classList.remove('charitable-dashboard-v2-tab-content-active'));
3763
3764 // Add active class to selected tab and content
3765 const activeTab = document.querySelector(`[data-tab="${targetTab}"]`);
3766 const activeContent = document.querySelector(`.charitable-dashboard-v2-tab-content[data-tab="${targetTab}"]`);
3767
3768 if (activeTab) {
3769 activeTab.classList.add('charitable-dashboard-v2-tab-nav-active');
3770 }
3771 if (activeContent) {
3772 activeContent.classList.add('charitable-dashboard-v2-tab-content-active');
3773 }
3774
3775 // Update URL without page reload
3776 const url = new URL(window.location);
3777 url.searchParams.set('tab', targetTab);
3778 window.history.pushState({}, '', url);
3779 }
3780
3781 // Add click event listeners to tab navigation
3782 tabNavItems.forEach(item => {
3783 item.addEventListener('click', function(e) {
3784 e.preventDefault();
3785 const targetTab = this.getAttribute('data-tab');
3786 switchTab(targetTab);
3787 });
3788 });
3789
3790 // Handle browser back/forward buttons
3791 window.addEventListener('popstate', function() {
3792 const urlParams = new URLSearchParams(window.location.search);
3793 const tab = urlParams.get('tab') || 'top-campaigns';
3794 switchTab(tab);
3795 });
3796
3797 // Addon activation functionality
3798 const activateButton = document.getElementById('charitable-activate-donor-comments');
3799 if (activateButton) {
3800 activateButton.addEventListener('click', function(e) {
3801 e.preventDefault();
3802
3803 const plugin = this.getAttribute('data-plugin');
3804 const buttonText = this.querySelector('.button-text');
3805 const buttonLoading = this.querySelector('.button-loading');
3806
3807 // Show loading state
3808 buttonText.style.display = 'none';
3809 buttonLoading.style.display = 'inline-flex';
3810 this.disabled = true;
3811
3812 // Make AJAX request
3813 const formData = new FormData();
3814 formData.append('action', 'charitable_activate_addon');
3815 formData.append('plugin', plugin);
3816 formData.append('nonce', charitable_dashboard_v2_reporting.admin_nonce);
3817
3818 fetch(charitable_dashboard_v2_reporting.ajax_url, {
3819 method: 'POST',
3820 body: formData
3821 })
3822 .then(response => response.json())
3823 .then(data => {
3824 if (data.success) {
3825 // Show success message and reload page
3826 alert(data.data.message);
3827 window.location.reload();
3828 } else {
3829 // Show error message
3830 alert(data.data.message || charitable_dashboard_v2_reporting.strings.activation_failed);
3831
3832 // Reset button state
3833 buttonText.style.display = 'inline';
3834 buttonLoading.style.display = 'none';
3835 this.disabled = false;
3836 }
3837 })
3838 .catch(error => {
3839 console.error('Error:', error);
3840 alert(charitable_dashboard_v2_reporting.strings.error_occurred);
3841
3842 // Reset button state
3843 buttonText.style.display = 'inline';
3844 buttonLoading.style.display = 'none';
3845 this.disabled = false;
3846 });
3847 });
3848 }
3849
3850 // Comment actions functionality
3851 let isProcessing = false;
3852
3853 // Rollback function - defined outside to be accessible
3854 function rollbackUI(originalState, actionElement) {
3855 // Restore original state for the specific action
3856 const actionText = actionElement.querySelector('.action-text');
3857 const actionLoading = actionElement.querySelector('.action-loading');
3858
3859 actionText.textContent = originalState.actionText;
3860 actionText.style.display = 'inline';
3861 actionLoading.style.display = originalState.actionLoading;
3862
3863 // Restore the icon
3864 const icon = actionElement.querySelector('svg, img');
3865 if (icon) {
3866 icon.style.display = originalState.icon;
3867 }
3868
3869 // Re-enable all comment actions
3870 const allCommentActions = document.querySelectorAll('.comment-action');
3871 allCommentActions.forEach(btn => {
3872 btn.style.pointerEvents = 'auto';
3873 btn.style.opacity = '1';
3874 });
3875
3876 isProcessing = false;
3877 }
3878
3879 // Re-enable all comment actions function
3880 function reEnableAllActions() {
3881 const allCommentActions = document.querySelectorAll('.comment-action');
3882 allCommentActions.forEach(btn => {
3883 btn.style.pointerEvents = 'auto';
3884 btn.style.opacity = '1';
3885 });
3886 isProcessing = false;
3887 }
3888
3889 const commentActions = document.querySelectorAll('.comment-action');
3890 commentActions.forEach(action => {
3891 action.addEventListener('click', function(e) {
3892 e.preventDefault();
3893
3894 // Prevent multiple simultaneous requests
3895 if (isProcessing) {
3896 return;
3897 }
3898
3899 const commentId = this.getAttribute('data-comment-id');
3900 const actionType = this.classList.contains('approve') ? 'approve' : 'delete';
3901 const actionText = this.querySelector('.action-text');
3902 const actionLoading = this.querySelector('.action-loading');
3903 const row = this.closest('tr');
3904
3905 // Store original state for rollback
3906 const originalState = {
3907 actionText: actionText.textContent,
3908 actionLoading: actionLoading.style.display,
3909 icon: this.querySelector('svg, img').style.display,
3910 pointerEvents: this.style.pointerEvents
3911 };
3912
3913 // Disable ALL comment actions in the entire comments area
3914 isProcessing = true;
3915 const allCommentActions = document.querySelectorAll('.comment-action');
3916 allCommentActions.forEach(btn => {
3917 btn.style.pointerEvents = 'none';
3918 btn.style.opacity = '0.5';
3919 });
3920
3921 // Show loading state for this specific action
3922 actionText.style.display = 'none';
3923 actionLoading.style.display = 'inline-flex';
3924
3925 // Hide the icon during processing
3926 const icon = this.querySelector('svg, img');
3927 if (icon) {
3928 icon.style.display = 'none';
3929 }
3930
3931 // Make AJAX request
3932 const formData = new FormData();
3933 formData.append('action', 'charitable_' + actionType + '_comment');
3934 formData.append('comment_id', commentId);
3935 formData.append('nonce', charitable_dashboard_v2_reporting.admin_nonce);
3936
3937 fetch(charitable_dashboard_v2_reporting.ajax_url, {
3938 method: 'POST',
3939 body: formData
3940 })
3941 .then(response => {
3942 if (!response.ok) {
3943 throw new Error('HTTP ' + response.status + ': ' + response.statusText);
3944 }
3945 return response.json();
3946 })
3947 .then(data => {
3948 if (data.success) {
3949 // Success - update UI with slight delay
3950 setTimeout(() => {
3951 if (actionType === 'approve') {
3952 // Replace with approved status
3953 const statusCell = row.querySelector('td:last-child');
3954 statusCell.innerHTML = '<span class="comment-status-approved">' + charitable_dashboard_v2_reporting.strings.approved + '</span>';
3955 // Re-enable all other actions after successful approve
3956 reEnableAllActions();
3957 } else if (actionType === 'delete') {
3958 // Fade out and remove row
3959 row.style.transition = 'opacity 0.3s ease';
3960 row.style.opacity = '0';
3961 setTimeout(() => {
3962 row.remove();
3963 // Check if table is now empty
3964 checkEmptyState();
3965 // Re-enable all other actions after successful delete
3966 reEnableAllActions();
3967 }, 300);
3968 }
3969 }, 200); // Slight delay as requested
3970 } else {
3971 // Error - rollback UI
3972 console.error('Charitable: AJAX error:', data.data.message);
3973 rollbackUI(originalState, this);
3974 }
3975 })
3976 .catch(error => {
3977 console.error('Charitable: Network error:', error);
3978 rollbackUI(originalState, this);
3979 });
3980 });
3981 });
3982
3983 // Check if comments table is empty and show empty state
3984 function checkEmptyState() {
3985 const tbody = document.querySelector('.charitable-dashboard-v2-table-comments tbody');
3986 const visibleRows = tbody.querySelectorAll('tr[data-comment-id]');
3987
3988 if (visibleRows.length === 0) {
3989 // Show empty state
3990 tbody.innerHTML = `
3991 <tr>
3992 <td colspan="2" class="charitable-dashboard-v2-no-comments">
3993 <p><strong>` + charitable_dashboard_v2_reporting.strings.no_comments_title + `</strong></p>
3994 <p>` + charitable_dashboard_v2_reporting.strings.no_comments_message + `</p>
3995 </td>
3996 </tr>
3997 `;
3998 }
3999 }
4000 });
4001 </script>
4002 <?php
4003 }
4004
4005 /**
4006 * Returns and/or create the single instance of this class.
4007 *
4008 * @since 1.8.1
4009 *
4010 * @return Charitable_Dashboard
4011 */
4012 public static function get_instance() {
4013 if ( is_null( self::$instance ) ) {
4014 self::$instance = new self();
4015 }
4016
4017 return self::$instance;
4018 }
4019 }
4020
4021 endif;
4022