PluginProbe
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder / 51.1.76
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder v51.1.76
51.1.83 51.1.82 51.1.81 51.1.79 51.1.78 51.1.77 51.1.76 51.1.74 51.1.75 51.1.65 51.1.64 51.1.63 trunk 51.1.14 51.1.2 51.1.35 51.1.36 51.1.37 51.1.38 51.1.39 51.1.44 51.1.45 51.1.46 51.1.47 51.1.49 All 37 releases
king-addons / includes / extensions / Fomo_Notifications / Fomo_Notifications.php

Fomo_Notifications.php in King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder 51.1.76, at includes/extensions/Fomo_Notifications/Fomo_Notifications.php

1,900 lines 64.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Fomo Notifications Extension
4 *
5 * Social proof and FOMO notifications system for WordPress.
6 * Creates notification bars, sales popups, review alerts and more.
7 *
8 * @package King_Addons
9 * @since 1.0.0
10 */
11
12 namespace King_Addons;
13
14 if (!defined('ABSPATH')) {
15 exit;
16 }
17
18 /**
19 * Class Fomo_Notifications
20 *
21 * Main class for the Fomo Notifications extension.
22 * Handles CPT registration, admin pages, frontend rendering and analytics.
23 *
24 * @since 1.0.0
25 */
26 final class Fomo_Notifications
27 {
28 /**
29 * Extension version
30 *
31 * @var string
32 */
33 const VERSION = '1.0.0';
34
35 /**
36 * Custom post type name
37 *
38 * @var string
39 */
40 const POST_TYPE = 'kng_fomo_notif';
41
42 /**
43 * Stats table name (without prefix)
44 *
45 * @var string
46 */
47 const STATS_TABLE = 'kng_fomo_stats';
48
49 /**
50 * Free version notification limit
51 *
52 * @var int
53 */
54 const FREE_LIMIT = 3;
55
56 /**
57 * Singleton instance
58 *
59 * @var Fomo_Notifications|null
60 */
61 private static ?Fomo_Notifications $instance = null;
62
63 /**
64 * Extension directory path
65 *
66 * @var string
67 */
68 private string $dir;
69
70 /**
71 * Extension directory URL
72 *
73 * @var string
74 */
75 private string $url;
76
77 /**
78 * Get singleton instance
79 *
80 * @return Fomo_Notifications
81 */
82 public static function instance(): Fomo_Notifications
83 {
84 if (is_null(self::$instance)) {
85 self::$instance = new self();
86 }
87 return self::$instance;
88 }
89
90 /**
91 * Constructor
92 */
93 private function __construct()
94 {
95 $this->dir = plugin_dir_path(__FILE__);
96 $this->url = plugin_dir_url(__FILE__);
97
98 $this->init_hooks();
99 }
100
101 /**
102 * Initialize WordPress hooks
103 *
104 * @return void
105 */
106 private function init_hooks(): void
107 {
108 // Core initialization
109 add_action('init', [$this, 'register_post_type']);
110 add_action('init', [$this, 'register_post_meta']);
111
112 // Admin
113 add_action('admin_menu', [$this, 'register_admin_menu'], 99);
114 add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_assets']);
115
116 // AJAX handlers
117 add_action('wp_ajax_kng_fomo_save_notification', [$this, 'ajax_save_notification']);
118 add_action('wp_ajax_kng_fomo_get_notification', [$this, 'ajax_get_notification']);
119 add_action('wp_ajax_kng_fomo_delete_notification', [$this, 'ajax_delete_notification']);
120 add_action('wp_ajax_kng_fomo_toggle_status', [$this, 'ajax_toggle_status']);
121 add_action('wp_ajax_kng_fomo_duplicate_notification', [$this, 'ajax_duplicate_notification']);
122 add_action('wp_ajax_kng_fomo_save_settings', [$this, 'ajax_save_settings']);
123 add_action('wp_ajax_kng_fomo_get_analytics', [$this, 'ajax_get_analytics']);
124 add_action('wp_ajax_kng_fomo_export_notification', [$this, 'ajax_export_notification']);
125 add_action('wp_ajax_kng_fomo_import_notification', [$this, 'ajax_import_notification']);
126 add_action('wp_ajax_kng_fomo_fetch_wporg_data', [$this, 'ajax_fetch_wporg_data']);
127 add_action('wp_ajax_kng_fomo_purge_cache', [$this, 'ajax_purge_cache']);
128
129 // Frontend
130 add_action('wp_enqueue_scripts', [$this, 'enqueue_frontend_assets']);
131 add_action('wp_footer', [$this, 'render_notifications']);
132
133 // Tracking endpoints (public)
134 add_action('wp_ajax_kng_fomo_track_event', [$this, 'ajax_track_event']);
135 add_action('wp_ajax_nopriv_kng_fomo_track_event', [$this, 'ajax_track_event']);
136
137 // Database setup on init (activation hook won't work here)
138 add_action('admin_init', [$this, 'maybe_create_stats_table']);
139 }
140
141 /**
142 * Register custom post type for notifications
143 *
144 * @return void
145 */
146 public function register_post_type(): void
147 {
148 $labels = [
149 'name' => __('Notifications', 'king-addons'),
150 'singular_name' => __('Notification', 'king-addons'),
151 'add_new' => __('Add New', 'king-addons'),
152 'add_new_item' => __('Add New Notification', 'king-addons'),
153 'edit_item' => __('Edit Notification', 'king-addons'),
154 'new_item' => __('New Notification', 'king-addons'),
155 'view_item' => __('View Notification', 'king-addons'),
156 'search_items' => __('Search Notifications', 'king-addons'),
157 'not_found' => __('No notifications found', 'king-addons'),
158 'not_found_in_trash' => __('No notifications found in Trash', 'king-addons'),
159 ];
160
161 $args = [
162 'labels' => $labels,
163 'public' => false,
164 'publicly_queryable' => false,
165 'show_ui' => false,
166 'show_in_menu' => false,
167 'query_var' => false,
168 'rewrite' => false,
169 'capability_type' => 'post',
170 'has_archive' => false,
171 'hierarchical' => false,
172 'supports' => ['title'],
173 'show_in_rest' => false,
174 ];
175
176 register_post_type(self::POST_TYPE, $args);
177 }
178
179 /**
180 * Register post meta fields
181 *
182 * @return void
183 */
184 public function register_post_meta(): void
185 {
186 // Simple string fields
187 $string_fields = [
188 '_kng_fomo_status',
189 '_kng_fomo_type',
190 '_kng_fomo_source',
191 ];
192
193 foreach ($string_fields as $meta_key) {
194 register_post_meta(self::POST_TYPE, $meta_key, [
195 'type' => 'string',
196 'single' => true,
197 'show_in_rest' => false,
198 'sanitize_callback' => 'sanitize_text_field',
199 ]);
200 }
201
202 // JSON fields — sanitize_text_field would break JSON, so use a custom callback
203 $json_fields = [
204 '_kng_fomo_source_config',
205 '_kng_fomo_design',
206 '_kng_fomo_content',
207 '_kng_fomo_display',
208 '_kng_fomo_customize',
209 ];
210
211 foreach ($json_fields as $meta_key) {
212 register_post_meta(self::POST_TYPE, $meta_key, [
213 'type' => 'string',
214 'single' => true,
215 'show_in_rest' => false,
216 'sanitize_callback' => [$this, 'sanitize_json_meta'],
217 ]);
218 }
219
220 // Numeric fields
221 $numeric_fields = [
222 '_kng_fomo_views',
223 '_kng_fomo_clicks',
224 ];
225
226 foreach ($numeric_fields as $meta_key) {
227 register_post_meta(self::POST_TYPE, $meta_key, [
228 'type' => 'string',
229 'single' => true,
230 'show_in_rest' => false,
231 'sanitize_callback' => 'absint',
232 ]);
233 }
234 }
235
236 /**
237 * Sanitize JSON meta value
238 *
239 * @param mixed $value The value to sanitize
240 * @return string
241 */
242 public function sanitize_json_meta($value): string
243 {
244 if (is_string($value)) {
245 $decoded = json_decode($value, true);
246 if (json_last_error() === JSON_ERROR_NONE) {
247 return wp_json_encode($decoded);
248 }
249 }
250 if (is_array($value)) {
251 return wp_json_encode($value);
252 }
253 return '{}';
254 }
255
256 /**
257 * Maybe create stats table (runs once)
258 *
259 * @return void
260 */
261 public function maybe_create_stats_table(): void
262 {
263 $version_key = 'kng_fomo_db_version';
264 $current_version = '1.0';
265
266 if (get_option($version_key) !== $current_version) {
267 $this->create_stats_table();
268 update_option($version_key, $current_version);
269 }
270 }
271
272 /**
273 * Create stats table on activation
274 *
275 * @return void
276 */
277 public function create_stats_table(): void
278 {
279 global $wpdb;
280
281 $table_name = $wpdb->prefix . self::STATS_TABLE;
282 $charset_collate = $wpdb->get_charset_collate();
283
284 $sql = "CREATE TABLE IF NOT EXISTS $table_name (
285 id bigint(20) NOT NULL AUTO_INCREMENT,
286 notification_id bigint(20) NOT NULL,
287 stat_date date NOT NULL,
288 views int(11) NOT NULL DEFAULT 0,
289 clicks int(11) NOT NULL DEFAULT 0,
290 PRIMARY KEY (id),
291 UNIQUE KEY notification_date (notification_id, stat_date),
292 KEY notification_id (notification_id),
293 KEY stat_date (stat_date)
294 ) $charset_collate;";
295
296 require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
297 dbDelta($sql);
298 }
299
300 /**
301 * Register admin menu pages
302 *
303 * @return void
304 */
305 public function register_admin_menu(): void
306 {
307 // Main page (Dashboard)
308 add_submenu_page(
309 'king-addons',
310 __('Fomo Notifications', 'king-addons'),
311 __('Fomo Notifications', 'king-addons'),
312 'manage_options',
313 'king-addons-fomo',
314 [$this, 'render_admin_page']
315 );
316 }
317
318 /**
319 * Enqueue admin assets
320 *
321 * @param string $hook Current admin page hook
322 * @return void
323 */
324 public function enqueue_admin_assets(string $hook): void
325 {
326 // Check if we're on the Fomo Notifications admin page
327 if (strpos($hook, 'king-addons-fomo') === false &&
328 (!isset($_GET['page']) || $_GET['page'] !== 'king-addons-fomo')) {
329 return;
330 }
331
332 // Chart.js for analytics
333 wp_enqueue_script(
334 'chartjs',
335 'https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js',
336 [],
337 '4.4.1',
338 true
339 );
340
341 // Admin CSS
342 wp_enqueue_style(
343 'kng-fomo-admin',
344 $this->url . 'assets/admin.css',
345 [],
346 self::VERSION
347 );
348
349 // Admin JS
350 wp_enqueue_script(
351 'kng-fomo-admin',
352 $this->url . 'assets/admin.js',
353 ['jquery', 'wp-util'],
354 self::VERSION,
355 true
356 );
357
358 wp_localize_script('kng-fomo-admin', 'kngFomoAdmin', [
359 'ajaxUrl' => admin_url('admin-ajax.php'),
360 'nonce' => wp_create_nonce('kng_fomo_admin'),
361 'restUrl' => rest_url('kng-fomo/v1/'),
362 'listUrl' => admin_url('admin.php?page=king-addons-fomo&view=list'),
363 'hasPro' => self::hasPro(),
364 'freeLimit' => self::FREE_LIMIT,
365 'upgradeUrl' => 'https://kingaddons.com/pricing/?utm_source=kng-fomo-notifications&utm_medium=plugin',
366 'typeDefaults' => [
367 'notification_bar' => self::get_type_defaults('notification_bar'),
368 'woocommerce_sales' => self::get_type_defaults('woocommerce_sales'),
369 'wordpress_comments' => self::get_type_defaults('wordpress_comments'),
370 'wporg_downloads' => self::get_type_defaults('wporg_downloads'),
371 'reviews' => self::get_type_defaults('reviews'),
372 'email_subscription' => self::get_type_defaults('email_subscription'),
373 'donations' => self::get_type_defaults('donations'),
374 'flashing_tab' => self::get_type_defaults('flashing_tab'),
375 'custom_csv' => self::get_type_defaults('custom_csv'),
376 ],
377 'i18n' => [
378 'confirmDelete' => __('Are you sure you want to delete this notification?', 'king-addons'),
379 'saved' => __('Notification saved successfully!', 'king-addons'),
380 'deleted' => __('Notification deleted.', 'king-addons'),
381 'duplicated' => __('Notification duplicated.', 'king-addons'),
382 'error' => __('An error occurred. Please try again.', 'king-addons'),
383 'limitReached' => __('Free version limit reached. Upgrade to Pro for unlimited notifications.', 'king-addons'),
384 'proFeature' => __('This feature is available in Pro version.', 'king-addons'),
385 'select_type' => __('Please select a notification type.', 'king-addons'),
386 'select_template' => __('Please select a template.', 'king-addons'),
387 'add_content' => __('Please add a title or message.', 'king-addons'),
388 'settings_saved' => __('Settings saved successfully!', 'king-addons'),
389 'exported' => __('Notification exported successfully!', 'king-addons'),
390 ],
391 ]);
392 }
393
394 /**
395 * Enqueue frontend assets
396 *
397 * @return void
398 */
399 public function enqueue_frontend_assets(): void
400 {
401 // Check if there are active notifications for current page
402 $notifications = $this->get_active_notifications_for_page();
403 if (empty($notifications)) {
404 return;
405 }
406
407 wp_enqueue_style(
408 'kng-fomo-frontend',
409 $this->url . 'assets/frontend.css',
410 [],
411 self::VERSION
412 );
413
414 wp_enqueue_script(
415 'kng-fomo-frontend',
416 $this->url . 'assets/frontend.js',
417 [],
418 self::VERSION,
419 true
420 );
421
422 // Prepare notifications data for JS
423 $notifications_data = [];
424 $settings = $this->get_settings();
425 foreach ($notifications as $notification) {
426 $notifications_data[] = $this->prepare_notification_for_frontend($notification);
427 }
428
429 wp_localize_script('kng-fomo-frontend', 'kngFomoData', [
430 'ajaxUrl' => admin_url('admin-ajax.php'),
431 'nonce' => wp_create_nonce('kng_fomo_frontend'),
432 'notifications' => $notifications_data,
433 'settings' => [
434 'position' => 'bottom-left',
435 'delayBefore' => 5,
436 'displayFor' => 5,
437 'delayBetween' => 5,
438 'sessionLimit' => 0,
439 'soundVolume' => (int)($settings['sound_volume'] ?? 50),
440 'loop' => true,
441 ],
442 ]);
443 }
444
445 /**
446 * Render admin page based on view parameter
447 *
448 * @return void
449 */
450 public function render_admin_page(): void
451 {
452 $view = isset($_GET['view']) ? sanitize_text_field($_GET['view']) : 'dashboard';
453 $template_map = [
454 'dashboard' => 'admin-dashboard.php',
455 'list' => 'admin-list.php',
456 'new' => 'admin-wizard.php',
457 'wizard' => 'admin-wizard.php',
458 'edit' => 'admin-wizard.php',
459 'settings' => 'admin-settings.php',
460 'analytics' => 'admin-analytics.php',
461 ];
462
463 $template = $template_map[$view] ?? 'admin-dashboard.php';
464 $template_path = $this->dir . 'templates/' . $template;
465
466 if (file_exists($template_path)) {
467 // Prepare variables for templates
468 $has_pro = self::hasPro();
469 $notifications = $this->get_all_notifications();
470 $notification_count = count($notifications);
471 $at_limit = !$has_pro && $notification_count >= self::FREE_LIMIT;
472 $settings = $this->get_settings();
473 $notification = null;
474 $is_edit = ($view === 'edit' || ($view === 'wizard' && isset($_GET['edit'])));
475
476 if ($is_edit && isset($_GET['edit'])) {
477 $notification = $this->get_notification((int)$_GET['edit']);
478 }
479
480 include $template_path;
481 }
482 }
483
484 /**
485 * Check if user has Pro version
486 *
487 * @return bool
488 */
489 public static function hasPro(): bool
490 {
491 return function_exists('king_addons_freemius') && king_addons_freemius()->can_use_premium_code();
492 }
493
494 /**
495 * Get all notifications
496 *
497 * @param array $args Query arguments
498 * @return array
499 */
500 public function get_all_notifications(array $args = []): array
501 {
502 $defaults = [
503 'post_type' => self::POST_TYPE,
504 'posts_per_page' => -1,
505 'post_status' => 'any',
506 'orderby' => 'date',
507 'order' => 'DESC',
508 ];
509
510 $args = wp_parse_args($args, $defaults);
511 $posts = get_posts($args);
512
513 $notifications = [];
514 foreach ($posts as $post) {
515 $notifications[] = $this->format_notification($post);
516 }
517
518 return $notifications;
519 }
520
521 /**
522 * Get single notification by ID
523 *
524 * @param int $id Notification ID
525 * @return array|null
526 */
527 public function get_notification(int $id): ?array
528 {
529 $post = get_post($id);
530 if (!$post || $post->post_type !== self::POST_TYPE) {
531 return null;
532 }
533
534 return $this->format_notification($post);
535 }
536
537 /**
538 * Format notification post to array
539 *
540 * @param \WP_Post $post Post object
541 * @return array
542 */
543 private function format_notification(\WP_Post $post): array
544 {
545 $views = (int)get_post_meta($post->ID, '_kng_fomo_views', true);
546 $clicks = (int)get_post_meta($post->ID, '_kng_fomo_clicks', true);
547 $ctr = $views > 0 ? round(($clicks / $views) * 100, 2) : 0;
548
549 return [
550 'id' => $post->ID,
551 'title' => $post->post_title,
552 'status' => get_post_meta($post->ID, '_kng_fomo_status', true) ?: 'disabled',
553 'type' => get_post_meta($post->ID, '_kng_fomo_type', true) ?: 'notification_bar',
554 'source' => get_post_meta($post->ID, '_kng_fomo_source', true) ?: 'manual',
555 'source_config' => json_decode(get_post_meta($post->ID, '_kng_fomo_source_config', true) ?: '{}', true),
556 'design' => json_decode(get_post_meta($post->ID, '_kng_fomo_design', true) ?: '{}', true),
557 'content' => json_decode(get_post_meta($post->ID, '_kng_fomo_content', true) ?: '{}', true),
558 'display' => json_decode(get_post_meta($post->ID, '_kng_fomo_display', true) ?: '{}', true),
559 'customize' => json_decode(get_post_meta($post->ID, '_kng_fomo_customize', true) ?: '{}', true),
560 'views' => $views,
561 'clicks' => $clicks,
562 'ctr' => $ctr,
563 'date' => $post->post_date,
564 'date_formatted' => get_the_date('M j, Y', $post),
565 ];
566 }
567
568 /**
569 * Get extension settings
570 *
571 * @return array
572 */
573 public function get_settings(): array
574 {
575 $defaults = [
576 'enabled' => true,
577 'tracking_enabled' => true,
578 'track_for' => 'everyone',
579 'exclude_bots' => true,
580 'cache_ttl' => 300,
581 'anonymize_names' => false,
582 'sound_volume' => 50,
583 'modules' => [
584 'notification_bar' => true,
585 'woocommerce_sales' => true,
586 'wordpress_comments' => true,
587 'wporg_downloads' => true,
588 'reviews' => false,
589 'email_subscription' => false,
590 'donations' => false,
591 'flashing_tab' => false,
592 'custom_csv' => false,
593 ],
594 ];
595
596 $saved = get_option('kng_fomo_settings', []);
597 return wp_parse_args($saved, $defaults);
598 }
599
600 /**
601 * Save extension settings
602 *
603 * @param array $settings Settings to save
604 * @return bool
605 */
606 public function save_settings(array $settings): bool
607 {
608 return update_option('kng_fomo_settings', $settings);
609 }
610
611 /**
612 * Get active notifications that should display on current page
613 *
614 * @return array
615 */
616 public function get_active_notifications_for_page(): array
617 {
618 $all = $this->get_all_notifications([
619 'meta_key' => '_kng_fomo_status',
620 'meta_value' => 'enabled',
621 ]);
622
623 $active = [];
624 foreach ($all as $notification) {
625 if ($this->should_display_notification($notification)) {
626 $active[] = $notification;
627 }
628 }
629
630 return $active;
631 }
632
633 /**
634 * Check if notification should display on current page
635 *
636 * @param array $notification Notification data
637 * @return bool
638 */
639 private function should_display_notification(array $notification): bool
640 {
641 $display = $notification['display'];
642
643 // Check device visibility
644 $customize = $notification['customize'];
645 $visibility = $customize['visibility'] ?? ['desktop' => true, 'tablet' => true, 'mobile' => true];
646 if (!$this->check_device_visibility($visibility)) {
647 return false;
648 }
649
650 // Legacy show_on rules
651 $show_on = $display['show_on'] ?? 'everywhere';
652 if ($show_on === 'include' && !empty($display['include_pages'])) {
653 if (!$this->is_current_page_in_list($display['include_pages'])) {
654 return false;
655 }
656 } elseif ($show_on === 'exclude' && !empty($display['exclude_pages'])) {
657 if ($this->is_current_page_in_list($display['exclude_pages'])) {
658 return false;
659 }
660 }
661
662 // Wizard page rules: pages + page_rules
663 $pages_mode = $display['pages'] ?? 'all';
664 if ($pages_mode === 'specific') {
665 $page_rules = $display['page_rules'] ?? [];
666 if (is_array($page_rules) && !empty($page_rules)) {
667 $has_include = false;
668 $include_matched = false;
669
670 foreach ($page_rules as $rule) {
671 if (!is_array($rule)) {
672 continue;
673 }
674
675 $type = sanitize_text_field((string)($rule['type'] ?? 'include'));
676 $matched = $this->match_page_rule($rule);
677
678 if ($type === 'exclude' && $matched) {
679 return false;
680 }
681
682 if ($type === 'include') {
683 $has_include = true;
684 if ($matched) {
685 $include_matched = true;
686 }
687 }
688 }
689
690 if ($has_include && !$include_matched) {
691 return false;
692 }
693 }
694 }
695
696 // Check display_for / audience (supports both legacy and wizard keys)
697 $display_for = $display['display_for'] ?? ($display['audience'] ?? 'everyone');
698
699 if (($display_for === 'guests' || $display_for === 'logged_out') && is_user_logged_in()) {
700 return false;
701 }
702
703 if ($display_for === 'logged_in' && !is_user_logged_in()) {
704 return false;
705 }
706
707 return true;
708 }
709
710 /**
711 * Match a single wizard page rule against current request
712 *
713 * @param array $rule Page rule
714 * @return bool
715 */
716 private function match_page_rule(array $rule): bool
717 {
718 $condition = sanitize_text_field((string)($rule['condition'] ?? 'url_contains'));
719 $value = trim((string)($rule['value'] ?? ''));
720
721 if ($value === '') {
722 return false;
723 }
724
725 if ($condition === 'page' || $condition === 'post') {
726 if (is_numeric($value)) {
727 return ((int)get_queried_object_id()) === (int)$value;
728 }
729 return false;
730 }
731
732 $request_uri = isset($_SERVER['REQUEST_URI']) ? wp_unslash((string)$_SERVER['REQUEST_URI']) : '';
733 $current_url = strtolower(home_url($request_uri));
734 $needle = strtolower($value);
735
736 if ($condition === 'url_is') {
737 // Exact full URL
738 if ($current_url === $needle) {
739 return true;
740 }
741
742 // Exact path (with or without leading slash)
743 $current_path = strtolower((string)wp_parse_url($current_url, PHP_URL_PATH));
744 $needle_path = strtolower((string)wp_parse_url($needle, PHP_URL_PATH));
745 if ($needle_path === '' && strpos($needle, '/') === false) {
746 $needle_path = '/' . ltrim($needle, '/');
747 }
748 return $needle_path !== '' && $current_path === $needle_path;
749 }
750
751 // Default: url_contains
752 return strpos($current_url, $needle) !== false;
753 }
754
755 /**
756 * Check device visibility based on screen width
757 *
758 * @param array $visibility Visibility settings
759 * @return bool
760 */
761 private function check_device_visibility(array $visibility): bool
762 {
763 // Server-side we can't detect device, so return true and let JS handle it
764 return true;
765 }
766
767 /**
768 * Check if current page is in the given list
769 *
770 * @param array $page_list List of page IDs
771 * @return bool
772 */
773 private function is_current_page_in_list(array $page_list): bool
774 {
775 $current_id = get_queried_object_id();
776 return in_array($current_id, $page_list, true);
777 }
778
779 /**
780 * Render notifications on frontend
781 *
782 * @return void
783 */
784 public function render_notifications(): void
785 {
786 // Notifications data is passed via wp_localize_script in enqueue_frontend_assets().
787 // This method only outputs the container div for popup notifications.
788 $notifications = $this->get_active_notifications_for_page();
789 if (empty($notifications)) {
790 return;
791 }
792
793 echo '<div id="kng-fomo-container"></div>';
794 }
795
796 /**
797 * Prepare notification data for frontend rendering
798 *
799 * @param array $notification Notification data
800 * @return array
801 */
802 private function prepare_notification_for_frontend(array $notification): array
803 {
804 $content = $this->get_notification_content($notification);
805 $design = $notification['design'];
806 $customize = $notification['customize'];
807 $display = $notification['display'];
808
809 // Ensure loop is always set (default true for cycling)
810 if (!isset($display['loop'])) {
811 $display['loop'] = true;
812 }
813
814 return [
815 'id' => $notification['id'],
816 'type' => $notification['type'],
817 'design' => $design,
818 'content' => $content,
819 'customize' => $customize,
820 'display' => $display,
821 // Flat fields for frontend.js convenience
822 'title' => $content['title'] ?? '',
823 'message' => $content['message'] ?? '',
824 'image' => $content['image'] ?? '',
825 'image_style' => $content['image_type'] ?? 'product',
826 'time_text' => $content['time_text'] ?? '',
827 'cta_text' => $content['cta_text'] ?? '',
828 'cta_url' => $content['cta_url'] ?? '',
829 'click_url' => ($customize['click_action'] ?? 'link') === 'link' ? ($content['cta_url'] ?? '') : '',
830 'click_target' => '_self',
831 'bg_color' => $design['bg_color'] ?? '#ffffff',
832 'text_color' => $design['text_color'] ?? '#1d1d1f',
833 'accent_color' => $design['accent_color'] ?? '#0071e3',
834 'animation' => $design['animation'] ?? 'slide',
835 'display_time' => (($display['duration'] ?? 5) * 1000),
836 'sound' => !empty($customize['sound']) ? 'pop' : false,
837 'device' => 'all',
838 'bar_position' => $design['position'] ?? 'top',
839 'page_rules' => $display['page_rules'] ?? null,
840 // Items for dynamic notifications (WooCommerce, comments, etc.)
841 'items' => $content['items'] ?? [],
842 ];
843 }
844
845 /**
846 * Get default content templates and source config for a notification type.
847 *
848 * These are used when the user hasn't provided custom templates, and as
849 * fallback data when real sources return empty.
850 *
851 * @param string $type Notification type
852 * @return array { content_defaults: array, source_config_defaults: array, fallback_items: array }
853 */
854 public static function get_type_defaults(string $type): array
855 {
856 $defaults = [
857 'notification_bar' => [
858 'content_defaults' => [
859 'title' => '',
860 'message' => '',
861 ],
862 'source_config_defaults' => [],
863 'fallback_items' => [],
864 ],
865 'woocommerce_sales' => [
866 'content_defaults' => [
867 'title' => '{{name}}',
868 'message' => 'just purchased {{product}}',
869 ],
870 'source_config_defaults' => [
871 'order_status' => 'any',
872 'time_range' => '7d',
873 'limit' => 10,
874 ],
875 'fallback_items' => [
876 [
877 'username' => 'Sarah',
878 'product' => 'Premium Bundle',
879 'product_url' => '#',
880 'product_image' => '',
881 'location' => 'New York, US',
882 'time' => time() - 180,
883 'time_ago' => '3 mins ago',
884 ],
885 [
886 'username' => 'Michael',
887 'product' => 'Starter Pack',
888 'product_url' => '#',
889 'product_image' => '',
890 'location' => 'London, UK',
891 'time' => time() - 720,
892 'time_ago' => '12 mins ago',
893 ],
894 [
895 'username' => 'Emma',
896 'product' => 'Annual Plan',
897 'product_url' => '#',
898 'product_image' => '',
899 'location' => 'Toronto, CA',
900 'time' => time() - 1800,
901 'time_ago' => '30 mins ago',
902 ],
903 [
904 'username' => 'James',
905 'product' => 'Pro License',
906 'product_url' => '#',
907 'product_image' => '',
908 'location' => 'Sydney, AU',
909 'time' => time() - 3600,
910 'time_ago' => '1 hour ago',
911 ],
912 [
913 'username' => 'Lisa',
914 'product' => 'Business Suite',
915 'product_url' => '#',
916 'product_image' => '',
917 'location' => 'Berlin, DE',
918 'time' => time() - 7200,
919 'time_ago' => '2 hours ago',
920 ],
921 ],
922 ],
923 'wordpress_comments' => [
924 'content_defaults' => [
925 'title' => '{{name}}',
926 'message' => 'commented on {{product}}',
927 ],
928 'source_config_defaults' => [
929 'post_types' => ['post'],
930 'comments_count' => 10,
931 ],
932 'fallback_items' => [
933 [
934 'username' => 'Alex',
935 'content' => 'Great article! Very helpful.',
936 'post_title' => 'Getting Started Guide',
937 'post_url' => '#',
938 'avatar' => '',
939 'time' => time() - 300,
940 'time_ago' => '5 mins ago',
941 ],
942 [
943 'username' => 'Maria',
944 'content' => 'Thanks for sharing this!',
945 'post_title' => 'Tips & Tricks',
946 'post_url' => '#',
947 'avatar' => '',
948 'time' => time() - 900,
949 'time_ago' => '15 mins ago',
950 ],
951 [
952 'username' => 'David',
953 'content' => 'Exactly what I was looking for.',
954 'post_title' => 'Complete Tutorial',
955 'post_url' => '#',
956 'avatar' => '',
957 'time' => time() - 2700,
958 'time_ago' => '45 mins ago',
959 ],
960 ],
961 ],
962 'wporg_downloads' => [
963 'content_defaults' => [
964 'title' => '{{name}}',
965 'message' => '{{active_installs}} active installs',
966 ],
967 'source_config_defaults' => [
968 'wporg_slug' => '',
969 'wporg_type' => 'plugin',
970 'data_type' => 'downloads',
971 ],
972 'fallback_items' => [],
973 ],
974 'reviews' => [
975 'content_defaults' => [
976 'title' => '{{name}}',
977 'message' => 'left a review on {{product}}',
978 ],
979 'source_config_defaults' => [],
980 'fallback_items' => [
981 [
982 'username' => 'John',
983 'product' => 'Premium Plugin',
984 'post_title' => 'Premium Plugin',
985 'product_url' => '#',
986 'avatar' => '',
987 'time' => time() - 600,
988 'time_ago' => '10 mins ago',
989 ],
990 ],
991 ],
992 'email_subscription' => [
993 'content_defaults' => [
994 'title' => '{{name}}',
995 'message' => 'just subscribed to the newsletter',
996 ],
997 'source_config_defaults' => [],
998 'fallback_items' => [
999 [
1000 'username' => 'Subscriber',
1001 'email' => 'user@example.com',
1002 'avatar' => '',
1003 'time' => time() - 120,
1004 'time_ago' => '2 mins ago',
1005 ],
1006 ],
1007 ],
1008 'donations' => [
1009 'content_defaults' => [
1010 'title' => '{{name}}',
1011 'message' => 'just donated',
1012 ],
1013 'source_config_defaults' => [],
1014 'fallback_items' => [
1015 [
1016 'username' => 'Donor',
1017 'product' => '$25',
1018 'time' => time() - 240,
1019 'time_ago' => '4 mins ago',
1020 ],
1021 ],
1022 ],
1023 'custom_csv' => [
1024 'content_defaults' => [
1025 'title' => '{{name}}',
1026 'message' => '{{content}}',
1027 ],
1028 'source_config_defaults' => [],
1029 'fallback_items' => [],
1030 ],
1031 ];
1032
1033 return $defaults[$type] ?? [
1034 'content_defaults' => ['title' => '', 'message' => ''],
1035 'source_config_defaults' => [],
1036 'fallback_items' => [],
1037 ];
1038 }
1039
1040 /**
1041 * Get notification content with dynamic data
1042 *
1043 * @param array $notification Notification data
1044 * @return array
1045 */
1046 private function get_notification_content(array $notification): array
1047 {
1048 $content = $notification['content'];
1049 $source = $notification['source'];
1050 $type = $notification['type'];
1051 $source_config = $notification['source_config'];
1052
1053 // Overlay type-specific content defaults when user hasn't provided templates.
1054 $type_defaults = self::get_type_defaults($type);
1055 $cd = $type_defaults['content_defaults'];
1056
1057 // If title doesn't contain a {{placeholder}}, overlay the type default
1058 if (!empty($cd['title']) && (empty($content['title']) || strpos($content['title'], '{{') === false)) {
1059 $content['title'] = $cd['title'];
1060 }
1061 if (!empty($cd['message']) && (empty($content['message']) || strpos($content['message'], '{{') === false)) {
1062 $content['message'] = $cd['message'];
1063 }
1064
1065 // For dynamic sources, fetch real data.
1066 // The wizard sets source = type, so we match both legacy and current values.
1067 $items = [];
1068 if ($source === 'woocommerce' || $source === 'woocommerce_sales' || $type === 'woocommerce_sales') {
1069 $items = $this->get_woocommerce_data($source_config);
1070 } elseif ($source === 'comments' || $source === 'wordpress_comments' || $type === 'wordpress_comments') {
1071 $items = $this->get_comments_data($source_config);
1072 } elseif ($source === 'wporg' || $source === 'wporg_downloads' || $type === 'wporg_downloads') {
1073 $items = $this->get_wporg_data($source_config);
1074 }
1075
1076 // Fallback: use demo/sample items when real source returned nothing
1077 if (empty($items) && !empty($type_defaults['fallback_items'])) {
1078 $items = $type_defaults['fallback_items'];
1079 // Mark as demo data so frontend can optionally indicate it
1080 foreach ($items as &$item) {
1081 $item['_demo'] = true;
1082 }
1083 unset($item);
1084 }
1085
1086 $content['items'] = $items;
1087
1088 return $content;
1089 }
1090
1091 /**
1092 * Get WooCommerce recent orders data
1093 *
1094 * @param array $config Source configuration
1095 * @return array
1096 */
1097 private function get_woocommerce_data(array $config): array
1098 {
1099 if (!class_exists('WooCommerce')) {
1100 return [];
1101 }
1102
1103 $limit = (int)($config['limit'] ?? 10);
1104
1105 $days = isset($config['days']) ? (int)$config['days'] : 0;
1106 if ($days <= 0 && !empty($config['time_range'])) {
1107 $time_range = sanitize_text_field((string)$config['time_range']);
1108 $days_map = [
1109 '24h' => 1,
1110 '7d' => 7,
1111 '30d' => 30,
1112 ];
1113 $days = $days_map[$time_range] ?? 7;
1114 }
1115 if ($days <= 0) {
1116 $days = 7;
1117 }
1118
1119 $order_status = sanitize_text_field((string)($config['order_status'] ?? 'any'));
1120 $status = ['wc-completed', 'wc-processing'];
1121 if ($order_status === 'completed') {
1122 $status = ['wc-completed'];
1123 } elseif ($order_status === 'processing') {
1124 $status = ['wc-processing'];
1125 }
1126
1127 $args = [
1128 'limit' => $limit,
1129 'status' => $status,
1130 'date_created' => '>' . (time() - ($days * DAY_IN_SECONDS)),
1131 'orderby' => 'date',
1132 'order' => 'DESC',
1133 ];
1134
1135 $orders = wc_get_orders($args);
1136 $items = [];
1137
1138 foreach ($orders as $order) {
1139 $customer_name = $order->get_billing_first_name();
1140 if (empty($customer_name)) {
1141 $customer_name = __('Someone', 'king-addons');
1142 }
1143
1144 $products = $order->get_items();
1145 $product = reset($products);
1146 if ($product) {
1147 $product_obj = $product->get_product();
1148 $items[] = [
1149 'username' => $customer_name,
1150 'product' => $product->get_name(),
1151 'product_url' => $product_obj ? get_permalink($product_obj->get_id()) : '',
1152 'product_image' => $product_obj ? wp_get_attachment_url($product_obj->get_image_id()) : '',
1153 'location' => $order->get_billing_city() . ', ' . $order->get_billing_country(),
1154 'time' => $order->get_date_created()->getTimestamp(),
1155 'time_ago' => human_time_diff($order->get_date_created()->getTimestamp(), time()),
1156 ];
1157 }
1158 }
1159
1160 return $items;
1161 }
1162
1163 /**
1164 * Get WordPress comments data
1165 *
1166 * @param array $config Source configuration
1167 * @return array
1168 */
1169 private function get_comments_data(array $config): array
1170 {
1171 $limit = (int)($config['limit'] ?? ($config['comments_count'] ?? 10));
1172 if ($limit <= 0) {
1173 $limit = 10;
1174 }
1175 $post_scope = $config['post_scope'] ?? 'all';
1176 $post_types = $config['post_types'] ?? [];
1177 if (!is_array($post_types)) {
1178 $post_types = [$post_types];
1179 }
1180 $post_types = array_values(array_filter(array_map('sanitize_text_field', $post_types)));
1181
1182 $args = [
1183 'number' => $limit,
1184 'status' => 'approve',
1185 'orderby' => 'comment_date',
1186 'order' => 'DESC',
1187 ];
1188
1189 if (!empty($post_types)) {
1190 $args['post_type'] = $post_types;
1191 }
1192
1193 if ($post_scope !== 'all' && is_numeric($post_scope)) {
1194 $args['post_id'] = (int)$post_scope;
1195 }
1196
1197 $comments = get_comments($args);
1198 $items = [];
1199
1200 foreach ($comments as $comment) {
1201 $items[] = [
1202 'username' => $comment->comment_author,
1203 'content' => wp_trim_words($comment->comment_content, 15),
1204 'post_title' => get_the_title($comment->comment_post_ID),
1205 'post_url' => get_permalink($comment->comment_post_ID),
1206 'avatar' => get_avatar_url($comment->comment_author_email, ['size' => 64]),
1207 'time' => strtotime($comment->comment_date),
1208 'time_ago' => human_time_diff(strtotime($comment->comment_date), time()),
1209 ];
1210 }
1211
1212 return $items;
1213 }
1214
1215 /**
1216 * Get WordPress.org plugin/theme data
1217 *
1218 * @param array $config Source configuration
1219 * @return array
1220 */
1221 private function get_wporg_data(array $config): array
1222 {
1223 $slug = sanitize_text_field((string)($config['slug'] ?? ($config['wporg_slug'] ?? '')));
1224 $type = sanitize_text_field((string)($config['product_type'] ?? ($config['wporg_type'] ?? 'plugin')));
1225 $data_type = sanitize_text_field((string)($config['data_type'] ?? 'downloads'));
1226
1227 if (empty($slug)) {
1228 return [];
1229 }
1230
1231 $cache_key = 'kng_fomo_wporg_' . md5($slug . $type . $data_type);
1232 $cached = get_transient($cache_key);
1233 if ($cached !== false) {
1234 return $cached;
1235 }
1236
1237 $api_url = $type === 'plugin'
1238 ? 'https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&slug=' . urlencode($slug)
1239 : 'https://api.wordpress.org/themes/info/1.2/?action=theme_information&slug=' . urlencode($slug);
1240
1241 $response = wp_remote_get($api_url, ['timeout' => 10]);
1242 if (is_wp_error($response)) {
1243 return [];
1244 }
1245
1246 $data = json_decode(wp_remote_retrieve_body($response), true);
1247 if (empty($data)) {
1248 return [];
1249 }
1250
1251 $items = [];
1252 if ($data_type === 'downloads') {
1253 $items[] = [
1254 'name' => $data['name'] ?? $slug,
1255 'downloads' => number_format($data['downloaded'] ?? 0),
1256 'active_installs' => number_format($data['active_installs'] ?? 0),
1257 'rating' => $data['rating'] ?? 0,
1258 'num_ratings' => $data['num_ratings'] ?? 0,
1259 ];
1260 }
1261
1262 $cache_ttl = $config['cache_ttl'] ?? 3600;
1263 set_transient($cache_key, $items, $cache_ttl);
1264
1265 return $items;
1266 }
1267
1268 /**
1269 * AJAX: Save notification
1270 *
1271 * @return void
1272 */
1273 public function ajax_save_notification(): void
1274 {
1275 check_ajax_referer('kng_fomo_admin', 'nonce');
1276
1277 if (!current_user_can('manage_options')) {
1278 wp_send_json_error(['message' => __('Permission denied.', 'king-addons')]);
1279 }
1280
1281 $id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
1282 $title = isset($_POST['title']) ? sanitize_text_field($_POST['title']) : '';
1283 // Use separate 'name' field for post_title if provided, otherwise fall back to 'title'
1284 $name = isset($_POST['name']) ? sanitize_text_field($_POST['name']) : '';
1285 if (!empty($name)) {
1286 $title = $name;
1287 }
1288 $status = isset($_POST['status']) ? sanitize_text_field($_POST['status']) : 'disabled';
1289 $type = isset($_POST['type']) ? sanitize_text_field($_POST['type']) : 'notification_bar';
1290 $source = isset($_POST['source']) ? sanitize_text_field($_POST['source']) : 'manual';
1291 $source_config = isset($_POST['source_config']) ? $_POST['source_config'] : '{}';
1292 $design = isset($_POST['design']) ? $_POST['design'] : '{}';
1293 $content = isset($_POST['content']) ? $_POST['content'] : '{}';
1294 $display = isset($_POST['display']) ? $_POST['display'] : '{}';
1295 $customize = isset($_POST['customize']) ? $_POST['customize'] : '{}';
1296
1297 // Check free limit
1298 if (!$id && !self::hasPro()) {
1299 $count = count($this->get_all_notifications());
1300 if ($count >= self::FREE_LIMIT) {
1301 wp_send_json_error(['message' => __('Free version limit reached.', 'king-addons')]);
1302 }
1303 }
1304
1305 $post_data = [
1306 'post_title' => $title ?: __('Untitled Notification', 'king-addons'),
1307 'post_type' => self::POST_TYPE,
1308 'post_status' => 'publish',
1309 ];
1310
1311 if ($id) {
1312 $post_data['ID'] = $id;
1313 $post_id = wp_update_post($post_data);
1314 } else {
1315 $post_id = wp_insert_post($post_data);
1316 }
1317
1318 if (is_wp_error($post_id)) {
1319 wp_send_json_error(['message' => $post_id->get_error_message()]);
1320 }
1321
1322 // Save meta
1323 update_post_meta($post_id, '_kng_fomo_status', $status);
1324 update_post_meta($post_id, '_kng_fomo_type', $type);
1325 update_post_meta($post_id, '_kng_fomo_source', $source);
1326 update_post_meta($post_id, '_kng_fomo_source_config', $source_config);
1327 update_post_meta($post_id, '_kng_fomo_design', $design);
1328 update_post_meta($post_id, '_kng_fomo_content', $content);
1329 update_post_meta($post_id, '_kng_fomo_display', $display);
1330 update_post_meta($post_id, '_kng_fomo_customize', $customize);
1331
1332 wp_send_json_success([
1333 'id' => $post_id,
1334 'message' => __('Notification saved successfully!', 'king-addons'),
1335 ]);
1336 }
1337
1338 /**
1339 * AJAX: Get notification for editing
1340 *
1341 * @return void
1342 */
1343 public function ajax_get_notification(): void
1344 {
1345 check_ajax_referer('kng_fomo_admin', 'nonce');
1346
1347 if (!current_user_can('manage_options')) {
1348 wp_send_json_error(['message' => __('Permission denied.', 'king-addons')]);
1349 }
1350
1351 $id = isset($_POST['notification_id']) ? (int)$_POST['notification_id'] : 0;
1352 if (!$id) {
1353 wp_send_json_error(['message' => __('Invalid notification ID.', 'king-addons')]);
1354 }
1355
1356 $post = get_post($id);
1357 if (!$post || $post->post_type !== self::POST_TYPE) {
1358 wp_send_json_error(['message' => __('Notification not found.', 'king-addons')]);
1359 }
1360
1361 $data = [
1362 'notification_id' => $id,
1363 'type' => get_post_meta($id, '_kng_fomo_type', true) ?: 'notification_bar',
1364 'source' => get_post_meta($id, '_kng_fomo_source', true) ?: 'manual',
1365 'source_config' => json_decode(get_post_meta($id, '_kng_fomo_source_config', true) ?: '{}', true),
1366 'design' => json_decode(get_post_meta($id, '_kng_fomo_design', true) ?: '{}', true),
1367 'content' => json_decode(get_post_meta($id, '_kng_fomo_content', true) ?: '{}', true),
1368 'display' => json_decode(get_post_meta($id, '_kng_fomo_display', true) ?: '{}', true),
1369 'customize' => json_decode(get_post_meta($id, '_kng_fomo_customize', true) ?: '{}', true),
1370 ];
1371
1372 // Provide the notification name separately from content.title
1373 $data['name'] = $post->post_title;
1374
1375 wp_send_json_success($data);
1376 }
1377
1378 /**
1379 * AJAX: Delete notification
1380 *
1381 * @return void
1382 */
1383 public function ajax_delete_notification(): void
1384 {
1385 check_ajax_referer('kng_fomo_admin', 'nonce');
1386
1387 if (!current_user_can('manage_options')) {
1388 wp_send_json_error(['message' => __('Permission denied.', 'king-addons')]);
1389 }
1390
1391 $id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
1392 if (!$id) {
1393 wp_send_json_error(['message' => __('Invalid notification ID.', 'king-addons')]);
1394 }
1395
1396 $result = wp_delete_post($id, true);
1397 if (!$result) {
1398 wp_send_json_error(['message' => __('Failed to delete notification.', 'king-addons')]);
1399 }
1400
1401 wp_send_json_success(['message' => __('Notification deleted.', 'king-addons')]);
1402 }
1403
1404 /**
1405 * AJAX: Toggle notification status
1406 *
1407 * @return void
1408 */
1409 public function ajax_toggle_status(): void
1410 {
1411 check_ajax_referer('kng_fomo_admin', 'nonce');
1412
1413 if (!current_user_can('manage_options')) {
1414 wp_send_json_error(['message' => __('Permission denied.', 'king-addons')]);
1415 }
1416
1417 $id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
1418 $status = isset($_POST['status']) ? sanitize_text_field($_POST['status']) : 'disabled';
1419
1420 if (!$id) {
1421 wp_send_json_error(['message' => __('Invalid notification ID.', 'king-addons')]);
1422 }
1423
1424 update_post_meta($id, '_kng_fomo_status', $status);
1425
1426 wp_send_json_success(['message' => __('Status updated.', 'king-addons')]);
1427 }
1428
1429 /**
1430 * AJAX: Duplicate notification
1431 *
1432 * @return void
1433 */
1434 public function ajax_duplicate_notification(): void
1435 {
1436 check_ajax_referer('kng_fomo_admin', 'nonce');
1437
1438 if (!current_user_can('manage_options')) {
1439 wp_send_json_error(['message' => __('Permission denied.', 'king-addons')]);
1440 }
1441
1442 $id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
1443 $original = $this->get_notification($id);
1444
1445 if (!$original) {
1446 wp_send_json_error(['message' => __('Notification not found.', 'king-addons')]);
1447 }
1448
1449 // Check free limit
1450 if (!self::hasPro()) {
1451 $count = count($this->get_all_notifications());
1452 if ($count >= self::FREE_LIMIT) {
1453 wp_send_json_error(['message' => __('Free version limit reached.', 'king-addons')]);
1454 }
1455 }
1456
1457 $new_id = wp_insert_post([
1458 'post_title' => $original['title'] . ' ' . __('(Copy)', 'king-addons'),
1459 'post_type' => self::POST_TYPE,
1460 'post_status' => 'publish',
1461 ]);
1462
1463 if (is_wp_error($new_id)) {
1464 wp_send_json_error(['message' => $new_id->get_error_message()]);
1465 }
1466
1467 // Copy meta
1468 update_post_meta($new_id, '_kng_fomo_status', 'disabled');
1469 update_post_meta($new_id, '_kng_fomo_type', $original['type']);
1470 update_post_meta($new_id, '_kng_fomo_source', $original['source']);
1471 update_post_meta($new_id, '_kng_fomo_source_config', wp_json_encode($original['source_config']));
1472 update_post_meta($new_id, '_kng_fomo_design', wp_json_encode($original['design']));
1473 update_post_meta($new_id, '_kng_fomo_content', wp_json_encode($original['content']));
1474 update_post_meta($new_id, '_kng_fomo_display', wp_json_encode($original['display']));
1475 update_post_meta($new_id, '_kng_fomo_customize', wp_json_encode($original['customize']));
1476
1477 wp_send_json_success([
1478 'id' => $new_id,
1479 'message' => __('Notification duplicated.', 'king-addons'),
1480 ]);
1481 }
1482
1483 /**
1484 * AJAX: Save settings
1485 *
1486 * @return void
1487 */
1488 public function ajax_save_settings(): void
1489 {
1490 check_ajax_referer('kng_fomo_admin', 'nonce');
1491
1492 if (!current_user_can('manage_options')) {
1493 wp_send_json_error(['message' => __('Permission denied.', 'king-addons')]);
1494 }
1495
1496 $raw_settings = isset($_POST['settings']) ? $_POST['settings'] : [];
1497
1498 // If settings were sent as JSON string, decode them
1499 if (is_string($raw_settings)) {
1500 $raw_settings = json_decode(stripslashes($raw_settings), true);
1501 if (!is_array($raw_settings)) {
1502 $raw_settings = [];
1503 }
1504 }
1505
1506 // Sanitize
1507 $sanitized = [
1508 'enabled' => !empty($raw_settings['enabled']),
1509 'tracking_enabled' => !empty($raw_settings['tracking_enabled']),
1510 'track_for' => sanitize_text_field($raw_settings['track_for'] ?? 'everyone'),
1511 'exclude_bots' => !empty($raw_settings['exclude_bots']),
1512 'cache_ttl' => (int)($raw_settings['cache_ttl'] ?? 300),
1513 'anonymize_names' => !empty($raw_settings['anonymize_names']),
1514 'sound_volume' => (int)($raw_settings['sound_volume'] ?? 50),
1515 'modules' => [],
1516 ];
1517
1518 if (!empty($raw_settings['modules']) && is_array($raw_settings['modules'])) {
1519 foreach ($raw_settings['modules'] as $module => $enabled) {
1520 $sanitized['modules'][sanitize_key($module)] = !empty($enabled);
1521 }
1522 }
1523
1524 $this->save_settings($sanitized);
1525
1526 wp_send_json_success(['message' => __('Settings saved.', 'king-addons')]);
1527 }
1528
1529 /**
1530 * AJAX: Track event (view or click)
1531 *
1532 * @return void
1533 */
1534 public function ajax_track_event(): void
1535 {
1536 // Verify nonce
1537 check_ajax_referer('kng_fomo_frontend', 'nonce');
1538
1539 // Rate limiting
1540 $ip = $_SERVER['REMOTE_ADDR'] ?? '';
1541 $rate_key = 'kng_fomo_rate_' . md5($ip);
1542 $rate = get_transient($rate_key);
1543 if ($rate && $rate > 100) {
1544 wp_send_json_error(['message' => 'Rate limit exceeded']);
1545 }
1546 set_transient($rate_key, ($rate ?: 0) + 1, MINUTE_IN_SECONDS);
1547
1548 $notification_id = isset($_POST['notification_id']) ? (int)$_POST['notification_id'] : 0;
1549 $event_type = isset($_POST['event_type']) ? sanitize_text_field($_POST['event_type']) : '';
1550
1551 if (!$notification_id || !in_array($event_type, ['view', 'click'], true)) {
1552 wp_send_json_error(['message' => 'Invalid request']);
1553 }
1554
1555 $settings = $this->get_settings();
1556 if (empty($settings['tracking_enabled'])) {
1557 wp_send_json_success();
1558 }
1559
1560 // Check audience
1561 $track_for = $settings['track_for'] ?? 'everyone';
1562 if ($track_for === 'guests' && is_user_logged_in()) {
1563 wp_send_json_success();
1564 }
1565 if ($track_for === 'logged_in' && !is_user_logged_in()) {
1566 wp_send_json_success();
1567 }
1568
1569 // Update total counts
1570 $meta_key = $event_type === 'view' ? '_kng_fomo_views' : '_kng_fomo_clicks';
1571 $current = (int)get_post_meta($notification_id, $meta_key, true);
1572 update_post_meta($notification_id, $meta_key, $current + 1);
1573
1574 // Update daily stats
1575 global $wpdb;
1576 $table = $wpdb->prefix . self::STATS_TABLE;
1577 $today = current_time('Y-m-d');
1578 $column = $event_type === 'view' ? 'views' : 'clicks';
1579
1580 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1581 $wpdb->query($wpdb->prepare(
1582 "INSERT INTO $table (notification_id, stat_date, $column) VALUES (%d, %s, 1)
1583 ON DUPLICATE KEY UPDATE $column = $column + 1",
1584 $notification_id,
1585 $today
1586 ));
1587
1588 wp_send_json_success();
1589 }
1590
1591 /**
1592 * Get total stats for all notifications
1593 *
1594 * @return array
1595 */
1596 public function get_total_stats(): array
1597 {
1598 global $wpdb;
1599 $table_name = $wpdb->prefix . self::STATS_TABLE;
1600
1601 // Check if table exists
1602 if ($wpdb->get_var("SHOW TABLES LIKE '$table_name'") != $table_name) {
1603 return [
1604 'views' => 0,
1605 'clicks' => 0,
1606 'ctr' => 0,
1607 ];
1608 }
1609
1610 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1611 $stats = $wpdb->get_row("SELECT SUM(views) as views, SUM(clicks) as clicks FROM $table_name", ARRAY_A);
1612
1613 $views = isset($stats['views']) ? (int)$stats['views'] : 0;
1614 $clicks = isset($stats['clicks']) ? (int)$stats['clicks'] : 0;
1615 $ctr = $views > 0 ? round(($clicks / $views) * 100, 2) : 0;
1616
1617 return [
1618 'views' => $views,
1619 'clicks' => $clicks,
1620 'ctr' => $ctr,
1621 'views_change' => 0,
1622 'clicks_change' => 0,
1623 ];
1624 }
1625
1626 /**
1627 * AJAX: Get analytics data
1628 *
1629 * @return void
1630 */
1631 public function ajax_get_analytics(): void
1632 {
1633 check_ajax_referer('kng_fomo_admin', 'nonce');
1634
1635 if (!current_user_can('manage_options')) {
1636 wp_send_json_error(['message' => __('Permission denied.', 'king-addons')]);
1637 }
1638
1639 global $wpdb;
1640 $table = $wpdb->prefix . self::STATS_TABLE;
1641
1642 $period = isset($_POST['period']) ? sanitize_text_field($_POST['period']) : '7days';
1643 if (empty($period) && isset($_POST['range'])) {
1644 $period = sanitize_text_field($_POST['range']);
1645 }
1646 $notification_id = isset($_POST['notification_id']) ? (int)$_POST['notification_id'] : 0;
1647
1648 // Calculate date range
1649 $end_date = current_time('Y-m-d');
1650 switch ($period) {
1651 case '30days':
1652 $start_date = wp_date('Y-m-d', strtotime('-30 days'));
1653 break;
1654 case '90days':
1655 $start_date = wp_date('Y-m-d', strtotime('-90 days'));
1656 break;
1657 case 'year':
1658 $start_date = wp_date('Y-01-01');
1659 break;
1660 case '7days':
1661 default:
1662 $start_date = wp_date('Y-m-d', strtotime('-7 days'));
1663 break;
1664 }
1665
1666 // Build query
1667 $where = $wpdb->prepare("WHERE stat_date BETWEEN %s AND %s", $start_date, $end_date);
1668 if ($notification_id) {
1669 $where .= $wpdb->prepare(" AND notification_id = %d", $notification_id);
1670 }
1671
1672 // Get totals
1673 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1674 $totals = $wpdb->get_row(
1675 "SELECT SUM(views) as views, SUM(clicks) as clicks FROM $table $where"
1676 );
1677
1678 $total_views = (int)($totals->views ?? 0);
1679 $total_clicks = (int)($totals->clicks ?? 0);
1680 $total_ctr = $total_views > 0 ? round(($total_clicks / $total_views) * 100, 2) : 0;
1681
1682 // Get daily data for chart
1683 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1684 $daily = $wpdb->get_results(
1685 "SELECT stat_date, SUM(views) as views, SUM(clicks) as clicks
1686 FROM $table $where
1687 GROUP BY stat_date
1688 ORDER BY stat_date ASC"
1689 );
1690
1691 $chart_data = [
1692 'labels' => [],
1693 'views' => [],
1694 'clicks' => [],
1695 ];
1696
1697 foreach ($daily as $row) {
1698 $chart_data['labels'][] = wp_date('M j', strtotime($row->stat_date));
1699 $chart_data['views'][] = (int)$row->views;
1700 $chart_data['clicks'][] = (int)$row->clicks;
1701 }
1702
1703 // Get top notifications
1704 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1705 $top_raw = $wpdb->get_results(
1706 "SELECT notification_id, SUM(views) as views, SUM(clicks) as clicks
1707 FROM $table $where
1708 GROUP BY notification_id
1709 ORDER BY clicks DESC
1710 LIMIT 5"
1711 );
1712
1713 $top_notifications = [];
1714 foreach ($top_raw as $row) {
1715 $post = get_post($row->notification_id);
1716 if ($post) {
1717 $ctr = $row->views > 0 ? round(($row->clicks / $row->views) * 100, 2) : 0;
1718 $top_notifications[] = [
1719 'id' => $row->notification_id,
1720 'title' => $post->post_title,
1721 'views' => (int)$row->views,
1722 'clicks' => (int)$row->clicks,
1723 'ctr' => $ctr,
1724 ];
1725 }
1726 }
1727
1728 wp_send_json_success([
1729 'totals' => [
1730 'views' => $total_views,
1731 'clicks' => $total_clicks,
1732 'ctr' => $total_ctr,
1733 ],
1734 'chart' => $chart_data,
1735 'top' => $top_notifications,
1736 ]);
1737 }
1738
1739 /**
1740 * AJAX: Export notification
1741 *
1742 * @return void
1743 */
1744 public function ajax_export_notification(): void
1745 {
1746 check_ajax_referer('kng_fomo_admin', 'nonce');
1747
1748 if (!current_user_can('manage_options')) {
1749 wp_send_json_error(['message' => __('Permission denied.', 'king-addons')]);
1750 }
1751
1752 $id = isset($_POST['id']) ? (int)$_POST['id'] : 0;
1753
1754 // Export single notification
1755 if ($id) {
1756 $notification = $this->get_notification($id);
1757
1758 if (!$notification) {
1759 wp_send_json_error(['message' => __('Notification not found.', 'king-addons')]);
1760 }
1761
1762 // Remove stats and IDs for export
1763 unset($notification['id'], $notification['views'], $notification['clicks'], $notification['ctr']);
1764
1765 wp_send_json_success([
1766 'data' => $notification,
1767 'filename' => 'fomo-notification-' . sanitize_title($notification['title']) . '.json',
1768 ]);
1769 return;
1770 }
1771
1772 // Export all notifications
1773 $all = $this->get_all_notifications();
1774 $export = [];
1775 foreach ($all as $notification) {
1776 unset($notification['id'], $notification['views'], $notification['clicks'], $notification['ctr']);
1777 $export[] = $notification;
1778 }
1779
1780 wp_send_json_success([
1781 'data' => $export,
1782 'filename' => 'fomo-notifications-export.json',
1783 ]);
1784 }
1785
1786 /**
1787 * AJAX: Import notification
1788 *
1789 * @return void
1790 */
1791 public function ajax_import_notification(): void
1792 {
1793 check_ajax_referer('kng_fomo_admin', 'nonce');
1794
1795 if (!current_user_can('manage_options')) {
1796 wp_send_json_error(['message' => __('Permission denied.', 'king-addons')]);
1797 }
1798
1799 $data = isset($_POST['data']) ? $_POST['data'] : '';
1800 if (empty($data)) {
1801 wp_send_json_error(['message' => __('No data to import.', 'king-addons')]);
1802 }
1803
1804 $notification = json_decode(stripslashes($data), true);
1805 if (!$notification || !is_array($notification)) {
1806 wp_send_json_error(['message' => __('Invalid import data.', 'king-addons')]);
1807 }
1808
1809 // Check free limit
1810 if (!self::hasPro()) {
1811 $count = count($this->get_all_notifications());
1812 if ($count >= self::FREE_LIMIT) {
1813 wp_send_json_error(['message' => __('Free version limit reached.', 'king-addons')]);
1814 }
1815 }
1816
1817 $post_id = wp_insert_post([
1818 'post_title' => $notification['title'] ?? __('Imported Notification', 'king-addons'),
1819 'post_type' => self::POST_TYPE,
1820 'post_status' => 'publish',
1821 ]);
1822
1823 if (is_wp_error($post_id)) {
1824 wp_send_json_error(['message' => $post_id->get_error_message()]);
1825 }
1826
1827 // Save meta
1828 update_post_meta($post_id, '_kng_fomo_status', 'disabled');
1829 update_post_meta($post_id, '_kng_fomo_type', $notification['type'] ?? 'notification_bar');
1830 update_post_meta($post_id, '_kng_fomo_source', $notification['source'] ?? 'manual');
1831 update_post_meta($post_id, '_kng_fomo_source_config', wp_json_encode($notification['source_config'] ?? []));
1832 update_post_meta($post_id, '_kng_fomo_design', wp_json_encode($notification['design'] ?? []));
1833 update_post_meta($post_id, '_kng_fomo_content', wp_json_encode($notification['content'] ?? []));
1834 update_post_meta($post_id, '_kng_fomo_display', wp_json_encode($notification['display'] ?? []));
1835 update_post_meta($post_id, '_kng_fomo_customize', wp_json_encode($notification['customize'] ?? []));
1836
1837 wp_send_json_success([
1838 'id' => $post_id,
1839 'message' => __('Notification imported successfully!', 'king-addons'),
1840 ]);
1841 }
1842
1843 /**
1844 * AJAX: Fetch WordPress.org data
1845 *
1846 * @return void
1847 */
1848 public function ajax_fetch_wporg_data(): void
1849 {
1850 check_ajax_referer('kng_fomo_admin', 'nonce');
1851
1852 if (!current_user_can('manage_options')) {
1853 wp_send_json_error(['message' => __('Permission denied.', 'king-addons')]);
1854 }
1855
1856 $slug = isset($_POST['slug']) ? sanitize_text_field($_POST['slug']) : '';
1857 $type = isset($_POST['type']) ? sanitize_text_field($_POST['type']) : 'plugin';
1858
1859 if (empty($slug)) {
1860 wp_send_json_error(['message' => __('Please enter a slug.', 'king-addons')]);
1861 }
1862
1863 $data = $this->get_wporg_data([
1864 'slug' => $slug,
1865 'product_type' => $type,
1866 'data_type' => 'downloads',
1867 ]);
1868
1869 if (empty($data)) {
1870 wp_send_json_error(['message' => __('Could not fetch data. Please check the slug.', 'king-addons')]);
1871 }
1872
1873 wp_send_json_success(['data' => $data]);
1874 }
1875
1876 /**
1877 * AJAX: Purge cache
1878 *
1879 * @return void
1880 */
1881 public function ajax_purge_cache(): void
1882 {
1883 check_ajax_referer('kng_fomo_admin', 'nonce');
1884
1885 if (!current_user_can('manage_options')) {
1886 wp_send_json_error(['message' => __('Permission denied.', 'king-addons')]);
1887 }
1888
1889 global $wpdb;
1890
1891 // Delete all kng_fomo transients
1892 // phpcs:ignore WordPress.DB.DirectDatabaseQuery
1893 $wpdb->query(
1894 "DELETE FROM $wpdb->options WHERE option_name LIKE '_transient_kng_fomo_%' OR option_name LIKE '_transient_timeout_kng_fomo_%'"
1895 );
1896
1897 wp_send_json_success(['message' => __('Cache cleared successfully.', 'king-addons')]);
1898 }
1899 }
1900