'notice',
'priority' => 3,
'title' => __( 'Finish Setting Up Charitable', 'charitable' ),
'message' => '
' . 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' ) . '
',
'custom_css' => 'charitable-notification-type-notice',
'button_url' => add_query_arg( array( 'resume' => 'true' ), charitable_get_onboarding_url() ),
'button_text' => __( 'Resume Setup Wizard', 'charitable' ),
);
update_option( 'charitable_dashboard_notifications', $notifications );
return;
}
if ( $present && ! $started ) {
unset( $notifications['resume_setup_wizard'] );
update_option( 'charitable_dashboard_notifications', $notifications );
}
}
/**
* Render the dashboard new page.
*
* @since 1.8.8
* @version 1.8.8.1
*/
public function render_dashboard_new() {
?>
$notification ) : ?>
array(
'href' => array(),
'title' => array(),
),
'strong' => array(),
'p' => array(),
'ol' => array(),
'ul' => array(),
'li' => array(),
'br' => array(),
'h1' => array(),
'h2' => array(),
'h3' => array(),
'h4' => array(),
'h5' => array(),
)
);
$notification_type = ! empty( $notification['type'] ) ? $notification['type'] : 'notice';
$type_icons = array(
'notice' => 'dashicons-info',
'warning' => 'dashicons-warning',
'error' => 'dashicons-dismiss',
);
$icon_class = isset( $type_icons[ $notification_type ] ) ? $type_icons[ $notification_type ] : 'dashicons-info';
$button_url = ! empty( $notification['button_url'] ) ? esc_url( $notification['button_url'] ) : '';
$button_text = ! empty( $notification['button_text'] ) ? sanitize_text_field( $notification['button_text'] ) : '';
?>
|
Name |
Total Donations |
Status |
|
|
|
|
|
$num_posts ) {
if ( 'trash' === $status ) {
continue;
}
if ( (int) $num_posts > 0 ) {
$has_non_trashed_campaign = true;
break 2;
}
}
}
}
?>
false,
'end_date' => false,
'days' => false,
);
$charitable_reports->init_with_array( 'dashboard', $args );
// Get donations data first
$charitable_reports->get_donations();
// Get top donors data (get more than needed to account for filtering)
$top_donors = $charitable_reports->get_top_donors_overview( $limit * 2 );
if ( empty( $top_donors ) || ! is_array( $top_donors ) ) {
return array();
}
$donors = array();
$unique_donors = array();
$count = 0;
foreach ( $top_donors as $donor_data ) {
// Skip if we've already processed this donor
if ( in_array( $donor_data->donor_id, $unique_donors, true ) ) {
continue;
}
// Stop if we've reached the limit
if ( $count >= $limit ) {
break;
}
$unique_donors[] = $donor_data->donor_id;
$count++;
// Get donor name
$donor_name = ! empty( $donor_data->donor_name ) ? $donor_data->donor_name : __( 'Unknown', 'charitable' );
// Get donor email and truncate if needed
$donor_email = ! empty( $donor_data->donor_email ) ? $donor_data->donor_email : '';
$donor_email_display = $donor_email;
if ( strlen( $donor_email ) > 30 ) {
$donor_email_display = substr( $donor_email, 0, 30 ) . '...';
}
// Get avatar (WordPress default)
$donor_avatar = ! empty( $donor_data->donor_avatar_url ) ? $donor_data->donor_avatar_url : false;
if ( false === $donor_avatar && function_exists( 'get_avatar_url' ) && ! empty( $donor_data->donor_email ) ) {
$donor_avatar = get_avatar_url(
$donor_data->donor_email,
array(
'size' => 40,
)
);
}
// Determine donor type and badge
$donation_parent = false;
if ( ! empty( $donor_data->donation_parent_id ) ) {
$donation = charitable_get_donation( $donor_data->donation_parent_id );
$donation_parent = ( $donation && is_object( $donation ) ) ? $donation->get( 'donation_type' ) : false;
}
$display_count = ! empty( $donor_data->total_donation_count ) ? intval( $donor_data->total_donation_count ) : 1;
$badge_css = strtolower( $donation_parent ) === 'recurring' ? 'recurring' : 'one-time';
$badge_label = strtolower( $donation_parent ) === 'recurring' ? esc_html__( 'Recurring', 'charitable' ) : esc_html__( 'One-Time', 'charitable' );
// Override for multiple donations
if ( $display_count > 1 ) {
$badge_css = 'multiple';
$badge_label = esc_html__( 'Multiple', 'charitable' );
}
// Format total amount
$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 );
$donors[] = array(
'name' => $donor_name,
'email' => $donor_email,
'email_display' => $donor_email_display,
'avatar' => $donor_avatar,
'total' => $display_amount,
'type' => $badge_css,
'type_label' => $badge_label,
'url' => admin_url( 'admin.php?page=charitable-donors' )
);
}
return $donors;
}
/**
* Render the comments tab content.
*
* @since 1.8.8
*/
private function render_comments_tab() {
?>
get_donor_comments_addon_status();
switch ( $addon_status ) {
case 'active':
$this->render_comments_table();
break;
case 'installed_inactive':
$this->render_addon_inactive_message();
break;
case 'not_installed_pro':
$this->render_addon_not_installed_pro_message();
break;
case 'not_installed_lite':
default:
$this->render_addon_not_installed_lite_message();
break;
}
}
/**
* Get donor comments addon status.
*
* @since 1.8.8
* @return string Status: 'active', 'installed_inactive', 'not_installed_pro', 'not_installed_lite'
*/
private function get_donor_comments_addon_status() {
// Check if addon is active
if ( class_exists( 'Charitable_Donor_Comments' ) ) {
return 'active';
}
// Check if addon is installed but not active
$plugin_file = 'charitable-donor-comments/charitable-donor-comments.php';
if ( file_exists( WP_PLUGIN_DIR . '/' . $plugin_file ) ) {
return charitable_is_pro() ? 'installed_inactive' : 'not_installed_lite';
}
// Addon not installed
return charitable_is_pro() ? 'not_installed_pro' : 'not_installed_lite';
}
/**
* Render comments table with actual data.
*
* @since 1.8.8
*/
private function render_comments_table() {
$comments = $this->get_comments();
?>
= 4 ) break; $count++; ?>
calculate_relative_time( $update['date'] ) ); ?>
→
Unable to load blog posts. Please check back later.
10,
'sslverify' => false,
'user-agent' => charitable_get_default_user_agent(),
];
$response = wp_remote_get(
self::BLOG_POSTS_SOURCE_URL,
$args
);
if ( is_wp_error( $response ) ) {
return false;
}
$body = wp_remote_retrieve_body( $response );
if ( empty( $body ) ) {
return false;
}
$decoded = json_decode( $body, true );
if ( json_last_error() !== JSON_ERROR_NONE ) {
return false;
}
return $this->verify_blog_posts( $decoded );
}
/**
* Calculate relative time from a date string.
*
* @since 1.8.8.4
*
* @param string $date_string Date string in format 'Y-m-d H:i:s'.
*
* @return string Relative time string (e.g., '2 days ago', '1 week ago').
*/
private function calculate_relative_time( $date_string ) {
if ( empty( $date_string ) ) {
return '';
}
$post_date = strtotime( $date_string );
$current_time = current_time( 'timestamp' );
$time_diff = $current_time - $post_date;
// If the date is in the future, return empty string
if ( $time_diff < 0 ) {
return '';
}
// Calculate relative time
$minutes = floor( $time_diff / 60 );
$hours = floor( $time_diff / 3600 );
$days = floor( $time_diff / 86400 );
$weeks = floor( $time_diff / 604800 );
$months = floor( $time_diff / 2592000 );
$years = floor( $time_diff / 31536000 );
if ( $years > 0 ) {
/* translators: %s: number of years */
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 ) );
} elseif ( $months > 0 ) {
/* translators: %s: number of months */
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 ) );
} elseif ( $weeks > 0 ) {
/* translators: %s: number of weeks */
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 ) );
} elseif ( $days > 0 ) {
/* translators: %s: number of days */
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 ) );
} elseif ( $hours > 0 ) {
/* translators: %s: number of hours */
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 ) );
} elseif ( $minutes > 0 ) {
/* translators: %s: number of minutes */
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 ) );
} else {
return _x( 'Just now', 'relative time', 'charitable' );
}
}
/**
* Verify blog posts data before it is saved.
*
* @since 1.8.8
*
* @param array $blog_posts Array of blog posts items to verify.
*
* @return array
*/
private function verify_blog_posts( $blog_posts ) {
$data = [];
if ( ! is_array( $blog_posts ) || empty( $blog_posts ) ) {
return $data;
}
// Check if we have the expected structure
if ( ! isset( $blog_posts['blog_posts'] ) || ! is_array( $blog_posts['blog_posts'] ) ) {
return $data;
}
// Store version information if available
if ( isset( $blog_posts['meta'] ) && is_array( $blog_posts['meta'] ) ) {
$this->version_info = array(
'current_charitable_version_lite' => ! empty( $blog_posts['meta']['current_charitable_version_lite'] ) ? sanitize_text_field( $blog_posts['meta']['current_charitable_version_lite'] ) : '',
'current_charitable_version_pro' => ! empty( $blog_posts['meta']['current_charitable_version_pro'] ) ? sanitize_text_field( $blog_posts['meta']['current_charitable_version_pro'] ) : '',
);
}
foreach ( $blog_posts['blog_posts'] as $post ) {
// Verify required fields
if ( empty( $post['title'] ) || empty( $post['url'] ) ) {
continue;
}
// Handle image data
$image_data = array(
'placeholder' => true,
'svg' => ''
);
if ( ! empty( $post['image'] ) && is_array( $post['image'] ) ) {
// Handle placeholder as both boolean and string
$placeholder_value = $post['image']['placeholder'] ?? true;
if ( is_string( $placeholder_value ) ) {
// Handle string values: 'false', '0', 'no' should be false
$placeholder_value = in_array( strtolower( $placeholder_value ), ['false', '0', 'no', ''], true ) ? false : true;
}
$image_data['placeholder'] = (bool) $placeholder_value;
if ( ! empty( $post['image']['img'] ) ) {
$image_data['img'] = esc_url_raw( $post['image']['img'] );
}
if ( ! empty( $post['image']['svg'] ) ) {
$image_data['svg'] = $post['image']['svg']; // SVG is already sanitized HTML
}
}
// Calculate timestamp from date field if available, otherwise use provided timestamp
$calculated_timestamp = '';
if ( ! empty( $post['date'] ) ) {
$calculated_timestamp = $this->calculate_relative_time( $post['date'] );
} elseif ( ! empty( $post['timestamp'] ) ) {
$calculated_timestamp = sanitize_text_field( $post['timestamp'] );
}
$data[] = array(
'title' => sanitize_text_field( $post['title'] ),
'url' => esc_url_raw( $post['url'] ),
'timestamp' => $calculated_timestamp,
'featured' => ! empty( $post['featured'] ) ? (bool) $post['featured'] : false,
'image' => $image_data,
);
}
return $data;
}
/**
* Get blog posts from transient cache.
*
* @since 1.8.8
*
* @return array
*/
private function get_blog_posts_from_cache() {
$cached_data = get_transient( 'charitable_blog_posts_cache' );
if ( false === $cached_data ) {
return [];
}
return (array) $cached_data;
}
/**
* Cache blog posts data using transients.
*
* @since 1.8.8
*
* @param array $blog_posts Blog posts data to cache.
*/
private function cache_blog_posts( $blog_posts ) {
$data = [
'update' => time(),
'blog_posts' => $blog_posts,
'version_info' => $this->version_info,
];
// Cache for 24 hours
set_transient( 'charitable_blog_posts_cache', $data, DAY_IN_SECONDS );
}
/**
* Get blog posts data.
*
* @since 1.8.8
* @return array
*/
private function get_blog_posts() {
// phpcs:disable WordPress.Security.NonceVerification.Recommended
$is_simulating = isset( $_GET['charitable_simulate_api_failure'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['charitable_simulate_api_failure'] ) );
$clear_cache = isset( $_GET['charitable_clear_cache'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['charitable_clear_cache'] ) );
// phpcs:enable WordPress.Security.NonceVerification.Recommended
// If simulating API failure, always return error
if ( $is_simulating ) {
return $this->get_blog_posts_error();
}
// Clear cache if requested
if ( $clear_cache ) {
$this->clear_blog_posts_cache();
}
// Try to get from cache first
$cached_data = $this->get_blog_posts_from_cache();
if ( ! empty( $cached_data ) && ! $clear_cache ) {
// Restore version info from cache
if ( isset( $cached_data['version_info'] ) ) {
$this->version_info = $cached_data['version_info'];
}
return $cached_data['blog_posts'] ?? [];
}
// Fetch fresh data from API
$blog_posts = $this->fetch_blog_posts_feed();
// If API fetch was successful, cache and return the data
if ( false !== $blog_posts && ! empty( $blog_posts ) ) {
$this->cache_blog_posts( $blog_posts );
return $blog_posts;
} else {
// API failed, show error
return $this->get_blog_posts_error();
}
}
/**
* Clear blog posts cache (for testing purposes).
*
* @since 1.8.8
*/
private function clear_blog_posts_cache() {
delete_transient( 'charitable_blog_posts_cache' );
}
/**
* Get blog posts error data when API fails and no cache is available.
*
* @since 1.8.8
* @return array
*/
private function get_blog_posts_error() {
// phpcs:disable WordPress.Security.NonceVerification.Recommended
$is_simulating = isset( $_GET['charitable_simulate_api_failure'] ) && '1' === sanitize_text_field( wp_unslash( $_GET['charitable_simulate_api_failure'] ) );
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$error_title = $is_simulating ? __( 'Unable to load blog posts (Simulated API Failure)', 'charitable' ) : __( 'Unable to load blog posts', 'charitable' );
return array(
array(
'title' => $error_title,
'url' => '#',
'timestamp' => '',
'featured' => false,
'error' => true
)
);
}
/**
* Check if blog posts contain an error.
*
* @since 1.8.8
* @param array $posts Blog posts array.
* @return bool True if posts contain an error.
*/
private function has_blog_posts_error( $posts ) {
if ( empty( $posts ) ) {
return true;
}
foreach ( $posts as $post ) {
if ( ! empty( $post['error'] ) ) {
return true;
}
}
return false;
}
/**
* Calculate how many updates behind the user is.
*
* @since 1.8.8
* @return int Number of updates behind, or 0 if up to date or unable to determine.
*/
private function get_updates_behind() {
// Get current installed version
$current_version = charitable()->get_version();
// Get latest version from API
$latest_version = $this->get_latest_version();
if ( empty( $latest_version ) ) {
return 0;
}
return $this->compare_versions( $current_version, $latest_version );
}
/**
* Get the latest version from the API data.
*
* @since 1.8.8
* @return string Latest version or empty string if not available.
*/
private function get_latest_version() {
if ( null === $this->version_info ) {
// Try to get version info from cached data
$cached_data = get_transient( 'charitable_blog_posts_cache' );
if ( false !== $cached_data && isset( $cached_data['version_info'] ) ) {
$this->version_info = $cached_data['version_info'];
}
}
if ( null === $this->version_info ) {
return '';
}
// For now, we're working with Lite version
return ! empty( $this->version_info['current_charitable_version_lite'] ) ? $this->version_info['current_charitable_version_lite'] : '';
}
/**
* Compare two version strings and return how many updates behind.
*
* @since 1.8.8
* @param string $current Current version (e.g., "1.8.4").
* @param string $latest Latest version (e.g., "1.8.8").
* @return int Number of updates behind.
*/
private function compare_versions( $current, $latest ) {
// Normalize versions by removing any non-numeric characters except dots
$current = preg_replace( '/[^0-9.]/', '', $current );
$latest = preg_replace( '/[^0-9.]/', '', $latest );
// Split into parts
$current_parts = array_map( 'intval', explode( '.', $current ) );
$latest_parts = array_map( 'intval', explode( '.', $latest ) );
// Ensure both arrays have the same length by padding with zeros
$max_length = max( count( $current_parts ), count( $latest_parts ) );
$current_parts = array_pad( $current_parts, $max_length, 0 );
$latest_parts = array_pad( $latest_parts, $max_length, 0 );
// Compare each part
for ( $i = 0; $i < $max_length; $i++ ) {
if ( $latest_parts[ $i ] > $current_parts[ $i ] ) {
// Calculate the difference
$updates_behind = 0;
for ( $j = $i; $j < $max_length; $j++ ) {
$updates_behind += $latest_parts[ $j ] - $current_parts[ $j ];
}
return $updates_behind;
} elseif ( $latest_parts[ $i ] < $current_parts[ $i ] ) {
// Current version is newer than latest (shouldn't happen)
return 0;
}
}
// Versions are equal
return 0;
}
/**
* Test version comparison logic (for debugging).
*
* @since 1.8.8
* @return void
*/
public function test_version_comparison() {
// Test cases
$test_cases = array(
array( '1.8.4', '1.8.8', 4 ),
array( '1.8.4.2', '1.8.8', 4 ),
array( '1.8.7.5', '1.8.8', 1 ),
array( '1.8.8', '1.8.8', 0 ),
array( '1.8.9', '1.8.8', 0 ),
array( '1.7.5', '1.8.2', 5 ),
);
foreach ( $test_cases as $test ) {
$result = $this->compare_versions( $test[0], $test[1] );
$status = ( $result === $test[2] ) ? 'PASS' : 'FAIL';
}
}
/**
* Render the quick access section.
*
* @since 1.8.8
* @version 1.8.8.6
*/
public function render_quick_access_section() {
?>
array(
'xmlns' => true,
'width' => true,
'height' => true,
'viewbox' => true,
'fill' => true,
'class' => true,
'aria-hidden' => true,
'role' => true,
),
'path' => array(
'd' => true,
'fill' => true,
'stroke' => true,
'stroke-width' => true,
'stroke-linecap' => true,
'stroke-linejoin'=> true,
),
'rect' => array(
'x' => true,
'y' => true,
'width' => true,
'height' => true,
'fill' => true,
),
'mask' => array(
'id' => true,
'style' => true,
'maskunits' => true,
'x' => true,
'y' => true,
'width' => true,
'height' => true,
),
'g' => array(
'mask' => true,
),
)
);
?>
__( 'Getting Started', 'charitable' ),
'url' => 'https://wpcharitable.com/start-here/?referrer=chariable-dashboard',
'icon' => '