PluginProbe
EmbedPress – PDF Embedder, 3D PDF FlipBook, Google Reviews, YouTube Videos, Upload & Embed PDF documents / trunk
EmbedPress – PDF Embedder, 3D PDF FlipBook, Google Reviews, YouTube Videos, Upload & Embed PDF documents vtrunk
4.6.5 4.6.4 4.6.3 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 trunk 1.0.0 1.1.0 1.1.1 1.1.2 1.1.3 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 All 189 releases
embedpress / EmbedPress / Includes / Classes / FeaturePreviewModal.php

FeaturePreviewModal.php in EmbedPress – PDF Embedder, 3D PDF FlipBook, Google Reviews, YouTube Videos, Upload & Embed PDF documents trunk, at EmbedPress/Includes/Classes/FeaturePreviewModal.php

804 lines 32.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace EmbedPress\Includes\Classes;
4
5 /**
6 * Feature Preview Modal — the post-update "What's New" announcement layer.
7 *
8 * A centered, split-panel modal shown once in wp-admin after an EmbedPress
9 * version bump. Left panel = animated feature preview (image / GIF / MP4 /
10 * inline HTML demo) inside faux browser chrome; right panel = eyebrow +
11 * headline + description + primary CTA + "see the full changelog" link.
12 *
13 * Adaptive: a single feature renders with no navigation; multiple features
14 * become a carousel with pager dots + Back/Next (Next → "Done ✓" on the last
15 * step) and an "n of N" counter in the eyebrow.
16 *
17 * Data-driven: each release registers a `feature_set` (an array of slides)
18 * via {@see FeaturePreviewModal::register()}; non-devs edit copy/CTA/media
19 * per release without touching the renderer or the JS.
20 *
21 * Gating mirrors the existing notice conventions:
22 * - Admin capability only (`manage_options` by default).
23 * - EmbedPress admin pages only (`?page=embedpress*`) — the modal fires where
24 * the user already has EmbedPress context, NOT on the generic WP dashboard.
25 * A blinking "New" indicator on the EmbedPress menu (shown on every admin
26 * page while a release is unseen) is what draws the user in; the modal then
27 * opens in-context.
28 * - Auto-open vs. click-to-open is a developer switch — the
29 * `embedpress_whatsnew_autoopen` filter (default true). When false, the
30 * modal does not auto-open; clicking the flagged EmbedPress menu opens it.
31 * - Fires once per version: the trigger version on the feature set must be
32 * newer than the per-user `embedpress_whatsnew_seen_version` option.
33 * - Dismissal (close / ESC / CTA / "Done") writes the trigger version to
34 * that option so the modal never re-shows for that release (and clears the
35 * menu indicator).
36 *
37 * This class does NOT introduce a parallel version-tracking system — it reuses
38 * the same "show once per version bump" idea the release-notes tooltip uses,
39 * keyed by its own option so the two surfaces don't fight over one flag.
40 *
41 * @package EmbedPress
42 * @since 4.5.7
43 */
44
45 (defined('ABSPATH') && defined('EMBEDPRESS_IS_LOADED')) or die("No direct script access allowed.");
46
47 class FeaturePreviewModal
48 {
49 /**
50 * Option storing the highest release version a user has already seen the
51 * modal for. Per-version gate: the modal only fires when a registered
52 * feature set's trigger version is newer than this.
53 */
54 const SEEN_VERSION_OPTION = 'embedpress_whatsnew_seen_version';
55
56 /**
57 * Option storing the highest release version the user has OPENED the modal
58 * for at least once. Gates the menu "New" indicator only — the badge clears
59 * as soon as the modal has been opened once, even if the user didn't dismiss
60 * it. The modal itself keeps re-showing until a real dismiss stamps
61 * {@see SEEN_VERSION_OPTION}. Two flags so "seen the badge" and "finished
62 * the modal" are independent.
63 */
64 const OPENED_VERSION_OPTION = 'embedpress_whatsnew_opened_version';
65
66 /**
67 * AJAX action used to persist a dismissal.
68 */
69 const DISMISS_ACTION = 'embedpress_dismiss_whatsnew_modal';
70
71 /**
72 * AJAX action used to persist a first-open (clears the menu indicator).
73 */
74 const OPENED_ACTION = 'embedpress_opened_whatsnew_modal';
75
76 /**
77 * AJAX action fired when the user interacts with any button in the modal —
78 * treated as consent to opt into WP-Insights usage tracking (unless the
79 * user has previously explicitly opted out).
80 */
81 const CONSENT_ACTION = 'embedpress_whatsnew_consent';
82
83 /**
84 * Nonce action / handle name.
85 */
86 const NONCE_ACTION = 'embedpress_whatsnew_modal';
87
88 /**
89 * Registered feature sets, keyed by an arbitrary id.
90 * @var array<string,array>
91 */
92 private $feature_sets = [];
93
94 /**
95 * Singleton instance.
96 * @var FeaturePreviewModal|null
97 */
98 private static $instance = null;
99
100 /**
101 * @return FeaturePreviewModal
102 */
103 public static function get_instance()
104 {
105 if (self::$instance === null) {
106 self::$instance = new self();
107 }
108 return self::$instance;
109 }
110
111 /**
112 * Top-level EmbedPress admin menu slug. The modal arms on this page and its
113 * `?page=embedpress&page_type=…` / `embedpress-…` submenus; the "New" menu
114 * indicator is appended to this menu item.
115 */
116 const MENU_SLUG = 'embedpress';
117
118 private function __construct()
119 {
120 add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);
121 add_action('admin_head', [$this, 'preload_media']);
122 add_action('admin_footer', [$this, 'render']);
123 add_action('wp_ajax_' . self::DISMISS_ACTION, [$this, 'ajax_dismiss']);
124 add_action('wp_ajax_' . self::OPENED_ACTION, [$this, 'ajax_opened']);
125 add_action('wp_ajax_' . self::CONSENT_ACTION, [$this, 'ajax_consent']);
126 // Blinking "New" indicator on the EmbedPress menu — visible from ANY
127 // admin page while there's an unseen release, so the announcement reaches
128 // the user wherever they are, then opens in-context when they enter
129 // EmbedPress. Late priority so it runs after the menu is registered.
130 add_action('admin_menu', [$this, 'add_menu_indicator'], 999);
131 // The menu bubble shows on every admin page, but the modal stylesheet
132 // only loads on EmbedPress pages — so print the tiny pulse CSS inline in
133 // the head wherever the bubble is present.
134 add_action('admin_head', [$this, 'print_menu_indicator_style']);
135 }
136
137 /**
138 * Append a blinking "New" bubble to the top-level EmbedPress menu title
139 * while there's an unseen release for the current user.
140 *
141 * Runs on every admin page (the indicator's whole job is to reach the user
142 * wherever they are), gated only by {@see resolve_unseen_set()} — NOT by the
143 * EmbedPress-page screen check, unlike the modal itself. The bubble reuses
144 * WP's native `.update-plugins` count styling plus an `.ep-whatsnew-badge`
145 * hook for the pulse animation (CSS lives in feature-preview-modal.css, which
146 * only loads on EmbedPress pages — the bubble is styled inline-safe elsewhere
147 * via the core class, and the pulse is a progressive enhancement).
148 *
149 * @return void
150 */
151 public function add_menu_indicator()
152 {
153 global $menu;
154
155 if (empty($menu) || !is_array($menu)) {
156 return;
157 }
158
159 // Badge gate: hide once the modal has been opened at least once.
160 if (!$this->has_unopened_release()) {
161 return;
162 }
163
164 // $menu is a list of [0=>title, 1=>cap, 2=>slug, …]. Find EmbedPress by
165 // its slug and append the bubble to its title, mirroring how WP renders
166 // the plugin-update count.
167 foreach ($menu as $key => $item) {
168 if (!isset($item[2]) || $item[2] !== self::MENU_SLUG) {
169 continue;
170 }
171
172 // Inline green "NEW" pill with a white-gradient shine. NOT WP's
173 // `.update-plugins` count bubble (that drops to its own line with a
174 // word). All layout is in the inline stylesheet so core menu CSS
175 // can't drag it around; the label uses nowrap to keep it on one line.
176 $bubble = ' <span class="ep-whatsnew-badge" aria-hidden="true">' . esc_html__('New', 'embedpress') . '</span>';
177 $menu[$key][0] .= $bubble;
178 break;
179 }
180 }
181
182 /**
183 * Print the pulse animation for the menu bubble, inline in <head>.
184 *
185 * The bubble appears on every admin page while a release is unseen, but the
186 * modal's stylesheet only enqueues on EmbedPress pages — so this small,
187 * self-contained rule keeps the blink working globally. Freezes under
188 * `prefers-reduced-motion`.
189 *
190 * @return void
191 */
192 public function print_menu_indicator_style()
193 {
194 if (!$this->has_unopened_release()) {
195 return;
196 }
197 ?>
198 <style id="ep-whatsnew-badge-style">
199 #adminmenu li.menu-top:has(.ep-whatsnew-badge) .wp-menu-name{white-space:nowrap}
200 #adminmenu .ep-whatsnew-badge{position:relative;overflow:hidden;display:inline-flex;align-items:center;justify-content:center;vertical-align:middle;width:28px;height:18px;margin:-2px 0 0 0;padding:0;border-radius:9px;background:#d63637;color:#fff;font-size:8px;font-weight:700;line-height:1.6;letter-spacing:.04em;text-transform:uppercase;text-align:center;box-shadow:none!important;animation:ep-whatsnew-glow 1.8s ease-in-out infinite}
201 #adminmenu .ep-whatsnew-badge::after{content:"";position:absolute;top:0;left:0;width:45%;height:100%;background:linear-gradient(100deg,transparent 0%,rgba(255,255,255,.85) 50%,transparent 100%);animation:ep-whatsnew-shine 1.8s ease-in-out infinite}
202 @keyframes ep-whatsnew-shine{0%{transform:translateX(-160%) skewX(-18deg)}55%,100%{transform:translateX(320%) skewX(-18deg)}}
203 @keyframes ep-whatsnew-glow{0%,100%{box-shadow:0 1px 4px rgba(0,185,105,.5)}50%{box-shadow:0 1px 9px rgba(0,185,105,.85)}}
204 @media (prefers-reduced-motion: reduce){#adminmenu .ep-whatsnew-badge{animation:none}#adminmenu .ep-whatsnew-badge::after{display:none}}
205 </style>
206 <?php
207 }
208
209 /**
210 * Warm the modal's media in the background.
211 *
212 * The modal opens shortly after the dashboard loads; if a feature uses a
213 * large image/GIF (e.g. a full product animation), fetching it only when
214 * the modal opens leaves the stage blank while it downloads. Emit a
215 * `<link rel="preload">` for each image/gif src in the active set so the
216 * browser starts fetching immediately on page load — by the time the modal
217 * appears the asset is warm (or already cached). Only fires on an
218 * EmbedPress admin page when a set is actually queued.
219 *
220 * @return void
221 */
222 public function preload_media()
223 {
224 if (!$this->is_target_screen()) {
225 return;
226 }
227
228 $set = $this->get_active_set();
229 if (!$set) {
230 return;
231 }
232
233 $seen = [];
234 foreach ($set['features'] as $feature) {
235 $media = isset($feature['media']) && is_array($feature['media']) ? $feature['media'] : [];
236 $type = isset($media['type']) ? sanitize_key($media['type']) : '';
237 // Only still images/GIFs benefit from rel=preload as=image. Video is
238 // streamed by the <video> element; html demos have no external asset.
239 if (!in_array($type, ['image', 'gif'], true) || empty($media['src'])) {
240 continue;
241 }
242 $src = $this->resolve_media_src($media['src']);
243 if ($src === '' || isset($seen[$src])) {
244 continue;
245 }
246 $seen[$src] = true;
247 printf(
248 '<link rel="preload" as="image" href="%s" fetchpriority="low" />' . "\n",
249 esc_url($src)
250 );
251 }
252 }
253
254 /**
255 * Whether the site owner has already opted into WP-Insights usage tracking
256 * for EmbedPress.
257 *
258 * Mirrors EmbedPress_Plugin_Usage_Tracker::is_tracking_allowed() — tracking
259 * is on when the plugin's key exists in the `wpins_allow_tracking` option.
260 * Used to SKIP the "What we collect" consent slide for users who already
261 * opted in (they've seen/accepted this already).
262 *
263 * @return bool
264 */
265 public static function is_tracking_enabled()
266 {
267 $allow = get_option('wpins_allow_tracking');
268 return is_array($allow) && isset($allow[self::MENU_SLUG]);
269 }
270
271 /**
272 * Register a release's feature set.
273 *
274 * Call this from {@see FeatureNotices::register_all_notices()} (or any
275 * `init`-time hook) — one call per release.
276 *
277 * @param string $id Unique id for the set (e.g. 'whatsnew_4_5_7').
278 * @param array $args {
279 * @type string $version Trigger version (e.g. '4.5.7'). The modal
280 * fires when this is newer than the user's
281 * seen-version. Defaults to EMBEDPRESS_VERSION.
282 * @type string $capability Required capability. Default 'manage_options'.
283 * @type string $changelog_url URL for the "see the full changelog" link.
284 * @type array $features List of slides. Each slide:
285 * @type string $eyebrow Small label above the title (e.g. 'New in 4.5.7').
286 * @type string $title Headline.
287 * @type string $desc 1–2 sentence description.
288 * @type array $media {
289 * @type string $type 'image' | 'gif' | 'video' | 'html'.
290 * @type string $src URL (image/gif/video) — relative paths are
291 * resolved against EMBEDPRESS_URL_ASSETS . 'images/'.
292 * @type string $html Raw inline demo markup (when type = 'html').
293 * @type string $badge Corner badge text. Default 'PREVIEW'.
294 * }
295 * @type array $cta {
296 * @type string $label Button label.
297 * @type string $url Button URL.
298 * @type bool $external Open in new tab. Default false.
299 * }
300 * }
301 * }
302 * @return void
303 */
304 public function register($id, array $args = [])
305 {
306 $defaults = [
307 'version' => defined('EMBEDPRESS_VERSION') ? EMBEDPRESS_VERSION : '0.0.0',
308 'capability' => 'manage_options',
309 'changelog_url' => 'https://embedpress.com/changelog/',
310 // Whether this set is allowed to show. Accepts a bool, or a callable
311 // that returns a bool for conditional gating (e.g. non-Pro only).
312 // `false` = registered but never shown (mirrors the admin-notice
313 // `display_if` convention).
314 'show_if' => true,
315 'features' => [],
316 ];
317
318 $set = wp_parse_args($args, $defaults);
319 $set['id'] = $id;
320 $this->feature_sets[$id] = $set;
321 }
322
323 /**
324 * Whether the current request is on an EmbedPress admin page.
325 *
326 * The modal (and its assets/preload) fire on the EmbedPress menu and its
327 * submenus — `?page=embedpress`, `?page=embedpress&page_type=…`, and the
328 * sibling `?page=embedpress-…` pages — never on the generic WP dashboard.
329 * The screen id for the top-level menu is `toplevel_page_embedpress`; the
330 * `$_GET['page']` prefix is the reliable common signal across all of them.
331 *
332 * @return bool
333 */
334 private function is_target_screen()
335 {
336 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page routing, no state change.
337 $page = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : '';
338 return $page !== '' && strpos($page, self::MENU_SLUG) === 0;
339 }
340
341 /**
342 * Resolve the newest qualifying feature set for the current user, ignoring
343 * the screen. Shared gate (capability + per-set enable + version) behind
344 * both the menu indicator and the modal — they differ only in WHICH
345 * per-user version option they compare against:
346 *
347 * - the MODAL passes SEEN_VERSION_OPTION → keeps re-showing until a real
348 * dismiss/Done stamps it;
349 * - the BADGE passes OPENED_VERSION_OPTION → clears as soon as the modal
350 * has been opened once.
351 *
352 * @param string $option Per-user version option to compare against.
353 * @return array|null The set, or null when nothing qualifies.
354 */
355 private function resolve_unseen_set($option = self::SEEN_VERSION_OPTION)
356 {
357 $marker = get_option($option, '0.0.0');
358 $candidate = null;
359
360 foreach ($this->feature_sets as $set) {
361 if (empty($set['features']) || !current_user_can($set['capability'])) {
362 continue;
363 }
364
365 // Per-set enable/disable. `show_if` may be a bool or a callable.
366 if (!$this->is_set_enabled($set)) {
367 continue;
368 }
369
370 // Only fire when this release is newer than the compared marker.
371 if (version_compare($set['version'], $marker, '<=')) {
372 continue;
373 }
374
375 // Prefer the newest registered release if several qualify.
376 if ($candidate === null || version_compare($set['version'], $candidate['version'], '>')) {
377 $candidate = $set;
378 }
379 }
380
381 return $candidate;
382 }
383
384 /**
385 * Whether the menu "New" indicator should show — i.e. there's a release the
386 * user hasn't OPENED the modal for yet. Independent of the modal's own
387 * re-show gate (which uses the seen/dismissed marker).
388 *
389 * @return bool
390 */
391 private function has_unopened_release()
392 {
393 return $this->resolve_unseen_set(self::OPENED_VERSION_OPTION) !== null;
394 }
395
396 /**
397 * Resolve which feature set (if any) the MODAL should render on this request.
398 *
399 * Adds the EmbedPress-page screen gate on top of {@see resolve_unseen_set()},
400 * so the modal only mounts in-context — never on the WP dashboard. Uses the
401 * SEEN marker, so the modal re-shows until the user dismisses/finishes it.
402 *
403 * @return array|null The set, or null when nothing should show here.
404 */
405 private function get_active_set()
406 {
407 if (!$this->is_target_screen()) {
408 return null;
409 }
410
411 return $this->resolve_unseen_set(self::SEEN_VERSION_OPTION);
412 }
413
414 /**
415 * Resolve a feature set's `show_if` switch.
416 *
417 * Accepts a literal bool or a callable (called with the set array, must
418 * return truthy to show). A global `embedpress_whatsnew_enabled` filter is
419 * also honoured as a site-wide kill switch — return false from it to
420 * disable every modal regardless of per-set config.
421 *
422 * @param array $set
423 * @return bool
424 */
425 private function is_set_enabled($set)
426 {
427 $show = isset($set['show_if']) ? $set['show_if'] : true;
428
429 if (is_callable($show)) {
430 $show = (bool) call_user_func($show, $set);
431 } else {
432 $show = (bool) $show;
433 }
434
435 /**
436 * Filter whether a "What's New" feature set may show.
437 *
438 * @param bool $show Resolved per-set show_if value.
439 * @param array $set The feature set being evaluated.
440 */
441 return (bool) apply_filters('embedpress_whatsnew_enabled', $show, $set);
442 }
443
444 /**
445 * Whether a modal is queued to show on this request.
446 *
447 * Used by FeatureNoticeManager to suppress the small menu tooltip when the
448 * bigger modal is about to take over the same dashboard view — so a release
449 * is never announced twice at once.
450 *
451 * @return bool
452 */
453 public function has_active_modal()
454 {
455 return $this->get_active_set() !== null;
456 }
457
458 /**
459 * Enqueue the modal CSS/JS on an EmbedPress admin page when a set is queued.
460 *
461 * @param string $hook Current admin page hook (unused — screen is resolved
462 * via is_target_screen()/get_active_set()).
463 * @return void
464 */
465 public function enqueue_assets($hook)
466 {
467 if (!$this->get_active_set()) {
468 return;
469 }
470
471 $version = defined('EMBEDPRESS_VERSION') ? EMBEDPRESS_VERSION : false;
472
473 // Cache-bust by file mtime so edits invalidate the browser cache even
474 // when the plugin version is unchanged (versions bump only at release).
475 // Falls back to the plugin version if the file can't be stat'd.
476 $css_path = defined('EMBEDPRESS_PATH_BASE') ? EMBEDPRESS_PATH_BASE . 'assets/css/feature-preview-modal.css' : '';
477 $js_path = defined('EMBEDPRESS_PATH_BASE') ? EMBEDPRESS_PATH_BASE . 'assets/js/feature-preview-modal.js' : '';
478 $css_ver = ($css_path && file_exists($css_path)) ? (string) filemtime($css_path) : $version;
479 $js_ver = ($js_path && file_exists($js_path)) ? (string) filemtime($js_path) : $version;
480
481 wp_enqueue_style(
482 'embedpress-whatsnew-modal',
483 EMBEDPRESS_URL_ASSETS . 'css/feature-preview-modal.css',
484 [],
485 $css_ver
486 );
487
488 wp_enqueue_script(
489 'embedpress-whatsnew-modal',
490 EMBEDPRESS_URL_ASSETS . 'js/feature-preview-modal.js',
491 [],
492 $js_ver,
493 true
494 );
495
496 wp_localize_script('embedpress-whatsnew-modal', 'EmbedPressWhatsNew', [
497 'ajaxUrl' => admin_url('admin-ajax.php'),
498 'nonce' => wp_create_nonce(self::NONCE_ACTION),
499 'action' => self::DISMISS_ACTION,
500 // Separate action fired on first open — clears the menu indicator
501 // without dismissing the modal.
502 'openedAction' => self::OPENED_ACTION,
503 // Action fired on the FIRST button interaction in the modal — opts
504 // the user into WP-Insights usage tracking (consent-on-engage).
505 // Only wired when tracking isn't already enabled, so the beacon is
506 // a no-op for users who've already consented.
507 'consentAction' => self::CONSENT_ACTION,
508 'consentNeeded' => !self::is_tracking_enabled(),
509 'logoUrl' => EMBEDPRESS_URL_ASSETS . 'images/logo.svg',
510 /**
511 * Developer switch — auto-open the modal on an EmbedPress page (true,
512 * default) vs. wait for the user to click the flagged EmbedPress menu
513 * (false). No settings UI; filter-only by design.
514 *
515 * @param bool $autoopen
516 */
517 'autoOpen' => (bool) apply_filters('embedpress_whatsnew_autoopen', true),
518 /* Slug used by the JS to bind click-to-open on the menu in click-mode. */
519 'menuSlug' => self::MENU_SLUG,
520 'i18n' => [
521 'close' => __('Close', 'embedpress'),
522 'back' => __('← Back', 'embedpress'),
523 'next' => __('Next →', 'embedpress'),
524 'done' => __('Done ✓', 'embedpress'),
525 'changelog' => __('See the full changelog', 'embedpress'),
526 'skip' => __('Skip · See the full changelog', 'embedpress'),
527 'whatWeCollect' => __('What we collect', 'embedpress'),
528 /* translators: 1: current step number, 2: total steps. */
529 'counter' => __('%1$d of %2$d', 'embedpress'),
530 ],
531 ]);
532 }
533
534 /**
535 * Print the modal data + mount node in the admin footer.
536 *
537 * The JS reads the JSON payload and builds the DOM — no markup is rendered
538 * server-side beyond the empty mount node, keeping escaping concerns in one
539 * place (wp_json_encode) and the structure entirely in the JS.
540 *
541 * @return void
542 */
543 public function render()
544 {
545 $set = $this->get_active_set();
546 if (!$set) {
547 return;
548 }
549
550 $payload = $this->prepare_payload($set);
551 ?>
552 <div id="embedpress-whatsnew-root" aria-hidden="true"></div>
553 <script type="application/json" id="embedpress-whatsnew-data">
554 <?php echo wp_json_encode($payload); ?>
555 </script>
556 <?php
557 }
558
559 /**
560 * Sanitise + normalise a feature set into the shape the JS expects.
561 *
562 * @param array $set
563 * @return array
564 */
565 private function prepare_payload($set)
566 {
567 $features = [];
568
569 foreach ($set['features'] as $feature) {
570 $media = isset($feature['media']) && is_array($feature['media']) ? $feature['media'] : [];
571 $type = isset($media['type']) ? sanitize_key($media['type']) : 'image';
572
573 $resolved_media = [
574 'type' => in_array($type, ['image', 'gif', 'video', 'html'], true) ? $type : 'image',
575 'badge' => isset($media['badge']) ? sanitize_text_field($media['badge']) : '',
576 ];
577
578 if ($resolved_media['type'] === 'html') {
579 $html = isset($media['html']) ? (string) $media['html'] : '';
580 // Inline demo markup is plugin-built (never user input). It may
581 // include inline SVG icons, which wp_kses_post strips — so allow
582 // a small SVG tag set on top of the post allowlist. Class/style
583 // are kept so the animation CSS can hook on.
584 $resolved_media['html'] = wp_kses($html, $this->demo_allowed_html());
585 } else {
586 $resolved_media['src'] = isset($media['src']) ? $this->resolve_media_src($media['src']) : '';
587 if ($resolved_media['type'] === 'video') {
588 $resolved_media['poster'] = isset($media['poster']) ? $this->resolve_media_src($media['poster']) : '';
589 }
590 }
591
592 $cta = isset($feature['cta']) && is_array($feature['cta']) ? $feature['cta'] : [];
593
594 $features[] = [
595 'eyebrow' => isset($feature['eyebrow']) ? sanitize_text_field($feature['eyebrow']) : '',
596 'title' => isset($feature['title']) ? sanitize_text_field($feature['title']) : '',
597 'desc' => isset($feature['desc']) ? wp_kses_post($feature['desc']) : '',
598 'media' => $resolved_media,
599 'cta' => [
600 'label' => isset($cta['label']) ? sanitize_text_field($cta['label']) : '',
601 'url' => isset($cta['url']) ? esc_url_raw($cta['url']) : '',
602 'external' => !empty($cta['external']),
603 ],
604 ];
605 }
606
607 return [
608 'id' => $set['id'],
609 'version' => $set['version'],
610 'changelogUrl' => esc_url_raw($set['changelog_url']),
611 // When set (tracking not yet opted-in), the bottom link becomes
612 // "What we collect" → this privacy-policy URL instead of the
613 // changelog link. Empty string = keep the changelog link.
614 'whatWeCollectUrl' => !empty($set['whatwe_collect_url'])
615 ? esc_url_raw($set['whatwe_collect_url'])
616 : '',
617 'features' => $features,
618 ];
619 }
620
621 /**
622 * Allowed-HTML map for inline demo markup. Extends the post allowlist with
623 * the small inline-SVG tag set the demos use for icons. Demo markup is
624 * always plugin-built (never user input), so this is safe.
625 *
626 * @return array
627 */
628 private function demo_allowed_html()
629 {
630 $allowed = wp_kses_allowed_html('post');
631
632 $svg_attrs = [
633 'class' => true, 'style' => true, 'width' => true, 'height' => true,
634 'viewbox' => true, 'fill' => true, 'stroke' => true,
635 'stroke-width' => true, 'stroke-linecap' => true,
636 'stroke-linejoin' => true, 'aria-hidden' => true,
637 ];
638
639 $allowed['svg'] = $svg_attrs;
640 $allowed['path'] = ['d' => true] + $svg_attrs;
641 $allowed['circle'] = ['cx' => true, 'cy' => true, 'r' => true] + $svg_attrs;
642 $allowed['line'] = ['x1' => true, 'y1' => true, 'x2' => true, 'y2' => true] + $svg_attrs;
643
644 return $allowed;
645 }
646
647 /**
648 * Resolve a media src — pass through full URLs, prefix bare paths with the
649 * plugin's assets/images/ directory (same rule the release-notes page uses).
650 *
651 * @param string $src
652 * @return string
653 */
654 private function resolve_media_src($src)
655 {
656 $src = trim((string) $src);
657 if ($src === '') {
658 return '';
659 }
660 if (preg_match('#^(https?:)?//#', $src) || strpos($src, 'data:') === 0) {
661 return esc_url_raw($src);
662 }
663 return esc_url_raw(EMBEDPRESS_URL_ASSETS . 'images/' . ltrim($src, '/'));
664 }
665
666 /**
667 * AJAX: persist the dismissal by stamping the seen-version option.
668 *
669 * Any close path (X, ESC, CTA click, "Done") calls this with the set's
670 * trigger version, so the modal won't fire again for that release.
671 *
672 * @return void
673 */
674 public function ajax_dismiss()
675 {
676 check_ajax_referer(self::NONCE_ACTION, 'nonce');
677
678 if (!current_user_can('manage_options')) {
679 wp_send_json_error(['message' => __('Permission denied.', 'embedpress')], 403);
680 }
681
682 $version = isset($_POST['version']) ? sanitize_text_field(wp_unslash($_POST['version'])) : '';
683 if ($version === '') {
684 wp_send_json_error(['message' => __('Missing version.', 'embedpress')]);
685 }
686
687 $seen = get_option(self::SEEN_VERSION_OPTION, '0.0.0');
688 // Never move the marker backwards.
689 if (version_compare($version, $seen, '>')) {
690 update_option(self::SEEN_VERSION_OPTION, $version);
691 }
692
693 // A dismiss implies the modal was opened — stamp the opened marker too,
694 // so the "New" badge can never linger after a dismiss even if the
695 // separate open() beacon was lost (e.g. the "What we collect" link
696 // navigates a new tab and closes the modal before the open-beacon
697 // lands). Keeps opened >= seen as an invariant.
698 $opened = get_option(self::OPENED_VERSION_OPTION, '0.0.0');
699 if (version_compare($version, $opened, '>')) {
700 update_option(self::OPENED_VERSION_OPTION, $version);
701 }
702
703 wp_send_json_success([
704 'seen' => get_option(self::SEEN_VERSION_OPTION),
705 'opened' => get_option(self::OPENED_VERSION_OPTION),
706 ]);
707 }
708
709 /**
710 * AJAX: persist a first-open by stamping the opened-version option.
711 *
712 * Called once when the modal opens. This clears the menu "New" indicator
713 * (which gates on the opened marker) WITHOUT dismissing the modal — the
714 * modal keeps re-showing until a real dismiss stamps the seen marker.
715 *
716 * @return void
717 */
718 public function ajax_opened()
719 {
720 check_ajax_referer(self::NONCE_ACTION, 'nonce');
721
722 if (!current_user_can('manage_options')) {
723 wp_send_json_error(['message' => __('Permission denied.', 'embedpress')], 403);
724 }
725
726 $version = isset($_POST['version']) ? sanitize_text_field(wp_unslash($_POST['version'])) : '';
727 if ($version === '') {
728 wp_send_json_error(['message' => __('Missing version.', 'embedpress')]);
729 }
730
731 $opened = get_option(self::OPENED_VERSION_OPTION, '0.0.0');
732 // Never move the marker backwards.
733 if (version_compare($version, $opened, '>')) {
734 update_option(self::OPENED_VERSION_OPTION, $version);
735 }
736
737 wp_send_json_success(['opened' => get_option(self::OPENED_VERSION_OPTION)]);
738 }
739
740 /**
741 * AJAX: opt the user into WP-Insights usage tracking.
742 *
743 * Fired when the user clicks any button in the What's New modal — treating
744 * that engagement as consent to enable usage tracking. Grants consent the
745 * same way the onboarding wizard does (see EmbedpressSettings): add the
746 * plugin to `wpins_allow_tracking`, suppress the legacy opt-in notice via
747 * `wpins_block_notice`, then schedule + fire an initial payload.
748 *
749 * Guarded:
750 * - nonce + `manage_options`;
751 * - a no-op if tracking is already enabled (idempotent).
752 *
753 * This mirrors the onboarding wizard's grant path, which is likewise an
754 * unconditional opt-in on the user's affirmative action.
755 *
756 * @return void
757 */
758 public function ajax_consent()
759 {
760 check_ajax_referer(self::NONCE_ACTION, 'nonce');
761
762 if (!current_user_can('manage_options')) {
763 wp_send_json_error(['message' => __('Permission denied.', 'embedpress')], 403);
764 }
765
766 // Already opted in — nothing to do.
767 if (self::is_tracking_enabled()) {
768 wp_send_json_success(['tracking' => true, 'changed' => false]);
769 }
770
771 // Add this plugin to the allow-tracking list (mirrors the tracker's own
772 // set_is_tracking_allowed(), which is protected).
773 $allow_tracking = get_option('wpins_allow_tracking');
774 if (empty($allow_tracking) || !is_array($allow_tracking)) {
775 $allow_tracking = [self::MENU_SLUG => self::MENU_SLUG];
776 } else {
777 $allow_tracking[self::MENU_SLUG] = self::MENU_SLUG;
778 }
779 update_option('wpins_allow_tracking', $allow_tracking);
780
781 // Suppress the legacy opt-in admin notice now that consent is captured.
782 $block_notice = get_option('wpins_block_notice', []);
783 if (!is_array($block_notice)) {
784 $block_notice = [];
785 }
786 $block_notice[self::MENU_SLUG] = self::MENU_SLUG;
787 update_option('wpins_block_notice', $block_notice);
788
789 // Register the daily cron and fire an initial payload immediately, so
790 // data reaches wpinsight.com without waiting for the first cron tick.
791 if (class_exists('\\EmbedPress\\Includes\\Classes\\EmbedPress_Plugin_Usage_Tracker') && defined('EMBEDPRESS_FILE')) {
792 $tracker = EmbedPress_Plugin_Usage_Tracker::get_instance(EMBEDPRESS_FILE, [
793 'opt_in' => true,
794 'goodbye_form' => true,
795 'item_id' => '98ba0ac16a4f7b3b940d',
796 ]);
797 $tracker->schedule_tracking();
798 $tracker->do_tracking(true);
799 }
800
801 wp_send_json_success(['tracking' => true, 'changed' => true]);
802 }
803 }
804