| 1 |
<?php |
| 2 |
|
| 3 |
namespace Wpxero\Marqueex\Core; |
| 4 |
|
| 5 |
use Wpxero\Marqueex\MarqueeX; |
| 6 |
|
| 7 |
if (!defined('ABSPATH')) { |
| 8 |
exit; |
| 9 |
} |
| 10 |
|
| 11 |
/** |
| 12 |
* Upgrade links and locked-feature markup. |
| 13 |
* |
| 14 |
* Single source of truth for two things that were previously scattered across |
| 15 |
* seven files with three different visual styles and two different (one stale) |
| 16 |
* destination URLs: |
| 17 |
* |
| 18 |
* 1. Where an "upgrade" click goes — Freemius' in-admin pricing screen, so the |
| 19 |
* user never leaves WordPress and the checkout is attributed to the exact |
| 20 |
* control they clicked. |
| 21 |
* 2. What a locked Pro feature looks like. |
| 22 |
* |
| 23 |
* Free users must always be able to SEE what Pro adds. Hiding a Pro option |
| 24 |
* entirely (rather than showing it locked) means nobody can want it. |
| 25 |
*/ |
| 26 |
class Upsell { |
| 27 |
|
| 28 |
/** |
| 29 |
* Fallback destination when Freemius is unavailable (e.g. its init was |
| 30 |
* skipped during a plugin upgrade request). |
| 31 |
*/ |
| 32 |
const FALLBACK_URL = 'https://wpxero.com/plugins/marqueex/pricing/'; |
| 33 |
|
| 34 |
/** |
| 35 |
* Upgrade destination for a given surface. |
| 36 |
* |
| 37 |
* @param string $source Short slug identifying the control/screen the click |
| 38 |
* came from, e.g. 'elementor-animated-heading'. Carried |
| 39 |
* through as a query arg so CTA performance can be |
| 40 |
* compared per surface. |
| 41 |
* @return string |
| 42 |
*/ |
| 43 |
public static function url($source = '') { |
| 44 |
$url = self::FALLBACK_URL; |
| 45 |
|
| 46 |
if (function_exists('\\Wpxero\\Marqueex\\marqueex_fs')) { |
| 47 |
$fs = \Wpxero\Marqueex\marqueex_fs(); |
| 48 |
|
| 49 |
if ($fs && method_exists($fs, 'pricing_url')) { |
| 50 |
try { |
| 51 |
if (self::has_license()) { |
| 52 |
// Freemius removes its pricing screen once a user has a |
| 53 |
// license, so linking there would 403. Send them to their |
| 54 |
// account, where the activation flow lives. |
| 55 |
$url = method_exists($fs, 'get_account_url') |
| 56 |
? $fs->get_account_url() |
| 57 |
: self::FALLBACK_URL; |
| 58 |
} else { |
| 59 |
// Offer the trial when one is available and unused — on a |
| 60 |
// product with no reviews yet, a trial does the reassuring |
| 61 |
// that social proof normally would. |
| 62 |
$url = self::is_trial_available() |
| 63 |
? $fs->get_trial_url() |
| 64 |
: $fs->get_upgrade_url(); |
| 65 |
} |
| 66 |
} catch (\Exception $e) { |
| 67 |
$url = self::FALLBACK_URL; |
| 68 |
} |
| 69 |
} |
| 70 |
} |
| 71 |
|
| 72 |
if ($source) { |
| 73 |
$url = add_query_arg('mqx_source', sanitize_key($source), $url); |
| 74 |
} |
| 75 |
|
| 76 |
return $url; |
| 77 |
} |
| 78 |
|
| 79 |
/** |
| 80 |
* Whether a free trial can still be started on this site. |
| 81 |
* |
| 82 |
* @return bool |
| 83 |
*/ |
| 84 |
public static function is_trial_available() { |
| 85 |
static $available = null; |
| 86 |
if ($available !== null) { |
| 87 |
return $available; |
| 88 |
} |
| 89 |
|
| 90 |
$available = false; |
| 91 |
|
| 92 |
if (function_exists('\\Wpxero\\Marqueex\\marqueex_fs')) { |
| 93 |
$fs = \Wpxero\Marqueex\marqueex_fs(); |
| 94 |
if ( |
| 95 |
$fs |
| 96 |
&& method_exists($fs, 'has_trial_plan') |
| 97 |
&& method_exists($fs, 'is_trial_utilized') |
| 98 |
) { |
| 99 |
try { |
| 100 |
$available = $fs->has_trial_plan() |
| 101 |
&& !$fs->is_trial_utilized() |
| 102 |
&& !self::has_license(); |
| 103 |
} catch (\Exception $e) { |
| 104 |
$available = false; |
| 105 |
} |
| 106 |
} |
| 107 |
} |
| 108 |
|
| 109 |
return $available; |
| 110 |
} |
| 111 |
|
| 112 |
/** |
| 113 |
* Label for the call to action. |
| 114 |
* |
| 115 |
* Deliberately not "Upgrade Now" — that names the transaction rather than |
| 116 |
* what the user gets. Callers pass the outcome; this only handles the |
| 117 |
* trial/no-trial split for the generic case. |
| 118 |
* |
| 119 |
* @return string |
| 120 |
*/ |
| 121 |
public static function cta_label() { |
| 122 |
if (self::has_license()) { |
| 123 |
return __('Activate your license', 'marqueex'); |
| 124 |
} |
| 125 |
|
| 126 |
return self::is_trial_available() |
| 127 |
? __('Try Pro free', 'marqueex') |
| 128 |
: __('See what Pro adds', 'marqueex'); |
| 129 |
} |
| 130 |
|
| 131 |
/** |
| 132 |
* Reassurance line shown under the call to action. |
| 133 |
* |
| 134 |
* @return string |
| 135 |
*/ |
| 136 |
public static function cta_meta() { |
| 137 |
if (self::has_license()) { |
| 138 |
return __('You already have a license — activate MarqueeX Pro to switch this on.', 'marqueex'); |
| 139 |
} |
| 140 |
|
| 141 |
return self::is_trial_available() |
| 142 |
? __('Free trial — no card required', 'marqueex') |
| 143 |
: __('From $59.99 · lifetime · 30-day refund', 'marqueex'); |
| 144 |
} |
| 145 |
|
| 146 |
/** |
| 147 |
* Whether upsell markup should be rendered at all. |
| 148 |
* |
| 149 |
* @return bool |
| 150 |
*/ |
| 151 |
public static function should_show() { |
| 152 |
// Shown whenever Pro features are locked — which covers two different |
| 153 |
// states. Someone with no license needs to be told what Pro adds; |
| 154 |
// someone who has already paid but not activated needs to be told to |
| 155 |
// activate, not sold to again. has_license() switches the copy; both |
| 156 |
// still need a card, or a locked control just fails silently. |
| 157 |
return !MarqueeX::is_premium_active(); |
| 158 |
} |
| 159 |
|
| 160 |
/** |
| 161 |
* Whether Freemius reports that this site already has a paid license. |
| 162 |
* |
| 163 |
* Distinct from MarqueeX::is_premium_active(), which additionally requires |
| 164 |
* the Pro plugin to be loaded. A site can have paid and not yet activated. |
| 165 |
* |
| 166 |
* @return bool |
| 167 |
*/ |
| 168 |
public static function has_license() { |
| 169 |
static $has = null; |
| 170 |
if ($has !== null) { |
| 171 |
return $has; |
| 172 |
} |
| 173 |
|
| 174 |
$has = false; |
| 175 |
|
| 176 |
if (function_exists('\\Wpxero\\Marqueex\\marqueex_fs')) { |
| 177 |
$fs = \Wpxero\Marqueex\marqueex_fs(); |
| 178 |
if ($fs) { |
| 179 |
try { |
| 180 |
if (method_exists($fs, 'has_active_valid_license') && $fs->has_active_valid_license()) { |
| 181 |
$has = true; |
| 182 |
} elseif (method_exists($fs, 'is_paying') && $fs->is_paying()) { |
| 183 |
$has = true; |
| 184 |
} |
| 185 |
} catch (\Exception $e) { |
| 186 |
$has = false; |
| 187 |
} |
| 188 |
} |
| 189 |
} |
| 190 |
|
| 191 |
return $has; |
| 192 |
} |
| 193 |
|
| 194 |
/** |
| 195 |
* Markup for a locked Pro feature. |
| 196 |
* |
| 197 |
* One card style used everywhere — Elementor control panels and anywhere |
| 198 |
* else a locked feature needs describing. |
| 199 |
* |
| 200 |
* @param array $args { |
| 201 |
* @type string $title Feature name. |
| 202 |
* @type string $description What the user gets, phrased as an outcome. |
| 203 |
* @type string[] $items Optional list of the specific options unlocked. |
| 204 |
* @type string $cta Optional CTA label override. |
| 205 |
* @type string $source Surface slug for attribution. |
| 206 |
* } |
| 207 |
* @return string |
| 208 |
*/ |
| 209 |
public static function locked_card(array $args) { |
| 210 |
$args = wp_parse_args($args, [ |
| 211 |
'title' => '', |
| 212 |
'description' => '', |
| 213 |
'items' => [], |
| 214 |
'cta' => '', |
| 215 |
'source' => '', |
| 216 |
]); |
| 217 |
|
| 218 |
// A caller's outcome-named CTA ("Unlock these animations") only makes |
| 219 |
// sense as a sales line. Once a license exists the instruction is |
| 220 |
// "activate", so the shared label wins. |
| 221 |
$cta = (!self::has_license() && $args['cta'] !== '') ? $args['cta'] : self::cta_label(); |
| 222 |
|
| 223 |
ob_start(); |
| 224 |
?> |
| 225 |
<div class="marqueex-upsell"> |
| 226 |
<div class="marqueex-upsell__head"> |
| 227 |
<i class="eicon-lock" aria-hidden="true"></i> |
| 228 |
<span class="marqueex-upsell__title"><?php echo esc_html($args['title']); ?></span> |
| 229 |
<span class="marqueex-upsell__badge"><?php esc_html_e('PRO', 'marqueex'); ?></span> |
| 230 |
</div> |
| 231 |
|
| 232 |
<?php if ($args['description']) : ?> |
| 233 |
<p class="marqueex-upsell__desc"><?php echo esc_html($args['description']); ?></p> |
| 234 |
<?php endif; ?> |
| 235 |
|
| 236 |
<?php if (!empty($args['items'])) : ?> |
| 237 |
<ul class="marqueex-upsell__items"> |
| 238 |
<?php foreach ($args['items'] as $item) : ?> |
| 239 |
<li><?php echo esc_html($item); ?></li> |
| 240 |
<?php endforeach; ?> |
| 241 |
</ul> |
| 242 |
<?php endif; ?> |
| 243 |
|
| 244 |
<a class="marqueex-upsell__cta" |
| 245 |
href="<?php echo esc_url(self::url($args['source'])); ?>" |
| 246 |
target="_blank" |
| 247 |
rel="noopener noreferrer"><?php echo esc_html($cta); ?></a> |
| 248 |
|
| 249 |
<span class="marqueex-upsell__meta"><?php echo esc_html(self::cta_meta()); ?></span> |
| 250 |
</div> |
| 251 |
<?php |
| 252 |
return ob_get_clean(); |
| 253 |
} |
| 254 |
|
| 255 |
/** |
| 256 |
* Add a locked-feature card to an Elementor widget as a RAW_HTML control. |
| 257 |
* |
| 258 |
* @param \Elementor\Widget_Base|\Elementor\Controls_Stack $element |
| 259 |
* @param string $control_id |
| 260 |
* @param array $args See locked_card(). |
| 261 |
* @return void |
| 262 |
*/ |
| 263 |
public static function add_elementor_notice($element, $control_id, array $args) { |
| 264 |
if (!self::should_show()) { |
| 265 |
return; |
| 266 |
} |
| 267 |
|
| 268 |
$element->add_control( |
| 269 |
$control_id, |
| 270 |
[ |
| 271 |
'type' => \Elementor\Controls_Manager::RAW_HTML, |
| 272 |
'raw' => self::locked_card($args), |
| 273 |
'content_classes' => 'marqueex-upsell-wrap', |
| 274 |
] |
| 275 |
); |
| 276 |
} |
| 277 |
|
| 278 |
/** |
| 279 |
* Merge Pro options into a select control's option list, marking the locked |
| 280 |
* ones so free users can see they exist. |
| 281 |
* |
| 282 |
* Elementor has no per-option disabled state, so a locked option is labelled |
| 283 |
* with a padlock and reverts on select (handled in elementor-editor.js). The |
| 284 |
* render side already falls back to the free default, so a stored Pro value |
| 285 |
* on an unlicensed site is harmless. |
| 286 |
* |
| 287 |
* @param array $free_options Options available to everyone. |
| 288 |
* @param array $pro_options Options that require a license. |
| 289 |
* @return array |
| 290 |
*/ |
| 291 |
public static function merge_select_options(array $free_options, array $pro_options) { |
| 292 |
if (!self::should_show()) { |
| 293 |
return array_merge($free_options, $pro_options); |
| 294 |
} |
| 295 |
|
| 296 |
$locked = []; |
| 297 |
foreach ($pro_options as $key => $label) { |
| 298 |
/* translators: %s: name of a Pro-only option in a dropdown. */ |
| 299 |
$locked[$key] = sprintf(__('%s (Pro)', 'marqueex'), $label); |
| 300 |
} |
| 301 |
|
| 302 |
return array_merge($free_options, $locked); |
| 303 |
} |
| 304 |
|
| 305 |
/** |
| 306 |
* Option keys that require a license, for the editor script to mark up. |
| 307 |
* |
| 308 |
* @param array $pro_options |
| 309 |
* @return string[] |
| 310 |
*/ |
| 311 |
public static function locked_option_keys(array $pro_options) { |
| 312 |
return self::should_show() ? array_keys($pro_options) : []; |
| 313 |
} |
| 314 |
} |
| 315 |
|