PluginProbe
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) / 1.8.13
Charitable – Donation & Fundraising Platform (Donation Forms, Recurring Donations & Fundraising Campaigns) v1.8.13
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 1.7.0.9 All 211 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.13, at includes/admin/dashboard/class-charitable-dashboard.php

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