PluginProbe
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) / 1.8.12.2
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) v1.8.12.2
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.12.2, at includes/admin/dashboard/class-charitable-dashboard.php

4,514 lines 186.6 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 * URL for the v2 Latest Updates feed (hosted on Cloudflare Pages).
63 *
64 * @since 1.8.12
65 * @var string
66 */
67 const LATEST_UPDATES_SOURCE_URL = 'https://plugin.wpcharitable.com/wp-content/charitable-latest-updates.json';
68
69 /**
70 * Cached version information from the API.
71 *
72 * @since 1.8.8
73 *
74 * @var array|null
75 */
76 private $version_info = null;
77
78 /**
79 * Create object instance.
80 *
81 * @since 1.8.8
82 */
83 public function __construct() {
84 // Constructor - AJAX handlers are registered in charitable-admin-hooks.php
85
86 // Hook into Charitable's cache clearing mechanism
87 add_action( 'charitable_after_clear_expired_options', array( $this, 'clear_dashboard_stats_cache' ) );
88
89 // Refresh the "resume setup wizard" notification entry just before
90 // the dashboard rail renders.
91 add_action( 'charitable_before_admin_dashboard_v2', array( $this, 'sync_resume_wizard_notification' ) );
92 }
93
94 /**
95 * Register or refresh the "resume setup wizard" entry in the dashboard
96 * notification rail when an onboarding session is in progress; remove
97 * any stale entry when it isn't.
98 *
99 * Fires on `charitable_before_admin_dashboard_v2`, immediately before
100 * `render_dashboard_notifications()` reads the same option.
101 *
102 * @since 1.8.10.5
103 *
104 * @return void
105 */
106 public function sync_resume_wizard_notification() {
107
108 $notifications = (array) get_option( 'charitable_dashboard_notifications', array() );
109 $started = 1 === (int) get_option( 'charitable_started_onboarding', 0 );
110 $present = isset( $notifications['resume_setup_wizard'] );
111 $dismissed = $present && isset( $notifications['resume_setup_wizard']['dismissed'] );
112
113 if ( $started && ! $dismissed ) {
114
115 $notifications['resume_setup_wizard'] = array(
116 'type' => 'notice',
117 'priority' => 3,
118 'title' => __( 'Finish Setting Up Charitable', 'charitable' ),
119 '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>',
120 'custom_css' => 'charitable-notification-type-notice',
121 'button_url' => add_query_arg( array( 'resume' => 'true' ), charitable_get_onboarding_url() ),
122 'button_text' => __( 'Resume Setup Wizard', 'charitable' ),
123 );
124
125 update_option( 'charitable_dashboard_notifications', $notifications );
126
127 return;
128 }
129
130 if ( $present && ! $started ) {
131 unset( $notifications['resume_setup_wizard'] );
132 update_option( 'charitable_dashboard_notifications', $notifications );
133 }
134 }
135
136
137 /**
138 * Render the dashboard new page.
139 *
140 * @since 1.8.8
141 * @version 1.8.8.1
142 */
143 public function render_dashboard_new() {
144 ?>
145 <div id="charitable-dashboard-v2" class="wrap">
146 <h1 class="screen-reader-text"><?php echo esc_html( get_admin_page_title() ); ?></h1>
147
148 <?php do_action( 'charitable_maybe_show_notification' ); ?>
149
150 <?php
151 /**
152 * Do or render something right before the dashboard new area.
153 *
154 * @since 1.8.8
155 */
156 do_action( 'charitable_before_admin_dashboard_v2' );
157 ?>
158
159 <?php $this->render_dashboard_header(); ?>
160
161 <?php $this->render_dashboard_notifications(); ?>
162
163 <div class="charitable-dashboard-v2-content">
164 <div class="charitable-dashboard-v2-layout">
165 <!-- Left Column -->
166 <div class="charitable-dashboard-v2-left-column">
167 <?php $this->render_stats_section(); ?>
168 <?php $this->render_tabs_section(); ?>
169 <?php $this->render_upgrade_section(); ?>
170 <?php
171 // Show Quick Access in left column for Pro users (when upgrade section is hidden)
172 if ( charitable_is_pro() ) {
173 $this->render_quick_access_section();
174 }
175 ?>
176 </div>
177
178 <!-- Right Column -->
179 <div class="charitable-dashboard-v2-right-column">
180 <?php $this->render_enhance_campaign_section(); ?>
181 <?php $this->render_latest_updates_section(); ?>
182 <?php
183 // Show Quick Access in right column for non-Pro users
184 if ( ! charitable_is_pro() ) {
185 $this->render_quick_access_section();
186 }
187 ?>
188 </div>
189 </div>
190 </div>
191
192 <?php
193 /**
194 * Do or render something right after the dashboard new area.
195 *
196 * @since 1.8.8
197 */
198 do_action( 'charitable_after_admin_dashboard_v2' );
199 ?>
200
201 <?php $this->render_dashboard_scripts(); ?>
202 </div>
203 <?php
204 }
205
206
207 /**
208 * Render the dashboard header section.
209 *
210 * @since 1.8.8
211 */
212 public function render_dashboard_header() {
213 ?>
214 <div class="charitable-dashboard-v2-header">
215 <div class="charitable-dashboard-v2-header-content">
216 <div class="charitable-dashboard-v2-header-left">
217 <h1><?php echo esc_html__( 'Overview', 'charitable' ); ?></h1>
218 </div>
219 <div class="charitable-dashboard-v2-header-right">
220 <?php if ( charitable_disable_legacy_campaigns() ) : ?>
221 <a href="<?php echo esc_url( admin_url( 'admin.php?page=charitable-campaign-builder&view=template' ) ); ?>" class="button button-primary charitable-button">
222 <?php echo esc_html__( '+ Add New Campaign', 'charitable' ); ?>
223 </a>
224 <?php else : ?>
225 <a href="<?php echo esc_url( admin_url( 'post-new.php?post_type=campaign' ) ); ?>" class="button button-primary charitable-button">
226 <?php echo esc_html__( '+ Add New Campaign', 'charitable' ); ?>
227 </a>
228 <?php endif; ?>
229 </div>
230 </div>
231 </div>
232 <?php
233 }
234
235 /**
236 * Render dashboard notifications.
237 *
238 * @since 1.8.8
239 */
240 public function render_dashboard_notifications() {
241 // Get dashboard notifications
242 $notifications = get_option( 'charitable_dashboard_notifications', array() );
243
244 if ( empty( $notifications ) || ! is_array( $notifications ) ) {
245 return;
246 }
247
248 // Remove any notifications that don't have a 'type' key
249 $notifications = array_filter(
250 $notifications,
251 function ( $notification ) {
252 return isset( $notification['type'] );
253 }
254 );
255
256 // Remove any notifications that have a 'dismissed' key
257 $notifications = array_filter(
258 $notifications,
259 function ( $notification ) {
260 return ! isset( $notification['dismissed'] );
261 }
262 );
263
264 if ( empty( $notifications ) ) {
265 return;
266 }
267
268 // Sort notifications by type = 'error' first, then 'warning', followed by 'notice'.
269 // Within the same type, sort by priority (lower number = shown first, default 10).
270 uasort(
271 $notifications,
272 function ( $a, $b ) {
273 $types = array( 'error', 'warning', 'notice' );
274 $pos_a = array_search( $a['type'], $types );
275 $pos_b = array_search( $b['type'], $types );
276
277 if ( $pos_a !== $pos_b ) {
278 return $pos_a - $pos_b;
279 }
280
281 $priority_a = isset( $a['priority'] ) ? (int) $a['priority'] : 10;
282 $priority_b = isset( $b['priority'] ) ? (int) $b['priority'] : 10;
283
284 return $priority_a - $priority_b;
285 }
286 );
287
288 $notifications_count = 1;
289 $notifications_total = count( $notifications );
290
291 ?>
292 <div class="charitable-container charitable-dashboard-notifications charitable-dashboard-v2-notifications">
293 <div class="charitable-dashboard-notification-bar">
294 <?php if ( (int) $notifications_total > 1 ) : ?>
295 <div class="charitable-dashboard-notification-navigation">
296 <a class="prev">
297 <span class="screen-reader-text"><?php esc_attr_e( 'Previous message', 'charitable' ); ?></span>
298 <span aria-hidden="true">&lsaquo;</span>
299 </a>
300 <span class="charitable-dashboard-notification-counter">
301 <?php
302 printf(
303 /* translators: 1: current notification number, 2: total notifications */
304 esc_html__( '%1$d of %2$d', 'charitable' ),
305 1,
306 (int) $notifications_total
307 );
308 ?>
309 </span>
310 <a class="next">
311 <span class="screen-reader-text"><?php esc_attr_e( 'Next message', 'charitable' ); ?></span>
312 <span aria-hidden="true">&rsaquo;</span>
313 </a>
314 </div>
315 <?php else : ?>
316 <div class="charitable-dashboard-notification-navigation"></div>
317 <?php endif; ?>
318
319 <a href="#" class="charitable-remove-dashboard-notification"></a>
320 </div>
321
322 <?php foreach ( $notifications as $notification_slug => $notification ) : ?>
323 <?php
324 $css_class = ! empty( $notification['custom_css'] ) ? $notification['custom_css'] : '';
325 $css_class .= $notifications_count === 1 ? '' : ' charitable-hidden';
326 $message_title = ! empty( $notification['title'] ) ? sanitize_text_field( $notification['title'] ) : esc_html__( 'Important', 'charitable' );
327 $message = ! empty( $notification['message'] ) ? $notification['message'] : '';
328 $message = wp_kses(
329 $message,
330 array(
331 'a' => array(
332 'href' => array(),
333 'title' => array(),
334 ),
335 'strong' => array(),
336 'p' => array(),
337 'ol' => array(),
338 'ul' => array(),
339 'li' => array(),
340 'br' => array(),
341 'h1' => array(),
342 'h2' => array(),
343 'h3' => array(),
344 'h4' => array(),
345 'h5' => array(),
346 )
347 );
348 $notification_type = ! empty( $notification['type'] ) ? $notification['type'] : 'notice';
349 $type_icons = array(
350 'notice' => 'dashicons-info',
351 'warning' => 'dashicons-warning',
352 'error' => 'dashicons-dismiss',
353 );
354 $icon_class = isset( $type_icons[ $notification_type ] ) ? $type_icons[ $notification_type ] : 'dashicons-info';
355 $button_url = ! empty( $notification['button_url'] ) ? esc_url( $notification['button_url'] ) : '';
356 $button_text = ! empty( $notification['button_text'] ) ? sanitize_text_field( $notification['button_text'] ) : '';
357 ?>
358
359 <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 ); ?>">
360 <div class="charitable-dashboard-notification-message">
361 <h4 class="charitable-dashboard-notification-headline">
362 <span class="dashicons <?php echo esc_attr( $icon_class ); ?> charitable-notification-type-icon"></span>
363 <?php echo esc_html( $message_title ); ?>
364 </h4>
365 <?php echo $message; // phpcs:ignore ?>
366 <?php if ( $button_url && $button_text ) : ?>
367 <a href="<?php echo esc_url( $button_url ); ?>" class="charitable-notification-cta-button"><?php echo esc_html( $button_text ); ?></a>
368 <?php endif; ?>
369 </div>
370 </div>
371
372 <?php ++$notifications_count; ?>
373 <?php endforeach; ?>
374 </div>
375 <?php
376 }
377
378
379 /**
380 * Render the stats section with time period filter and chart.
381 *
382 * @since 1.8.8
383 */
384 public function render_stats_section() {
385 ?>
386 <section class="charitable-dashboard-v2-section charitable-dashboard-v2-stats-row-section">
387 <div class="charitable-dashboard-v2-header-bar">
388 <div class="charitable-dashboard-v2-header-bar-left">
389 <select name="time-period" id="time-period-filter">
390 <option value="last-7-days" selected><?php esc_html_e( 'Last 7 Days', 'charitable' ); ?></option>
391 <option value="last-14-days"><?php esc_html_e( 'Last 14 Days', 'charitable' ); ?></option>
392 <option value="last-30-days"><?php esc_html_e( 'Last 30 Days', 'charitable' ); ?></option>
393 <?php /* <option value="this-month">This Month</option>
394 <option value="last-3-months">Last 3 Months</option>
395 <option value="last-6-months">Last 6 Months</option>
396 <option value="last-year">Last Year</option>
397 <option value="custom">Custom Range</option> */ ?>
398 </select>
399 </div>
400 <div class="charitable-dashboard-v2-header-bar-right">
401 <?php /* <button class="charitable-dashboard-v2-download-button">
402 <?php esc_html_e( 'Download Summary', 'charitable' ); ?>
403 <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">
404 <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"/>
405 </svg>
406 </button> */ ?>
407
408 <?php
409 // Get current date range for print functionality
410 // phpcs:disable WordPress.Security.NonceVerification.Recommended
411 $time_period = isset( $_GET['time-period'] ) ? sanitize_text_field( wp_unslash( $_GET['time-period'] ) ) : 'last-7-days';
412 // phpcs:enable WordPress.Security.NonceVerification.Recommended
413 $date_range = $this->get_date_range_for_period( $time_period );
414 ?>
415 <form action="" method="post" target="_blank" class="charitable-report-print" id="charitable-dashboard-v2-print">
416 <input name="charitable_report_action" type="hidden" value="charitable_report_print_dashboard" />
417 <input name="start_date" type="hidden" value="<?php echo esc_attr( $date_range['start_date'] ); ?>" />
418 <input name="end_date" type="hidden" value="<?php echo esc_attr( $date_range['end_date'] ); ?>" />
419 <input name="days" type="hidden" value="<?php echo esc_attr( $time_period ); ?>" />
420 <?php wp_nonce_field( 'charitable_export_report', 'charitable_export_report_nonce' ); ?>
421 <button type="submit" class="charitable-dashboard-v2-print-button" title="<?php echo esc_html__( 'Print Summary', 'charitable' ); ?>">
422 <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">
423 <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"/>
424 </svg>
425 </button>
426 </form>
427 </div>
428 </div>
429 <?php $this->render_stats_row(); ?>
430 <div class="charitable-dashboard-v2-header-bar-chart">
431 <div id="charitable-dashboard-v2-headline-graph" class="charitable-headline-graph"></div>
432 </div>
433 </section>
434 <?php
435 }
436
437 /**
438 * Render the stats row with donation statistics.
439 *
440 * @since 1.8.8
441 */
442 private function render_stats_row() {
443 // Get the selected time period from request, default to 'last-7-days'
444 // phpcs:disable WordPress.Security.NonceVerification.Recommended
445 $time_period = isset( $_GET['time-period'] ) ? sanitize_text_field( wp_unslash( $_GET['time-period'] ) ) : 'last-7-days';
446 // phpcs:enable WordPress.Security.NonceVerification.Recommended
447 $stats = $this->get_dashboard_stats( $time_period );
448 ?>
449 <div class="charitable-dashboard-v2-stats-row">
450 <div class="charitable-dashboard-v2-stat-item">
451 <div class="charitable-dashboard-v2-stat-title"><?php esc_html_e( 'Total Donations', 'charitable' ); ?></div>
452 <div class="charitable-dashboard-v2-stat-amount">
453 <?php echo esc_html( $stats['total_donations'] ); ?>
454 <?php if ( '0%' !== $stats['donations_change'] ) : ?>
455 <span class="charitable-dashboard-v2-stat-change">
456 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" xmlns="http://www.w3.org/2000/svg">
457 <mask id="mask0_1904_1526" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="13" height="13">
458 <rect y="0.25415" width="12.9062" height="12.3612" fill="#D9D9D9"/>
459 </mask>
460 <g mask="url(#mask0_1904_1526)">
461 <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"/>
462 </g>
463 </svg>
464 <?php echo esc_html( $stats['donations_change'] ); ?>%
465 </span>
466 <?php endif; ?>
467 </div>
468 </div>
469 <div class="charitable-dashboard-v2-stat-item">
470 <div class="charitable-dashboard-v2-stat-title"><?php esc_html_e( 'Avg. Donations', 'charitable' ); ?></div>
471 <div class="charitable-dashboard-v2-stat-amount">
472 <?php echo esc_html( $stats['avg_donations'] ); ?>
473 <?php if ( '0%' !== $stats['avg_change'] ) : ?>
474 <span class="charitable-dashboard-v2-stat-change">
475 <svg width="13" height="13" viewBox="0 0 13 13" fill="none" xmlns="http://www.w3.org/2000/svg">
476 <mask id="mask0_1904_1526" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="13" height="13">
477 <rect y="0.25415" width="12.9062" height="12.3612" fill="#D9D9D9"/>
478 </mask>
479 <g mask="url(#mask0_1904_1526)">
480 <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"/>
481 </g>
482 </svg>
483 <?php echo esc_html( $stats['avg_change'] ); ?>%
484 </span>
485 <?php endif; ?>
486 </div>
487 </div>
488 <div class="charitable-dashboard-v2-stat-item">
489 <div class="charitable-dashboard-v2-stat-title"><?php esc_html_e( 'Total Donors', 'charitable' ); ?></div>
490 <div class="charitable-dashboard-v2-stat-amount"><?php echo esc_html( $stats['total_donors'] ); ?></div>
491 </div>
492 <div class="charitable-dashboard-v2-stat-item">
493 <div class="charitable-dashboard-v2-stat-title"><?php echo esc_html( $stats['total_refunds_count'] ); ?> <?php esc_html_e( 'Refunds', 'charitable' ); ?></div>
494 <div class="charitable-dashboard-v2-stat-amount"><?php echo esc_html( $stats['total_refunds'] ); ?></div>
495 </div>
496 </div>
497 <?php
498 }
499
500 /**
501 * Get dashboard statistics data.
502 *
503 * @since 1.8.8
504 * @param string $time_period The selected time period.
505 * @return array
506 */
507 public function get_dashboard_stats( $time_period = 'last-7-days' ) {
508 // Clear stats cache if requested via URL parameter
509 // phpcs:disable WordPress.Security.NonceVerification.Recommended
510 if ( isset( $_GET['charitable_clear_stats_cache'] ) && '1' === $_GET['charitable_clear_stats_cache'] ) {
511 // phpcs:enable WordPress.Security.NonceVerification.Recommended
512 $this->clear_dashboard_stats_cache();
513 }
514
515 // TEMPORARY TESTING: Return empty stats if testing mode is enabled
516 if ( self::SHOW_EMPTY_DASHBOARD ) {
517 // Decode HTML entities in currency-formatted strings to prevent double-encoding in JSON.
518 $empty_total = html_entity_decode( charitable_format_money( 0 ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
519 $empty_avg = html_entity_decode( charitable_format_money( 0 ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
520 $empty_refunds = html_entity_decode( charitable_format_money( 0 ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
521 return array(
522 'total_donations' => $empty_total,
523 'donations_change' => '0%',
524 'avg_donations' => $empty_avg,
525 'avg_change' => '0%',
526 'total_donors' => 0,
527 'total_refunds' => $empty_refunds,
528 'total_refunds_count' => 0
529 );
530 }
531
532 // Calculate dates based on time period
533 $date_range = $this->get_date_range_for_period( $time_period );
534 $start_date = $date_range['start_date'];
535 $end_date = $date_range['end_date'];
536 $days = $date_range['days'];
537
538 // Initialize Charitable Reports
539 $charitable_reports = Charitable_Reports::get_instance();
540 $args = array(
541 'start_date' => $start_date,
542 'end_date' => $end_date,
543 'days' => $days,
544 );
545 $charitable_reports->init_with_array( 'dashboard', $args );
546
547 // Get donations data
548 $donations = $charitable_reports->get_donations();
549 $total_count_donations = count( $donations );
550 $total_amount_donations = $charitable_reports->get_donations_total();
551 $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 );
552 $donation_total = charitable_format_money( $total_amount_donations );
553
554 // Get donors count
555 $total_count_donors = $charitable_reports->get_donors_count();
556
557 // Get refunds data
558 $refunds_data = $charitable_reports->get_refunds();
559 $total_amount_refunds = charitable_format_money( $refunds_data['total_amount'] );
560 $total_count_refunds = $refunds_data['total_count'];
561
562 // Decode HTML entities in currency-formatted strings to prevent double-encoding in JSON.
563 // charitable_format_money() returns HTML entities (e.g., &#36; for $), so we decode them
564 // before sending via AJAX to avoid double-encoding issues.
565 $donation_total = html_entity_decode( $donation_total, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
566 $donation_average = html_entity_decode( $donation_average, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
567 $total_amount_refunds = html_entity_decode( $total_amount_refunds, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
568
569 // Calculate percentage changes
570 $changes = $this->calculate_percentage_changes( $time_period, $total_amount_donations, $donation_average );
571 $donations_change = $changes['donations_change'];
572 $avg_change = $changes['avg_change'];
573
574 // Defensive guard: if current average is zero, hide avg change
575 if ( 0.0 === (float) $this->parse_money_amount( $donation_average ) ) {
576 $avg_change = '';
577 }
578
579 // Allow filtering to disable percentage displays completely
580 $show_percentage_changes = apply_filters( 'charitable_dashboard_show_percentage_changes', true );
581 if ( ! $show_percentage_changes ) {
582 $donations_change = '';
583 $avg_change = '';
584 }
585
586 return array(
587 'total_donations' => $donation_total,
588 'donations_change' => $donations_change,
589 'avg_donations' => $donation_average,
590 'avg_change' => $avg_change,
591 'total_donors' => $total_count_donors,
592 'total_refunds' => $total_amount_refunds,
593 'total_refunds_count' => $total_count_refunds
594 );
595 }
596
597 /**
598 * Get date range for a given time period.
599 *
600 * @since 1.8.8
601 * @param string $time_period The time period.
602 * @return array
603 */
604 public function get_date_range_for_period( $time_period ) {
605 $end_date = gmdate( 'Y/m/d' );
606
607 switch ( $time_period ) {
608 case 'last-7-days':
609 $start_date = gmdate( 'Y/m/d', strtotime( '-7 days' ) );
610 $days = 7;
611 break;
612 case 'last-14-days':
613 $start_date = gmdate( 'Y/m/d', strtotime( '-14 days' ) );
614 $days = 14;
615 break;
616 case 'last-30-days':
617 $start_date = gmdate( 'Y/m/d', strtotime( '-30 days' ) );
618 $days = 30;
619 break;
620 case 'this-month':
621 $start_date = gmdate( 'Y/m/d', strtotime( 'first day of this month' ) );
622 $days = gmdate( 'j' ); // Current day of month
623 break;
624 case 'last-3-months':
625 $start_date = gmdate( 'Y/m/d', strtotime( '-3 months' ) );
626 $days = 90;
627 break;
628 case 'last-6-months':
629 $start_date = gmdate( 'Y/m/d', strtotime( '-6 months' ) );
630 $days = 180;
631 break;
632 case 'last-year':
633 $start_date = gmdate( 'Y/m/d', strtotime( '-1 year' ) );
634 $days = 365;
635 break;
636 default:
637 $start_date = gmdate( 'Y/m/d', strtotime( '-7 days' ) );
638 $days = 7;
639 break;
640 }
641
642 return array(
643 'start_date' => $start_date,
644 'end_date' => $end_date,
645 'days' => $days
646 );
647 }
648
649 /**
650 * Calculate percentage changes for donations and average.
651 *
652 * @since 1.8.8
653 * @param string $time_period Current time period.
654 * @param float $current_total Current total donations.
655 * @param string $current_avg Current average donation.
656 * @return array
657 */
658 private function calculate_percentage_changes( $time_period, $current_total, $current_avg ) {
659
660 $debug_no_cache = ( defined( 'CHARITABLE_DEBUG' ) && true === constant( 'CHARITABLE_DEBUG' ) );
661
662 // Get cache duration from filter (default 4 hours)
663 $cache_duration = apply_filters( 'charitable_dashboard_stats_cache_duration', 4 * HOUR_IN_SECONDS );
664
665 // Create cache key
666 $cache_key = 'charitable_dashboard_stats_changes_' . $time_period . '_' . gmdate( 'Y-m-d' );
667
668 // Try to get from cache first (unless debugging)
669 if ( ! $debug_no_cache ) {
670 $cached_changes = get_transient( $cache_key );
671 if ( false !== $cached_changes ) {
672 return $cached_changes;
673 }
674 }
675
676 // Calculate previous period date range
677 $previous_period_range = $this->get_previous_period_date_range( $time_period );
678 if ( false === $previous_period_range ) {
679 // If we can't calculate previous period, return no change
680 $changes = array(
681 'donations_change' => '',
682 'avg_change' => ''
683 );
684 if ( ! $debug_no_cache ) {
685 set_transient( $cache_key, $changes, $cache_duration );
686 }
687 return $changes;
688 }
689
690 // Get previous period data
691 $previous_data = $this->get_previous_period_data( $previous_period_range );
692 if ( false === $previous_data ) {
693 // If we can't get previous data, return no change
694 $changes = array(
695 'donations_change' => '',
696 'avg_change' => ''
697 );
698 if ( ! $debug_no_cache ) {
699 set_transient( $cache_key, $changes, $cache_duration );
700 }
701 return $changes;
702 }
703
704 // Calculate percentage changes
705 $donations_change = $this->calculate_percentage_change( $previous_data['total'], $current_total );
706 $avg_change = $this->calculate_percentage_change( $previous_data['avg'], $this->parse_money_amount( $current_avg ) );
707
708 $changes = array(
709 'donations_change' => $donations_change,
710 'avg_change' => $avg_change
711 );
712
713 // Cache the results unless debugging
714 if ( ! $debug_no_cache ) {
715 set_transient( $cache_key, $changes, $cache_duration );
716 }
717
718 return $changes;
719 }
720
721 /**
722 * Get previous period date range for comparison.
723 *
724 * @since 1.8.8
725 * @param string $time_period Current time period.
726 * @return array|false
727 */
728 private function get_previous_period_date_range( $time_period ) {
729 $current_range = $this->get_date_range_for_period( $time_period );
730 $current_start = $current_range['start_date'];
731 $current_days = $current_range['days'];
732
733 // Calculate previous period dates
734 $previous_end = gmdate( 'Y/m/d', strtotime( $current_start . ' -1 day' ) );
735 $previous_start = gmdate( 'Y/m/d', strtotime( $previous_end . ' -' . ( $current_days - 1 ) . ' days' ) );
736
737 return array(
738 'start_date' => $previous_start,
739 'end_date' => $previous_end,
740 'days' => $current_days
741 );
742 }
743
744 /**
745 * Get previous period donation data.
746 *
747 * @since 1.8.8
748 * @param array $date_range Previous period date range.
749 * @return array|false
750 */
751 private function get_previous_period_data( $date_range ) {
752 try {
753 // Initialize Charitable Reports for previous period
754 $charitable_reports = Charitable_Reports::get_instance();
755 $args = array(
756 'start_date' => $date_range['start_date'],
757 'end_date' => $date_range['end_date'],
758 'days' => $date_range['days'],
759 );
760 $charitable_reports->init_with_array( 'dashboard', $args );
761
762 // Get donations data
763 $donations = $charitable_reports->get_donations();
764 $total_count_donations = count( $donations );
765 $total_amount_donations = $charitable_reports->get_donations_total();
766
767 // Calculate average
768 $donation_average = ( $total_amount_donations > 0 && $total_count_donations > 0 ) ? ( $total_amount_donations / $total_count_donations ) : 0;
769
770 return array(
771 'total' => $total_amount_donations,
772 'avg' => $donation_average
773 );
774 } catch ( Exception $e ) {
775 // Log error and return false
776 if ( charitable_is_debug() ) {
777 error_log( 'Charitable Dashboard: Error getting previous period data - ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
778 }
779 return false;
780 }
781 }
782
783 /**
784 * Calculate percentage change between two values.
785 *
786 * @since 1.8.8
787 * @param float $previous Previous value.
788 * @param float $current Current value.
789 * @return string
790 */
791 private function calculate_percentage_change( $previous, $current ) {
792 // Handle edge cases: if either value is zero or negative, do not show a percentage change
793 if ( $previous <= 0 || $current <= 0 ) {
794 return '';
795 }
796
797 $percentage = ( ( $current - $previous ) / $previous ) * 100;
798
799 // Round to 1 decimal place
800 $percentage = round( $percentage, 1 );
801
802 // Handle very large percentages
803 if ( $percentage > 99.9 ) {
804 return '+99.9%';
805 }
806 if ( $percentage < -99.9 ) {
807 return '-99.9%';
808 }
809
810 // Format with + or - sign
811 $sign = $percentage > 0 ? '+' : '';
812 return $sign . $percentage . '%';
813 }
814
815 /**
816 * Parse money amount from formatted string.
817 *
818 * @since 1.8.8
819 * @param string $money_formatted Formatted money string.
820 * @return float
821 */
822 private function parse_money_amount( $money_formatted ) {
823 // Decode HTML entities first (e.g., &#36; becomes $)
824 $money_formatted = html_entity_decode( $money_formatted, ENT_QUOTES, 'UTF-8' );
825
826 // Remove currency symbols and formatting
827 $amount = preg_replace( '/[^\d.,]/', '', $money_formatted );
828
829 // Handle different decimal separators
830 if ( strpos( $amount, ',' ) !== false && strpos( $amount, '.' ) !== false ) {
831 // Both comma and dot present - assume comma is thousands separator
832 $amount = str_replace( ',', '', $amount );
833 } elseif ( strpos( $amount, ',' ) !== false ) {
834 // Only comma - could be decimal separator
835 $amount = str_replace( ',', '.', $amount );
836 }
837
838 return (float) $amount;
839 }
840
841 /**
842 * Clear dashboard stats cache.
843 *
844 * @since 1.8.8
845 * @version 1.8.8.4 - added blog posts cache clearing
846 */
847 public function clear_dashboard_stats_cache() {
848 // Delete all dashboard stats cache transients
849 global $wpdb;
850
851 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
852 $wpdb->query(
853 $wpdb->prepare(
854 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
855 $wpdb->esc_like( '_transient_charitable_dashboard_stats_changes_' ) . '%'
856 )
857 );
858
859 // Also delete the transient timeout entries
860 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
861 $wpdb->query(
862 $wpdb->prepare(
863 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
864 $wpdb->esc_like( '_transient_timeout_charitable_dashboard_stats_changes_' ) . '%'
865 )
866 );
867
868 // Clear blog posts cache and Latest Updates feed cache.
869 delete_transient( 'charitable_blog_posts_cache' );
870 delete_transient( 'charitable_latest_updates_cache' );
871 }
872
873 /**
874 * Get the default tab from URL parameter.
875 *
876 * @since 1.8.8
877 * @return string
878 */
879 private function get_default_tab() {
880 $valid_tabs = array( 'top-campaigns', 'latest-donations', 'top-donors', 'comments' );
881 // phpcs:disable WordPress.Security.NonceVerification.Recommended
882 $default_tab = isset( $_GET['tab'] ) ? sanitize_text_field( wp_unslash( $_GET['tab'] ) ) : 'top-campaigns';
883 // phpcs:enable WordPress.Security.NonceVerification.Recommended
884
885 // Validate the tab parameter
886 if ( ! in_array( $default_tab, $valid_tabs, true ) ) {
887 $default_tab = 'top-campaigns';
888 }
889
890 return $default_tab;
891 }
892
893 /**
894 * Check if a tab should be active.
895 *
896 * @since 1.8.8
897 * @param string $tab_name The tab name to check.
898 * @return bool
899 */
900 private function is_tab_active( $tab_name ) {
901 return $this->get_default_tab() === $tab_name;
902 }
903
904 /**
905 * Render the tabs section with campaigns, donations, donors, and comments.
906 *
907 * @since 1.8.8
908 */
909 public function render_tabs_section() {
910 ?>
911 <section class="charitable-dashboard-v2-section charitable-dashboard-top-campaigns">
912 <header class="charitable-dashboard-v2-section-header">
913 <nav class="charitable-dashboard-v2-tab-nav">
914 <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">
915 Top Campaigns
916 </a>
917 <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">
918 Latest Donations
919 </a>
920 <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">
921 Top Donors
922 </a>
923 <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">
924 Comments
925 </a>
926 </nav>
927 </header>
928 <div class="charitable-dashboard-v2-section-content">
929 <?php $this->render_top_campaigns_tab(); ?>
930 <?php $this->render_latest_donations_tab(); ?>
931 <?php $this->render_top_donors_tab(); ?>
932 <?php $this->render_comments_tab(); ?>
933 </div>
934 </section>
935 <?php
936 }
937
938 /**
939 * Render the top campaigns tab content.
940 *
941 * @since 1.8.8
942 */
943 private function render_top_campaigns_tab() {
944 $campaigns = $this->get_top_campaigns();
945 ?>
946 <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">
947 <?php if ( ! empty( $campaigns ) ) : ?>
948 <div class="charitable-dashboard-v2-campaigns-table">
949 <table class="charitable-dashboard-v2-table">
950 <thead>
951 <tr>
952 <th>Campaign Title</th>
953 <th>Goal</th>
954 <th>Raised</th>
955 <th>Status</th>
956 <th>Progress</th>
957 </tr>
958 </thead>
959 <tbody>
960 <?php foreach ( $campaigns as $campaign ) : ?>
961 <tr>
962 <td class="campaign-title">
963 <a href="<?php echo esc_url( $campaign['url'] ); ?>">
964 <?php echo esc_html( $campaign['title'] ); ?>
965 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" xmlns="http://www.w3.org/2000/svg">
966 <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"/>
967 <path d="M4 6.16363L4.76364 6.92726L9.83636 1.85454V3.81818H10.9273V0H7.10909V1.09091H9.07272L4 6.16363Z" fill="#191D2D" fill-opacity="0.6"/>
968 </svg>
969 </a>
970 </td>
971 <td><?php echo esc_html( $campaign['goal'] ); ?></td>
972 <td><?php echo esc_html( $campaign['reach'] ); ?></td>
973 <td><span class="status-badge status-<?php echo esc_attr( $campaign['status'] ); ?>"><?php echo esc_html( $campaign['status_label'] ); ?></span></td>
974 <td>
975 <div class="progress-circle" data-progress="<?php echo esc_attr( $campaign['progress'] ); ?>">
976 <svg width="40" height="40" viewBox="0 0 40 40">
977 <circle cx="20" cy="20" r="18" fill="none" stroke="rgba(222, 234, 255, 1)" stroke-width="3"/>
978 <circle cx="20" cy="20" r="18" fill="none" stroke="rgba(76, 123, 207, 1)" stroke-width="3"/>
979 </svg>
980 <span class="progress-text"><?php echo esc_html( $campaign['progress'] ); ?>%</span>
981 </div>
982 </td>
983 </tr>
984 <?php endforeach; ?>
985 </tbody>
986 </table>
987 </div>
988 <?php else : ?>
989 <div class="charitable-dashboard-v2-empty-state">
990 <div class="charitable-dashboard-v2-empty-state-content">
991 <h3><?php esc_html_e( 'No Campaigns Yet!', 'charitable' ); ?></h3>
992 <p><?php esc_html_e( 'Create your first campaign to start receiving donations.', 'charitable' ); ?></p>
993 <?php if ( charitable_disable_legacy_campaigns() ) : ?>
994 <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">
995 <?php esc_html_e( 'Create Campaign', 'charitable' ); ?>
996 </a>
997 <?php else : ?>
998 <a href="<?php echo esc_url( admin_url( 'post-new.php?post_type=campaign' ) ); ?>" class="charitable-dashboard-v2-button charitable-dashboard-v2-button-primary">
999 <?php esc_html_e( 'Create Campaign', 'charitable' ); ?>
1000 </a>
1001 <?php endif; ?>
1002 </div>
1003 </div>
1004 <?php endif; ?>
1005 </div>
1006 <?php
1007 }
1008
1009 /**
1010 * Get top campaigns data.
1011 *
1012 * @since 1.8.8
1013 * @return array
1014 */
1015 private function get_top_campaigns() {
1016 // TEMPORARY TESTING: Return empty campaigns if testing mode is enabled
1017 if ( self::SHOW_EMPTY_DASHBOARD ) {
1018 return array();
1019 }
1020
1021 // Get top campaigns using the same method as legacy dashboard
1022 $args = array(
1023 'posts_per_page' => apply_filters( 'charitable_dashboard_top_campaigns_amount', 5 )
1024 );
1025 $top_campaigns = Charitable_Campaigns::ordered_by_amount( $args );
1026
1027 $campaigns = array();
1028
1029 if ( ! empty( $top_campaigns->posts ) ) {
1030 foreach ( $top_campaigns->posts as $campaign ) {
1031 $_campaign = charitable_get_campaign( $campaign->ID );
1032
1033 if ( ! $_campaign ) {
1034 continue;
1035 }
1036
1037 // Get campaign data (same as legacy)
1038 $donated_amount = $_campaign->get_donated_amount();
1039 $donation_goal = $_campaign->get_goal();
1040 $status = $_campaign->get_status();
1041 $percent_donated = $_campaign->get_percent_donated_raw();
1042
1043 // Filter out draft campaigns
1044 if ( 'draft' === $status ) {
1045 continue;
1046 }
1047
1048 // Cap percentage at 100%
1049 if ( $percent_donated > 100 ) {
1050 $percent_donated = 100;
1051 }
1052
1053 // Map status to display format (same as legacy)
1054 $status_labels = array(
1055 'active' => __( 'Active', 'charitable' ),
1056 'completed' => __( 'Finished', 'charitable' ),
1057 'failed' => __( 'Failed', 'charitable' )
1058 );
1059 $status_label = isset( $status_labels[ $status ] ) ? $status_labels[ $status ] : ucwords( $status );
1060
1061 // Map status for CSS class
1062 $status_class = $status;
1063 if ( 'completed' === $status ) {
1064 $status_class = 'finished';
1065 }
1066
1067 // Build campaign URL
1068 $url = charitable_disable_legacy_campaigns()
1069 ? admin_url( 'admin.php?page=charitable-campaign-builder&view=template&campaign_id=' . $campaign->ID )
1070 : admin_url( 'post.php?post=' . $campaign->ID . '&action=edit' );
1071
1072 // Handle goal display for finished campaigns
1073 $goal_display = '-';
1074 if ( $donation_goal ) {
1075 $goal_display = charitable_format_money( $donation_goal );
1076 } elseif ( 'completed' === $status ) {
1077 // For finished campaigns without goals, show the total raised
1078 $goal_display = charitable_format_money( $donated_amount );
1079 }
1080
1081 $campaigns[] = array(
1082 'title' => $campaign->post_title,
1083 'url' => $url,
1084 'goal' => $goal_display,
1085 'reach' => charitable_format_money( $donated_amount ),
1086 'status' => $status_class,
1087 'status_label' => $status_label,
1088 'progress' => round( $percent_donated )
1089 );
1090 }
1091 }
1092
1093 // If no campaigns, return empty array
1094 return $campaigns;
1095 }
1096
1097 /**
1098 * Render the latest donations tab content.
1099 *
1100 * @since 1.8.8
1101 */
1102 private function render_latest_donations_tab() {
1103 $donations = $this->get_latest_donations();
1104 ?>
1105 <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">
1106 <?php if ( ! empty( $donations ) ) : ?>
1107 <div class="charitable-dashboard-v2-table-container">
1108 <table class="charitable-dashboard-v2-table charitable-dashboard-v2-table-donations">
1109 <thead>
1110 <tr>
1111 <th>Donation Details</th>
1112 <th>Status</th>
1113 </tr>
1114 </thead>
1115 <tbody>
1116 <?php foreach ( $donations as $donation ) : ?>
1117 <tr>
1118 <td>
1119 <div class="donation-details">
1120 <div class="donation-header">
1121 <span class="donation-name">
1122 <a href="<?php echo esc_url( $donation['url'] ); ?>">
1123 <span class="donation-id">#<?php echo esc_html( $donation['id'] ); ?></span>
1124 <span class="donor-name"><?php echo esc_html( $donation['donor_name'] ); ?></span>
1125 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" xmlns="http://www.w3.org/2000/svg">
1126 <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"/>
1127 <path d="M4 6.16363L4.76364 6.92726L9.83636 1.85454V3.81818H10.9273V0H7.10909V1.09091H9.07272L4 6.16363Z" fill="#191D2D" fill-opacity="0.6"/>
1128 </svg>
1129 </a>
1130 </span>
1131 <span class="donation-timestamp"><?php echo esc_html( $donation['timestamp'] ); ?></span>
1132 <span class="donation-amount">
1133 <strong>Amount Donated:</strong> <?php echo esc_html( $donation['amount'] ); ?>
1134 </span>
1135 <span class="donation-method <?php echo esc_attr( $donation['method_class'] ); ?>">
1136 <strong>Method:</strong> <?php echo esc_html( $donation['method'] ); ?>
1137 </span>
1138 </div>
1139 </div>
1140 </td>
1141 <td><span class="status-badge status-<?php echo esc_attr( $donation['status'] ); ?>"><?php echo esc_html( $donation['status_label'] ); ?></span></td>
1142 </tr>
1143 <?php endforeach; ?>
1144 </tbody>
1145 </table>
1146 </div>
1147 <?php else : ?>
1148 <div class="charitable-dashboard-v2-empty-state">
1149 <div class="charitable-dashboard-v2-empty-state-content">
1150 <h3><?php esc_html_e( 'No Donations Yet!', 'charitable' ); ?></h3>
1151 <p><?php esc_html_e( 'Start receiving donations to see them here.', 'charitable' ); ?></p>
1152 <a href="<?php echo esc_url( admin_url( 'post-new.php?post_type=donation' ) ); ?>" class="charitable-dashboard-v2-button charitable-dashboard-v2-button-primary">
1153 <?php esc_html_e( 'Add Donation', 'charitable' ); ?>
1154 </a>
1155 </div>
1156 </div>
1157 <?php endif; ?>
1158 </div>
1159 <?php
1160 }
1161
1162 /**
1163 * Get latest donations data.
1164 *
1165 * @since 1.8.8
1166 * @return array
1167 */
1168 private function get_latest_donations() {
1169 // TEMPORARY TESTING: Return empty donations if testing mode is enabled
1170 if ( self::SHOW_EMPTY_DASHBOARD ) {
1171 return array();
1172 }
1173
1174 // Check for test parameter to simulate empty state
1175 // phpcs:disable WordPress.Security.NonceVerification.Recommended
1176 if ( isset( $_GET['charitable_test_no_donations'] ) && '1' === $_GET['charitable_test_no_donations'] ) {
1177 // phpcs:enable WordPress.Security.NonceVerification.Recommended
1178 return array();
1179 }
1180
1181 // Get latest donations using Charitable_Reports
1182 $charitable_reports = Charitable_Reports::get_instance();
1183 $args = array(
1184 'start_date' => gmdate( 'Y/m/d', strtotime( '-30 days' ) ),
1185 'end_date' => gmdate( 'Y/m/d' ),
1186 'days' => 30,
1187 );
1188 $charitable_reports->init_with_array( 'dashboard', $args );
1189 $donations = $charitable_reports->get_donations();
1190
1191 $latest_donations = array();
1192 $limit = apply_filters( 'charitable_dashboard_latest_donations_amount', 3 );
1193
1194 if ( ! empty( $donations ) ) {
1195 // Sort donations by date (newest first)
1196 usort( $donations, function( $a, $b ) {
1197 return strtotime( $b->post_date ) - strtotime( $a->post_date );
1198 });
1199
1200 // Get valid donation statuses for mapping
1201 $statuses = charitable_get_valid_donation_statuses();
1202
1203 $count = 0;
1204 foreach ( $donations as $donation ) {
1205 if ( $count >= $limit ) {
1206 break;
1207 }
1208
1209 $donation_id = intval( $donation->donation_id );
1210 $donor_name = trim( esc_html( $donation->first_name ) . ' ' . esc_html( $donation->last_name ) );
1211 $payment_method = strtolower( $donation->payment_gateway );
1212 $donation_status = $donation->donation_status;
1213 $amount = charitable_format_money( $donation->amount );
1214 $timestamp = human_time_diff( strtotime( $donation->post_date ), current_time( 'timestamp' ) ) . ' ago';
1215
1216 // Map status to display format
1217 $status_label = array_key_exists( $donation_status, $statuses ) ? $statuses[ $donation_status ] : ucwords( $donation_status );
1218
1219 // Map status for CSS class
1220 $status_class = $donation_status;
1221 if ( 'charitable-completed' === $donation_status ) {
1222 $status_class = 'paid';
1223 } elseif ( strpos( $donation_status, 'charitable-' ) === 0 ) {
1224 $status_class = substr( $donation_status, 11 ); // Remove 'charitable-' prefix
1225 }
1226
1227 // Determine payment method CSS class
1228 $method_class = '';
1229 if ( 'stripe' === $payment_method ) {
1230 $method_class = 'donation-method-stripe';
1231 } elseif ( 'paypal' === $payment_method ) {
1232 $method_class = 'donation-method-paypal';
1233 }
1234
1235 // Build donation URL
1236 $url = admin_url( 'post.php?post=' . $donation_id . '&action=edit' );
1237
1238 $latest_donations[] = array(
1239 'id' => $donation_id,
1240 'url' => $url,
1241 'donor_name' => $donor_name,
1242 'timestamp' => $timestamp,
1243 'amount' => $amount,
1244 'method' => ucwords( $payment_method ),
1245 'method_class' => $method_class,
1246 'status' => $status_class,
1247 'status_label' => $status_label
1248 );
1249
1250 $count++;
1251 }
1252 }
1253
1254 return $latest_donations;
1255 }
1256
1257 /**
1258 * Render the top donors tab content.
1259 *
1260 * @since 1.8.8
1261 */
1262 private function render_top_donors_tab() {
1263 $donors = $this->get_top_donors();
1264 ?>
1265 <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">
1266 <?php if ( ! empty( $donors ) ) : ?>
1267 <div class="charitable-dashboard-v2-table-container">
1268 <table class="charitable-dashboard-v2-table charitable-dashboard-v2-table-donors">
1269 <thead>
1270 <tr>
1271 <th style="width: 60px;"></th>
1272 <th style="width: 200px;">Name</th>
1273 <th style="width: 150px;">Total Donations</th>
1274 <th style="width: 120px;">Status</th>
1275 </tr>
1276 </thead>
1277 <tbody>
1278 <?php foreach ( $donors as $donor ) : ?>
1279 <tr>
1280 <td>
1281 <div class="donor-avatar">
1282 <img src="<?php echo esc_url( $donor['avatar'] ); ?>" alt="<?php echo esc_attr( $donor['name'] ); ?>" width="40" height="40">
1283 </div>
1284 </td>
1285 <td>
1286 <div class="donor-details">
1287 <div class="donor-name">
1288 <a href="<?php echo esc_url( $donor['url'] ); ?>" class="donor-name-link"><?php echo esc_html( $donor['name'] ); ?></a>
1289 </div>
1290 <div class="donor-email">
1291 <?php if ( ! empty( $donor['email'] ) ) : ?>
1292 <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>
1293 <?php else : ?>
1294 <span class="no-email"><?php esc_html_e( 'No email', 'charitable' ); ?></span>
1295 <?php endif; ?>
1296 </div>
1297 </div>
1298 </td>
1299 <td>
1300 <div class="donor-amount"><?php echo esc_html( $donor['total'] ); ?></div>
1301 </td>
1302 <td>
1303 <span class="status-badge status-<?php echo esc_attr( $donor['type'] ); ?>"><?php echo esc_html( $donor['type_label'] ); ?></span>
1304 </td>
1305 </tr>
1306 <?php endforeach; ?>
1307 </tbody>
1308 </table>
1309 </div>
1310 <?php else : ?>
1311 <?php
1312 // Determine if there is at least one non-trashed campaign.
1313 $has_non_trashed_campaign = false;
1314 foreach ( array( 'campaign', 'charitable_campaign' ) as $campaign_cpt ) {
1315 $counts = wp_count_posts( $campaign_cpt );
1316 if ( $counts && is_object( $counts ) ) {
1317 foreach ( $counts as $status => $num_posts ) {
1318 if ( 'trash' === $status ) {
1319 continue;
1320 }
1321 if ( (int) $num_posts > 0 ) {
1322 $has_non_trashed_campaign = true;
1323 break 2;
1324 }
1325 }
1326 }
1327 }
1328 ?>
1329 <div class="charitable-dashboard-v2-empty-state">
1330 <div class="charitable-dashboard-v2-empty-state-content">
1331 <h3><?php esc_html_e( 'No Donors Yet!', 'charitable' ); ?></h3>
1332 <p><?php esc_html_e( 'Start receiving donations to see your top donors here.', 'charitable' ); ?></p>
1333 <?php if ( $has_non_trashed_campaign ) : ?>
1334 <a href="<?php echo esc_url( admin_url( 'edit.php?post_type=donation' ) ); ?>" class="charitable-dashboard-v2-button charitable-dashboard-v2-button-primary">
1335 <?php esc_html_e( 'Add Donation', 'charitable' ); ?>
1336 </a>
1337 <?php else : ?>
1338 <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">
1339 <?php esc_html_e( 'Create Campaign', 'charitable' ); ?>
1340 </a>
1341 <?php endif; ?>
1342 </div>
1343 </div>
1344 <?php endif; ?>
1345 </div>
1346 <?php
1347 }
1348
1349 /**
1350 * Get top donors data.
1351 *
1352 * @since 1.8.8
1353 * @return array
1354 */
1355 private function get_top_donors() {
1356 // TEMPORARY TESTING: Return empty donors if testing mode is enabled
1357 if ( self::SHOW_EMPTY_DASHBOARD ) {
1358 return array();
1359 }
1360
1361 // Get configurable limit (default 10)
1362 $limit = apply_filters( 'charitable_dashboard_top_donors_limit', 10 );
1363
1364 // Initialize Charitable_Reports for all-time data (no date filtering)
1365 $charitable_reports = Charitable_Reports::get_instance();
1366 $args = array(
1367 'start_date' => false,
1368 'end_date' => false,
1369 'days' => false,
1370 );
1371 $charitable_reports->init_with_array( 'dashboard', $args );
1372
1373 // Get donations data first
1374 $charitable_reports->get_donations();
1375
1376 // Get top donors data (get more than needed to account for filtering)
1377 $top_donors = $charitable_reports->get_top_donors_overview( $limit * 2 );
1378
1379 if ( empty( $top_donors ) || ! is_array( $top_donors ) ) {
1380 return array();
1381 }
1382
1383 $donors = array();
1384 $unique_donors = array();
1385 $count = 0;
1386
1387 foreach ( $top_donors as $donor_data ) {
1388 // Skip if we've already processed this donor
1389 if ( in_array( $donor_data->donor_id, $unique_donors, true ) ) {
1390 continue;
1391 }
1392
1393 // Stop if we've reached the limit
1394 if ( $count >= $limit ) {
1395 break;
1396 }
1397
1398 $unique_donors[] = $donor_data->donor_id;
1399 $count++;
1400
1401 // Get donor name
1402 $donor_name = ! empty( $donor_data->donor_name ) ? $donor_data->donor_name : __( 'Unknown', 'charitable' );
1403
1404 // Get donor email and truncate if needed
1405 $donor_email = ! empty( $donor_data->donor_email ) ? $donor_data->donor_email : '';
1406 $donor_email_display = $donor_email;
1407 if ( strlen( $donor_email ) > 30 ) {
1408 $donor_email_display = substr( $donor_email, 0, 30 ) . '...';
1409 }
1410
1411 // Get avatar (WordPress default)
1412 $donor_avatar = ! empty( $donor_data->donor_avatar_url ) ? $donor_data->donor_avatar_url : false;
1413 if ( false === $donor_avatar && function_exists( 'get_avatar_url' ) && ! empty( $donor_data->donor_email ) ) {
1414 $donor_avatar = get_avatar_url(
1415 $donor_data->donor_email,
1416 array(
1417 'size' => 40,
1418 )
1419 );
1420 }
1421
1422 // Determine donor type and badge
1423 $donation_parent = false;
1424 if ( ! empty( $donor_data->donation_parent_id ) ) {
1425 $donation = charitable_get_donation( $donor_data->donation_parent_id );
1426 $donation_parent = ( $donation && is_object( $donation ) ) ? $donation->get( 'donation_type' ) : false;
1427 }
1428 $display_count = ! empty( $donor_data->total_donation_count ) ? intval( $donor_data->total_donation_count ) : 1;
1429
1430 $badge_css = strtolower( $donation_parent ) === 'recurring' ? 'recurring' : 'one-time';
1431 $badge_label = strtolower( $donation_parent ) === 'recurring' ? esc_html__( 'Recurring', 'charitable' ) : esc_html__( 'One-Time', 'charitable' );
1432
1433 // Override for multiple donations
1434 if ( $display_count > 1 ) {
1435 $badge_css = 'multiple';
1436 $badge_label = esc_html__( 'Multiple', 'charitable' );
1437 }
1438
1439 // Format total amount
1440 $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 );
1441
1442 $donors[] = array(
1443 'name' => $donor_name,
1444 'email' => $donor_email,
1445 'email_display' => $donor_email_display,
1446 'avatar' => $donor_avatar,
1447 'total' => $display_amount,
1448 'type' => $badge_css,
1449 'type_label' => $badge_label,
1450 'url' => admin_url( 'admin.php?page=charitable-donors' )
1451 );
1452 }
1453
1454 return $donors;
1455 }
1456
1457 /**
1458 * Render the comments tab content.
1459 *
1460 * @since 1.8.8
1461 */
1462 private function render_comments_tab() {
1463 ?>
1464 <div class="charitable-dashboard-v2-tab-content<?php echo $this->is_tab_active( 'comments' ) ? ' charitable-dashboard-v2-tab-content-active' : ''; ?>" data-tab="comments">
1465 <?php $this->render_comments_content(); ?>
1466 </div>
1467 <?php
1468 }
1469
1470 /**
1471 * Render comments content based on addon status.
1472 *
1473 * @since 1.8.8
1474 */
1475 private function render_comments_content() {
1476 $addon_status = $this->get_donor_comments_addon_status();
1477
1478 switch ( $addon_status ) {
1479 case 'active':
1480 $this->render_comments_table();
1481 break;
1482 case 'installed_inactive':
1483 $this->render_addon_inactive_message();
1484 break;
1485 case 'not_installed_pro':
1486 $this->render_addon_not_installed_pro_message();
1487 break;
1488 case 'not_installed_lite':
1489 default:
1490 $this->render_addon_not_installed_lite_message();
1491 break;
1492 }
1493 }
1494
1495 /**
1496 * Get donor comments addon status.
1497 *
1498 * @since 1.8.8
1499 * @return string Status: 'active', 'installed_inactive', 'not_installed_pro', 'not_installed_lite'
1500 */
1501 private function get_donor_comments_addon_status() {
1502 // Check if addon is active
1503 if ( class_exists( 'Charitable_Donor_Comments' ) ) {
1504 return 'active';
1505 }
1506
1507 // Check if addon is installed but not active
1508 $plugin_file = 'charitable-donor-comments/charitable-donor-comments.php';
1509 if ( file_exists( WP_PLUGIN_DIR . '/' . $plugin_file ) ) {
1510 return charitable_is_pro() ? 'installed_inactive' : 'not_installed_lite';
1511 }
1512
1513 // Addon not installed
1514 return charitable_is_pro() ? 'not_installed_pro' : 'not_installed_lite';
1515 }
1516
1517 /**
1518 * Render comments table with actual data.
1519 *
1520 * @since 1.8.8
1521 */
1522 private function render_comments_table() {
1523 $comments = $this->get_comments();
1524 ?>
1525 <div class="charitable-dashboard-v2-table-container">
1526 <table class="charitable-dashboard-v2-table charitable-dashboard-v2-table-comments">
1527 <thead>
1528 <tr>
1529 <th>Comment Details</th>
1530 <th>Status</th>
1531 </tr>
1532 </thead>
1533 <tbody>
1534 <?php if ( ! empty( $comments ) ) : ?>
1535 <?php foreach ( $comments as $comment ) : ?>
1536 <tr data-comment-id="<?php echo esc_attr( $comment['id'] ); ?>">
1537 <td>
1538 <div class="comment-details">
1539 <div class="comment-header">
1540 <span class="comment-name"><?php echo esc_html( $comment['name'] ); ?></span>
1541 <span class="comment-text">"<?php echo esc_html( $comment['text'] ); ?>"</span>
1542 </div>
1543 </div>
1544 </td>
1545 <td>
1546 <?php if ( 'approved' === $comment['status'] ) : ?>
1547 <span class="comment-status-approved">Approved</span>
1548 <?php else : ?>
1549 <div class="comment-actions">
1550 <a href="#" class="comment-action approve" data-comment-id="<?php echo esc_attr( $comment['id'] ); ?>">
1551 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
1552 <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" fill="#10B981"/>
1553 </svg>
1554 <span class="action-text">Approve</span>
1555 <span class="action-loading" style="display: none;">
1556 <svg class="charitable-spinner" width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
1557 <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-dasharray="31.416" stroke-dashoffset="31.416">
1558 <animate attributeName="stroke-dasharray" dur="2s" values="0 31.416;15.708 15.708;0 31.416" repeatCount="indefinite"/>
1559 <animate attributeName="stroke-dashoffset" dur="2s" values="0;-15.708;-31.416" repeatCount="indefinite"/>
1560 </circle>
1561 </svg>
1562 Processing...
1563 </span>
1564 </a>
1565 <span class="comment-action-separator">|</span>
1566 <a href="#" class="comment-action delete" data-comment-id="<?php echo esc_attr( $comment['id'] ); ?>">
1567 <img src="/wp-content/plugins/charitable/assets/images/icons/trash.svg" width="12" height="12" alt="Delete">
1568 <span class="action-text"><?php esc_html_e( 'Delete', 'charitable' ); ?></span>
1569 <span class="action-loading" style="display: none;">
1570 <svg class="charitable-spinner" width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
1571 <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-dasharray="31.416" stroke-dashoffset="31.416">
1572 <animate attributeName="stroke-dasharray" dur="2s" values="0 31.416;15.708 15.708;0 31.416" repeatCount="indefinite"/>
1573 <animate attributeName="stroke-dashoffset" dur="2s" values="0;-15.708;-31.416" repeatCount="indefinite"/>
1574 </circle>
1575 </svg>
1576 Processing...
1577 </span>
1578 </a>
1579 </div>
1580 <?php endif; ?>
1581 </td>
1582 </tr>
1583 <?php endforeach; ?>
1584 <?php else : ?>
1585 <tr>
1586 <td colspan="2" class="charitable-dashboard-v2-no-comments">
1587 <p><strong><?php echo esc_html__( 'No donor comments yet.', 'charitable' ); ?></strong></p>
1588 <p><?php echo esc_html__( 'Donor comments will appear here once donors start leaving messages with their donations.', 'charitable' ); ?></p>
1589 </td>
1590 </tr>
1591 <?php endif; ?>
1592 </tbody>
1593 </table>
1594 </div>
1595 <?php
1596 }
1597
1598 /**
1599 * Render message for addon not installed (Lite users).
1600 *
1601 * @since 1.8.8
1602 */
1603 private function render_addon_not_installed_lite_message() {
1604 ?>
1605 <div class="charitable-dashboard-v2-empty-state">
1606 <div class="charitable-dashboard-v2-empty-state-content">
1607 <h3><?php esc_html_e( 'Donor Comments Not Available', 'charitable' ); ?></h3>
1608 <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>
1609 <a href="https://wpcharitable.com/pricing" target="_blank" rel="noopener noreferrer" class="charitable-dashboard-v2-button charitable-dashboard-v2-button-primary">
1610 <?php esc_html_e( 'Upgrade to Pro', 'charitable' ); ?>
1611 </a>
1612 </div>
1613 </div>
1614 <?php
1615 }
1616
1617 /**
1618 * Render message for addon not installed (Pro users).
1619 *
1620 * @since 1.8.8
1621 */
1622 private function render_addon_not_installed_pro_message() {
1623 ?>
1624 <div class="charitable-dashboard-v2-empty-state">
1625 <div class="charitable-dashboard-v2-empty-state-content">
1626 <h3><?php esc_html_e( 'Donor Comments Addon Not Installed', 'charitable' ); ?></h3>
1627 <p><?php esc_html_e( 'Download and activate the Donor Comments addon to view donor comments.', 'charitable' ); ?></p>
1628 <a href="https://wpcharitable.com/addons" target="_blank" rel="noopener noreferrer" class="charitable-dashboard-v2-button charitable-dashboard-v2-button-primary">
1629 <?php esc_html_e( 'Download Addon', 'charitable' ); ?>
1630 </a>
1631 </div>
1632 </div>
1633 <?php
1634 }
1635
1636 /**
1637 * Render message for addon installed but not activated.
1638 *
1639 * @since 1.8.8
1640 */
1641 private function render_addon_inactive_message() {
1642 ?>
1643 <div class="charitable-dashboard-v2-empty-state">
1644 <div class="charitable-dashboard-v2-empty-state-content">
1645 <h3><?php esc_html_e( 'Donor Comments Addon Not Activated', 'charitable' ); ?></h3>
1646 <p><?php esc_html_e( 'The Donor Comments addon is installed but not activated. Click the button below to activate it.', 'charitable' ); ?></p>
1647 <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">
1648 <span class="button-text"><?php esc_html_e( 'Activate Addon', 'charitable' ); ?></span>
1649 <span class="button-loading" style="display: none;">
1650 <svg class="charitable-spinner" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
1651 <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-dasharray="31.416" stroke-dashoffset="31.416">
1652 <animate attributeName="stroke-dasharray" dur="2s" values="0 31.416;15.708 15.708;0 31.416" repeatCount="indefinite"/>
1653 <animate attributeName="stroke-dashoffset" dur="2s" values="0;-15.708;-31.416" repeatCount="indefinite"/>
1654 </circle>
1655 </svg>
1656 <?php esc_html_e( 'Activating...', 'charitable' ); ?>
1657 </span>
1658 </button>
1659 </div>
1660 </div>
1661 <?php
1662 }
1663
1664 /**
1665 * Get comments data.
1666 *
1667 * @since 1.8.8
1668 * @return array
1669 */
1670 private function get_comments() {
1671 // TEMPORARY TESTING: Return empty comments if testing mode is enabled
1672 if ( self::SHOW_EMPTY_DASHBOARD ) {
1673 return array();
1674 }
1675
1676 // Check if donor comments addon is active
1677 if ( ! class_exists( 'Charitable_Donor_Comments' ) ) {
1678 return array();
1679 }
1680
1681 // Get comments limit (configurable)
1682 $limit = apply_filters( 'charitable_dashboard_comments_limit', 10 );
1683
1684 // Get charitable comments
1685 $comments = get_comments( array(
1686 'type' => 'charitable_comment',
1687 'status' => 'all', // Include pending and approved
1688 'number' => $limit,
1689 'orderby' => 'comment_date',
1690 'order' => 'DESC',
1691 'post_type' => Charitable::CAMPAIGN_POST_TYPE,
1692 ) );
1693
1694 $formatted_comments = array();
1695
1696 if ( ! empty( $comments ) ) {
1697 foreach ( $comments as $comment ) {
1698 // Get comment author (handle anonymous)
1699 $author = get_comment_author( $comment );
1700 if ( empty( $author ) ) {
1701 $author = __( 'Anonymous', 'charitable' );
1702 }
1703
1704 // Get comment text and sanitize it
1705 $text = get_comment_text( $comment );
1706 if ( empty( $text ) ) {
1707 continue; // Skip comments without text
1708 }
1709
1710 // Strip HTML tags except for bold and italic, then convert to proper formatting
1711 $allowed_tags = array(
1712 'strong' => array(),
1713 'b' => array(),
1714 'em' => array(),
1715 'i' => array(),
1716 );
1717 $text = wp_kses( $text, $allowed_tags );
1718
1719 // Convert HTML tags to proper bold/italic formatting
1720 $text = str_replace( array( '<strong>', '</strong>', '<b>', '</b>' ), array( '<strong>', '</strong>', '<strong>', '</strong>' ), $text );
1721 $text = str_replace( array( '<em>', '</em>', '<i>', '</i>' ), array( '<em>', '</em>', '<em>', '</em>' ), $text );
1722
1723 // Determine status
1724 $status = $comment->comment_approved ? 'approved' : 'pending';
1725
1726 $formatted_comments[] = array(
1727 'id' => $comment->comment_ID,
1728 'name' => $author,
1729 'text' => $text,
1730 'status' => $status,
1731 'date' => $comment->comment_date,
1732 );
1733 }
1734 }
1735
1736 return $formatted_comments;
1737 }
1738
1739 /**
1740 * AJAX handler for activating addons.
1741 *
1742 * @since 1.8.8
1743 */
1744 public function ajax_activate_addon() {
1745 // Verify nonce
1746 // phpcs:disable WordPress.Security.NonceVerification.Missing
1747 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1748 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( wp_unslash( $_POST['nonce'] ), 'charitable-admin' ) ) {
1749 wp_die( esc_html( __( 'Security check failed', 'charitable' ) ) );
1750 }
1751
1752 // Check user capabilities
1753 if ( ! current_user_can( 'activate_plugins' ) ) {
1754 wp_send_json_error( array( 'message' => __( 'You do not have permission to activate plugins.', 'charitable' ) ) );
1755 }
1756
1757 $plugin = isset( $_POST['plugin'] ) ? sanitize_text_field( wp_unslash( $_POST['plugin'] ) ) : '';
1758 // phpcs:enable WordPress.Security.NonceVerification.Missing
1759
1760 if ( empty( $plugin ) ) {
1761 wp_send_json_error( array( 'message' => __( 'Plugin not specified.', 'charitable' ) ) );
1762 }
1763
1764 // Check if plugin exists
1765 if ( ! file_exists( WP_PLUGIN_DIR . '/' . $plugin ) ) {
1766 wp_send_json_error( array( 'message' => __( 'Plugin file not found.', 'charitable' ) ) );
1767 }
1768
1769 // Activate the plugin
1770 $result = activate_plugin( $plugin );
1771
1772 if ( is_wp_error( $result ) ) {
1773 wp_send_json_error( array( 'message' => $result->get_error_message() ) );
1774 }
1775
1776 wp_send_json_success( array( 'message' => __( 'Addon activated successfully!', 'charitable' ) ) );
1777 }
1778
1779
1780 /**
1781 * AJAX handler for approving comments.
1782 *
1783 * @since 1.8.8
1784 */
1785 public function ajax_approve_comment() {
1786 // Verify nonce
1787 // phpcs:disable WordPress.Security.NonceVerification.Missing
1788 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1789 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( wp_unslash( $_POST['nonce'] ), 'charitable-admin' ) ) {
1790 wp_die( esc_html( __( 'Security check failed', 'charitable' ) ) );
1791 }
1792
1793 // Check user capabilities
1794 if ( ! current_user_can( 'moderate_comments' ) ) {
1795 wp_send_json_error( array( 'message' => __( 'You do not have permission to moderate comments.', 'charitable' ) ) );
1796 }
1797
1798 $comment_id = isset( $_POST['comment_id'] ) ? intval( wp_unslash( $_POST['comment_id'] ) ) : 0;
1799 // phpcs:enable WordPress.Security.NonceVerification.Missing
1800
1801 if ( empty( $comment_id ) ) {
1802 wp_send_json_error( array( 'message' => __( 'Comment ID not specified.', 'charitable' ) ) );
1803 }
1804
1805 // Check if comment exists
1806 $comment = get_comment( $comment_id );
1807 if ( ! $comment ) {
1808 wp_send_json_error( array( 'message' => __( 'Comment not found.', 'charitable' ) ) );
1809 }
1810
1811 // Check if it's a charitable comment
1812 if ( 'charitable_comment' !== $comment->comment_type ) {
1813 wp_send_json_error( array( 'message' => __( 'Invalid comment type.', 'charitable' ) ) );
1814 }
1815
1816 // Approve the comment
1817 $result = wp_set_comment_status( $comment_id, 'approve' );
1818
1819 if ( is_wp_error( $result ) ) {
1820 wp_send_json_error( array( 'message' => $result->get_error_message() ) );
1821 }
1822
1823 wp_send_json_success( array( 'message' => __( 'Comment approved successfully!', 'charitable' ) ) );
1824 }
1825
1826 /**
1827 * AJAX handler for deleting comments.
1828 *
1829 * @since 1.8.8
1830 */
1831 public function ajax_delete_comment() {
1832 // Verify nonce
1833 // phpcs:disable WordPress.Security.NonceVerification.Missing
1834 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1835 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( wp_unslash( $_POST['nonce'] ), 'charitable-admin' ) ) {
1836 wp_die( esc_html( __( 'Security check failed', 'charitable' ) ) );
1837 }
1838
1839 // Check user capabilities
1840 if ( ! current_user_can( 'moderate_comments' ) ) {
1841 wp_send_json_error( array( 'message' => __( 'You do not have permission to moderate comments.', 'charitable' ) ) );
1842 }
1843
1844 $comment_id = isset( $_POST['comment_id'] ) ? intval( wp_unslash( $_POST['comment_id'] ) ) : 0;
1845 // phpcs:enable WordPress.Security.NonceVerification.Missing
1846
1847 if ( empty( $comment_id ) ) {
1848 wp_send_json_error( array( 'message' => __( 'Comment ID not specified.', 'charitable' ) ) );
1849 }
1850
1851 // Check if comment exists
1852 $comment = get_comment( $comment_id );
1853 if ( ! $comment ) {
1854 wp_send_json_error( array( 'message' => __( 'Comment not found.', 'charitable' ) ) );
1855 }
1856
1857 // Check if it's a charitable comment
1858 if ( 'charitable_comment' !== $comment->comment_type ) {
1859 wp_send_json_error( array( 'message' => __( 'Invalid comment type.', 'charitable' ) ) );
1860 }
1861
1862 // Delete the comment (force delete)
1863 $result = wp_delete_comment( $comment_id, true );
1864
1865 if ( ! $result ) {
1866 wp_send_json_error( array( 'message' => __( 'Failed to delete comment.', 'charitable' ) ) );
1867 }
1868
1869 wp_send_json_success( array( 'message' => __( 'Comment deleted successfully!', 'charitable' ) ) );
1870 }
1871
1872 /**
1873 * Render the upgrade section.
1874 *
1875 * @since 1.8.8
1876 */
1877 public function render_upgrade_section() {
1878 // Don't show upgrade section if user already has Pro license
1879 if ( charitable_is_pro() ) {
1880 return;
1881 }
1882 ?>
1883 <section class="charitable-dashboard-v2-section">
1884 <h4 class="charitable-dashboard-v2-upgrade-title"><?php esc_html_e( 'Upgrade to Pro to Unlock Powerful Donation Features', 'charitable' ); ?></h4>
1885 <div class="charitable-dashboard-v2-upgrade-features">
1886 <div class="charitable-dashboard-v2-upgrade-column">
1887 <?php $this->render_upgrade_features_left(); ?>
1888 </div>
1889 <div class="charitable-dashboard-v2-upgrade-column">
1890 <?php $this->render_upgrade_features_right(); ?>
1891 </div>
1892 </div>
1893 <div class="charitable-dashboard-v2-upgrade-actions">
1894 <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>
1895 <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>
1896 </div>
1897 </section>
1898 <?php
1899 }
1900
1901 /**
1902 * Render left column upgrade features.
1903 *
1904 * @since 1.8.8
1905 */
1906 private function render_upgrade_features_left() {
1907 $features = array(
1908 __( 'Recurring Donations', 'charitable' ),
1909 __( 'Peer-to-Peer Fundraising', 'charitable' ),
1910 __( 'Donor Database', 'charitable' ),
1911 __( 'Donor Dashboard', 'charitable' ),
1912 __( 'PDF & Annual Receipts', 'charitable' )
1913 );
1914
1915 foreach ( $features as $feature ) :
1916 ?>
1917 <div class="charitable-dashboard-v2-upgrade-feature">
1918 <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">
1919 <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"/>
1920 </svg>
1921 <span class="charitable-dashboard-v2-upgrade-feature-text"><?php echo esc_html( $feature ); ?></span>
1922 </div>
1923 <?php
1924 endforeach;
1925 }
1926
1927 /**
1928 * Render right column upgrade features.
1929 *
1930 * @since 1.8.8
1931 */
1932 private function render_upgrade_features_right() {
1933 $features = array(
1934 __( 'Anonymous Giving', 'charitable' ),
1935 __( 'Fee Coverage Option', 'charitable' ),
1936 __( 'Email Marketing Integration', 'charitable' ),
1937 __( 'Built-In Analytics & Reports', 'charitable' ),
1938 __( 'Flexible Payment Methods', 'charitable' )
1939 );
1940
1941 foreach ( $features as $feature ) :
1942 ?>
1943 <div class="charitable-dashboard-v2-upgrade-feature">
1944 <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">
1945 <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"/>
1946 </svg>
1947 <span class="charitable-dashboard-v2-upgrade-feature-text"><?php echo esc_html( $feature ); ?></span>
1948 </div>
1949 <?php
1950 endforeach;
1951 }
1952
1953 /**
1954 * Render the "Free Site Analysis" promo card in the dashboard right column. Links to the
1955 * Reports > Site Analysis tab, where the user runs their free Fundraising Score analysis.
1956 *
1957 * @since 1.8.12
1958 *
1959 * @return void
1960 */
1961 public function render_site_analysis_section() {
1962 $charitable_sa_url = admin_url( 'admin.php?page=charitable-reports&tab=site-analysis' );
1963 $charitable_sa_nonce = wp_create_nonce( 'charitable_site_analysis_click' );
1964 ?>
1965 <style>
1966 /* This card puts content directly in section-content, which has no horizontal padding by
1967 default; restore the standard section padding for this card only. */
1968 #charitable-dashboard-v2-site-analysis .charitable-dashboard-v2-section-content { padding: 0 20px 20px; }
1969 #charitable-dashboard-v2-site-analysis .charitable-sa-dash-new {
1970 display: inline-block; margin-left: 8px; vertical-align: middle;
1971 background: #5AA152; color: #fff; font-size: 10px; font-weight: 700;
1972 line-height: 1; letter-spacing: .04em; text-transform: uppercase;
1973 padding: 3px 7px; border-radius: 3px;
1974 }
1975 #charitable-dashboard-v2-site-analysis .charitable-sa-dash-body { display: flex; align-items: flex-start; gap: 14px; }
1976 #charitable-dashboard-v2-site-analysis .charitable-sa-dash-icon {
1977 flex: none; width: 40px; height: 40px; border-radius: 8px;
1978 display: flex; align-items: center; justify-content: center; background: #eaf4e8; color: #5AA152;
1979 }
1980 #charitable-dashboard-v2-site-analysis .charitable-sa-dash-icon .dashicons { font-size: 22px; width: 22px; height: 22px; }
1981 #charitable-dashboard-v2-site-analysis .charitable-sa-dash-text { margin: 0 0 12px; color: #50575e; font-size: 13.5px; line-height: 1.5; }
1982 #charitable-dashboard-v2-site-analysis .charitable-sa-dash-button { background-color: #5AA152 !important; border-color: #5AA152 !important; color: #fff !important; text-shadow: none; }
1983 #charitable-dashboard-v2-site-analysis .charitable-sa-dash-button:hover,
1984 #charitable-dashboard-v2-site-analysis .charitable-sa-dash-button:focus { background-color: #3f7539 !important; border-color: #3f7539 !important; color: #fff !important; }
1985 </style>
1986
1987 <section id="charitable-dashboard-v2-site-analysis" class="charitable-dashboard-v2-section">
1988 <header class="charitable-dashboard-v2-section-header">
1989 <h3>
1990 <?php esc_html_e( 'Free Site Analysis', 'charitable' ); ?>
1991 <span class="charitable-sa-dash-new"><?php esc_html_e( 'New', 'charitable' ); ?></span>
1992 </h3>
1993 </header>
1994 <div class="charitable-dashboard-v2-section-content">
1995 <div class="charitable-sa-dash-body">
1996 <span class="charitable-sa-dash-icon"><span class="dashicons dashicons-chart-area" aria-hidden="true"></span></span>
1997 <div>
1998 <p class="charitable-sa-dash-text">
1999 <?php esc_html_e( 'See how your fundraising setup measures up and get specific, prioritized steps to raise more. Free to run, based on your current Charitable configuration.', 'charitable' ); ?>
2000 </p>
2001 <a href="<?php echo esc_url( $charitable_sa_url ); ?>" class="button button-primary charitable-sa-dash-button">
2002 <?php esc_html_e( 'Run Your Free Analysis', 'charitable' ); ?>
2003 </a>
2004 </div>
2005 </div>
2006 </div>
2007 </section>
2008 <script>
2009 /* Record the click via Charitable's consent-gated usage tracking (sendBeacon so it fires
2010 before the link navigates away). No effect if the user hasn't opted into usage tracking. */
2011 ( function () {
2012 var link = document.querySelector( '#charitable-dashboard-v2-site-analysis .charitable-sa-dash-button' );
2013 if ( ! link || ! navigator.sendBeacon ) { return; }
2014 link.addEventListener( 'click', function () {
2015 try {
2016 var fd = new FormData();
2017 fd.append( 'action', 'charitable_track_site_analysis_click' );
2018 fd.append( 'nonce', '<?php echo esc_js( $charitable_sa_nonce ); ?>' );
2019 navigator.sendBeacon( '<?php echo esc_url_raw( admin_url( 'admin-ajax.php' ) ); ?>', fd );
2020 } catch ( e ) {}
2021 } );
2022 } )();
2023 </script>
2024 <?php
2025 }
2026
2027 /**
2028 * AJAX: record a click on the dashboard "Run Your Free Analysis" button via the same
2029 * consent-gated usage-tracking channel as the recommendation cards. Lands in the
2030 * charitable_recommended_plugins_clicked option, which Charitable_Tracking reports to
2031 * awesomemotive as recommended_plugins_clicked (filter by context 'dashboard_site_analysis').
2032 *
2033 * @since 1.8.12
2034 *
2035 * @return void
2036 */
2037 public function ajax_track_site_analysis_click() {
2038 if ( ! check_ajax_referer( 'charitable_site_analysis_click', 'nonce', false ) || ! current_user_can( 'manage_charitable_settings' ) ) {
2039 wp_send_json_error();
2040 }
2041 $this->track_recommendation_click( 'clicked', 'site-analysis', 'feature', 'dashboard_site_analysis' );
2042 wp_send_json_success();
2043 }
2044
2045 /**
2046 * Render the enhance campaign section.
2047 *
2048 * @since 1.8.8
2049 */
2050 public function render_enhance_campaign_section() {
2051 // Hide section if no items to show
2052 if ( ! $this->should_show_enhance_section() ) {
2053 return;
2054 }
2055
2056 ?>
2057
2058 <style>
2059 .charitable-dashboard-v2-setup-button {
2060 background-color: #28a745 !important;
2061 color: white !important;
2062 border-color: #28a745 !important;
2063 }
2064 .charitable-dashboard-v2-setup-button:hover {
2065 background-color: #218838 !important;
2066 border-color: #1e7e34 !important;
2067 }
2068 .charitable-dashboard-v2-activate-button:disabled {
2069 background-color: #6c757d !important;
2070 color: #fff !important;
2071 border-color: #6c757d !important;
2072 opacity: 0.65 !important;
2073 cursor: not-allowed !important;
2074 }
2075 .charitable-dashboard-v2-upgrade-button {
2076 background-color: #E38632 !important;
2077 color: white !important;
2078 border-color: #E38632 !important;
2079 }
2080 .charitable-dashboard-v2-upgrade-button:hover {
2081 background-color: #E38632 !important;
2082 border-color: #E38632 !important;
2083 }
2084 .charitable-dashboard-v2-installed-button {
2085 background-color: #6c757d !important;
2086 color: #fff !important;
2087 border-color: #6c757d !important;
2088 cursor: not-allowed !important;
2089 }
2090
2091 /* Hide all text in stats row initially */
2092 .charitable-dashboard-v2-stats-row .charitable-dashboard-v2-stat-title,
2093 .charitable-dashboard-v2-stats-row .charitable-dashboard-v2-stat-amount {
2094 visibility: hidden;
2095 }
2096
2097 /* Show text when loading is complete */
2098 .charitable-dashboard-v2-stats-row.charitable-dashboard-stats-loaded .charitable-dashboard-v2-stat-title,
2099 .charitable-dashboard-v2-stats-row.charitable-dashboard-stats-loaded .charitable-dashboard-v2-stat-amount {
2100 visibility: visible;
2101 }
2102 </style>
2103
2104 <section id="charitable-dashboard-v2-enhance-campaign" class="charitable-dashboard-v2-section">
2105 <header class="charitable-dashboard-v2-section-header">
2106 <h3><?php esc_html_e( 'Enhance Your Campaign', 'charitable' ); ?></h3>
2107 </header>
2108 <div class="charitable-dashboard-v2-section-content">
2109 <div class="charitable-dashboard-v2-enhance-grid">
2110 <?php $this->render_enhance_grid_items(); ?>
2111 </div>
2112 <footer class="charitable-dashboard-v2-enhance-campaign-footer">
2113 <div class="charitable-dashboard-v2-enhance-campaign-footer-content">
2114 <div class="charitable-dashboard-v2-enhance-campaign-footer-left">
2115 <a href="<?php echo esc_url( admin_url( 'admin.php?page=charitable-addons' ) ); ?>" class="charitable-dashboard-v2-addons-link">
2116 <span class="charitable-dashboard-v2-addons-text"><?php esc_html_e( 'View All Addons', 'charitable' ); ?></span>
2117 <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">
2118 <path d="M6 12L10 8L6 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
2119 </svg>
2120 </a>
2121 </div>
2122 </div>
2123 </footer>
2124 </div>
2125 </section>
2126 <?php
2127 }
2128
2129 /**
2130 * Render enhance grid items.
2131 *
2132 * @since 1.8.8
2133 */
2134 private function render_enhance_grid_items() {
2135 $items = $this->get_enhance_items();
2136
2137 foreach ( $items as $item ) :
2138 $button_attributes = '';
2139 if ( isset( $item['button_action'] ) ) {
2140 $button_attributes = 'data-action="' . esc_attr( $item['button_action'] ) . '"';
2141 $button_attributes .= ' data-slug="' . esc_attr( $item['slug'] ) . '"';
2142 $button_attributes .= ' data-type="' . esc_attr( $item['type'] ) . '"';
2143
2144 // Add basename for third-party plugins and charitable addons
2145 if ( isset( $item['basename'] ) ) {
2146 $button_attributes .= ' data-basename="' . esc_attr( $item['basename'] ) . '"';
2147 }
2148
2149 // Add setup URL for third-party plugins (always available for setup state)
2150 if ( $item['type'] === 'third_party' && isset( $item['setup_url'] ) ) {
2151 $button_attributes .= ' data-setup-url="' . esc_url( $item['setup_url'] ) . '"';
2152 }
2153
2154 // Add upgrade URL for charitable addons
2155 if ( $item['type'] === 'charitable_addon' && isset( $item['upgrade_url'] ) ) {
2156 $button_attributes .= ' data-upgrade-url="' . esc_url( $item['upgrade_url'] ) . '"';
2157 }
2158
2159 // Add disabled state
2160 if ( isset( $item['button_disabled'] ) && $item['button_disabled'] ) {
2161 $button_attributes .= ' disabled';
2162 }
2163 }
2164 ?>
2165 <div class="charitable-dashboard-v2-enhance-grid-item">
2166 <div class="charitable-dashboard-v2-enhance-grid-content">
2167 <div class="charitable-dashboard-v2-enhance-grid-header">
2168 <div class="charitable-dashboard-v2-enhance-grid-icon">
2169 <?php
2170 // Allow SVG icons through as trusted raw output.
2171 if ( isset( $item['icon'] ) && is_string( $item['icon'] ) ) {
2172 echo $item['icon']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- SVG markup is trusted as curated/provided internally.
2173 }
2174 ?>
2175 </div>
2176 <h4 class="charitable-dashboard-v2-enhance-grid-title"><?php echo esc_html( $item['title'] ); ?></h4>
2177 </div>
2178 <p class="charitable-dashboard-v2-enhance-grid-description"><?php echo esc_html( $item['description'] ); ?></p>
2179 <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>
2180 </div>
2181 </div>
2182 <?php
2183 endforeach;
2184 }
2185
2186 /**
2187 * Get enhance items data.
2188 *
2189 * @since 1.8.8
2190 * @return array
2191 */
2192 private function get_enhance_items() {
2193 // TODO: Future caching implementation
2194 // Consider caching plugin states to avoid repeated get_plugins() calls
2195 // Cache key: 'charitable_dashboard_plugin_states'
2196 // Cache duration: 1 hour or until plugin state changes
2197 // Invalidate cache when: plugins installed/activated/deactivated
2198
2199 $items = array();
2200 $third_party_plugins = $this->get_curated_third_party_plugins();
2201 $charitable_addons = $this->get_curated_charitable_addons();
2202
2203 // Get third-party plugin data
2204 $all_plugins = $this->get_third_party_plugin_data();
2205
2206 // Get first 3 available third-party plugins
2207 $available_third_party = 0;
2208 foreach ( $third_party_plugins as $slug ) {
2209 if ( $available_third_party >= 3 ) {
2210 break;
2211 }
2212
2213 if ( isset( $all_plugins[ $slug ] ) ) {
2214 $plugin_data = $all_plugins[ $slug ];
2215 if ( ! $plugin_data['active'] ) { // Show if not active (installed or not)
2216 $items[] = $this->format_plugin_item( $plugin_data, 'third_party' );
2217 $available_third_party++;
2218 }
2219 }
2220 }
2221
2222 // Fill remaining slots with Charitable addons
2223 $remaining_slots = 4 - count( $items );
2224 $available_charitable = 0;
2225
2226 foreach ( $charitable_addons as $slug ) {
2227 if ( $available_charitable >= $remaining_slots ) {
2228 break;
2229 }
2230
2231 $addon_data = $this->get_charitable_addon_data( $slug );
2232
2233 if ( $addon_data && ! ( $addon_data['installed'] && $addon_data['active'] ) ) {
2234 $items[] = $this->format_addon_item( $addon_data );
2235 $available_charitable++;
2236 }
2237 }
2238
2239 return $items;
2240 }
2241
2242 /**
2243 * AJAX handler for installing Charitable addons.
2244 *
2245 * @since 1.8.8
2246 */
2247 public function ajax_install_charitable_addon() {
2248 // Security check
2249 // phpcs:disable WordPress.Security.NonceVerification.Missing
2250 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
2251 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( wp_unslash( $_POST['nonce'] ), 'charitable-admin' ) ) {
2252 wp_send_json_error( 'Invalid nonce.' );
2253 return;
2254 }
2255
2256 // Permission check
2257 if ( ! current_user_can( 'install_plugins' ) ) {
2258 wp_send_json_error( 'Insufficient permissions.' );
2259 return;
2260 }
2261
2262 $slug = isset( $_POST['slug'] ) ? sanitize_text_field( wp_unslash( $_POST['slug'] ) ) : '';
2263 // phpcs:enable WordPress.Security.NonceVerification.Missing
2264 if ( empty( $slug ) ) {
2265 wp_send_json_error( 'Addon slug is required.' );
2266 return;
2267 }
2268
2269 // Get addon data
2270 $addon_data = $this->get_charitable_addon_data( $slug );
2271 if ( ! $addon_data ) {
2272 wp_send_json_error( 'Addon not found.' );
2273 return;
2274 }
2275
2276 // Check if already installed
2277 if ( $addon_data['installed'] ) {
2278 wp_send_json_error( 'Addon is already installed.' );
2279 return;
2280 }
2281
2282 // Check if download URL is available
2283 if ( ! isset( $addon_data['download_url'] ) || empty( $addon_data['download_url'] ) ) {
2284 wp_send_json_error( 'Download URL not available for this addon.' );
2285 return;
2286 }
2287
2288 // Download and install the addon
2289 $result = $this->install_charitable_addon_from_url( $addon_data['download_url'], $slug );
2290
2291 if ( is_wp_error( $result ) ) {
2292 wp_send_json_error( $result->get_error_message() );
2293 return;
2294 }
2295
2296 wp_send_json_success( array(
2297 'message' => __( 'Addon installed successfully.', 'charitable' ),
2298 'button_text' => __( 'Activate', 'charitable' ),
2299 'button_class' => 'charitable-dashboard-v2-activate-button',
2300 'button_action' => 'activate_addon'
2301 ) );
2302 }
2303
2304 /**
2305 * AJAX handler for activating Charitable addons.
2306 *
2307 * @since 1.8.8
2308 */
2309 public function ajax_activate_charitable_addon() {
2310 // Security check
2311 // phpcs:disable WordPress.Security.NonceVerification.Missing
2312 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
2313 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( wp_unslash( $_POST['nonce'] ), 'charitable-admin' ) ) {
2314 wp_send_json_error( 'Invalid nonce.' );
2315 return;
2316 }
2317
2318 // Permission check
2319 if ( ! current_user_can( 'activate_plugins' ) ) {
2320 wp_send_json_error( 'Insufficient permissions.' );
2321 return;
2322 }
2323
2324 $slug = isset( $_POST['slug'] ) ? sanitize_text_field( wp_unslash( $_POST['slug'] ) ) : '';
2325 // phpcs:enable WordPress.Security.NonceVerification.Missing
2326 if ( empty( $slug ) ) {
2327 wp_send_json_error( 'Addon slug is required.' );
2328 return;
2329 }
2330
2331 // Get addon data
2332 $addon_data = $this->get_charitable_addon_data( $slug );
2333 if ( ! $addon_data ) {
2334 wp_send_json_error( 'Addon not found.' );
2335 return;
2336 }
2337
2338 // Check if installed
2339 if ( ! $addon_data['installed'] ) {
2340 wp_send_json_error( 'Addon is not installed.' );
2341 return;
2342 }
2343
2344 // Check if already active
2345 if ( $addon_data['active'] ) {
2346 wp_send_json_error( 'Addon is already active.' );
2347 return;
2348 }
2349
2350 // Activate the plugin
2351 $result = activate_plugin( $addon_data['basename'] );
2352
2353 if ( is_wp_error( $result ) ) {
2354 wp_send_json_error( 'Failed to activate addon: ' . $result->get_error_message() );
2355 return;
2356 }
2357
2358 wp_send_json_success( array(
2359 'message' => __( 'Addon activated successfully.', 'charitable' ),
2360 'button_text' => __( 'Installed', 'charitable' ),
2361 'button_class' => 'charitable-dashboard-v2-installed-button',
2362 'button_action' => 'installed',
2363 'button_disabled' => true
2364 ) );
2365 }
2366
2367 /**
2368 * Get curated third-party plugins list.
2369 *
2370 * @since 1.8.8
2371 * @return array
2372 */
2373 private function get_curated_third_party_plugins() {
2374 return array(
2375 'monsterinsights', // Analytics
2376 'wpforms-lite', // Forms
2377 'aioseo', // SEO
2378 'duplicator', // Backup
2379 'pushengage', // Notifications
2380 'uncanny-automator', // Automation
2381 'envira-gallery', // Gallery
2382 // Add more as you review
2383 );
2384 }
2385
2386 /**
2387 * Get custom SVG icons for plugins.
2388 *
2389 * @since 1.8.8
2390 * @return array
2391 */
2392 private function get_custom_plugin_icons() {
2393 return array(
2394 '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>',
2395 'all-in-one-seo-pack' => '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
2396 <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"/>
2397 </svg>',
2398 'duplicator' => '<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
2399 <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"/>
2400 </svg>',
2401 // Add more custom SVG icons as needed
2402 );
2403 }
2404
2405 /**
2406 * Get curated Charitable addons list.
2407 *
2408 * @since 1.8.8
2409 * @return array
2410 */
2411 private function get_curated_charitable_addons() {
2412 return array(
2413 'charitable-recurring',
2414 'charitable-donortrust',
2415 'charitable-gift-aid',
2416 // Add more as you review
2417 );
2418 }
2419
2420 /**
2421 * Get custom title overrides for Charitable addons.
2422 *
2423 * @since 1.8.8
2424 * @return array
2425 */
2426 private function get_charitable_addon_title_overrides() {
2427 return array(
2428 'charitable-recurring' => __( 'Recurring Donations', 'charitable' ),
2429 'charitable-donortrust' => __( 'DonorTrust', 'charitable' ),
2430 'charitable-gift-aid' => __( 'Gift Aid', 'charitable' ),
2431 'charitable-ambassadors' => __( 'Ambassadors', 'charitable' ),
2432 'charitable-anonymous-donations' => __( 'Anonymous Donations', 'charitable' ),
2433 'charitable-fee-relief' => __( 'Fee Relief', 'charitable' ),
2434 // Add more overrides as needed
2435 );
2436 }
2437
2438 /**
2439 * Get third-party plugin data.
2440 *
2441 * @since 1.8.8
2442 * @return array
2443 */
2444 private function get_third_party_plugin_data() {
2445 $plugins_class = Charitable_Admin_Plugins_Third_Party::get_instance();
2446 return $plugins_class->get_plugins( false );
2447 }
2448
2449 /**
2450 * Get Charitable addon data.
2451 *
2452 * @since 1.8.8
2453 * @param string $slug Addon slug.
2454 * @return array|false
2455 */
2456 private function get_charitable_addon_data( $slug ) {
2457 // Debug: Output current plan and license info
2458 $addons_directory = Charitable_Addons_Directory::get_instance();
2459 $current_plan = $addons_directory->get_current_plan_slug();
2460 $licenses = charitable_get_licenses();
2461 $is_pro = charitable_is_pro();
2462
2463 // First check if the addon is installed as a WordPress plugin
2464 $plugin_file = $slug . '/' . $slug . '.php';
2465 $is_installed = file_exists( WP_PLUGIN_DIR . '/' . $plugin_file );
2466 $is_active = is_plugin_active( $plugin_file );
2467
2468 // If not installed, try to get addon data from directory
2469 if ( ! $is_installed ) {
2470 $addons_directory = Charitable_Addons_Directory::get_instance();
2471 $addon = $addons_directory->get_addon( $slug );
2472
2473 if ( ! $addon ) {
2474 // Check if user has access to this addon by checking if it's in the recommended addons
2475 $all_addons = $addons_directory->get_addons();
2476 $has_access = false;
2477
2478 // Check if addon is in recommended, licensed, or unlicensed categories
2479 foreach ( $all_addons as $category => $addons ) {
2480 foreach ( $addons as $addon_data ) {
2481 if ( isset( $addon_data['slug'] ) && $addon_data['slug'] === $slug ) {
2482 $has_access = true;
2483 $addon = $addon_data;
2484 break 2;
2485 }
2486 }
2487 }
2488
2489 if ( ! $has_access ) {
2490 return false;
2491 }
2492
2493 // Process the raw addon data to add status, action, and plugin_allow fields
2494 // This mimics what get_addon() does but for raw data from get_addons()
2495 $addon = $this->process_raw_addon_data( $addon, $slug );
2496 }
2497 } else {
2498 // Create addon data for installed plugin
2499 $addon = array(
2500 'name' => ucwords( str_replace( '-', ' ', $slug ) ),
2501 'status' => $is_active ? 'active' : 'installed',
2502 'action' => $is_active ? 'deactivate' : 'activate',
2503 'path' => $plugin_file,
2504 'plugin_allow' => true,
2505 'upgrade_url' => '',
2506 // Installed plugin may not have directory metadata; default license to empty
2507 'license' => array(),
2508 );
2509 }
2510
2511 // Get title with overrides
2512 $title_overrides = $this->get_charitable_addon_title_overrides();
2513 if ( isset( $title_overrides[ $slug ] ) ) {
2514 $title = $title_overrides[ $slug ];
2515 } else {
2516 $title = isset( $addon['name'] ) ? $addon['name'] : ucwords( str_replace( '-', ' ', $slug ) );
2517 $title = preg_replace( '/^Charitable\s+/i', '', $title );
2518 }
2519
2520 // Get local icon
2521 $icon = $this->get_charitable_addon_icon( $slug );
2522
2523 $final_data = array(
2524 'title' => $title,
2525 'description' => $this->get_charitable_addon_description( $slug ),
2526 'icon' => $icon,
2527 'installed' => $addon['status'] === 'installed' || $addon['status'] === 'active',
2528 'active' => $addon['status'] === 'active',
2529 'download_url' => isset( $addon['download_link'] ) ? $addon['download_link'] : '',
2530 'slug' => $slug,
2531 'type' => 'charitable_addon',
2532 'basename' => $addon['path'],
2533 'status' => $addon['status'],
2534 'action' => $addon['action'],
2535 'plugin_allow' => $addon['plugin_allow'],
2536 'upgrade_url' => $addon['upgrade_url'] ?? '',
2537 'license' => isset( $addon['license'] ) ? (array) $addon['license'] : array(),
2538 );
2539
2540 return $final_data;
2541 }
2542
2543 /**
2544 * Get Charitable addon description.
2545 *
2546 * @since 1.8.8.4
2547 * @param string $slug Addon slug.
2548 * @return string Description.
2549 */
2550 public function get_charitable_addon_description( $slug ) {
2551
2552 $descriptions = array(
2553 // Charitable official addons
2554 'charitable-recurring' => __( 'Let donors give automatically on a daily, weekly, monthly, or yearly schedule.', 'charitable' ),
2555 'charitable-donortrust' => __( 'Build Trust and Boost Donations with Real-Time Social Proof.', 'charitable' ),
2556 'charitable-gift-aid' => __( 'Allow UK donors to add Gift Aid and boost eligible donations by 25%.', 'charitable' ),
2557 'charitable-anonymous-donations' => __( 'Give supporters the option to hide their name from public donations.', 'charitable' ),
2558 'charitable-fee-relief' => __( 'Let donors optionally cover processing fees.', 'charitable' ),
2559
2560 // Add more addon descriptions here as they are surfaced in the dashboard
2561 'google-analytics-for-wordpress' => __( 'Get detailed insights into your donation traffic and campaign performance.', 'charitable' ),
2562 'wpforms-lite' => __( 'Create custom donation forms and surveys to engage with supporters.', 'charitable' ),
2563 'all-in-one-seo-pack' => __( 'Optimize your campaigns for search engines to reach more potential donors.', 'charitable' ),
2564 'duplicator' => __( 'Safely backup and migrate your donation data and campaign content.', 'charitable' ),
2565 'pushengage' => __( 'Send targeted push notifications to re-engage donors and drive contributions.', 'charitable' ),
2566 'uncanny-automator' => __( 'Automate your donation workflows and connect with other fundraising tools.', 'charitable' ),
2567 'envira-gallery' => __( 'Create beautiful photo galleries to showcase your charitable impact.', 'charitable' ),
2568 );
2569
2570 if ( isset( $descriptions[ $slug ] ) ) {
2571 return $descriptions[ $slug ];
2572 }
2573
2574 // Sensible default if an addon is missing a curated description
2575 return __( 'Enhance your campaign with powerful fundraising features.', 'charitable' );
2576 }
2577
2578 /**
2579 * Get Charitable addon icon from local assets.
2580 *
2581 * @since 1.8.8
2582 * @param string $slug Addon slug.
2583 * @return string Icon URL or generic icon.
2584 */
2585 private function get_charitable_addon_icon( $slug ) {
2586 // Map addon slugs to their base filenames (without extension)
2587 $icon_mapping = array(
2588 'charitable-donortrust' => 'donortrust',
2589 'charitable-gift-aid' => 'gift-aid',
2590 'charitable-recurring' => 'recurring-donations',
2591 'charitable-anonymous-donations' => 'anonymous-donations',
2592 'charitable-fee-relief' => 'fee-relief',
2593 );
2594
2595 // Check if we have a local icon for this addon
2596 if ( isset( $icon_mapping[ $slug ] ) ) {
2597 $base_filename = $icon_mapping[ $slug ];
2598 $base_path = charitable()->get_path( 'assets' ) . 'images/plugins/charitable/' . $base_filename;
2599
2600 // First try SVG
2601 $svg_path = $base_path . '.svg';
2602 if ( file_exists( $svg_path ) ) {
2603 $svg_content = file_get_contents( $svg_path );
2604 return $svg_content ? $svg_content : $this->get_generic_plugin_icon();
2605 }
2606
2607 // Fallback to PNG
2608 $png_path = $base_path . '.png';
2609 if ( file_exists( $png_path ) ) {
2610 $png_url = charitable()->get_path( 'directory', false ) . 'assets/images/plugins/charitable/' . $base_filename . '.png';
2611 return '<img src="' . esc_url( $png_url ) . '" alt="' . esc_attr( $base_filename ) . '" width="20" height="20" />';
2612 }
2613 }
2614
2615 // Fall back to generic puzzle piece icon
2616 return $this->get_generic_plugin_icon();
2617 }
2618
2619 /**
2620 * Get generic plugin icon SVG.
2621 *
2622 * @since 1.8.8
2623 * @return string Generic icon SVG.
2624 */
2625 private function get_generic_plugin_icon() {
2626 return '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
2627 <path d="M8 2L12 6L8 10L4 6L8 2Z" fill="#6B7280"/>
2628 <path d="M8 10L12 14L8 18L4 14L8 10Z" fill="#6B7280"/>
2629 <path d="M12 6L16 10L12 14L8 10L12 6Z" fill="#6B7280"/>
2630 </svg>';
2631 }
2632
2633 /**
2634 * Format plugin item for display.
2635 *
2636 * @since 1.8.8
2637 * @param array $plugin_data Plugin data.
2638 * @param string $type Plugin type.
2639 * @return array
2640 */
2641 private function format_plugin_item( $plugin_data, $type ) {
2642 $button_state = $this->get_button_state( $plugin_data, $type );
2643
2644
2645 $item = array(
2646 'title' => $plugin_data['title'],
2647 'description' => $this->get_charitable_addon_description( $plugin_data['slug'] ),
2648 'button_text' => $button_state['text'],
2649 'button_class' => $button_state['class'],
2650 'button_action' => $button_state['action'],
2651 'icon' => $this->get_plugin_icon( $plugin_data ),
2652 'slug' => $plugin_data['slug'],
2653 'basename' => $plugin_data['basename'],
2654 'type' => $type
2655 );
2656
2657 // Always add setup URL if available (for when button changes to setup state)
2658 if ( ! empty( $plugin_data['setup'] ) ) {
2659 $item['setup_url'] = $plugin_data['setup'];
2660 }
2661
2662 return $item;
2663 }
2664
2665 /**
2666 * Format addon item for display.
2667 *
2668 * @since 1.8.8
2669 * @param array $addon_data Addon data.
2670 * @return array
2671 */
2672 private function format_addon_item( $addon_data ) {
2673 $button_state = $this->get_button_state( $addon_data, 'charitable_addon' );
2674
2675 $item = array(
2676 'title' => $addon_data['title'],
2677 'description' => isset( $addon_data['description'] ) ? $addon_data['description'] : $this->get_charitable_addon_description( $addon_data['slug'] ),
2678 'button_text' => $button_state['text'],
2679 'button_class' => $button_state['class'],
2680 'button_action' => $button_state['action'],
2681 'icon' => $addon_data['icon'],
2682 'slug' => $addon_data['slug'],
2683 'type' => 'charitable_addon'
2684 );
2685
2686 // Add upgrade URL for upgrade buttons
2687 if ( $button_state['action'] === 'upgrade' && isset( $button_state['upgrade_url'] ) ) {
2688 $item['upgrade_url'] = $button_state['upgrade_url'];
2689 }
2690
2691 // Add disabled state for installed buttons
2692 if ( isset( $button_state['disabled'] ) && $button_state['disabled'] ) {
2693 $item['button_disabled'] = true;
2694 }
2695
2696 return $item;
2697 }
2698
2699 /**
2700 * Install a Charitable addon from download URL.
2701 *
2702 * @param string $download_url The download URL for the addon.
2703 * @param string $slug The addon slug.
2704 * @return bool|WP_Error True on success, WP_Error on failure.
2705 */
2706 private function install_charitable_addon_from_url( $download_url, $slug ) {
2707 // Include WordPress filesystem API
2708 if ( ! function_exists( 'download_url' ) ) {
2709 require_once ABSPATH . 'wp-admin/includes/file.php';
2710 }
2711 if ( ! function_exists( 'unzip_file' ) ) {
2712 require_once ABSPATH . 'wp-admin/includes/file.php';
2713 }
2714
2715 // Download the addon zip file
2716 $temp_file = download_url( $download_url );
2717
2718 if ( is_wp_error( $temp_file ) ) {
2719 return new WP_Error( 'download_failed', 'Failed to download addon: ' . $temp_file->get_error_message() );
2720 }
2721
2722 // Get the WordPress filesystem
2723 $wp_filesystem = $this->get_wp_filesystem();
2724 if ( ! $wp_filesystem ) {
2725 unlink( $temp_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
2726 return new WP_Error( 'filesystem_error', 'Unable to access WordPress filesystem.' );
2727 }
2728
2729 // Create the plugin directory
2730 $plugin_dir = WP_PLUGIN_DIR . '/' . $slug;
2731 if ( ! $wp_filesystem->is_dir( $plugin_dir ) ) {
2732 $wp_filesystem->mkdir( $plugin_dir );
2733 }
2734
2735 // Unzip the file
2736 $result = unzip_file( $temp_file, WP_PLUGIN_DIR );
2737
2738 // Clean up temp file
2739 unlink( $temp_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
2740
2741 if ( is_wp_error( $result ) ) {
2742 return new WP_Error( 'unzip_failed', 'Failed to extract addon: ' . $result->get_error_message() );
2743 }
2744
2745 return true;
2746 }
2747
2748 /**
2749 * Get WordPress filesystem instance.
2750 *
2751 * @return WP_Filesystem_Base|false
2752 */
2753 private function get_wp_filesystem() {
2754 global $wp_filesystem;
2755
2756 if ( empty( $wp_filesystem ) ) {
2757 require_once ABSPATH . '/wp-admin/includes/file.php';
2758 WP_Filesystem();
2759 }
2760
2761 return $wp_filesystem;
2762 }
2763
2764 /**
2765 * Process raw addon data to add status, action, and plugin_allow fields.
2766 * This mimics what get_addon() does but for raw data from get_addons().
2767 *
2768 * @version 1.8.8.4
2769 *
2770 * @param array $raw_addon The raw addon data from get_addons().
2771 * @param string $slug The addon slug.
2772 * @return array The processed addon data.
2773 */
2774 private function process_raw_addon_data( $raw_addon, $slug ) {
2775 // Check if plugin is installed
2776 $plugin_file = $slug . '/' . $slug . '.php';
2777 $is_installed = file_exists( WP_PLUGIN_DIR . '/' . $plugin_file );
2778 $is_active = $is_installed ? is_plugin_active( $plugin_file ) : false;
2779 $is_pro = charitable_is_pro();
2780 $current_plan = Charitable_Addons_Directory::get_current_plan_slug();
2781 $required_plans = isset( $raw_addon['license'] ) ? (array) $raw_addon['license'] : array();
2782 $plan_allows = empty( $required_plans ) ? false : in_array( $current_plan, $required_plans, true );
2783
2784 // Determine status and action based on installation state and license
2785 if ( $is_active ) {
2786 $status = 'active';
2787 $action = 'deactivate';
2788 $plugin_allow = true;
2789 } elseif ( $is_installed ) {
2790 $status = 'installed';
2791 $action = 'activate';
2792 $plugin_allow = true;
2793 } else {
2794 $status = 'uninstalled';
2795 // Check if user has a valid license and required plan before allowing install
2796 if ( $is_pro && $plan_allows ) {
2797 $action = 'install';
2798 $plugin_allow = true;
2799 } else {
2800 $action = 'upgrade';
2801 $plugin_allow = false;
2802 // Provide upgrade URL so the button can link to pricing
2803 $addons_directory = Charitable_Addons_Directory::get_instance();
2804 if ( method_exists( $addons_directory, 'charitable_get_upgrade_link' ) ) {
2805 $raw_addon['upgrade_url'] = $addons_directory->charitable_get_upgrade_link();
2806 }
2807 }
2808 }
2809
2810 // Add the processed fields to the raw addon data
2811 $raw_addon['status'] = $status;
2812 $raw_addon['action'] = $action;
2813 $raw_addon['plugin_allow'] = $plugin_allow;
2814 $raw_addon['path'] = $plugin_file;
2815
2816 return $raw_addon;
2817 }
2818
2819 /**
2820 * Get button state for plugin/addon.
2821 *
2822 * @since 1.8.8
2823 * @param array $item_data Item data.
2824 * @param string $type Item type.
2825 * @return array
2826 */
2827 private function get_button_state( $item_data, $type = 'third_party' ) {
2828 if ( $type === 'third_party' ) {
2829 if ( ! $item_data['installed'] ) {
2830 return array(
2831 'text' => __( 'Install & Activate', 'charitable' ),
2832 'action' => 'install',
2833 'class' => '' // Same as hardcoded
2834 );
2835 } elseif ( ! $item_data['active'] ) {
2836 return array(
2837 'text' => __( 'Activate', 'charitable' ),
2838 'action' => 'activate',
2839 'class' => 'charitable-dashboard-v2-activate-button' // Same as hardcoded
2840 );
2841 } else {
2842 return array(
2843 'text' => __( 'Setup', 'charitable' ),
2844 'action' => 'setup',
2845 'class' => 'charitable-dashboard-v2-setup-button' // New class for setup
2846 );
2847 }
2848 } else { // Charitable addon
2849 // Use the action from addons directory
2850 switch ( $item_data['action'] ) {
2851 case 'upgrade':
2852 case 'license':
2853 return array(
2854 'text' => __( 'Upgrade', 'charitable' ),
2855 'action' => 'upgrade',
2856 'class' => 'charitable-dashboard-v2-upgrade-button',
2857 'upgrade_url' => $item_data['upgrade_url']
2858 );
2859 case 'install':
2860 return array(
2861 'text' => 'Install & Activate',
2862 'action' => 'install_addon',
2863 'class' => ''
2864 );
2865 case 'activate':
2866 return array(
2867 'text' => __( 'Activate', 'charitable' ),
2868 'action' => 'activate_addon',
2869 'class' => 'charitable-dashboard-v2-activate-button'
2870 );
2871 case 'deactivate':
2872 return array(
2873 'text' => __( 'Installed', 'charitable' ),
2874 'action' => 'installed',
2875 'class' => 'charitable-dashboard-v2-installed-button',
2876 'disabled' => true
2877 );
2878 default:
2879 return array(
2880 'text' => __( 'Upgrade', 'charitable' ),
2881 'action' => 'upgrade',
2882 'class' => 'charitable-dashboard-v2-upgrade-button',
2883 'upgrade_url' => $item_data['upgrade_url']
2884 );
2885 }
2886 }
2887 }
2888
2889 /**
2890 * Get plugin icon.
2891 *
2892 * @since 1.8.8
2893 * @param array $plugin_data Plugin data.
2894 * @return string
2895 */
2896 private function get_plugin_icon( $plugin_data ) {
2897 // Step 1: Try PNG icon from plugin data (check if file exists)
2898 if ( ! empty( $plugin_data['icon'] ) ) {
2899 $icon_url = $plugin_data['icon'];
2900 if ( filter_var( $icon_url, FILTER_VALIDATE_URL ) ) {
2901 // Convert URL to server path for file existence check
2902 $icon_path = $this->url_to_server_path( $icon_url );
2903 if ( $icon_path && file_exists( $icon_path ) ) {
2904 return '<img src="' . esc_url( $icon_url ) . '" alt="' . esc_attr( $plugin_data['title'] ) . '" width="20" height="20" />';
2905 }
2906 }
2907 }
2908
2909 // Step 2: Try custom SVG icon
2910 $custom_icons = $this->get_custom_plugin_icons();
2911 if ( isset( $custom_icons[ $plugin_data['slug'] ] ) ) {
2912 return $custom_icons[ $plugin_data['slug'] ];
2913 }
2914
2915 // Step 3: Try the 'icon' from plugin data even if file doesn't exist
2916 // (might be a different format or external URL)
2917 if ( ! empty( $plugin_data['icon'] ) ) {
2918 $icon_url = $plugin_data['icon'];
2919 if ( filter_var( $icon_url, FILTER_VALIDATE_URL ) ) {
2920 // Check if the URL actually exists
2921 $response = wp_remote_head( $icon_url, array( 'timeout' => 5 ) );
2922 if ( ! is_wp_error( $response ) && wp_remote_retrieve_response_code( $response ) === 200 ) {
2923 return '<img src="' . esc_url( $icon_url ) . '" alt="' . esc_attr( $plugin_data['title'] ) . '" width="20" height="20" />';
2924 }
2925 }
2926 }
2927
2928 // Step 4: Default puzzle piece icon
2929 return '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
2930 <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"/>
2931 <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"/>
2932 </svg>';
2933 }
2934
2935 /**
2936 * Convert URL to server file path.
2937 *
2938 * @since 1.8.8
2939 * @param string $url The URL to convert.
2940 * @return string|false The server path or false if conversion fails.
2941 */
2942 private function url_to_server_path( $url ) {
2943 // Get the site URL and uploads directory
2944 $site_url = get_site_url();
2945 $upload_dir = wp_upload_dir();
2946
2947 // Check if it's a local URL
2948 if ( strpos( $url, $site_url ) === 0 ) {
2949 // Remove site URL from the beginning
2950 $relative_path = str_replace( $site_url, '', $url );
2951 // Get the absolute path
2952 $absolute_path = ABSPATH . ltrim( $relative_path, '/' );
2953 return $absolute_path;
2954 }
2955
2956 // Check if it's a plugin URL
2957 $plugin_url = plugins_url();
2958 if ( strpos( $url, $plugin_url ) === 0 ) {
2959 // Remove plugin URL from the beginning
2960 $relative_path = str_replace( $plugin_url, '', $url );
2961 // Get the absolute path
2962 $absolute_path = WP_PLUGIN_DIR . ltrim( $relative_path, '/' );
2963 return $absolute_path;
2964 }
2965
2966 // Check if it's a theme URL
2967 $theme_url = get_template_directory_uri();
2968 if ( strpos( $url, $theme_url ) === 0 ) {
2969 // Remove theme URL from the beginning
2970 $relative_path = str_replace( $theme_url, '', $url );
2971 // Get the absolute path
2972 $absolute_path = get_template_directory() . ltrim( $relative_path, '/' );
2973 return $absolute_path;
2974 }
2975
2976 // Check if it's a content URL
2977 $content_url = content_url();
2978 if ( strpos( $url, $content_url ) === 0 ) {
2979 // Remove content URL from the beginning
2980 $relative_path = str_replace( $content_url, '', $url );
2981 // Get the absolute path
2982 $absolute_path = WP_CONTENT_DIR . ltrim( $relative_path, '/' );
2983 return $absolute_path;
2984 }
2985
2986 return false;
2987 }
2988
2989 /**
2990 * Track recommendation interaction.
2991 *
2992 * @since 1.8.8
2993 * @param string $action Action performed.
2994 * @param string $plugin_slug Plugin slug.
2995 * @param string $plugin_type Plugin type.
2996 * @param string $context Context of interaction.
2997 * @return void
2998 */
2999 private function track_recommendation_click( $action, $plugin_slug, $plugin_type, $context = 'dashboard_enhance' ) {
3000 if ( ! charitable_get_usage_tracking_setting() ) {
3001 return;
3002 }
3003
3004 $tracked_data = get_option( 'charitable_recommended_plugins_' . $action, array() );
3005 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
3006 $tracked_data[] = array(
3007 'plugin' => $plugin_slug,
3008 'type' => $plugin_type,
3009 'context' => $context,
3010 'timestamp' => time(),
3011 'user_agent' => $user_agent,
3012 'referrer' => wp_get_referer() ?: 'direct'
3013 );
3014
3015 // Keep only last 100 entries
3016 if ( count( $tracked_data ) > 100 ) {
3017 $tracked_data = array_slice( $tracked_data, -100 );
3018 }
3019
3020 update_option( 'charitable_recommended_plugins_' . $action, $tracked_data );
3021 }
3022
3023 /**
3024 * Check if enhance section should be shown.
3025 *
3026 * @since 1.8.8
3027 * @return bool
3028 */
3029 private function should_show_enhance_section() {
3030 $items = $this->get_enhance_items();
3031 return count( $items ) > 0; // Hide if no items to show
3032 }
3033
3034 /**
3035 * Render the latest updates section.
3036 *
3037 * @since 1.8.8
3038 */
3039 public function render_latest_updates_section() {
3040 // Try v2 feed first, fall back to legacy blog posts.
3041 $updates = $this->get_latest_updates();
3042 $has_posts = ! empty( $updates );
3043 if ( ! $has_posts ) {
3044 $posts = $this->get_blog_posts();
3045 $has_posts = ! empty( $posts ) && ! $this->has_blog_posts_error( $posts );
3046 }
3047 $footer_link_text = __( 'See More Updates', 'charitable' );
3048
3049 // Threshold resolves in order: feed meta.behind_threshold → WP filter → hardcoded default (3).
3050 $updates_behind = $this->get_updates_behind();
3051 $threshold = 3;
3052 // isset (not ! empty) so a feed-supplied 0 correctly overrides to "always alert".
3053 if ( isset( $this->version_info['behind_threshold'] ) ) {
3054 $threshold = (int) $this->version_info['behind_threshold'];
3055 }
3056 /**
3057 * Filter the "behind versions" threshold above which the big update alert renders.
3058 *
3059 * @since 1.8.12
3060 *
3061 * @param int $threshold Default 3, or value supplied by the feed.
3062 */
3063 $threshold = (int) apply_filters( 'charitable_latest_updates_behind_threshold', $threshold );
3064 $show_version_update = $updates_behind > $threshold;
3065
3066 // Version-state pill resolution (mutually exclusive with the big alert). @since 1.8.12
3067 // - on_latest : remote latest is known AND user is 0 behind.
3068 // - mildly_behind : 1+ behind but not past the threshold (shows a compact amber nudge).
3069 $latest_known_version = $this->get_latest_version();
3070 $on_latest = ! empty( $latest_known_version ) && 0 === $updates_behind;
3071 $mildly_behind = ! empty( $latest_known_version ) && $updates_behind > 0 && ! $show_version_update;
3072
3073 // If blog posts can't load and there's no version update to show, don't display the section at all
3074 if ( ! $has_posts && ! $show_version_update ) {
3075 return;
3076 }
3077
3078 $section_class = 'charitable-dashboard-v2-section';
3079 if ( ! $show_version_update ) {
3080 $section_class .= ' no-update-alert';
3081 }
3082 ?>
3083 <section id="charitable-dashboard-v2-latest-updates" class="<?php echo esc_attr( $section_class ); ?>">
3084 <header class="charitable-dashboard-v2-section-header">
3085 <h3>Latest Updates</h3>
3086 </header>
3087 <?php if ( $on_latest ) : ?>
3088 <div class="charitable-dashboard-v2-latest-updates-uptodate">
3089 <svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" width="14" height="14" aria-hidden="true">
3090 <path d="M16.7 5.3L7.5 14.6L3.3 10.4" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
3091 </svg>
3092 <span><?php
3093 echo wp_kses(
3094 sprintf(
3095 /* translators: %s: current installed version number */
3096 __( "You're running the latest version (<strong>%s</strong>).", 'charitable' ),
3097 esc_html( charitable()->get_version() )
3098 ),
3099 array( 'strong' => array() )
3100 );
3101 ?></span>
3102 </div>
3103 <?php elseif ( $mildly_behind ) : ?>
3104 <div class="charitable-dashboard-v2-latest-updates-behind">
3105 <svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" width="14" height="14" aria-hidden="true">
3106 <circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="2"/>
3107 <path d="M10 9v4.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
3108 <circle cx="10" cy="6.3" r="1.1" fill="currentColor"/>
3109 </svg>
3110 <span><?php
3111 $versions_label = sprintf(
3112 /* translators: %d: number of versions behind */
3113 _n( '%d version', '%d versions', $updates_behind, 'charitable' ),
3114 $updates_behind
3115 );
3116 echo wp_kses(
3117 sprintf(
3118 /* translators: 1: versions-behind label (e.g. "2 versions"), 2: plugins page URL */
3119 __( 'You are behind <strong>%1$s</strong>. <a href="%2$s">Update now</a>', 'charitable' ),
3120 esc_html( $versions_label ),
3121 esc_url( admin_url( 'plugins.php?s=charitable' ) )
3122 ),
3123 array( 'strong' => array(), 'a' => array( 'href' => array() ) )
3124 );
3125 ?></span>
3126 </div>
3127 <?php endif; ?>
3128 <div class="charitable-dashboard-v2-section-content">
3129 <?php if ( $show_version_update ) : ?>
3130 <div class="charitable-dashboard-v2-latest-updates-background">
3131 <div class="charitable-dashboard-v2-latest-updates-background-content">
3132 <div class="charitable-dashboard-v2-latest-updates-flex">
3133 <div class="charitable-dashboard-v2-latest-updates-icon">
3134 <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">
3135 <path d="M13 2L24 22H2L13 2Z" stroke="#fff" stroke-width="2" stroke-linejoin="round"/>
3136 <path d="M13 10V16" stroke="#fff" stroke-width="2" stroke-linecap="round"/>
3137 <circle cx="13" cy="19.5" r="1.25" fill="#fff"/>
3138 </svg>
3139 </div>
3140 <div class="charitable-dashboard-v2-latest-updates-content">
3141 <h4 class="charitable-dashboard-v2-latest-updates-headline"><?php
3142 $version_label = sprintf( '<strong>%s</strong>', esc_html( sprintf( _n( '%d version', '%d versions', $updates_behind, 'charitable' ), $updates_behind ) ) );
3143 $update_link = sprintf( '<a href="%s">%s</a>', esc_url( admin_url( 'plugins.php?s=charitable' ) ), esc_html__( 'Update now', 'charitable' ) );
3144 printf(
3145 wp_kses(
3146 /* translators: 1: versions-behind label (e.g. "4 versions"), 2: "Update now" link */
3147 __( 'You are currently %1$s behind. Please upgrade Charitable to take advantage of the features and addons below. %2$s', 'charitable' ),
3148 array( 'strong' => array(), 'a' => array( 'href' => array() ) )
3149 ),
3150 $version_label,
3151 $update_link
3152 );
3153 ?></h4>
3154 </div>
3155 </div>
3156 </div>
3157 </div>
3158 <?php endif; ?>
3159 <?php $this->render_latest_updates_items(); ?>
3160 <footer class="charitable-dashboard-v2-latest-updates-footer">
3161 <div class="charitable-dashboard-v2-latest-updates-footer-content">
3162 <div class="charitable-dashboard-v2-latest-updates-footer-left">
3163 <a href="https://www.wpcharitable.com/charitable-changelog/" target="_blank" rel="noopener noreferrer" class="charitable-dashboard-v2-blog-link">
3164 <span class="charitable-dashboard-v2-blog-text"><?php echo esc_html( $footer_link_text ); ?></span>
3165 <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">
3166 <path d="M6 12L10 8L6 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
3167 </svg>
3168 </a>
3169 </div>
3170 </div>
3171 </footer>
3172 </div>
3173 </section>
3174 <?php
3175 }
3176
3177
3178 /**
3179 * Render the "Are you enjoying Charitable?" section on the dashboard.
3180 * Mirrors the top admin notice (five-star review) but styled to match the dashboard.
3181 * Only shows when the same eligibility conditions are met and it hasn't been dismissed.
3182 *
3183 * @since 1.8.12
3184 */
3185 public function render_enjoying_section() {
3186 // ?preview_enjoy=1 bypasses all eligibility checks for testing (admins only).
3187 $preview = ! empty( $_GET['preview_enjoy'] ) && current_user_can( 'manage_options' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
3188
3189 if ( ! $preview ) {
3190 $activated_datetime = get_option( 'wpcharitable_activated_datetime', false );
3191 $days = 0;
3192 if ( $activated_datetime ) {
3193 $diff = current_time( 'timestamp' ) - $activated_datetime;
3194 $days = (int) abs( round( $diff / 86400 ) );
3195 }
3196
3197 $count_campaigns = wp_count_posts( 'campaign' );
3198 $total_campaigns = isset( $count_campaigns->publish ) ? (int) $count_campaigns->publish : 0;
3199 $count_donations = wp_count_posts( 'donation' );
3200 $total_donations = isset( $count_donations->{'charitable-completed'} ) ? (int) $count_donations->{'charitable-completed'} : 0;
3201
3202 if ( $days < apply_filters( 'charitable_days_since_activated', 14 ) || $total_campaigns < 1 || $total_donations < 1 ) {
3203 return;
3204 }
3205
3206 // Use the same transient as the top notice so dismissing either dismisses both.
3207 if ( get_transient( 'charitable_five-star-review_banner' ) ) {
3208 return;
3209 }
3210 }
3211
3212 $feedback_url = 'https://wpcharitable.com/charitable-admin-review/';
3213 $rating_url = 'https://wpcharitable.com/charitable-admin-review/';
3214 ?>
3215 <section id="charitable-dashboard-enjoy-section" class="charitable-dashboard-v2-section charitable-dashboard-enjoy-section">
3216 <button type="button" class="charitable-enjoy-dismiss-btn" aria-label="<?php esc_attr_e( 'Dismiss', 'charitable' ); ?>">
3217 <svg width="10" height="10" viewBox="0 0 12 12" fill="none"><path d="M1 1L11 11M11 1L1 11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
3218 </button>
3219
3220 <div class="charitable-enjoy-step" data-step="1">
3221 <p class="charitable-enjoy-question"><?php esc_html_e( 'Are You Enjoying Charitable?', 'charitable' ); ?></p>
3222 <p class="charitable-enjoy-actions">
3223 <button type="button" class="charitable-enjoy-link charitable-enjoy-link--yes" data-navigate="3"><?php esc_html_e( 'Yes', 'charitable' ); ?></button>
3224 <button type="button" class="charitable-enjoy-link charitable-enjoy-link--no" data-navigate="2"><?php esc_html_e( 'Not Really', 'charitable' ); ?></button>
3225 </p>
3226 </div>
3227
3228 <div class="charitable-enjoy-step" data-step="2" style="display:none;">
3229 <p class="charitable-enjoy-question charitable-enjoy-question--title"><?php esc_html_e( "Sorry To Hear That!", 'charitable' ); ?></p>
3230 <p class="charitable-enjoy-question"><?php esc_html_e( "We're sorry to hear you aren't enjoying Charitable. We would love a chance to improve. Could you take a minute and let us know what we can do better?", 'charitable' ); ?></p>
3231 <p class="charitable-enjoy-actions">
3232 <a href="<?php echo esc_url( $feedback_url ); ?>" class="charitable-enjoy-link charitable-enjoy-link--yes charitable-enjoy-dismiss-action" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Give Feedback', 'charitable' ); ?></a>
3233 <button type="button" class="charitable-enjoy-link charitable-enjoy-link--no charitable-enjoy-dismiss-action"><?php esc_html_e( 'No Thanks', 'charitable' ); ?></button>
3234 </p>
3235 </div>
3236
3237 <div class="charitable-enjoy-step" data-step="3" style="display:none;">
3238 <p class="charitable-enjoy-question charitable-enjoy-question--title"><?php esc_html_e( "That's Awesome!", 'charitable' ); ?></p>
3239 <p class="charitable-enjoy-question"><?php esc_html_e( "Could you please do us a BIG favor and give it a 5-star rating on WordPress to help us spread the word and boost our team motivation?", 'charitable' ); ?></p>
3240 <p class="charitable-enjoy-team"><strong><?php esc_html_e( '~ WP Charitable Team', 'charitable' ); ?></strong></p>
3241 <p class="charitable-enjoy-actions">
3242 <a href="<?php echo esc_url( $rating_url ); ?>" class="charitable-enjoy-link charitable-enjoy-link--yes charitable-enjoy-dismiss-action" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Ok, You Deserve It', 'charitable' ); ?></a>
3243 <button type="button" class="charitable-enjoy-link charitable-enjoy-link--no charitable-enjoy-dismiss-action"><?php esc_html_e( 'I Already Did', 'charitable' ); ?></button>
3244 </p>
3245 </div>
3246 </section>
3247 <script>
3248 ( function ( $ ) {
3249 var section = $( '#charitable-dashboard-enjoy-section' );
3250
3251 // Navigate between steps.
3252 section.on( 'click', '[data-navigate]', function () {
3253 var target = $( this ).data( 'navigate' );
3254 section.find( '.charitable-enjoy-step' ).hide();
3255 section.find( '.charitable-enjoy-step[data-step="' + target + '"]' ).show();
3256 } );
3257
3258 // Dismiss — fires AJAX then removes the section.
3259 function dismissSection() {
3260 $.post( ajaxurl, {
3261 action: 'charitable_dismiss_admin_five_star_rating',
3262 banner_id: 'five-star-review',
3263 } );
3264 section.fadeOut( 300, function () { $( this ).remove(); } );
3265 }
3266
3267 section.on( 'click', '.charitable-enjoy-dismiss-btn', dismissSection );
3268 section.on( 'click', '.charitable-enjoy-dismiss-action', function () {
3269 setTimeout( dismissSection, 150 );
3270 } );
3271 }( jQuery ) );
3272 </script>
3273 <?php
3274 }
3275 /**
3276 * Map a URL to a local filesystem path if it lives under WP_CONTENT_URL.
3277 *
3278 * @param string $url URL to check.
3279 * @return string|false Absolute file path, or false if the URL is external.
3280 */
3281 private function get_local_file_path_for_url( $url ) {
3282 $content_url = untrailingslashit( content_url() );
3283 // Normalize schemes so http/https mismatch doesn't break the comparison.
3284 $url_norm = preg_replace( '#^https?://#', '//', $url );
3285 $content_norm = preg_replace( '#^https?://#', '//', $content_url );
3286 if ( strpos( $url_norm, $content_norm ) !== 0 ) {
3287 return false;
3288 }
3289 $relative = substr( $url_norm, strlen( $content_norm ) );
3290 $file = WP_CONTENT_DIR . $relative;
3291 return file_exists( $file ) ? $file : false;
3292 }
3293 /**
3294 * Return a local video URL for a given post title, if one exists.
3295 * Used to override missing video_url values from the remote feed.
3296 *
3297 * @param string $title Post title from the feed.
3298 * @return string URL or empty string.
3299 */
3300 private function get_local_video_url( $title ) {
3301 $map = array(
3302 'Mini Donation Widget' => site_url( '/_promo_videos/charitable_dashboard_updates_video_mini-widget.mp4' ),
3303 );
3304
3305 foreach ( $map as $keyword => $url ) {
3306 if ( false !== stripos( $title, $keyword ) ) {
3307 return $url;
3308 }
3309 }
3310
3311 return '';
3312 }
3313 /**
3314 * Return a local thumbnail URL for a given post title, if one exists.
3315 *
3316 * @param string $title Post title from the feed.
3317 * @return string URL or empty string.
3318 */
3319 private function get_local_thumbnail_url( $title ) {
3320 $base = charitable()->get_path( 'assets', false ) . 'images/dashboard/';
3321 $map = array(
3322 'Mini Donation Widget' => site_url( '/_promo_videos/mini-widget-thumbnail.svg' ),
3323 'Donations Feed' => site_url( '/_promo_videos/donations-feed-thumbnail.svg' ),
3324 'Campaign Showcase' => $base . 'thumb-campaign-showcase.png',
3325 'Campaign Updates' => $base . 'thumb-campaign-updates.svg',
3326 );
3327
3328 foreach ( $map as $keyword => $url ) {
3329 if ( false !== stripos( $title, $keyword ) ) {
3330 return $url;
3331 }
3332 }
3333
3334 return '';
3335 }
3336 /**
3337 * Return a local badge type for a given post title, if one should be overridden.
3338 * Values: 'pro', 'addon', or empty string.
3339 *
3340 * @param string $title Post title from the feed.
3341 * @return string Badge type or empty string.
3342 */
3343 private function get_local_badge( $title ) {
3344 $pro_keywords = array( 'Mini Donation Widget', 'Donations Feed', 'Campaign Showcase' );
3345 foreach ( $pro_keywords as $keyword ) {
3346 if ( false !== stripos( $title, $keyword ) ) {
3347 return 'pro';
3348 }
3349 }
3350
3351 $addon_keywords = array( 'Campaign Updates Addon', 'Campaigns Update Addon' );
3352 foreach ( $addon_keywords as $keyword ) {
3353 if ( false !== stripos( $title, $keyword ) ) {
3354 return 'addon';
3355 }
3356 }
3357
3358 return '';
3359 }
3360 /**
3361 * Fetch and return the v2 Latest Updates feed.
3362 *
3363 * @since 1.8.12
3364 * @return array Array of update items, or empty array on failure.
3365 */
3366 private function get_latest_updates() {
3367 $cached = get_transient( 'charitable_latest_updates_cache' );
3368 if ( false !== $cached ) {
3369 if ( isset( $cached['version_info'] ) ) {
3370 $this->version_info = $cached['version_info'];
3371 }
3372 return $cached['updates'] ?? [];
3373 }
3374
3375 $response = wp_remote_get( self::LATEST_UPDATES_SOURCE_URL, [
3376 'timeout' => 10,
3377 'sslverify' => false,
3378 ] );
3379
3380 if ( is_wp_error( $response ) ) {
3381 return [];
3382 }
3383
3384 $body = wp_remote_retrieve_body( $response );
3385 if ( empty( $body ) ) {
3386 return [];
3387 }
3388
3389 $decoded = json_decode( $body, true );
3390 if ( json_last_error() !== JSON_ERROR_NONE || empty( $decoded['updates'] ) ) {
3391 return [];
3392 }
3393
3394 // Store version info from meta.
3395 if ( isset( $decoded['meta'] ) && is_array( $decoded['meta'] ) ) {
3396 $this->version_info = [
3397 'current_charitable_version_lite' => ! empty( $decoded['meta']['current_charitable_version_lite'] ) ? sanitize_text_field( $decoded['meta']['current_charitable_version_lite'] ) : '',
3398 'current_charitable_version_pro' => ! empty( $decoded['meta']['current_charitable_version_pro'] ) ? sanitize_text_field( $decoded['meta']['current_charitable_version_pro'] ) : '',
3399 ];
3400 }
3401
3402 // Sanitize each update item.
3403 $updates = [];
3404 foreach ( $decoded['updates'] as $item ) {
3405 if ( empty( $item['title'] ) || empty( $item['url'] ) ) {
3406 continue;
3407 }
3408 $updates[] = [
3409 'id' => sanitize_key( $item['id'] ?? '' ),
3410 'title' => sanitize_text_field( $item['title'] ),
3411 'description' => sanitize_text_field( $item['description'] ?? '' ),
3412 'url' => esc_url_raw( $item['url'] ),
3413 'date' => sanitize_text_field( $item['date'] ?? '' ),
3414 'badge' => sanitize_key( $item['badge'] ?? '' ),
3415 'is_new' => ! empty( $item['is_new'] ),
3416 'image' => ! empty( $item['image'] ) ? esc_url_raw( $item['image'] ) : '',
3417 ];
3418 }
3419
3420 // Cache for 24 hours.
3421 set_transient( 'charitable_latest_updates_cache', [
3422 'updates' => $updates,
3423 'version_info' => $this->version_info,
3424 ], DAY_IN_SECONDS );
3425
3426 return $updates;
3427 }
3428 /**
3429 * Render the v2 Latest Updates items.
3430 *
3431 * @since 1.8.12
3432 */
3433 private function render_latest_updates_items() {
3434 $updates = $this->get_latest_updates();
3435
3436 if ( empty( $updates ) ) {
3437 // Fall back to legacy blog posts.
3438 $this->render_blog_posts();
3439 return;
3440 }
3441
3442 ?>
3443 <div class="charitable-dashboard-v2-blog-posts" id="charitable-blog-posts-container">
3444 <?php $count = 0; foreach ( $updates as $update ) : if ( $count >= 4 ) break; $count++; ?>
3445 <div class="charitable-dashboard-v2-blog-post-row">
3446 <div class="charitable-dashboard-v2-blog-post-left">
3447 <div class="charitable-dashboard-v2-blog-post-timestamp-row">
3448 <?php if ( ! empty( $update['date'] ) ) : ?>
3449 <span class="charitable-dashboard-v2-blog-post-timestamp"><?php echo esc_html( $this->calculate_relative_time( $update['date'] ) ); ?></span>
3450 <?php endif; ?>
3451 <?php if ( ! empty( $update['is_new'] ) ) : ?>
3452 <span class="charitable-dashboard-v2-blog-post-badge-new"><?php esc_html_e( 'New', 'charitable' ); ?></span>
3453 <?php endif; ?>
3454 <?php if ( 'pro' === $update['badge'] ) : ?>
3455 <span class="charitable-dashboard-v2-blog-post-badge-pro"><?php esc_html_e( 'Pro', 'charitable' ); ?></span>
3456 <?php elseif ( 'addon' === $update['badge'] ) : ?>
3457 <span class="charitable-dashboard-v2-blog-post-badge-addon"><?php esc_html_e( 'Addon', 'charitable' ); ?></span>
3458 <?php endif; ?>
3459 </div>
3460 <h5 class="charitable-dashboard-v2-blog-post-title">
3461 <a href="<?php echo esc_url( $update['url'] ); ?>" target="_blank" rel="noopener noreferrer"><?php echo esc_html( $update['title'] ); ?></a>
3462 </h5>
3463 <?php if ( ! empty( $update['description'] ) ) : ?>
3464 <p class="charitable-dashboard-v2-blog-post-desc"><?php echo esc_html( $update['description'] ); ?> <a href="<?php echo esc_url( $update['url'] ); ?>" target="_blank" rel="noopener noreferrer" class="charitable-dashboard-v2-blog-post-learn-more"><?php esc_html_e( 'Learn More', 'charitable' ); ?> →</a></p>
3465 <?php endif; ?>
3466 </div>
3467 <div class="charitable-dashboard-v2-blog-post-right">
3468 <?php if ( ! empty( $update['image'] ) ) : ?>
3469 <a href="<?php echo esc_url( $update['url'] ); ?>" target="_blank" rel="noopener noreferrer" class="charitable-dashboard-v2-blog-post-thumb charitable-dashboard-v2-blog-post-thumb--bg" style="background-image:url('<?php echo esc_url( $update['image'] ); ?>');">
3470 </a>
3471 <?php else : ?>
3472 <a href="<?php echo esc_url( $update['url'] ); ?>" target="_blank" rel="noopener noreferrer" class="charitable-dashboard-v2-blog-post-thumb">
3473 <svg width="96" height="64" viewBox="0 0 96 64" fill="none" xmlns="http://www.w3.org/2000/svg">
3474 <rect width="96" height="64" rx="6" fill="#E5E7EB"/>
3475 <path d="M16 46L30 29L46 39L62 21L82 33" stroke="#9CA3AF" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
3476 <circle cx="30" cy="29" r="2.5" fill="#9CA3AF"/>
3477 <circle cx="46" cy="39" r="2.5" fill="#9CA3AF"/>
3478 <circle cx="62" cy="21" r="2.5" fill="#9CA3AF"/>
3479 </svg>
3480 </a>
3481 <?php endif; ?>
3482 </div>
3483 </div>
3484 <?php endforeach; ?>
3485 </div>
3486 <?php
3487 }
3488
3489 /**
3490 * Render update features.
3491 *
3492 * @since 1.8.8
3493 */
3494 private function render_update_features() {
3495 $features = array(
3496 __( 'Enhancement Payments', 'charitable' ),
3497 __( 'Reply-To Donors', 'charitable' ),
3498 __( 'Square Gateway', 'charitable' ),
3499 __( 'DonorTrust', 'charitable' )
3500 );
3501
3502 foreach ( $features as $feature ) :
3503 ?>
3504 <span class="charitable-dashboard-v2-latest-updates-feature">
3505 <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">
3506 <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"/>
3507 </svg>
3508 <?php echo esc_html( $feature ); ?>
3509 </span>
3510 <?php
3511 endforeach;
3512 }
3513
3514 /**
3515 * Render blog posts.
3516 *
3517 * @since 1.8.8
3518 */
3519 private function render_blog_posts() {
3520 $posts = $this->get_blog_posts();
3521
3522 // phpcs:disable WordPress.Security.NonceVerification.Recommended
3523 $is_simulating = isset( $_GET['charitable_simulate_api_failure'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['charitable_simulate_api_failure'] ) );
3524 $clear_cache = isset( $_GET['charitable_clear_cache'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['charitable_clear_cache'] ) );
3525 // phpcs:enable WordPress.Security.NonceVerification.Recommended
3526
3527
3528 ?>
3529 <div class="charitable-dashboard-v2-blog-posts" id="charitable-blog-posts-container">
3530
3531 <?php if ( $is_simulating || $clear_cache || ! $posts ) : ?>
3532 <div class="charitable-dashboard-v2-blog-post-row error-display">
3533 <h5 class="charitable-dashboard-v2-blog-post-title">Unable to load blog posts. Please check back later.</h5>
3534 </div>
3535 <?php else : ?>
3536 <?php foreach ( $posts as $post ) : ?>
3537 <div class="charitable-dashboard-v2-blog-post-row<?php echo ! empty( $post['featured'] ) ? ' featured' : ''; ?><?php echo ! empty( $post['error'] ) ? ' error' : ''; ?>">
3538 <div class="charitable-dashboard-v2-blog-post-left">
3539 <?php if ( ! empty( $post['timestamp'] ) ) : ?>
3540 <div class="charitable-dashboard-v2-blog-post-timestamp"><?php echo esc_html( $post['timestamp'] ); ?></div>
3541 <?php endif; ?>
3542 <h5 class="charitable-dashboard-v2-blog-post-title">
3543 <?php if ( ! empty( $post['error'] ) ) : ?>
3544 <span class="charitable-dashboard-v2-blog-post-error"><?php echo esc_html( $post['title'] ); ?></span>
3545 <?php else : ?>
3546 <a href="<?php echo esc_url( $post['url'] ); ?>" target="_blank" rel="noopener noreferrer"><?php echo esc_html( $post['title'] ); ?></a>
3547 <?php endif; ?>
3548 </h5>
3549 </div>
3550 <div class="charitable-dashboard-v2-blog-post-right">
3551 <?php if ( ! empty( $post['error'] ) ) : ?>
3552 <div class="charitable-dashboard-v2-blog-post-error-icon">
3553 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
3554 <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"/>
3555 </svg>
3556 </div>
3557 <?php else : ?>
3558 <?php if ( ! empty( $post['image'] ) && is_array( $post['image'] ) ) : ?>
3559 <?php if ( ! empty( $post['image']['img'] ) ) : ?>
3560 <!-- Actual image -->
3561 <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;" />
3562 <?php elseif ( ! empty( $post['image']['svg'] ) ) : ?>
3563 <!-- Custom SVG placeholder -->
3564 <?php echo wp_kses_post( $post['image']['svg'] ); ?>
3565 <?php else : ?>
3566 <!-- Default SVG placeholder -->
3567 <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">
3568 <rect width="106" height="56" rx="4" fill="#E5E7EB"/>
3569 <path d="M20 40L35 25L50 35L65 20L80 30L95 15" stroke="#9CA3AF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
3570 <circle cx="35" cy="25" r="3" fill="#9CA3AF"/>
3571 <circle cx="50" cy="35" r="3" fill="#9CA3AF"/>
3572 <circle cx="65" cy="20" r="3" fill="#9CA3AF"/>
3573 <circle cx="80" cy="30" r="3" fill="#9CA3AF"/>
3574 <circle cx="95" cy="15" r="3" fill="#9CA3AF"/>
3575 </svg>
3576 <?php endif; ?>
3577 <?php else : ?>
3578 <!-- Fallback default SVG placeholder -->
3579 <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">
3580 <rect width="106" height="56" rx="4" fill="#E5E7EB"/>
3581 <path d="M20 40L35 25L50 35L65 20L80 30L95 15" stroke="#9CA3AF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
3582 <circle cx="35" cy="25" r="3" fill="#9CA3AF"/>
3583 <circle cx="50" cy="35" r="3" fill="#9CA3AF"/>
3584 <circle cx="65" cy="20" r="3" fill="#9CA3AF"/>
3585 <circle cx="80" cy="30" r="3" fill="#9CA3AF"/>
3586 <circle cx="95" cy="15" r="3" fill="#9CA3AF"/>
3587 </svg>
3588 <?php endif; ?>
3589 <?php endif; ?>
3590 </div>
3591 </div>
3592 <?php endforeach; ?>
3593 <?php endif; ?>
3594 </div>
3595
3596 <?php
3597
3598
3599 }
3600
3601 /**
3602 * Fetch blog posts from feed.
3603 *
3604 * @since 1.8.8
3605 *
3606 * @return array|false Returns array on success, false on failure
3607 */
3608 private function fetch_blog_posts_feed() {
3609
3610 // Allow simulating API failure for testing
3611 // phpcs:disable WordPress.Security.NonceVerification.Recommended
3612 if ( isset( $_GET['charitable_simulate_api_failure'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['charitable_simulate_api_failure'] ) ) ) {
3613 // phpcs:enable WordPress.Security.NonceVerification.Recommended
3614 return false;
3615 }
3616
3617 $args = [
3618 'timeout' => 10,
3619 'sslverify' => false,
3620 'user-agent' => charitable_get_default_user_agent(),
3621 ];
3622
3623 $response = wp_remote_get(
3624 self::BLOG_POSTS_SOURCE_URL,
3625 $args
3626 );
3627
3628 if ( is_wp_error( $response ) ) {
3629 return false;
3630 }
3631
3632 $body = wp_remote_retrieve_body( $response );
3633
3634 if ( empty( $body ) ) {
3635 return false;
3636 }
3637
3638 $decoded = json_decode( $body, true );
3639 if ( json_last_error() !== JSON_ERROR_NONE ) {
3640 return false;
3641 }
3642
3643
3644 return $this->verify_blog_posts( $decoded );
3645 }
3646
3647 /**
3648 * Calculate relative time from a date string.
3649 *
3650 * @since 1.8.8.4
3651 *
3652 * @param string $date_string Date string in format 'Y-m-d H:i:s'.
3653 *
3654 * @return string Relative time string (e.g., '2 days ago', '1 week ago').
3655 */
3656 private function calculate_relative_time( $date_string ) {
3657 if ( empty( $date_string ) ) {
3658 return '';
3659 }
3660
3661 $post_date = strtotime( $date_string );
3662 $current_time = current_time( 'timestamp' );
3663 $time_diff = $current_time - $post_date;
3664
3665 // If the date is in the future, return empty string
3666 if ( $time_diff < 0 ) {
3667 return '';
3668 }
3669
3670 // Calculate relative time
3671 $minutes = floor( $time_diff / 60 );
3672 $hours = floor( $time_diff / 3600 );
3673 $days = floor( $time_diff / 86400 );
3674 $weeks = floor( $time_diff / 604800 );
3675 $months = floor( $time_diff / 2592000 );
3676 $years = floor( $time_diff / 31536000 );
3677
3678 if ( $years > 0 ) {
3679 /* translators: %s: number of years */
3680 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 ) );
3681 } elseif ( $months > 0 ) {
3682 /* translators: %s: number of months */
3683 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 ) );
3684 } elseif ( $weeks > 0 ) {
3685 /* translators: %s: number of weeks */
3686 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 ) );
3687 } elseif ( $days > 0 ) {
3688 /* translators: %s: number of days */
3689 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 ) );
3690 } elseif ( $hours > 0 ) {
3691 /* translators: %s: number of hours */
3692 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 ) );
3693 } elseif ( $minutes > 0 ) {
3694 /* translators: %s: number of minutes */
3695 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 ) );
3696 } else {
3697 return _x( 'Just now', 'relative time', 'charitable' );
3698 }
3699 }
3700
3701 /**
3702 * Verify blog posts data before it is saved.
3703 *
3704 * @since 1.8.8
3705 *
3706 * @param array $blog_posts Array of blog posts items to verify.
3707 *
3708 * @return array
3709 */
3710 private function verify_blog_posts( $blog_posts ) {
3711
3712 $data = [];
3713
3714 if ( ! is_array( $blog_posts ) || empty( $blog_posts ) ) {
3715 return $data;
3716 }
3717
3718 // Check if we have the expected structure
3719 if ( ! isset( $blog_posts['blog_posts'] ) || ! is_array( $blog_posts['blog_posts'] ) ) {
3720 return $data;
3721 }
3722
3723 // Store version information if available
3724 if ( isset( $blog_posts['meta'] ) && is_array( $blog_posts['meta'] ) ) {
3725 $this->version_info = array(
3726 'current_charitable_version_lite' => ! empty( $blog_posts['meta']['current_charitable_version_lite'] ) ? sanitize_text_field( $blog_posts['meta']['current_charitable_version_lite'] ) : '',
3727 'current_charitable_version_pro' => ! empty( $blog_posts['meta']['current_charitable_version_pro'] ) ? sanitize_text_field( $blog_posts['meta']['current_charitable_version_pro'] ) : '',
3728 );
3729 }
3730
3731 foreach ( $blog_posts['blog_posts'] as $post ) {
3732
3733 // Verify required fields
3734 if ( empty( $post['title'] ) || empty( $post['url'] ) ) {
3735 continue;
3736 }
3737
3738 // Handle image data
3739 $image_data = array(
3740 'placeholder' => true,
3741 'svg' => ''
3742 );
3743
3744 if ( ! empty( $post['image'] ) && is_array( $post['image'] ) ) {
3745 // Handle placeholder as both boolean and string
3746 $placeholder_value = $post['image']['placeholder'] ?? true;
3747 if ( is_string( $placeholder_value ) ) {
3748 // Handle string values: 'false', '0', 'no' should be false
3749 $placeholder_value = in_array( strtolower( $placeholder_value ), ['false', '0', 'no', ''], true ) ? false : true;
3750 }
3751 $image_data['placeholder'] = (bool) $placeholder_value;
3752
3753 if ( ! empty( $post['image']['img'] ) ) {
3754 $image_data['img'] = esc_url_raw( $post['image']['img'] );
3755 }
3756
3757 if ( ! empty( $post['image']['svg'] ) ) {
3758 $image_data['svg'] = $post['image']['svg']; // SVG is already sanitized HTML
3759 }
3760 }
3761
3762 // Calculate timestamp from date field if available, otherwise use provided timestamp
3763 $calculated_timestamp = '';
3764 if ( ! empty( $post['date'] ) ) {
3765 $calculated_timestamp = $this->calculate_relative_time( $post['date'] );
3766 } elseif ( ! empty( $post['timestamp'] ) ) {
3767 $calculated_timestamp = sanitize_text_field( $post['timestamp'] );
3768 }
3769
3770 $data[] = array(
3771 'title' => sanitize_text_field( $post['title'] ),
3772 'url' => esc_url_raw( $post['url'] ),
3773 'timestamp' => $calculated_timestamp,
3774 'featured' => ! empty( $post['featured'] ) ? (bool) $post['featured'] : false,
3775 'image' => $image_data,
3776 );
3777
3778 }
3779
3780 return $data;
3781 }
3782
3783 /**
3784 * Get blog posts from transient cache.
3785 *
3786 * @since 1.8.8
3787 *
3788 * @return array
3789 */
3790 private function get_blog_posts_from_cache() {
3791
3792 $cached_data = get_transient( 'charitable_blog_posts_cache' );
3793
3794 if ( false === $cached_data ) {
3795 return [];
3796 }
3797
3798 return (array) $cached_data;
3799 }
3800
3801 /**
3802 * Cache blog posts data using transients.
3803 *
3804 * @since 1.8.8
3805 *
3806 * @param array $blog_posts Blog posts data to cache.
3807 */
3808 private function cache_blog_posts( $blog_posts ) {
3809
3810 $data = [
3811 'update' => time(),
3812 'blog_posts' => $blog_posts,
3813 'version_info' => $this->version_info,
3814 ];
3815
3816 // Cache for 24 hours
3817 set_transient( 'charitable_blog_posts_cache', $data, DAY_IN_SECONDS );
3818 }
3819
3820 /**
3821 * Get blog posts data.
3822 *
3823 * @since 1.8.8
3824 * @return array
3825 */
3826 private function get_blog_posts() {
3827
3828 // phpcs:disable WordPress.Security.NonceVerification.Recommended
3829 $is_simulating = isset( $_GET['charitable_simulate_api_failure'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['charitable_simulate_api_failure'] ) );
3830 $clear_cache = isset( $_GET['charitable_clear_cache'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['charitable_clear_cache'] ) );
3831 // phpcs:enable WordPress.Security.NonceVerification.Recommended
3832
3833 // If simulating API failure, always return error
3834 if ( $is_simulating ) {
3835 return $this->get_blog_posts_error();
3836 }
3837
3838 // Clear cache if requested
3839 if ( $clear_cache ) {
3840 $this->clear_blog_posts_cache();
3841 }
3842
3843 // Try to get from cache first
3844 $cached_data = $this->get_blog_posts_from_cache();
3845 if ( ! empty( $cached_data ) && ! $clear_cache ) {
3846 // Restore version info from cache
3847 if ( isset( $cached_data['version_info'] ) ) {
3848 $this->version_info = $cached_data['version_info'];
3849 }
3850 return $cached_data['blog_posts'] ?? [];
3851 }
3852
3853 // Fetch fresh data from API
3854 $blog_posts = $this->fetch_blog_posts_feed();
3855
3856 // If API fetch was successful, cache and return the data
3857 if ( false !== $blog_posts && ! empty( $blog_posts ) ) {
3858 $this->cache_blog_posts( $blog_posts );
3859 return $blog_posts;
3860 } else {
3861 // API failed, show error
3862 return $this->get_blog_posts_error();
3863 }
3864 }
3865
3866 /**
3867 * Clear blog posts cache (for testing purposes).
3868 *
3869 * @since 1.8.8
3870 */
3871 private function clear_blog_posts_cache() {
3872 delete_transient( 'charitable_blog_posts_cache' );
3873 }
3874
3875 /**
3876 * Get blog posts error data when API fails and no cache is available.
3877 *
3878 * @since 1.8.8
3879 * @return array
3880 */
3881 private function get_blog_posts_error() {
3882 // phpcs:disable WordPress.Security.NonceVerification.Recommended
3883 $is_simulating = isset( $_GET['charitable_simulate_api_failure'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['charitable_simulate_api_failure'] ) );
3884 // phpcs:enable WordPress.Security.NonceVerification.Recommended
3885 $error_title = $is_simulating ? __( 'Unable to load blog posts (Simulated API Failure)', 'charitable' ) : __( 'Unable to load blog posts', 'charitable' );
3886
3887 return array(
3888 array(
3889 'title' => $error_title,
3890 'url' => '#',
3891 'timestamp' => '',
3892 'featured' => false,
3893 'error' => true
3894 )
3895 );
3896 }
3897
3898 /**
3899 * Check if blog posts contain an error.
3900 *
3901 * @since 1.8.8
3902 * @param array $posts Blog posts array.
3903 * @return bool True if posts contain an error.
3904 */
3905 private function has_blog_posts_error( $posts ) {
3906 if ( empty( $posts ) ) {
3907 return true;
3908 }
3909
3910 foreach ( $posts as $post ) {
3911 if ( ! empty( $post['error'] ) ) {
3912 return true;
3913 }
3914 }
3915
3916 return false;
3917 }
3918
3919 /**
3920 * Calculate how many updates behind the user is.
3921 *
3922 * @since 1.8.8
3923 * @return int Number of updates behind, or 0 if up to date or unable to determine.
3924 */
3925 private function get_updates_behind() {
3926 // Get current installed version
3927 $current_version = charitable()->get_version();
3928
3929 // Get latest version from API
3930 $latest_version = $this->get_latest_version();
3931
3932 if ( empty( $latest_version ) ) {
3933 return 0;
3934 }
3935
3936 return $this->compare_versions( $current_version, $latest_version );
3937 }
3938
3939 /**
3940 * Get the latest version from the API data.
3941 *
3942 * @since 1.8.8
3943 * @return string Latest version or empty string if not available.
3944 */
3945 private function get_latest_version() {
3946 if ( null === $this->version_info ) {
3947 // Try to get version info from cached data
3948 $cached_data = get_transient( 'charitable_blog_posts_cache' );
3949 if ( false !== $cached_data && isset( $cached_data['version_info'] ) ) {
3950 $this->version_info = $cached_data['version_info'];
3951 }
3952 }
3953
3954 if ( null === $this->version_info ) {
3955 return '';
3956 }
3957
3958 // For now, we're working with Lite version
3959 return ! empty( $this->version_info['current_charitable_version_lite'] ) ? $this->version_info['current_charitable_version_lite'] : '';
3960 }
3961
3962 /**
3963 * Compare two version strings and return how many updates behind.
3964 *
3965 * @since 1.8.8
3966 * @param string $current Current version (e.g., "1.8.4").
3967 * @param string $latest Latest version (e.g., "1.8.8").
3968 * @return int Number of updates behind.
3969 */
3970 private function compare_versions( $current, $latest ) {
3971 // Normalize versions by removing any non-numeric characters except dots
3972 $current = preg_replace( '/[^0-9.]/', '', $current );
3973 $latest = preg_replace( '/[^0-9.]/', '', $latest );
3974
3975 // Split into parts
3976 $current_parts = array_map( 'intval', explode( '.', $current ) );
3977 $latest_parts = array_map( 'intval', explode( '.', $latest ) );
3978
3979 // Ensure both arrays have the same length by padding with zeros
3980 $max_length = max( count( $current_parts ), count( $latest_parts ) );
3981 $current_parts = array_pad( $current_parts, $max_length, 0 );
3982 $latest_parts = array_pad( $latest_parts, $max_length, 0 );
3983
3984 // Compare each part
3985 for ( $i = 0; $i < $max_length; $i++ ) {
3986 if ( $latest_parts[ $i ] > $current_parts[ $i ] ) {
3987 // Calculate the difference
3988 $updates_behind = 0;
3989 for ( $j = $i; $j < $max_length; $j++ ) {
3990 $updates_behind += $latest_parts[ $j ] - $current_parts[ $j ];
3991 }
3992 return $updates_behind;
3993 } elseif ( $latest_parts[ $i ] < $current_parts[ $i ] ) {
3994 // Current version is newer than latest (shouldn't happen)
3995 return 0;
3996 }
3997 }
3998
3999 // Versions are equal
4000 return 0;
4001 }
4002
4003 /**
4004 * Test version comparison logic (for debugging).
4005 *
4006 * @since 1.8.8
4007 * @return void
4008 */
4009 public function test_version_comparison() {
4010 // Test cases
4011 $test_cases = array(
4012 array( '1.8.4', '1.8.8', 4 ),
4013 array( '1.8.4.2', '1.8.8', 4 ),
4014 array( '1.8.7.5', '1.8.8', 1 ),
4015 array( '1.8.8', '1.8.8', 0 ),
4016 array( '1.8.9', '1.8.8', 0 ),
4017 array( '1.7.5', '1.8.2', 5 ),
4018 );
4019
4020 foreach ( $test_cases as $test ) {
4021 $result = $this->compare_versions( $test[0], $test[1] );
4022 $status = ( $result === $test[2] ) ? 'PASS' : 'FAIL';
4023 }
4024 }
4025
4026 /**
4027 * Render the quick access section.
4028 *
4029 * @since 1.8.8
4030 * @version 1.8.8.6
4031 */
4032 public function render_quick_access_section() {
4033 ?>
4034 <section id="charitable-dashboard-v2-quick-access" class="charitable-dashboard-v2-section charitable-dashboard-v2-collapsible">
4035 <header class="charitable-dashboard-v2-section-header">
4036 <h3>Quick Access</h3>
4037 <a href="#" class="charitable-dashboard-v2-toggle">
4038 <i class="fa fa-angle-down charitable-dashboard-v2-angle-down"></i>
4039 </a>
4040 </header>
4041 <div class="charitable-dashboard-v2-section-content">
4042 <div class="charitable-dashboard-v2-quick-access-content">
4043 <div class="charitable-dashboard-v2-quick-access-items">
4044 <?php $this->render_quick_access_items(); ?>
4045 </div>
4046 </div>
4047 <footer class="charitable-dashboard-v2-quick-access-footer">
4048 <div class="charitable-dashboard-v2-quick-access-footer-content">
4049 <div class="charitable-dashboard-v2-quick-access-footer-left">
4050 <a href="https://wpcharitable.com/charitable-admin-review/" class="charitable-dashboard-v2-rate-link" target="_blank" rel="noopener noreferrer">
4051 <span class="charitable-dashboard-v2-rate-text">Rate us 5 stars</span>
4052 <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">
4053 <path d="M6 12L10 8L6 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
4054 </svg>
4055 </a>
4056 </div>
4057 <div class="charitable-dashboard-v2-quick-access-footer-right">
4058 <div class="charitable-dashboard-v2-stars">
4059 <span class="charitable-dashboard-v2-star">�
4060 </span>
4061 <span class="charitable-dashboard-v2-star">�
4062 </span>
4063 <span class="charitable-dashboard-v2-star">�
4064 </span>
4065 <span class="charitable-dashboard-v2-star">�
4066 </span>
4067 <span class="charitable-dashboard-v2-star">�
4068 </span>
4069 </div>
4070 </div>
4071 </div>
4072 </footer>
4073 </div>
4074 </section>
4075 <?php
4076 }
4077
4078 /**
4079 * Render quick access items.
4080 *
4081 * @since 1.8.8
4082 */
4083 private function render_quick_access_items() {
4084 $items = $this->get_quick_access_items();
4085
4086 foreach ( $items as $item ) :
4087 ?>
4088 <div class="charitable-dashboard-v2-quick-access-item">
4089 <div class="charitable-dashboard-v2-quick-access-icon">
4090 <?php
4091 // Only allow specific SVG-related tags and attributes for safety.
4092 echo wp_kses(
4093 $item['icon'],
4094 array(
4095 'svg' => array(
4096 'xmlns' => true,
4097 'width' => true,
4098 'height' => true,
4099 'viewbox' => true,
4100 'fill' => true,
4101 'class' => true,
4102 'aria-hidden' => true,
4103 'role' => true,
4104 ),
4105 'path' => array(
4106 'd' => true,
4107 'fill' => true,
4108 'stroke' => true,
4109 'stroke-width' => true,
4110 'stroke-linecap' => true,
4111 'stroke-linejoin'=> true,
4112 ),
4113 'rect' => array(
4114 'x' => true,
4115 'y' => true,
4116 'width' => true,
4117 'height' => true,
4118 'fill' => true,
4119 ),
4120 'mask' => array(
4121 'id' => true,
4122 'style' => true,
4123 'maskunits' => true,
4124 'x' => true,
4125 'y' => true,
4126 'width' => true,
4127 'height' => true,
4128 ),
4129 'g' => array(
4130 'mask' => true,
4131 ),
4132 )
4133 );
4134 ?>
4135 </div>
4136 <div class="charitable-dashboard-v2-quick-access-title">
4137 <a href="<?php echo esc_url( $item['url'] ); ?>" target="_blank"><?php echo esc_html( $item['title'] ); ?></a>
4138 </div>
4139 </div>
4140 <?php
4141 endforeach;
4142 }
4143
4144 /**
4145 * Get quick access items data.
4146 *
4147 * @since 1.8.8
4148 * @return array
4149 */
4150 private function get_quick_access_items() {
4151 return array(
4152 array(
4153 'title' => __( 'Getting Started', 'charitable' ),
4154 'url' => 'https://wpcharitable.com/start-here/?referrer=chariable-dashboard',
4155 'icon' => '<svg width="22" height="20" viewBox="0 0 22 20" fill="none" xmlns="http://www.w3.org/2000/svg">
4156 <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"/>
4157 </svg>'
4158 ),
4159 array(
4160 'title' => __( 'Support Ticket', 'charitable' ),
4161 'url' => 'https://wpcharitable.com/support?referrer=chariable-dashboard',
4162 'icon' => '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
4163 <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"/>
4164 </svg>'
4165 ),
4166 array(
4167 'title' => __( 'Documentation', 'charitable' ),
4168 'url' => 'https://wpcharitable.com/documentation?referrer=chariable-dashboard',
4169 'icon' => '<svg width="22" height="16" viewBox="0 0 22 16" fill="none" xmlns="http://www.w3.org/2000/svg">
4170 <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"/>
4171 </svg>'
4172 ),
4173 array(
4174 'title' => __( 'Upgrade To Pro', 'charitable' ),
4175 'url' => 'https://wpcharitable.com/lite-vs-pro?referrer=chariable-dashboard',
4176 'icon' => '<svg width="16" height="21" viewBox="0 0 16 21" fill="none" xmlns="http://www.w3.org/2000/svg">
4177 <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"/>
4178 </svg>'
4179 )
4180 );
4181 }
4182
4183 /**
4184 * Render dashboard scripts.
4185 *
4186 * @since 1.8.8
4187 */
4188 public function render_dashboard_scripts() {
4189 $charitable_dashboard_legacy = Charitable_Dashboard_Legacy::get_instance();
4190 $charitable_dashboard_legacy->generate_dashboard_report_html();
4191 $donation_axis = $charitable_dashboard_legacy->get_donation_axis();
4192 $date_axis = $charitable_dashboard_legacy->get_date_axis();
4193
4194 // Decode HTML entities in the donation axis data and extract numeric values
4195 if ( is_array( $donation_axis ) ) {
4196 $donation_axis = array_map( function( $value ) {
4197 $decoded = html_entity_decode( $value, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
4198 if ( preg_match( '/(\d+\.?\d*)/', $decoded, $matches ) ) {
4199 return floatval( $matches[1] );
4200 }
4201 return 0;
4202 }, $donation_axis );
4203 }
4204 ?>
4205 <?php
4206 // Decode HTML entities in currency symbol before passing to JavaScript.
4207 // The currency symbol comes as HTML entities (e.g., &#36; for $), so we decode it
4208 // before using esc_js() to avoid double-encoding issues.
4209 $currency_helper = charitable_get_currency_helper();
4210 $currency_symbol = html_entity_decode( $currency_helper->get_currency_symbol(), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
4211 $decimal_count = $currency_helper->get_decimals();
4212 ?>
4213 <script id="charitable-dashboard-report-data-js">
4214 var charitable_dashboard_reporting = {
4215 version: '<?php echo esc_js( Charitable::VERSION ); ?>',
4216 user_id: '<?php echo esc_js( get_current_user_id() ); ?>',
4217 nonce: '<?php echo esc_js( wp_create_nonce( 'charitable-reporting' ) ); ?>',
4218 dashboard_nonce: '<?php echo esc_js( wp_create_nonce( 'charitable-dashboard' ) ); ?>',
4219 admin_nonce: '<?php echo esc_js( wp_create_nonce( 'charitable-admin' ) ); ?>',
4220 currency_symbol: '<?php echo esc_js( $currency_symbol ); ?>',
4221 decimal_count: <?php echo (int) $decimal_count; ?>,
4222 ajax_url: '<?php echo esc_js( admin_url( 'admin-ajax.php' ) ); ?>',
4223 date_select_day: 'DD',
4224 date_select_month: 'MM',
4225 strings: {
4226 activation_failed: '<?php echo esc_js( __( 'Activation failed. Please try again.', 'charitable' ) ); ?>',
4227 error_occurred: '<?php echo esc_js( __( 'An error occurred. Please try again.', 'charitable' ) ); ?>',
4228 approved: '<?php echo esc_js( __( 'Approved', 'charitable' ) ); ?>',
4229 no_comments_title: '<?php echo esc_js( __( 'No donor comments yet.', 'charitable' ) ); ?>',
4230 no_comments_message: '<?php echo esc_js( __( 'Donor comments will appear here once donors start leaving messages with their donations.', 'charitable' ) ); ?>'
4231 },
4232 headline_chart_options: {
4233 donation_axis: <?php echo wp_json_encode( (array) $donation_axis ); ?>,
4234 date_axis: <?php echo wp_json_encode( (array) $date_axis ); ?>,
4235 enable_tooltips: <?php echo $charitable_dashboard_legacy->enable_tooltips() ? 'true' : 'false'; ?>
4236 },
4237 payment_methods_chart_options: {
4238 payment_percentages: [],
4239 payment_labels: []
4240 }
4241 };
4242 </script>
4243
4244 <script id="charitable-dashboard-tabs-js">
4245 document.addEventListener('DOMContentLoaded', function() {
4246 // Tab switching functionality
4247 const tabNavItems = document.querySelectorAll('.charitable-dashboard-v2-tab-nav-item');
4248 const tabContents = document.querySelectorAll('.charitable-dashboard-v2-tab-content');
4249
4250 // Function to switch tabs
4251 function switchTab(targetTab) {
4252 // Remove active classes from all tabs and contents
4253 tabNavItems.forEach(item => item.classList.remove('charitable-dashboard-v2-tab-nav-active'));
4254 tabContents.forEach(content => content.classList.remove('charitable-dashboard-v2-tab-content-active'));
4255
4256 // Add active class to selected tab and content
4257 const activeTab = document.querySelector(`[data-tab="${targetTab}"]`);
4258 const activeContent = document.querySelector(`.charitable-dashboard-v2-tab-content[data-tab="${targetTab}"]`);
4259
4260 if (activeTab) {
4261 activeTab.classList.add('charitable-dashboard-v2-tab-nav-active');
4262 }
4263 if (activeContent) {
4264 activeContent.classList.add('charitable-dashboard-v2-tab-content-active');
4265 }
4266
4267 // Update URL without page reload
4268 const url = new URL(window.location);
4269 url.searchParams.set('tab', targetTab);
4270 window.history.pushState({}, '', url);
4271 }
4272
4273 // Add click event listeners to tab navigation
4274 tabNavItems.forEach(item => {
4275 item.addEventListener('click', function(e) {
4276 e.preventDefault();
4277 const targetTab = this.getAttribute('data-tab');
4278 switchTab(targetTab);
4279 });
4280 });
4281
4282 // Handle browser back/forward buttons
4283 window.addEventListener('popstate', function() {
4284 const urlParams = new URLSearchParams(window.location.search);
4285 const tab = urlParams.get('tab') || 'top-campaigns';
4286 switchTab(tab);
4287 });
4288
4289 // Addon activation functionality
4290 const activateButton = document.getElementById('charitable-activate-donor-comments');
4291 if (activateButton) {
4292 activateButton.addEventListener('click', function(e) {
4293 e.preventDefault();
4294
4295 const plugin = this.getAttribute('data-plugin');
4296 const buttonText = this.querySelector('.button-text');
4297 const buttonLoading = this.querySelector('.button-loading');
4298
4299 // Show loading state
4300 buttonText.style.display = 'none';
4301 buttonLoading.style.display = 'inline-flex';
4302 this.disabled = true;
4303
4304 // Make AJAX request
4305 const formData = new FormData();
4306 formData.append('action', 'charitable_activate_addon');
4307 formData.append('plugin', plugin);
4308 formData.append('nonce', charitable_dashboard_v2_reporting.admin_nonce);
4309
4310 fetch(charitable_dashboard_v2_reporting.ajax_url, {
4311 method: 'POST',
4312 body: formData
4313 })
4314 .then(response => response.json())
4315 .then(data => {
4316 if (data.success) {
4317 // Show success message and reload page
4318 alert(data.data.message);
4319 window.location.reload();
4320 } else {
4321 // Show error message
4322 alert(data.data.message || charitable_dashboard_v2_reporting.strings.activation_failed);
4323
4324 // Reset button state
4325 buttonText.style.display = 'inline';
4326 buttonLoading.style.display = 'none';
4327 this.disabled = false;
4328 }
4329 })
4330 .catch(error => {
4331 console.error('Error:', error);
4332 alert(charitable_dashboard_v2_reporting.strings.error_occurred);
4333
4334 // Reset button state
4335 buttonText.style.display = 'inline';
4336 buttonLoading.style.display = 'none';
4337 this.disabled = false;
4338 });
4339 });
4340 }
4341
4342 // Comment actions functionality
4343 let isProcessing = false;
4344
4345 // Rollback function - defined outside to be accessible
4346 function rollbackUI(originalState, actionElement) {
4347 // Restore original state for the specific action
4348 const actionText = actionElement.querySelector('.action-text');
4349 const actionLoading = actionElement.querySelector('.action-loading');
4350
4351 actionText.textContent = originalState.actionText;
4352 actionText.style.display = 'inline';
4353 actionLoading.style.display = originalState.actionLoading;
4354
4355 // Restore the icon
4356 const icon = actionElement.querySelector('svg, img');
4357 if (icon) {
4358 icon.style.display = originalState.icon;
4359 }
4360
4361 // Re-enable all comment actions
4362 const allCommentActions = document.querySelectorAll('.comment-action');
4363 allCommentActions.forEach(btn => {
4364 btn.style.pointerEvents = 'auto';
4365 btn.style.opacity = '1';
4366 });
4367
4368 isProcessing = false;
4369 }
4370
4371 // Re-enable all comment actions function
4372 function reEnableAllActions() {
4373 const allCommentActions = document.querySelectorAll('.comment-action');
4374 allCommentActions.forEach(btn => {
4375 btn.style.pointerEvents = 'auto';
4376 btn.style.opacity = '1';
4377 });
4378 isProcessing = false;
4379 }
4380
4381 const commentActions = document.querySelectorAll('.comment-action');
4382 commentActions.forEach(action => {
4383 action.addEventListener('click', function(e) {
4384 e.preventDefault();
4385
4386 // Prevent multiple simultaneous requests
4387 if (isProcessing) {
4388 return;
4389 }
4390
4391 const commentId = this.getAttribute('data-comment-id');
4392 const actionType = this.classList.contains('approve') ? 'approve' : 'delete';
4393 const actionText = this.querySelector('.action-text');
4394 const actionLoading = this.querySelector('.action-loading');
4395 const row = this.closest('tr');
4396
4397 // Store original state for rollback
4398 const originalState = {
4399 actionText: actionText.textContent,
4400 actionLoading: actionLoading.style.display,
4401 icon: this.querySelector('svg, img').style.display,
4402 pointerEvents: this.style.pointerEvents
4403 };
4404
4405 // Disable ALL comment actions in the entire comments area
4406 isProcessing = true;
4407 const allCommentActions = document.querySelectorAll('.comment-action');
4408 allCommentActions.forEach(btn => {
4409 btn.style.pointerEvents = 'none';
4410 btn.style.opacity = '0.5';
4411 });
4412
4413 // Show loading state for this specific action
4414 actionText.style.display = 'none';
4415 actionLoading.style.display = 'inline-flex';
4416
4417 // Hide the icon during processing
4418 const icon = this.querySelector('svg, img');
4419 if (icon) {
4420 icon.style.display = 'none';
4421 }
4422
4423 // Make AJAX request
4424 const formData = new FormData();
4425 formData.append('action', 'charitable_' + actionType + '_comment');
4426 formData.append('comment_id', commentId);
4427 formData.append('nonce', charitable_dashboard_v2_reporting.admin_nonce);
4428
4429 fetch(charitable_dashboard_v2_reporting.ajax_url, {
4430 method: 'POST',
4431 body: formData
4432 })
4433 .then(response => {
4434 if (!response.ok) {
4435 throw new Error('HTTP ' + response.status + ': ' + response.statusText);
4436 }
4437 return response.json();
4438 })
4439 .then(data => {
4440 if (data.success) {
4441 // Success - update UI with slight delay
4442 setTimeout(() => {
4443 if (actionType === 'approve') {
4444 // Replace with approved status
4445 const statusCell = row.querySelector('td:last-child');
4446 statusCell.innerHTML = '<span class="comment-status-approved">' + charitable_dashboard_v2_reporting.strings.approved + '</span>';
4447 // Re-enable all other actions after successful approve
4448 reEnableAllActions();
4449 } else if (actionType === 'delete') {
4450 // Fade out and remove row
4451 row.style.transition = 'opacity 0.3s ease';
4452 row.style.opacity = '0';
4453 setTimeout(() => {
4454 row.remove();
4455 // Check if table is now empty
4456 checkEmptyState();
4457 // Re-enable all other actions after successful delete
4458 reEnableAllActions();
4459 }, 300);
4460 }
4461 }, 200); // Slight delay as requested
4462 } else {
4463 // Error - rollback UI
4464 console.error('Charitable: AJAX error:', data.data.message);
4465 rollbackUI(originalState, this);
4466 }
4467 })
4468 .catch(error => {
4469 console.error('Charitable: Network error:', error);
4470 rollbackUI(originalState, this);
4471 });
4472 });
4473 });
4474
4475 // Check if comments table is empty and show empty state
4476 function checkEmptyState() {
4477 const tbody = document.querySelector('.charitable-dashboard-v2-table-comments tbody');
4478 const visibleRows = tbody.querySelectorAll('tr[data-comment-id]');
4479
4480 if (visibleRows.length === 0) {
4481 // Show empty state
4482 tbody.innerHTML = `
4483 <tr>
4484 <td colspan="2" class="charitable-dashboard-v2-no-comments">
4485 <p><strong>` + charitable_dashboard_v2_reporting.strings.no_comments_title + `</strong></p>
4486 <p>` + charitable_dashboard_v2_reporting.strings.no_comments_message + `</p>
4487 </td>
4488 </tr>
4489 `;
4490 }
4491 }
4492 });
4493 </script>
4494 <?php
4495 }
4496
4497 /**
4498 * Returns and/or create the single instance of this class.
4499 *
4500 * @since 1.8.1
4501 *
4502 * @return Charitable_Dashboard
4503 */
4504 public static function get_instance() {
4505 if ( is_null( self::$instance ) ) {
4506 self::$instance = new self();
4507 }
4508
4509 return self::$instance;
4510 }
4511 }
4512
4513 endif;
4514