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

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

1,599 lines 57.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Charitable Addons Directory.
4 *
5 * @package Charitable/Classes/Charitable_Addons_Directory
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.0.0
10 * @version 1.8.1.3
11 */
12
13 // Exit if accessed directly.
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit;
16 }
17
18 if ( ! class_exists( 'Charitable_Addons_Directory' ) ) :
19
20 /**
21 * Charitable_Addons_Directory
22 *
23 * @final
24 * @since 1.0.0
25 */
26 final class Charitable_Addons_Directory {
27
28 /**
29 * The update URL.
30 *
31 * @var string
32 */
33 const UPDATE_URL = 'https://wpcharitable.com';
34
35 /**
36 * The single instance of this class.
37 *
38 * @var Charitable_Addons_Directory|null
39 */
40 private static $instance = null;
41
42 /**
43 * All the stored licenses.
44 *
45 * @var array
46 */
47 private $licenses;
48
49 /**
50 * The key for the failed request cache.
51 *
52 * @var string
53 */
54 private $failed_request_cache_key = 'wpc_edd_sl_failed_plugin_versions';
55
56 /**
57 * Determine if the plugin/addon installations are allowed.
58 *
59 * @since 1.6.7
60 *
61 * @var bool
62 */
63 private $can_install;
64
65 /**
66 * Create object instance.
67 *
68 * @since 1.0.0
69 */
70 private function __construct() {
71 }
72
73 /**
74 * Init.
75 *
76 * @since 1.6.7
77 */
78 public function init() {
79
80 $this->can_install = charitable_can_install( 'addon' );
81
82 do_action( 'charitable_addons_directory_settings_start', $this );
83
84 add_action( 'admin_enqueue_scripts', array( $this, 'charitable_admin_scripts' ) );
85
86 // Add callbacks for content.
87 add_action( 'charitable_addons_directory_section', array( $this, 'addons_directory_content' ) );
88 }
89
90 /**
91 * Add JS text scripts and load admin scripts.
92 *
93 * @since 1.8.0
94 * @version 1.8.1.15 - Added upgrade_modal.
95 */
96 public function charitable_admin_scripts() {
97
98 $min = charitable_get_min_suffix();
99 $version = charitable()->get_version();
100
101 $assets_dir = charitable()->get_path( 'assets', false );
102
103 $strings = apply_filters(
104 'charitable_admin_strings',
105 array(
106 'nonce' => wp_create_nonce( 'charitable-admin' ),
107 'addon_activate' => esc_html__( 'Activate', 'charitable' ),
108 'addon_activated' => esc_html__( 'Activated', 'charitable' ),
109 'addon_active' => esc_html__( 'Active', 'charitable' ),
110 'addon_deactivate' => esc_html__( 'Deactivate', 'charitable' ),
111 'addon_inactive' => esc_html__( 'Inactive', 'charitable' ),
112 'addon_install' => esc_html__( 'Install Addon', 'charitable' ),
113 'addon_error' => sprintf(
114 wp_kses( /* translators: %1$s - An addon download URL, %2$s - Link to manual installation guide. */
115 __( 'Could not install the addon. Please <a href="%1$s" target="_blank" rel="noopener noreferrer">download it from wpcharitable.com</a> and <a href="%2$s" target="_blank" rel="noopener noreferrer">install it manually</a>.', 'charitable' ),
116 [
117 'a' => [
118 'href' => true,
119 'target' => true,
120 'rel' => true,
121 ],
122 ]
123 ),
124 'https://wpcharitable.com/account/licenses/',
125 'https://wpcharitable.com/docs/how-to-manually-install-addons-in-charitable/'
126 ),
127 'plugin_error' => esc_html__( 'Could not install the plugin automatically. Please download and install it manually.', 'charitable' ),
128 'addon_search' => esc_html__( 'Searching Addons', 'charitable' ),
129 'ajax_url' => admin_url( 'admin-ajax.php' ),
130 'admin_url' => admin_url(),
131 'ok' => esc_html__( 'OK', 'charitable' ),
132 'cancel' => esc_html__( 'Cancel', 'charitable' ),
133 'close' => esc_html__( 'Close', 'charitable' ),
134 'plugin_install_activate_btn' => esc_html__( 'Install and Activate', 'charitable' ),
135 'plugin_install_activate_confirm' => esc_html__( 'needs to be installed and activated to import its forms. Would you like us to install and activate it for you?', 'charitable' ),
136 'plugin_activate_btn' => esc_html__( 'Activate', 'charitable' ),
137 'plugin_activate_confirm' => esc_html__( 'needs to be activated to import its forms. Would you like us to activate it for you?', 'charitable' ),
138 'connecting' => esc_html__( 'Connecting...', 'charitable' ),
139 'save_refresh' => esc_html__( 'Save and Refresh', 'charitable' ),
140 'server_error' => esc_html__( 'Unfortunately there was a server connection error.', 'charitable' ),
141 'testing' => esc_html__( 'Testing', 'charitable' ),
142 'upgrade_completed' => esc_html__( 'Upgrade was successfully completed!', 'charitable' ),
143 'edit_license' => esc_html__( 'To edit the License Key, please first click the Deactivate Key button. Please note that deactivating this key will remove access to updates, addons, and support.', 'charitable' ),
144 'something_went_wrong' => esc_html__( 'Something went wrong', 'charitable' ),
145 'success' => esc_html__( 'Success', 'charitable' ),
146 'loading' => esc_html__( 'Loading...', 'charitable' ),
147 'use_simple_contact_form' => esc_html__( 'Use Simple Contact Form Template', 'charitable' ),
148 'error_select_template' => esc_html__( 'Something went wrong while applying the template.', 'charitable' ),
149 'plugin_asset_dir' => charitable()->get_path( 'assets', false ),
150 'install' => esc_html__( 'Install', 'charitable' ),
151 'activate' => esc_html__( 'Activate', 'charitable' ),
152 'setup' => esc_html__( 'Setup', 'charitable' ),
153 'settings' => esc_html__( 'Settings', 'charitable' ),
154 'thanks_for_interest' => esc_html__( 'Thanks for your interest in Charitable Pro!', 'charitable' ),
155 'upgrade_modal' => charitable_get_upgrade_modal_text(),
156 'autoshow_plugin_notifications' => charitable_get_autoshow_plugin_notifications(),
157 'enable_express_checkout_message' => __( 'To enable this payment method, enable Express Checkout', 'charitable' ),
158 'activate_now' => esc_html__( 'Activate Now', 'charitable' ),
159 'activate_later' => esc_html__( 'Activate Later', 'charitable' ),
160 'download_pro' => esc_html__( 'Download Pro', 'charitable' ),
161 'downloading' => esc_html__( 'Downloading...', 'charitable' ),
162 'downloading_pro_message' => esc_html__( 'Please wait while we download Charitable Pro.', 'charitable' ),
163 'activating' => esc_html__( 'Activating...', 'charitable' ),
164 'activating_pro_message' => esc_html__( 'Please wait while we activate Charitable Pro.', 'charitable' ),
165 'download_failed' => esc_html__( 'Download failed. Please try again.', 'charitable' ),
166 'activation_failed' => esc_html__( 'Activation failed. You can activate it manually from the Plugins page.', 'charitable' ),
167 'visit_addons_page' => esc_html__( 'Visit Addons Page', 'charitable' ),
168 'ready_to_use' => esc_html__( 'Ready to Use', 'charitable' ),
169 'pro_ready_to_activate' => esc_html__( 'Charitable Pro has been downloaded and is ready to activate when you\'re ready. You can activate it from the Plugins page.', 'charitable' ),
170 'verifying' => esc_html__( 'Verifying...', 'charitable' ),
171 'deactivating' => esc_html__( 'Deactivating...', 'charitable' ),
172 'manual_install' => esc_html__( 'Manual Install', 'charitable' ),
173 'license_validated' => esc_html__( 'License Validated', 'charitable' ),
174 )
175 );
176
177 wp_enqueue_script(
178 'listjs',
179 $assets_dir . 'js/libraries/list.min.js',
180 [ 'jquery' ],
181 $version,
182 false
183 );
184
185 wp_register_style(
186 'charitable-admin-addons-directory',
187 $assets_dir . 'css/admin/charitable-admin-addons-directory' . $min . '.css',
188 array(),
189 $version
190 );
191 wp_enqueue_style( 'charitable-admin-addons-directory' );
192
193 wp_register_script(
194 'charitable-admin-addon-directory',
195 $assets_dir . 'js/admin/charitable-admin-addon-directory' . $min . '.js',
196 array( 'jquery' ),
197 $version,
198 true
199 );
200
201 wp_enqueue_script( 'charitable-admin-addon-directory' );
202
203 wp_localize_script(
204 'charitable-admin-addon-directory',
205 'charitable_admin',
206 $strings
207 );
208 }
209
210 /**
211 * Returns and/or create the single instance of this class.
212 *
213 * @since 1.2.0
214 *
215 * @return Charitable_Addons_Directory
216 */
217 public static function get_instance() {
218 if ( is_null( self::$instance ) ) {
219 self::$instance = new self();
220 }
221
222 return self::$instance;
223 }
224
225 /**
226 * Attemps to confirm if the current license is a legacy one, even if we have a plan_id / slug (which normally came from newer license)
227 *
228 * @since 1.7.0.4
229 *
230 * @return Charitable_Addons_Directory
231 */
232 public static function is_current_plan_legacy() {
233
234 $settings = get_option( 'charitable_settings' );
235
236 // always check the v2 settings first.
237 $plan_id = isset( $settings['licenses']['charitable-v2']['plan_id'] ) ? intval( $settings['licenses']['charitable-v2']['plan_id'] ) : false;
238 $valid = isset( $settings['licenses']['charitable-v2']['valid'] ) ? trim( esc_html( $settings['licenses']['charitable-v2']['valid'] ) ) : false;
239
240 if ( false === $valid || '' === $valid ) {
241 // try looking at the "legacy" license for a plan_id.
242 if ( isset( $settings['licenses']['charitable'] ) ) {
243 $plan_id = isset( $settings['licenses']['charitable']['plan_id'] ) ? intval( $settings['licenses']['charitable']['plan_id'] ) : false;
244 $valid = isset( $settings['licenses']['charitable']['valid'] ) ? trim( esc_html( $settings['licenses']['charitable']['valid'] ) ) : false;
245
246 if ( $plan_id ) {
247 return true;
248 }
249 }
250
251 // try looking for an install that just installed an addon license only, which would be another example of a "legacy" license.
252 if ( ! empty( $settings['licenses'] ) ) {
253 $addon_licenses = $settings['licenses'];
254 if ( isset( $addon_licenses['charitable'] ) ) {
255 unset( $addon_licenses['charitable'] );
256 }
257 if ( isset( $addon_licenses['charitable_pro'] ) ) {
258 unset( $addon_licenses['charitable_pro'] );
259 }
260 foreach ( $addon_licenses as $addon_license ) {
261 if ( isset( $addon_license['valid'] ) && 1 === intval( $addon_license['valid'] ) ) {
262 return true;
263 }
264 }
265 }
266 }
267
268 return false;
269 }
270
271 /**
272 * Returns and/or create the single instance of this class.
273 *
274 * @since 1.2.0
275 *
276 * @return Charitable_Addons_Directory
277 */
278 public static function get_current_plan_slug() {
279
280 $settings = get_option( 'charitable_settings' );
281
282 // always check the v2 settings first.
283 $plan_id = isset( $settings['licenses']['charitable-v2']['plan_id'] ) ? intval( $settings['licenses']['charitable-v2']['plan_id'] ) : false;
284 $valid = isset( $settings['licenses']['charitable-v2']['valid'] ) ? trim( esc_html( $settings['licenses']['charitable-v2']['valid'] ) ) : false;
285
286 if ( false === $valid || '' === $valid ) {
287 // try looking at the "legacy" license for a plan_id.
288 if ( isset( $settings['licenses']['charitable'] ) ) {
289 $plan_id = isset( $settings['licenses']['charitable']['plan_id'] ) ? intval( $settings['licenses']['charitable']['plan_id'] ) : false;
290 $valid = isset( $settings['licenses']['charitable']['valid'] ) ? trim( esc_html( $settings['licenses']['charitable']['valid'] ) ) : false;
291 }
292 }
293
294 if ( $valid ) {
295 switch ( $plan_id ) {
296 case 1:
297 $plan_slug = 'basic';
298 break;
299 case 2:
300 $plan_slug = 'plus';
301 break;
302 case 3:
303 $plan_slug = 'pro';
304 break;
305 case 4:
306 $plan_slug = 'agency';
307 break;
308 default:
309 $plan_slug = 'lite';
310 break;
311 }
312 } else {
313 $plan_slug = 'lite';
314 }
315
316 return $plan_slug;
317 }
318
319 /**
320 * Callback for displaying the UI for Addons.
321 *
322 * @since 1.7.0
323 * @version 1.8.8.6
324 */
325 public function addons_directory_content() {
326
327 // Get Addons.
328 $temp_addons = $this->get_addons();
329
330 // If error(s) occured during license key verification, display them and exit now.
331 if ( empty( $temp_addons ) ) { ?>
332
333 <h1 class="page-title"><?php esc_html_e( 'Charitable Addons', 'charitable' ); ?></h1>
334
335 <div class="error below-h2">
336 <p>
337 <?php esc_html_e( 'There are some communication or license issues. Please make sure your server is able to communicate with wpcharitable.com. Contact Charitable Support for additional assistance.', 'charitable' ); ?>
338 </p>
339 </div>
340 <?php
341
342 if ( charitable_is_debug() ) { // @codingStandardsIgnoreLine
343 error_log( 'addons_directory_content - communication issue' ); // @codingStandardsIgnoreLine
344 }
345
346 return;
347 }
348
349 $recommended_addons = array_map( array( $this, 'prepare_addon_data' ), ! empty( $temp_addons['recommended'] ) ? $temp_addons['recommended'] : array() );
350 $unlicensed_addons = array_map( array( $this, 'prepare_addon_data' ), ! empty( $temp_addons['unlicensed'] ) ? $temp_addons['unlicensed'] : array() );
351 $licensed_addons = array_map( array( $this, 'prepare_addon_data' ), ! empty( $temp_addons['licensed'] ) ? $temp_addons['licensed'] : array() );
352
353 $installed_plugins = get_plugins();
354
355 // Categorize addons.
356 $featured_addons = array();
357 $installed_addons = array();
358 $available_addons = array();
359 $premium_addons = array();
360 $all_addons_display = array();
361
362 // Combine all addons into a single array to avoid duplicates.
363 $all_api_addons = array_merge( $recommended_addons, $licensed_addons, $unlicensed_addons );
364 $all_addons = array();
365 foreach ( $all_api_addons as $addon ) {
366 if ( isset( $addon['slug'] ) ) {
367 $all_addons[ $addon['slug'] ] = $addon;
368 }
369 }
370
371 foreach ( $all_addons as $addon ) {
372 // Apply slug replacements for plugin detection (same logic as in prepare_addon_data).
373 $replacements = array(
374 'charitable-recurring-donations' => 'charitable-recurring',
375 'charitable-anonymous-donations' => 'charitable-anonymous',
376 'charitable-easy-digital-downloads-connect' => 'charitable-edd',
377 );
378
379 $slug_for_detection = $addon['slug'];
380 if ( array_key_exists( $addon['slug'], $replacements ) ) {
381 $slug_for_detection = $replacements[ $addon['slug'] ];
382 }
383
384 $plugin_basename = $this->get_plugin_basename_from_slug( $slug_for_detection );
385 $is_installed = array_key_exists( $plugin_basename, $installed_plugins );
386 $is_featured = ! empty( $addon['featured'] ) && in_array( 'recommended', $addon['featured'], true );
387 $is_available = ! empty( $addon['license'] ) && in_array( $this->get_current_plan_slug(), $addon['license'], true );
388 $addon['is_updated'] = $this->is_addon_updated( $addon );
389 $addon['is_new'] = $this->is_addon_new( $addon );
390 $addon['is_active'] = is_plugin_active( $plugin_basename );
391
392 // Check if addon is included in pro.
393 $addons_included_in_pro = array(
394 'charitable-videos',
395 'charitable-pdf-receipts',
396 'charitable-stripe',
397 'charitable-geolocation',
398 'charitable-donor-comments',
399 );
400 $is_included_in_pro = in_array( $addon['slug'], $addons_included_in_pro, true );
401 $addon['is_included_in_pro'] = $is_included_in_pro;
402
403 // Prioritize installed addons over featured status.
404 // Don't add addons included in pro to the installed category.
405 if ( $is_installed && ! $is_included_in_pro ) {
406 $installed_addons[] = $addon;
407 } elseif ( $is_featured ) {
408 $featured_addons[] = $addon;
409 } elseif ( $is_available && ! $is_included_in_pro ) {
410 $available_addons[] = $addon;
411 } elseif ( ! $is_included_in_pro ) {
412 $premium_addons[] = $addon;
413 }
414 }
415
416 $addon_categories = array(
417 'featured' => array(
418 'label' => __( 'Featured', 'charitable' ),
419 'addons' => $featured_addons,
420 ),
421 'installed' => array(
422 'label' => __( 'Installed', 'charitable' ),
423 'addons' => $installed_addons,
424 ),
425 'available' => array(
426 'label' => __( 'Available In Your Plan', 'charitable' ),
427 'addons' => $available_addons,
428 ),
429 'premium' => array(
430 'label' => __( 'Premium Addons', 'charitable' ),
431 'addons' => $premium_addons,
432 ),
433 );
434
435 // If no Addon(s) were returned, our API call returned an error.
436 // Show an error message with a button to reload the page, which will trigger another API call.
437 if ( ! $all_addons ) {
438 ?>
439 <form id="charitable-addons-refresh-addons-form" method="post">
440 <p>
441 <?php esc_html_e( 'There was an issue retrieving the addons for this site. Please click on the button below the refresh the addons data.', 'charitable' ); ?>
442 </p>
443 <p>
444 <a href="<?php echo esc_url( wp_unslash( $_SERVER['REQUEST_URI'] ) ); ?>" class="button button-primary"><?php esc_html_e( 'Refresh Addons', 'charitable' ); ?></a> <?php // @codingStandardsIgnoreLine ?>
445 </p>
446 </form>
447 <?php
448 return;
449 }
450
451 // If here, we have Addons to display, so let's output them now.
452 // Get installed plugins and upgrade URL.
453 $upgrade_url = $this->charitable_get_upgrade_link();
454 $type = ( isset( $_GET['plan'] ) ) ? esc_attr( $_GET['plan'] ) : $this->get_current_plan_slug(); // phpcs:ignore
455 $preset_search = ( isset( $_GET['search'] ) ) ? trim( esc_attr( $_GET['search'] ) ) : ''; // phpcs:ignore
456 ?>
457
458 <div class="charitable-addons-header">
459 <h1 class="charitable-addons-header-title">
460 Charitable Addons
461 <a href="<?php echo esc_url( add_query_arg( 'charitable_refresh_addons', '1' ) ); ?>" class="charitable-addons-link">
462 <?php esc_html_e( 'Refresh Addons', 'charitable' ); ?>
463 </a>
464 </h1>
465 <div class="charitable-addons-header-search">
466 <input type="search" placeholder="<?php esc_html_e( 'Search Addons', 'charitable' ); ?>" id="charitable-admin-addons-search" value="<?php echo esc_html( $preset_search ); ?>" />
467 </div>
468 </div>
469 <?php
470
471 // phpcs:disable
472 if ( defined( 'CHARITABLE_DEBUG_LICENSE' ) && CHARITABLE_DEBUG_LICENSE ) {
473 echo '<p>Plan type is: ' . $type . '</p>';
474 if ( charitable_is_debug() ) {
475 error_log( 'addons_directory_content' );
476 error_log( print_r( $type, true ) );
477 error_log( print_r( $installed_plugins, true ) );
478 error_log( print_r( $upgrade_url, true ) );
479 }
480 }
481 // phpcs:enable
482
483 /*
484 * Pro addon card visibility in the Charitable → Addons screen.
485 *
486 * Default: both `charitable-pro-plugin` and `charitable-pro` slugs are hidden
487 * so the normal upgrade path (wpcharitable.com checkout → Plugins → Add New
488 * upload) is the only way to install Pro.
489 *
490 * With CHARITABLE_SHOW_PRO_IN_ADDONS defined truthy (wp-config.php or a
491 * code-snippet plugin), the Pro card is exposed so Pro can be installed
492 * server-to-server from this screen — unblocking managed-host customers
493 * (WP.com, Kinsta, WP Engine, SiteGround) whose manual-upload path rejects
494 * the Pro zip with `http_error`.
495 *
496 * The wpcharitable.com addons API currently returns Pro under the legacy slug
497 * `charitable-pro-plugin`, while on disk Pro lives at `charitable-pro/
498 * charitable-pro.php`. If we rendered the raw entry, install-state detection,
499 * basename lookup, and activation would all use the wrong path and the flow
500 * would fail at activation. So when we expose Pro we NORMALISE the entry in
501 * place: rewrite its slug to `charitable-pro` and its path to match the
502 * on-disk location.
503 *
504 * Wrapped in try/catch so that any unexpected payload shape cannot fatal the
505 * admin Addons screen. On failure we fall back to the safe pre-1.8.10.5
506 * behaviour of hiding both Pro slugs.
507 *
508 * @since 1.8.5
509 */
510 try {
511 $show_pro = defined( 'CHARITABLE_SHOW_PRO_IN_ADDONS' ) && CHARITABLE_SHOW_PRO_IN_ADDONS;
512
513 foreach ( $addon_categories as $slug => &$category ) {
514 if ( ! is_array( $category ) || ! isset( $category['addons'] ) || ! is_array( $category['addons'] ) ) {
515 continue;
516 }
517 foreach ( $category['addons'] as $k => &$v ) {
518 if ( ! is_array( $v ) || ! isset( $v['slug'] ) ) {
519 continue;
520 }
521
522 $is_pro_legacy = 'charitable-pro-plugin' === $v['slug'];
523 $is_pro_canonical = 'charitable-pro' === $v['slug'];
524
525 if ( ! $is_pro_legacy && ! $is_pro_canonical ) {
526 continue;
527 }
528
529 if ( ! $show_pro ) {
530 unset( $category['addons'][ $k ] );
531 continue;
532 }
533
534 // Normalise the legacy slug onto the canonical on-disk layout so
535 // install, activation, and "is installed" detection all line up.
536 if ( $is_pro_legacy ) {
537 $v['slug'] = 'charitable-pro';
538 $v['path'] = 'charitable-pro/charitable-pro.php';
539 }
540 }
541 // Reindex array after any removal.
542 $category['addons'] = array_values( $category['addons'] );
543 }
544 unset( $category ); // Break the reference.
545 unset( $v ); // Break the reference.
546
547 // Defensive dedup: if both the legacy and canonical slugs were present
548 // server-side, the rename above could produce two `charitable-pro`
549 // entries. Keep only the first across all categories.
550 if ( $show_pro ) {
551 $seen_pro = false;
552 foreach ( $addon_categories as $slug => &$category ) {
553 if ( ! is_array( $category ) || ! isset( $category['addons'] ) || ! is_array( $category['addons'] ) ) {
554 continue;
555 }
556 foreach ( $category['addons'] as $k => &$v ) {
557 if ( is_array( $v ) && isset( $v['slug'] ) && 'charitable-pro' === $v['slug'] ) {
558 if ( $seen_pro ) {
559 unset( $category['addons'][ $k ] );
560 } else {
561 $seen_pro = true;
562 }
563 }
564 }
565 $category['addons'] = array_values( $category['addons'] );
566 }
567 unset( $category );
568 unset( $v );
569 }
570 } catch ( \Throwable $e ) {
571 // Fall back to the pre-1.8.10.5 behaviour: always hide both Pro slugs.
572 $addons_to_remove = array( 'charitable-pro-plugin', 'charitable-pro' );
573 if ( is_array( $addon_categories ) ) {
574 foreach ( $addon_categories as $slug => &$category ) {
575 if ( ! is_array( $category ) || ! isset( $category['addons'] ) || ! is_array( $category['addons'] ) ) {
576 continue;
577 }
578 foreach ( $category['addons'] as $k => &$v ) {
579 if ( is_array( $v ) && isset( $v['slug'] ) && in_array( $v['slug'], $addons_to_remove, true ) ) {
580 unset( $category['addons'][ $k ] );
581 }
582 }
583 $category['addons'] = array_values( $category['addons'] );
584 }
585 unset( $category );
586 unset( $v );
587 }
588 if ( function_exists( 'charitable_is_debug' ) && charitable_is_debug() ) {
589 error_log( 'CHARITABLE: Pro addon card gating failed: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
590 }
591 }
592 ?>
593
594 <div id="charitable-addons">
595 <?php foreach ( $addon_categories as $slug => $category ) : ?>
596 <?php if ( ! empty( $category['addons'] ) ) : ?>
597 <div class="charitable-addons-section">
598 <?php
599 $is_featured_section = ( 'featured' === $slug );
600 $header_classes = array( 'charitable-addons-section-header' );
601 if ( $is_featured_section ) {
602 $header_classes[] = 'charitable-addons-section-header-featured';
603 }
604 ?>
605 <div class="<?php echo esc_attr( implode( ' ', $header_classes ) ); ?>" data-section="<?php echo esc_attr( $slug ); ?>">
606 <div class="charitable-addons-section-title">
607 <?php echo esc_html( $category['label'] ); ?>
608 <?php
609 if ( 'available' === $slug ) {
610 $plan_slug = $this->get_current_plan_slug();
611 if ( ! empty( $plan_slug ) && 'lite' !== $plan_slug ) {
612 echo ' <span class="charitable-badge charitable-badge-lg charitable-badge-inline charitable-badge-titanium charitable-badge-rounded">' . esc_html( $plan_slug ) . '</span>';
613 }
614 }
615 ?>
616 </div>
617 <?php if ( ! $is_featured_section ) : ?>
618 <div class="charitable-addons-section-toggle">
619 <span class="dashicons dashicons-arrow-right-alt2"></span>
620 </div>
621 <?php endif; ?>
622 </div>
623 <div id="charitable-addons-<?php echo esc_attr( $slug ); ?>" class="charitable-addons-list charitable-addons-section-content"<?php echo ( $is_featured_section ) ? '' : ' style="' . esc_attr( 'display: none;' ) . '"'; ?>>
624 <?php
625 foreach ( (array) $category['addons'] as $i => $addon ) {
626 $this->get_addon_card( $addon, $i, true, $installed_plugins );
627 }
628 ?>
629 </div>
630 </div>
631 <?php endif; ?>
632 <?php endforeach; ?>
633 </div>
634 <?php
635 }
636
637 /**
638 * Retrieves addons from the stored transient or remote server.
639 *
640 * @since 1.7.0
641 *
642 * @return bool | array false | Array of licensed and unlicensed Addons.
643 */
644 public function get_addons() {
645
646 // Get license key and type.
647 $key = false; // charitable_get_license_key();.
648 $type = $this->get_current_plan_slug();
649 $addons = get_transient( '_charitable_addons' ); // @codingStandardsIgnoreLine - testing.
650
651 // Get addons data from transient or perform API query if no transient.
652 if ( false === $addons ) {
653 $addons = $this->get_addons_data_from_server( $key );
654 }
655
656 // If no Addons exist, return false.
657 if ( ! $addons ) {
658 return array();
659 }
660
661 // Iterate through Addons, to build two arrays:
662 // - Addons the user is licensed to use,
663 // - Addons the user isn't licensed to use.
664 $results = array(
665 'recommended' => array(),
666 'licensed' => array(),
667 'unlicensed' => array(),
668 );
669
670 foreach ( (array) $addons as $i => $addon ) {
671
672 if ( ! isset( $addon['slug'] ) || $addon['slug'] === '' || strtolower( $addon['slug'] ) === 'auto draft' ) {
673 continue;
674 }
675
676 if ( ! is_array( $addon ) ) {
677 $addon = array();
678 }
679
680 $addon['path'] = ( '' === $addon['path'] ) ? sprintf( '%1$s/%1$s.php', $addon['slug'] ) : $addon['path'];
681
682 $type = ( isset( $_GET['license'] ) ) ? $_GET['license'] : $this->get_current_plan_slug(); // phpcs:ignore
683
684 if ( isset( $addon['featured'] ) && in_array( 'recommended', $addon['featured'] ) ) { // phpcs:ignore
685 $results['recommended'][] = (array) $addon;
686 continue;
687 } else { // phpcs:ignore
688 // Determine if the addon belongs licensed or unlicensed based on the confirmed plan.
689 if ( isset( $addon['license'] ) && in_array( $type, $addon['license'] ) ) { // phpcs:ignore
690 $results['licensed'][] = (array) $addon;
691 continue;
692 } else {
693 $results['unlicensed'][] = (array) $addon;
694 continue;
695 }
696 }
697 }
698
699 // Return Addons, split by licensed and unlicensed.
700 return $results;
701 }
702
703
704 /**
705 * Stores an upgrade link used for the addons page when a person needs a pro license.
706 *
707 * @since 1.7.0.4
708 *
709 * @return string The url
710 */
711 public function charitable_get_upgrade_link() {
712 return 'https://wpcharitable.com/lite-upgrade/?discount=LITEUPGRADE&utm_source=WordPress&utm_campaign=liteplugin&utm_medium=addons&utm_content=ActiveCampaign%20Addon';
713 }
714
715 /**
716 * Retrieve the plugin basename from the plugin slug.
717 *
718 * @since 1.7.0
719 *
720 * @param string $slug The plugin slug.
721 * @return string The plugin basename if found, else the plugin slug.
722 */
723 public function get_plugin_basename_from_slug( $slug ) {
724
725 $keys = array_keys( get_plugins() );
726
727 foreach ( $keys as $key ) {
728 if ( preg_match( '|^' . $slug . '|', $key ) ) {
729 return $key;
730 }
731 }
732
733 return $slug;
734 }
735
736 /**
737 * Outputs the addon "box" on the addons page.
738 *
739 * @since 1.7.0
740 * @version 1.8.1.12 - Added EDD to replacements string check.
741 *
742 * @param object $addon Addon data from the API / transient call.
743 * @param int $counter Index of this Addon in the collection.
744 * @param bool $is_licensed Whether the Addon is licensed for use.
745 * @param array $installed_plugins Installed WordPress Plugins.
746 */
747 public function get_addon_card( $addon, $counter = 0, $is_licensed = false, $installed_plugins = false ) {
748
749 if ( ! isset( $addon['slug'] ) ) {
750 return;
751 }
752
753 // Setup some vars.
754 $plugin_basename = $this->get_plugin_basename_from_slug( $addon['slug'] );
755 $replacements = array(
756 'charitable-recurring-donations' => 'charitable-recurring',
757 'charitable-anonymous-donations' => 'charitable-anonymous',
758 'charitable-easy-digital-downloads-connect' => 'charitable-edd',
759 );
760
761 if ( array_key_exists( $addon['slug'], $replacements ) ) {
762 $plugin_basename = $this->get_plugin_basename_from_slug( $replacements[ $addon['slug'] ] );
763 }
764
765 if ( ! $installed_plugins ) {
766 $installed_plugins = get_plugins();
767 }
768
769 // If the Addon doesn't supply an upgrade_url key, it's because the user hasn't provided a license.
770 // get_upgrade_link() will return the Lite or Pro link as necessary for us.
771 if ( ! isset( $addon['upgrade_url'] ) ) {
772 $addon['upgrade_url'] = $this->charitable_get_upgrade_link();
773 }
774
775 // Add marketing tracking.
776 $addon['upgrade_url'] = add_query_arg(
777 array(
778 'utm_source' => 'proplugin',
779 'utm_medium' => 'addonspage',
780 'utm_campaign' => str_replace( '-', '', $addon['slug'] ) . 'addon',
781 ),
782 $addon['upgrade_url']
783 );
784
785 switch ( $this->get_current_plan_slug() ) {
786 case 'basic':
787 $the_plans = array( 'basic' );
788 break;
789 case 'plus':
790 $the_plans = array( 'basic', 'plus' );
791 break;
792 case 'pro':
793 $the_plans = array( 'basic', 'plus', 'pro' );
794 break;
795 case 'agency':
796 $the_plans = array( 'basic', 'plus', 'pro', 'agency', 'elite' );
797 break;
798
799 default:
800 $the_plans = array();
801 break;
802 }
803
804 if ( ! isset( $installed_plugins[ $plugin_basename ] ) ) {
805
806 $addon['status'] = 'missing';
807 $addon['action'] = 'license';
808
809 foreach ( $addon['license'] as $license_to_check ) {
810 if ( in_array( $license_to_check, $the_plans ) ) { // phpcs:ignore
811 $addon['status'] = 'missing';
812 $addon['action'] = 'install';
813 }
814 }
815
816 /*
817 * Charitable Pro is available to every legitimate license tier (Basic,
818 * Plus, Pro, Agency). The tier-match loop above would otherwise show
819 * "Upgrade Now" to Basic/Plus customers because Pro's API license field
820 * typically only lists `pro`. Override to "install" when the current
821 * site has any valid license AND the API actually returned a usable
822 * download_link — without a download_link the install step would fail,
823 * so we fall back to the upgrade CTA in that case.
824 *
825 * Wrapped in try/catch so that a failure in the licence helper chain
826 * (registry / vendor licence lookup) cannot fatal an admin page render.
827 * On failure the card simply keeps whatever action the tier-match loop
828 * above assigned — i.e. the original pre-1.8.10.5 behaviour.
829 */
830 try {
831 if ( 'charitable-pro' === $addon['slug']
832 && 'install' !== $addon['action']
833 && function_exists( 'charitable_is_pro' )
834 && charitable_is_pro()
835 && ! empty( $addon['download_link'] ) ) {
836 $addon['status'] = 'missing';
837 $addon['action'] = 'install';
838 }
839 } catch ( \Throwable $e ) {
840 if ( function_exists( 'charitable_is_debug' ) && charitable_is_debug() ) {
841 error_log( 'CHARITABLE: Pro install-action override failed: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
842 }
843 }
844 } else { // phpcs:ignore
845 // Plugin is installed.
846 if ( $addon['is_active'] ) {
847 $addon['status'] = 'active';
848 $addon['action'] = 'deactivate';
849 } else {
850 $addon['status'] = 'installed';
851 $addon['action'] = 'activate';
852 }
853 }
854
855 // Check if addon is included in pro.
856 $addons_included_in_pro = array(
857 'charitable-videos',
858 'charitable-pdf-receipts',
859 'charitable-stripe',
860 'charitable-geolocation',
861 'charitable-donor-comments',
862 );
863 $is_included_in_pro = in_array( $addon['slug'], $addons_included_in_pro, true );
864
865 // Check if addon is installed.
866 $is_installed = array_key_exists( $plugin_basename, $installed_plugins );
867
868 // Override status for addons included in pro.
869 if ( $is_included_in_pro ) {
870 $addon['status'] = 'included_in_pro';
871 $addon['action'] = 'none';
872 }
873
874 // Override status for installed addons to show "Installed" text instead of button.
875 if ( $is_installed && ! $is_included_in_pro ) {
876 $addon['status'] = 'installed_text';
877 $addon['action'] = 'none';
878 }
879
880 $button = $this->get_addon_button_html( $addon );
881
882 $status_label = $this->get_addon_status_label( $addon['status'] );
883
884 // Resolve the icon. Lookup order:
885 // 1. Local SVG (assets/images/addons/addon-icon-{slug}.svg)
886 // 2. Local PNG (assets/images/addons/addon-icon-{slug}.png)
887 // 3. API-supplied icon URL (so newly added addons show the right art
888 // even before a local asset ships in a Lite release)
889 // 4. Stripe placeholder (last resort so the card never renders broken)
890 $icon_file_name = str_replace( array( 'charitable-', '-banner' ), '', $addon['slug'] );
891 $assets_path_fs = charitable()->get_path( 'assets', true ) . 'images/addons/';
892 $assets_path_url = charitable()->get_path( 'assets', false ) . 'images/addons/';
893 $placeholder_icon = $assets_path_url . 'addon-icon-stripe.png';
894
895 if ( file_exists( $assets_path_fs . 'addon-icon-' . $icon_file_name . '.svg' ) ) {
896 $icon = $assets_path_url . 'addon-icon-' . $icon_file_name . '.svg';
897 } elseif ( file_exists( $assets_path_fs . 'addon-icon-' . $icon_file_name . '.png' ) ) {
898 $icon = $assets_path_url . 'addon-icon-' . $icon_file_name . '.png';
899 } elseif ( ! empty( $addon['icon'] ) && filter_var( $addon['icon'], FILTER_VALIDATE_URL ) ) {
900 $icon = esc_url( $addon['icon'] );
901 } else {
902 $icon = $placeholder_icon;
903 }
904
905 // get the plugin description.
906 // Guard against a missing / malformed `sections` entry from the API so a
907 // single bad addon row can't blow up the whole directory render. Belt +
908 // suspenders: the conditions below handle normal failure modes, and the
909 // try/catch handles anything pathological.
910 $sections = array();
911 $description = '';
912 try {
913 if ( isset( $addon['sections'] ) && is_string( $addon['sections'] ) && '' !== $addon['sections'] ) {
914 $maybe_sections = @unserialize( $addon['sections'], array( 'allowed_classes' => false ) ); // phpcs:ignore
915 if ( is_array( $maybe_sections ) ) {
916 $sections = $maybe_sections;
917 }
918 }
919 $description = wp_strip_all_tags( isset( $sections['description'] ) ? (string) $sections['description'] : '' );
920 } catch ( \Throwable $e ) {
921 $description = '';
922 if ( function_exists( 'charitable_is_debug' ) && charitable_is_debug() ) {
923 error_log( 'CHARITABLE: Addon description parse failed: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
924 }
925 }
926
927 // The wpcharitable.com addons API currently returns an empty description for
928 // the Charitable Pro entry. Provide a hard-coded fallback so the card always
929 // has a meaningful description on this screen.
930 if ( 'charitable-pro' === $addon['slug'] && '' === trim( $description ) ) {
931 $description = __( 'Charitable Pro is the upgraded version of your current Charitable plugin, with built-in features and compatibility for more advanced addons. Installing Pro will replace this Lite version while keeping all your existing settings and data.', 'charitable' );
932 }
933 $is_recommended = ( isset( $addon['featured'] ) && is_array( $addon['featured'] ) && in_array( 'recommended', $addon['featured'] ) ) ? true : false; // phpcs:ignore
934
935 // css.
936 $css = array();
937 if ( $is_recommended ) {
938 $css[] = 'has-badge';
939 $css[] = 'recommended';
940 }
941
942 // Output the card.
943 // Most addon cards strip the redundant "Charitable " prefix so the title
944 // reads cleanly (e.g. "Fee Relief" instead of "Charitable Fee Relief").
945 // The Pro card is the exception — without the brand name it just reads as
946 // "Pro", which is meaningless. Keep the full name only for that slug.
947 $addon_name = ( 'charitable-pro' === $addon['slug'] )
948 ? $addon['name']
949 : str_replace( 'Charitable ', '', $addon['name'] );
950 $landing_page_url = ! empty( $addon['homepage'] ) ? $addon['homepage'] : false;
951 $landing_page_url = false === $landing_page_url ? $addon['upgrade_url'] : $landing_page_url;
952 ?>
953
954
955 <div class="charitable-addons-list-item <?php echo esc_attr( implode( ' ', $css ) ); ?>">
956 <div class="charitable-addons-list-item-header">
957 <img src="<?php echo esc_url( $icon ); ?>" alt="<?php echo esc_html( $addon['name'] ); ?> logo" onerror="this.onerror=null;this.src='<?php echo esc_url( $placeholder_icon ); ?>';">
958
959 <div class="charitable-addons-list-item-header-meta">
960 <div class="charitable-addons-list-item-header-meta-title">
961 <a href="<?php echo esc_url( $landing_page_url ); ?>" title="Learn more" target="_blank" rel="noopener noreferrer" class="addon-link"><?php echo esc_html( $addon_name ); ?></a>
962 <?php if ( $is_recommended ) : ?>
963 <?php /* <span class="charitable-badge charitable-badge-sm charitable-badge-inline charitable-badge-green charitable-badge-rounded"><i class="fa fa-star" aria-hidden="true"></i>Recommended</span> */ ?>
964 <span class="charitable-badge charitable-badge-sm charitable-badge-inline charitable-badge-orange charitable-badge-rounded">Featured</span>
965 <?php elseif ( $addon['is_updated'] ) : ?>
966 <span class="charitable-badge charitable-badge-sm charitable-badge-inline charitable-badge-blue charitable-badge-rounded">Updated</span>
967 <?php endif; ?>
968 </div>
969
970 <div class="charitable-addons-list-item-header-meta-excerpt addon-description">
971 <?php echo wpautop( $description ); // phpcs:ignore ?> </div>
972 </div>
973 </div>
974
975 <div class="charitable-addons-list-item-footer charitable-addons-list-item-footer-installed" data-plugin="<?php echo esc_attr( $addon['path'] ); ?>" data-type="addon">
976 <div>
977 <?php if ( ! empty( $addon['license'] ) ) : ?>
978 <?php
979 // The license badge defaults to the highest tier the addon is
980 // available in. Charitable Pro is available to every legitimate
981 // license tier, so showing the "Pro" tier label is misleading —
982 // surface "All Plans" instead so customers on Basic/Plus realise
983 // they can install it too.
984 $license_badge_text = ( 'charitable-pro' === $addon['slug'] )
985 ? __( 'All Plans', 'charitable' )
986 : end( $addon['license'] );
987 ?>
988 <span class="charitable-badge charitable-badge-lg charitable-badge-inline charitable-badge-titanium charitable-badge-rounded"><?php echo esc_html( $license_badge_text ); ?></span>
989 <?php endif; ?>
990 </div>
991
992 <div class="charitable-addons-list-item-footer-actions">
993 <?php echo wp_kses_post( $button ); ?>
994 </div>
995 </div>
996 </div>
997
998 <?php
999 }
1000
1001 /**
1002 * Get addon button HTML.
1003 *
1004 * @since 1.6.7
1005 *
1006 * @param array $addon Prepared addon data.
1007 *
1008 * @return string
1009 */
1010 private function get_addon_button_html( $addon ) {
1011
1012 // Check if addon is included in pro and return appropriate text.
1013 if ( ! empty( $addon['is_included_in_pro'] ) ) {
1014 return '<span class="charitable-addon-included-in-pro">' . esc_html__( 'Included In Pro', 'charitable' ) . '</span>';
1015 }
1016
1017 // Check if addon is installed and return appropriate text.
1018 if ( 'installed_text' === $addon['status'] ) {
1019 return '<span class="charitable-addon-installed">' . esc_html__( 'Installed', 'charitable' ) . '</span>';
1020 }
1021
1022 if ( $addon['action'] === 'upgrade' || $addon['action'] === 'license' || ! $addon['plugin_allow'] ) {
1023
1024 if ( charitable_is_pro() ) {
1025
1026 // go ahead and send those who have a license key added to the checkout page, but down the road we will send them to the pricing page and then customize the prices on the pricing page to reflect the upgrade amounts per plan.
1027
1028 $license_data_to_send = $this->get_license_key_for_upgrade();
1029
1030 if ( false !== $license_data_to_send && isset( $license_data_to_send['license'] ) ) {
1031
1032 $upid = 0; // the upgrade plan id.
1033
1034 if ( in_array( 'agency', $addon['license'] ) ) { // phpcs:ignore
1035 // if the desired addon is in the agency category license, we need to pass along the upgrade plan id of 4.
1036 $upid = 4;
1037 }
1038
1039 if ( in_array( 'pro', $addon['license'] ) ) { // phpcs:ignore
1040 // if the desired addon is in the pro category license, we need to pass along the upgrade plan id of 3.
1041 $upid = 3;
1042 }
1043
1044 if ( in_array( 'plus', $addon['license'] ) ) { // phpcs:ignore
1045 // if the desired addon is in the plus category license, we need to pass along the upgrade plan id of 2.
1046 $upid = 2;
1047 }
1048
1049 // we have a valid license that we could get an upgrade path, etc. from.
1050 $upgrade_url = charitable_ga_url(
1051 // pid = plan_id.
1052 self::UPDATE_URL . '/?license_upgrade=true&license_key=' . $license_data_to_send['license'] . '&upid=' . $upid . '&pid=' . $license_data_to_send['plan_id'] . '&addon=' . esc_html( $addon['name'] ),
1053 urlencode( 'Plugin Addon Page' ), // phpcs:ignore
1054 urlencode( $addon['name'] . ' Upgrade' ) // phpcs:ignore
1055 );
1056
1057 } else {
1058
1059 // they may be on 'pro' mode but they don't have a license we can send, so fall back to sending them to the pricing page.
1060 $upgrade_url = charitable_ga_url(
1061 'https://www.wpcharitable.com/pricing/',
1062 urlencode( 'Plugin Addon Page' ), // phpcs:ignore
1063 urlencode( $addon['name'] . ' Upgrade' ) // phpcs:ignore
1064 );
1065
1066 }
1067 } else {
1068 // they are on lite, with no license so send them to the pricing page.
1069 $upgrade_url = charitable_ga_url(
1070 'https://www.wpcharitable.com/pricing/',
1071 urlencode( 'Plugin Addon Page' ), // phpcs:ignore
1072 urlencode( $addon['name'] . ' Upgrade' ) // phpcs:ignore
1073 );
1074 }
1075
1076 return sprintf(
1077 '<a href="%1$s" target="_blank" rel="noopener noreferrer" class="button button-secondary">%2$s</a>',
1078 $upgrade_url,
1079 esc_html__( 'Upgrade Now', 'charitable' )
1080 );
1081 }
1082
1083 $html = '';
1084
1085 // Override to account for odd setup prior to new ownership.
1086 $addon['path'] = str_replace( 'charitable-recurring-donations', 'charitable-recurring', $addon['path'] );
1087
1088 // Apply filter, mostly for testing and troubleshooting.
1089 // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Legacy hook name maintained for backward compatibility with existing filters.
1090 $addon = apply_filters( 'get_addon_button_html_addon', $addon );
1091
1092 switch ( $addon['status'] ) {
1093 case 'active':
1094 $html = '<button class="button button-secondary status-' . esc_attr( $addon['status'] ) . '" data-plugin="' . esc_attr( $addon['path'] ) . '" data-type="addon">';
1095 $html .= esc_html__( 'Deactivate', 'charitable' );
1096 $html .= '</button>';
1097 break;
1098
1099 case 'installed':
1100 $html = '<button class="button button-secondary status-' . esc_attr( $addon['status'] ) . '" data-plugin="' . esc_attr( $addon['path'] ) . '" data-type="addon">';
1101 $html .= esc_html__( 'Activate', 'charitable' );
1102 $html .= '</button>';
1103 break;
1104
1105 case 'missing':
1106 if ( ! $this->can_install ) {
1107 break;
1108 }
1109
1110 // Testing backup links for download.
1111 $download_url = ! empty( $addon['download_link'] ) ? esc_url( $addon['download_link'] ) : false;
1112 $download_url = ( false === $download_url ) && ! empty( $addon['install'] ) ? esc_url( $addon['install'] ) : $download_url;
1113
1114 // Validate the download URL - if it's invalid, disable the button
1115 $is_valid_url = $download_url && filter_var( $download_url, FILTER_VALIDATE_URL ) && ! strpos( $download_url, 'invalid_price_id_plan__' );
1116
1117 if ( $is_valid_url ) {
1118 $html = '<button class="button button-secondary status-' . esc_attr( $addon['status'] ) . '" data-plugin="' . esc_url( $download_url ) . '" data-type="addon">';
1119 $html .= esc_html__( 'Install Addon', 'charitable' );
1120 $html .= '</button>';
1121 } else {
1122 // Invalid URL - disable button and add warning class
1123 $html = '<button class="button button-secondary status-' . esc_attr( $addon['status'] ) . ' disabled" data-plugin="invalid" data-type="addon" disabled>';
1124 $html .= esc_html__( 'Install Addon', 'charitable' );
1125 $html .= '</button>';
1126 }
1127 break;
1128 }
1129
1130 return $html;
1131 }
1132
1133
1134 /**
1135 * Check if any addons have invalid download URLs and display admin notice.
1136 *
1137 * @since 1.8.8
1138 * @version 1.8.9.1
1139 */
1140 public function check_for_invalid_urls() {
1141 $addons = $this->get_addons();
1142 $has_invalid_urls = false;
1143
1144 foreach ( $addons as $addon ) {
1145 if ( 'missing' === $addon['status'] ) {
1146 $download_url = ! empty( $addon['download_link'] ) ? $addon['download_link'] : false;
1147 $download_url = ( false === $download_url ) && ! empty( $addon['install'] ) ? $addon['install'] : $download_url;
1148
1149 if ( ! $download_url || ! filter_var( $download_url, FILTER_VALIDATE_URL ) || strpos( $download_url, 'invalid_price_id_plan__' ) !== false ) {
1150 $has_invalid_urls = true;
1151 break;
1152 }
1153 }
1154 }
1155
1156 if ( $has_invalid_urls ) {
1157 $settings_url = admin_url( 'admin.php?page=charitable-settings' );
1158 $notice = sprintf(
1159 '<div class="notice notice-warning is-dismissible"><p><strong>%s</strong> %s <a href="%s">%s</a></p></div>',
1160 esc_html__( 'License Data Warning:', 'charitable' ),
1161 esc_html__( 'Some addon download URLs appear to be invalid. This may indicate that your license data is not fully synchronized with the license server. You may need to reconnect your license by removing it and adding it back in the', 'charitable' ),
1162 esc_url( $settings_url ),
1163 esc_html__( 'Charitable Settings page', 'charitable' )
1164 );
1165
1166 echo wp_kses_post( $notice );
1167 }
1168 }
1169
1170 /**
1171 * Get addon status label.
1172 *
1173 * @since 1.6.7
1174 *
1175 * @param string $status Addon status.
1176 *
1177 * @return string
1178 */
1179 private function get_addon_status_label( $status ) {
1180
1181 $label = [
1182 'active' => esc_html__( 'Active', 'charitable' ),
1183 'installed' => esc_html__( 'Inactive', 'charitable' ),
1184 'missing' => esc_html__( 'Not Installed', 'charitable' ),
1185 ];
1186
1187 return isset( $label[ $status ] ) ? $label[ $status ] : '';
1188 }
1189
1190 /**
1191 * Check if addon is new.
1192 * This examines 'last_updated' from the addon data and compares it to the current date.
1193 *
1194 * @since 1.8.8
1195 *
1196 * @param array $addon Addon data.
1197 *
1198 * @return bool
1199 */
1200 public function is_addon_updated( $addon ) {
1201
1202 if ( empty( $addon['last_updated'] ) ) {
1203 return false;
1204 }
1205
1206 $addon_date = strtotime( $addon['last_updated'] );
1207 $current_date = strtotime( date( 'Y-m-d' ) ); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
1208
1209 // If the last_updated is within 90 days, return true.
1210 return $addon_date > $current_date - 90 * DAY_IN_SECONDS;
1211 }
1212
1213 /**
1214 * Check if addon is new.
1215 *
1216 * @since 1.8.8
1217 *
1218 * @param array $addon Addon data.
1219 *
1220 * @return bool
1221 */
1222 public function is_addon_new( $addon ) {
1223
1224 return false;
1225 }
1226
1227 /**
1228 * Prepare addon data.
1229 *
1230 * @since 1.6.6
1231 *
1232 * @param array $addon Addon data.
1233 *
1234 * @return array Extended addon data.
1235 */
1236 protected function prepare_addon_data( $addon ) {
1237
1238 if ( empty( $addon ) ) {
1239 return [];
1240 }
1241
1242 $addon['title'] = ! empty( $addon['title'] ) ? $addon['title'] : '';
1243 $addon['slug'] = ! empty( $addon['slug'] ) ? $addon['slug'] : '';
1244
1245 /* translators: %s - addon name. */
1246 $addon['modal_name'] = sprintf( esc_html__( '%s addon', 'charitable' ), $addon['name'] );
1247 $addon['clear_slug'] = str_replace( 'charitable-', '', $addon['slug'] );
1248 $addon['utm_content'] = ucwords( str_replace( '-', ' ', $addon['clear_slug'] ) );
1249 $addon['license'] = empty( $addon['license'] ) ? [] : (array) $addon['license'];
1250 $addon['license_level'] = $this->get_license_level( $addon );
1251 $addon['icon'] = ! empty( $addon['icon'] ) ? $addon['icon'] : '';
1252 $addon['path'] = sprintf( '%1$s/%1$s.php', $addon['slug'] );
1253 $addon['video'] = ! empty( $addon['video'] ) ? $addon['video'] : '';
1254 $addon['plugin_allow'] = $this->has_access( $addon );
1255 $addon['status'] = 'missing';
1256 $addon['action'] = 'upgrade';
1257 $addon['page_url'] = empty( $addon['url'] ) ? '' : $addon['url'];
1258 $addon['doc_url'] = empty( $addon['doc'] ) ? '' : $addon['doc'];
1259 $addon['url'] = '';
1260
1261 static $nonce = '';
1262 $nonce = empty( $nonce ) ? wp_create_nonce( 'charitable-admin' ) : $nonce;
1263 $addon['nonce'] = $nonce;
1264
1265 return $addon;
1266 }
1267
1268
1269 /**
1270 * Get available addon data by slug.
1271 *
1272 * @since 1.6.6
1273 *
1274 * @param string $slug Addon slug, can be both "charitable-drip" and "drip".
1275 *
1276 * @return array Single addon data. Empty array if addon is not found.
1277 */
1278 public function get_addon( $slug ) {
1279
1280 $slug = 'charitable-' . str_replace( 'charitable-', '', sanitize_key( $slug ) );
1281
1282 $addon = ! empty( $this->available_addons[ $slug ] ) ? $this->available_addons[ $slug ] : [];
1283
1284 // In case if addon is "not available" let's try to get and prepare addon data from all addons.
1285 if ( empty( $addon ) ) {
1286 $addon = ! empty( $this->addons[ $slug ] ) ? $this->prepare_addon_data( $this->addons[ $slug ] ) : [];
1287 }
1288
1289 return $addon;
1290 }
1291
1292 /**
1293 * Get license level of the addon.
1294 *
1295 * @since 1.6.6
1296 *
1297 * @param array|string $addon Addon data array OR addon slug.
1298 *
1299 * @return string License level: pro | elite.
1300 */
1301 private function get_license_level( $addon ) {
1302
1303 if ( empty( $addon ) ) {
1304 return '';
1305 }
1306
1307 $addon = is_string( $addon ) ? $this->get_addon( $addon ) : $addon;
1308 $addon['license'] = empty( $addon['license'] ) ? [] : (array) $addon['license'];
1309
1310 // TODO: convert to a class constant when we will drop PHP 5.5.
1311 $levels = [ 'basic', 'plus', 'pro', 'elite', 'agency', 'ultimate' ];
1312 $license = '';
1313
1314 foreach ( $levels as $level ) {
1315 if ( in_array( $level, $addon['license'], true ) ) {
1316 $license = $level;
1317
1318 break;
1319 }
1320 }
1321
1322 if ( empty( $license ) ) {
1323 return '';
1324 }
1325
1326 return in_array( $license, [ 'basic', 'plus', 'pro' ], true ) ? 'pro' : 'elite';
1327 }
1328
1329 /**
1330 * This attempts to send the BEST license key to Charitable whenever a suggested upgrade is possible.
1331 * Note: We look for "v2" licenses in settings first, then attempt to locate a charitable basic/plus/pro/agency (new or oldschool) license.
1332 * We don't return individual addons in this function because there's no good way to suggest an upgrade EDD license path.
1333 *
1334 * @since 1.7.0.4
1335 *
1336 * @return array
1337 */
1338 private function get_license_key_for_upgrade() {
1339
1340 $settings = get_option( 'charitable_settings' );
1341 if ( isset( $settings['licenses']['charitable-v2']['license'] ) && ! empty( $settings['licenses']['charitable-v2']['license'] ) ) {
1342
1343 // attempt to grab the "v2" license.
1344 $license = esc_html( $settings['licenses']['charitable-v2']['license'] );
1345 $plan_id = isset( $settings['licenses']['charitable-v2']['plan_id'] ) && ! empty( $settings['licenses']['charitable-v2']['plan_id'] ) ? intval( $settings['licenses']['charitable-v2']['plan_id'] ) : false;
1346 return array(
1347 'license' => $license,
1348 'plan_id' => $plan_id,
1349 );
1350
1351 } elseif ( isset( $settings['licenses']['charitable'] ) && ! empty( $settings['licenses']['charitable'] ) ) {
1352
1353 $license = esc_html( $settings['licenses']['charitable'] );
1354 return array(
1355 'license' => $license,
1356 'plan_id' => false,
1357 ); // no plan id when this was added into settings.
1358
1359 } elseif ( isset( $settings['licenses']['charitable_pro'] ) && ! empty( $settings['licenses']['charitable_pro'] ) ) {
1360
1361 // unlikely but technically possible.
1362 $license = esc_html( $settings['licenses']['charitable_pro'] );
1363 return array(
1364 'license' => $license,
1365 'plan_id' => false,
1366 ); // no plan id when this was added into settings.
1367
1368 }
1369
1370 return false;
1371 }
1372
1373 /**
1374 * Determine if user's license level has access.
1375 *
1376 * @since 1.6.6
1377 *
1378 * @param array|string $addon Addon data array OR addon slug.
1379 *
1380 * @return bool
1381 */
1382 protected function has_access( $addon ) { // phpcs:ignore
1383
1384 return true;
1385 }
1386
1387
1388 /**
1389 * Return the latest versions of Charitable plugins.
1390 *
1391 * @since 1.7.0.4
1392 *
1393 * @param array $licenses Array of license keys.
1394 *
1395 * @return array
1396 */
1397 public function get_addons_data_from_server( $licenses = false ) {
1398
1399 $versions = get_transient( '_charitable_plugin_versions' );
1400
1401 if ( false === $versions ) {
1402
1403 if ( false === $licenses ) {
1404
1405 $licenses = array();
1406
1407 foreach ( $this->get_licenses() as $license ) {
1408 if ( isset( $license['license'] ) ) {
1409 $licenses[] = $license['license'];
1410 }
1411 }
1412 }
1413
1414 $response = wp_remote_post(
1415 self::UPDATE_URL . '/edd-api/versions-v3/',
1416 array(
1417 'sslverify' => false,
1418 'timeout' => 15,
1419 'body' => array(
1420 'licenses' => $licenses,
1421 'url' => home_url(),
1422 ),
1423 )
1424 );
1425
1426 $response_body = wp_remote_retrieve_body( $response );
1427 $response_code = wp_remote_retrieve_response_code( $response );
1428
1429 // Bail out early if there are any errors.
1430 if ( (int) $response_code !== 200 || is_wp_error( $response_body ) ) {
1431 return false;
1432 }
1433
1434 $versions = json_decode( $response_body, true );
1435
1436 set_transient( '_charitable_plugin_versions', $versions, DAY_IN_SECONDS );
1437
1438 } // end if
1439
1440 return $versions;
1441 }
1442
1443 /**
1444 * Return the latest versions of Charitable plugins.
1445 *
1446 * @since 1.4.0
1447 * @since 1.8.1.1 - Removed cache_get method and site a site option instead.
1448 * @since 1.8.1.3 - Added request_recently_failed check and log_failed_request call.
1449 *
1450 * @return array
1451 */
1452 protected function get_versions() {
1453
1454 $versions = get_site_option( 'wpc_plugin_versions' );
1455
1456 if ( false === $versions || ! isset( $versions['expires'] ) || time() > $versions['expires'] || ! isset( $versions['data'] ) ) {
1457 /*
1458 * Check and see if this request recently failed.
1459 * Most likely because the site is down or didn't return a 200 response.
1460 * Regardless this means we're allowed to try again for a while.
1461 */
1462 if ( $this->request_recently_failed() ) {
1463 if ( charitable_is_debug() ) {
1464 error_log( 'WPCharitable Debug Error: get_versions (licenses) API call aborted because it recently failed' ); // @codingStandardsIgnoreLine
1465 }
1466 return false;
1467 }
1468
1469 $licenses = array();
1470
1471 foreach ( $this->get_licenses() as $license ) {
1472 if ( isset( $license['license'] ) ) {
1473 $licenses[] = $license['license'];
1474 }
1475 }
1476
1477 $response = wp_remote_post(
1478 self::UPDATE_URL . '/edd-api/versions-v2/',
1479 array(
1480 'sslverify' => false,
1481 'timeout' => 15,
1482 'body' => array(
1483 'licenses' => $licenses,
1484 'url' => home_url(),
1485 ),
1486 )
1487 );
1488
1489 // check the response code... if it's bad, then return what we have if anything.
1490 if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
1491
1492 // Log this failure so we don't keep trying.
1493 $this->log_failed_request();
1494
1495 if ( charitable_is_debug() ) {
1496 error_log( 'WPCharitable Debug Error: get_versions (licenses) API call failed' ); // @codingStandardsIgnoreLine
1497 error_log( print_r( $response, true ) ); // @codingStandardsIgnoreLine
1498 }
1499
1500 return ( false !== $versions && isset( $versions['data'] ) ) ? $versions['data'] : array();
1501 }
1502
1503 $versions = wp_remote_retrieve_body( $response );
1504
1505 $versions = json_decode( $versions, true );
1506
1507 update_site_option(
1508 'wpc_plugin_versions',
1509 array(
1510 'expires' => strtotime( '+1 hour', time() ),
1511 'data' => $versions,
1512 )
1513 );
1514
1515 if ( charitable_is_debug() ) {
1516 error_log( 'WPCharitable Debug Notice: get_versions (licenses) API call in addons directory successful and added to site option.' ); // @codingStandardsIgnoreLine
1517 error_log( // @codingStandardsIgnoreLine
1518 print_r( // @codingStandardsIgnoreLine
1519 array(
1520 'expires' => strtotime( '+1 hour', time() ),
1521 'data' => $versions,
1522 ),
1523 true
1524 )
1525 );
1526 }
1527 } else {
1528 $versions = $versions['data'];
1529 }
1530
1531 return $versions;
1532 }
1533
1534
1535 /**
1536 * Logs a failed HTTP request for this API URL.
1537 *
1538 * We set a timestamp for 1 hour from now. This prevents future API requests from being
1539 * made to this domain for 1 hour. Once the timestamp is in the past, API requests
1540 * will be allowed again. This way if the site is down for some reason we don't bombard
1541 * it with failed API requests.
1542 *
1543 * @see EDD_SL_Plugin_Updater::request_recently_failed
1544 *
1545 * @since 1.8.1.3
1546 */
1547 private function log_failed_request() {
1548 update_option( $this->failed_request_cache_key, strtotime( '+1 hour' ) );
1549 }
1550
1551 /**
1552 * Determines if a request has recently failed.
1553 *
1554 * @since 1.8.1.3
1555 *
1556 * @return bool
1557 */
1558 private function request_recently_failed() {
1559 $failed_request_details = get_option( $this->failed_request_cache_key );
1560
1561 // Request has never failed.
1562 if ( empty( $failed_request_details ) || ! is_numeric( $failed_request_details ) ) {
1563 return false;
1564 }
1565
1566 /*
1567 * Request previously failed, but the timeout has expired.
1568 * This means we're allowed to try again.
1569 */
1570 if ( time() > $failed_request_details ) {
1571 delete_option( $this->failed_request_cache_key );
1572
1573 return false;
1574 }
1575
1576 return true;
1577 }
1578
1579 /**
1580 * Return the list of licenses.
1581 *
1582 * Note: The licenses are not necessarily valid. If a user enters an invalid
1583 * license, the license will be stored but it will be flagged as invalid.
1584 *
1585 * @since 1.0.0
1586 *
1587 * @return array[]
1588 */
1589 public function get_licenses() {
1590 if ( ! isset( $this->licenses ) ) {
1591 $this->licenses = charitable_get_option( 'licenses', array() );
1592 }
1593
1594 return $this->licenses;
1595 }
1596 }
1597
1598 endif;
1599