PluginProbe
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder / 51.1.83
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder v51.1.83
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 / Age_Gate / Age_Gate.php

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

1,090 lines 36.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Age Gate extension - Free base class.
4 *
5 * @package King_Addons
6 */
7
8 namespace King_Addons;
9
10 if (!defined('ABSPATH')) {
11 exit;
12 }
13
14 /**
15 * Provides admin settings, frontend payload and cookie handling for Age Gate.
16 */
17 class Age_Gate
18 {
19 protected const OPTION_NAME = 'king_addons_age_gate_options';
20 protected const COOKIE_NAME = 'ka_age_gate_status';
21 protected const NONCE_ACTION = 'king_addons_age_gate_nonce';
22 protected const AJAX_ACTION_DOB = 'ka_age_gate_validate_dob';
23
24 protected static ?Age_Gate $instance = null;
25
26 /**
27 * Cached options.
28 *
29 * @var array<string, mixed>
30 */
31 protected array $options = [];
32
33 /**
34 * Singleton accessor that swaps in the Pro implementation when available.
35 *
36 * @return Age_Gate
37 */
38 public static function instance(): Age_Gate
39 {
40 if (is_null(self::$instance)) {
41 if (class_exists('\King_Addons\Age_Gate_Pro') && king_addons_freemius()->can_use_premium_code()) {
42 self::$instance = new Age_Gate_Pro();
43 } else {
44 self::$instance = new self();
45 }
46 }
47
48 return self::$instance;
49 }
50
51 /**
52 * Bootstraps the extension.
53 */
54 public function __construct()
55 {
56 $this->options = $this->get_options();
57
58 register_activation_hook(KING_ADDONS_PATH . 'king-addons.php', [$this, 'handle_activation']);
59
60 add_action('admin_init', [$this, 'register_settings']);
61 add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_assets']);
62
63 add_action('wp_enqueue_scripts', [$this, 'enqueue_front_assets']);
64 add_action('wp_footer', [$this, 'render_frontend_markup']);
65 add_action('wp_body_open', [$this, 'render_portal_container']);
66 add_action('template_redirect', [$this, 'maybe_handle_denied_redirect']);
67
68 // AJAX handler for DOB validation (Pro)
69 add_action('wp_ajax_' . self::AJAX_ACTION_DOB, [$this, 'ajax_validate_dob']);
70 add_action('wp_ajax_nopriv_' . self::AJAX_ACTION_DOB, [$this, 'ajax_validate_dob']);
71 }
72
73 /**
74 * AJAX handler for date of birth validation.
75 *
76 * @return void
77 */
78 public function ajax_validate_dob(): void
79 {
80 check_ajax_referer(self::NONCE_ACTION, 'nonce');
81
82 if (!$this->is_premium()) {
83 wp_send_json_error(['message' => esc_html__('Pro feature required.', 'king-addons')]);
84 return;
85 }
86
87 $day = isset($_POST['day']) ? absint($_POST['day']) : 0;
88 $month = isset($_POST['month']) ? absint($_POST['month']) : 0;
89 $year = isset($_POST['year']) ? absint($_POST['year']) : 0;
90
91 $dob_options = $this->options['dob'];
92 $min_age = (int) $this->options['general']['min_age'];
93 $max_age = (int) $dob_options['max_age'];
94
95 // Validate date components
96 if ($day < 1 || $day > 31 || $month < 1 || $month > 12 || $year < 1900) {
97 wp_send_json_error(['message' => $dob_options['error_invalid']]);
98 return;
99 }
100
101 // Check if date is valid
102 if (!checkdate($month, $day, $year)) {
103 wp_send_json_error(['message' => $dob_options['error_invalid']]);
104 return;
105 }
106
107 // Calculate age
108 $dob = new \DateTime(sprintf('%04d-%02d-%02d', $year, $month, $day));
109 $now = new \DateTime();
110 $age = $now->diff($dob)->y;
111
112 // Validate age range
113 if ($age < 0 || $age > $max_age) {
114 wp_send_json_error(['message' => $dob_options['error_invalid']]);
115 return;
116 }
117
118 // Check minimum age requirement
119 $required_age = $this->resolve_minimum_age();
120 if ($age < $required_age) {
121 wp_send_json_error(['message' => $dob_options['error_denied']]);
122 return;
123 }
124
125 // Set cookie server-side for extra security
126 $cookie_days = (int) $this->options['general']['cookie_days'];
127 $this->set_status_cookie('allowed', $cookie_days);
128
129 wp_send_json_success(['message' => esc_html__('Age verified.', 'king-addons')]);
130 }
131
132 /**
133 * Resolves minimum age considering geo rules.
134 *
135 * @return int
136 */
137 protected function resolve_minimum_age(): int
138 {
139 $base_age = (int) $this->options['general']['min_age'];
140
141 if (!$this->is_premium() || empty($this->options['geo']['enabled'])) {
142 return $base_age;
143 }
144
145 $geo_map = $this->options['geo']['map'];
146 $default_age = (int) $this->options['geo']['default_age'];
147 $country = $this->detect_country();
148
149 if ($country && isset($geo_map[$country])) {
150 return (int) $geo_map[$country];
151 }
152
153 return $default_age ?: $base_age;
154 }
155
156 /**
157 * Detects visitor country using WooCommerce geolocation if available.
158 *
159 * @return string Country code or empty string.
160 */
161 protected function detect_country(): string
162 {
163 // Try WooCommerce geolocation first
164 if (class_exists('WC_Geolocation')) {
165 $geo = \WC_Geolocation::geolocate_ip();
166 if (!empty($geo['country'])) {
167 return strtoupper($geo['country']);
168 }
169 }
170
171 return '';
172 }
173
174 /**
175 * Creates default options on activation.
176 *
177 * @return void
178 */
179 public function handle_activation(): void
180 {
181 if (!get_option(self::OPTION_NAME)) {
182 add_option(self::OPTION_NAME, $this->get_default_options());
183 }
184 }
185
186 /**
187 * Registers the settings entry and sanitize callback.
188 *
189 * @return void
190 */
191 public function register_settings(): void
192 {
193 register_setting('king_addons_age_gate', self::OPTION_NAME, [
194 'type' => 'array',
195 'sanitize_callback' => [$this, 'sanitize_options'],
196 ]);
197 }
198
199 /**
200 * Renders the admin settings page.
201 *
202 * @return void
203 */
204 public function render_admin_page(): void
205 {
206 if (!current_user_can('manage_options')) {
207 return;
208 }
209
210 $this->options = $this->get_options();
211 $options = $this->options;
212 $is_premium = $this->is_premium();
213
214 settings_errors('king_addons_age_gate');
215 include __DIR__ . '/templates/admin-page.php';
216 }
217
218 /**
219 * Enqueues admin assets for the Age Gate page.
220 *
221 * @param string $hook Current admin hook.
222 * @return void
223 */
224 public function enqueue_admin_assets(string $hook): void
225 {
226 if ($hook !== 'king-addons_page_king-addons-age-gate') {
227 return;
228 }
229
230 wp_enqueue_style(
231 'king-addons-age-gate-admin',
232 KING_ADDONS_URL . 'includes/extensions/Age_Gate/assets/admin.css',
233 [],
234 KING_ADDONS_VERSION
235 );
236
237 wp_enqueue_script(
238 'king-addons-age-gate-admin',
239 KING_ADDONS_URL . 'includes/extensions/Age_Gate/assets/admin.js',
240 ['jquery'],
241 KING_ADDONS_VERSION,
242 true
243 );
244 }
245
246 /**
247 * Registers and enqueues frontend assets when needed.
248 *
249 * @return void
250 */
251 public function enqueue_front_assets(): void
252 {
253 if (defined('KING_ADDONS_IS_MAINTENANCE_PAGE') && KING_ADDONS_IS_MAINTENANCE_PAGE) {
254 return;
255 }
256
257 if (is_admin()) {
258 return;
259 }
260
261 $should_render = $this->should_render() || $this->should_render_block_state() || $this->is_preview_mode();
262
263 if (!$should_render) {
264 return;
265 }
266
267 wp_register_style(
268 'king-addons-age-gate',
269 KING_ADDONS_URL . 'includes/widgets/Age_Gate/style.css',
270 [],
271 KING_ADDONS_VERSION
272 );
273
274 wp_register_script(
275 'king-addons-age-gate',
276 KING_ADDONS_URL . 'includes/widgets/Age_Gate/script.js',
277 [],
278 KING_ADDONS_VERSION,
279 true
280 );
281
282 wp_localize_script('king-addons-age-gate', 'kingAddonsAgeGate', $this->get_frontend_payload());
283
284 wp_enqueue_style('king-addons-age-gate');
285 wp_enqueue_script('king-addons-age-gate');
286 }
287
288 /**
289 * Outputs the overlay container markup.
290 *
291 * @return void
292 */
293 public function render_frontend_markup(): void
294 {
295 if (defined('KING_ADDONS_IS_MAINTENANCE_PAGE') && KING_ADDONS_IS_MAINTENANCE_PAGE) {
296 return;
297 }
298
299 if (!$this->should_render() && !$this->should_render_block_state() && !$this->is_preview_mode()) {
300 return;
301 }
302 ?>
303 <div id="king-addons-age-gate" class="king-addons-age-gate" aria-hidden="true">
304 <div class="king-addons-age-gate__overlay"></div>
305 <div class="king-addons-age-gate__card" role="dialog" aria-modal="true" aria-label="<?php echo esc_attr($this->options['design']['title']); ?>">
306 <div class="king-addons-age-gate__content"></div>
307 </div>
308 </div>
309 <?php
310 }
311
312 /**
313 * Adds an early portal container to the body for fixed overlays.
314 *
315 * @return void
316 */
317 public function render_portal_container(): void
318 {
319 if (defined('KING_ADDONS_IS_MAINTENANCE_PAGE') && KING_ADDONS_IS_MAINTENANCE_PAGE) {
320 return;
321 }
322
323 if (!$this->should_render() && !$this->should_render_block_state() && !$this->is_preview_mode()) {
324 return;
325 }
326
327 echo '<div id="king-addons-age-gate-portal" class="king-addons-age-gate__portal" aria-hidden="true"></div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
328 }
329
330 /**
331 * Performs redirect on denied state when configured.
332 *
333 * @return void
334 */
335 public function maybe_handle_denied_redirect(): void
336 {
337 if (defined('KING_ADDONS_IS_MAINTENANCE_PAGE') && KING_ADDONS_IS_MAINTENANCE_PAGE) {
338 return;
339 }
340
341 if (is_admin() || wp_doing_ajax()) {
342 return;
343 }
344
345 if (!$this->is_enabled()) {
346 return;
347 }
348
349 $status = $this->get_cookie_status();
350 $behaviour = $this->options['behaviour'];
351
352 if ($status !== 'denied' || !$this->is_deny_action_redirect($behaviour['deny_action'] ?? '')) {
353 return;
354 }
355
356 $deny_action = (string) ($behaviour['deny_action'] ?? '');
357 if ($deny_action === 'redirect_page' || $deny_action === 'redirect') {
358 $redirect_id = isset($behaviour['deny_redirect_page']) ? (int) $behaviour['deny_redirect_page'] : 0;
359 if ($redirect_id && is_page($redirect_id)) {
360 return;
361 }
362 }
363
364 $url = $this->resolve_deny_redirect_url();
365 if (!$url) {
366 return;
367 }
368
369 $request_uri = isset($_SERVER['REQUEST_URI']) ? (string) wp_unslash($_SERVER['REQUEST_URI']) : '';
370 if ($request_uri) {
371 $current_url = home_url($request_uri);
372 if (untrailingslashit($current_url) === untrailingslashit($url)) {
373 return;
374 }
375 }
376
377 $site_host = wp_parse_url(home_url(), PHP_URL_HOST);
378 $target_host = wp_parse_url($url, PHP_URL_HOST);
379
380 // Prefer safe redirects for internal destinations; allow external URLs when configured by admin.
381 if ($site_host && $target_host && strtolower((string) $site_host) === strtolower((string) $target_host)) {
382 wp_safe_redirect($url);
383 } else {
384 wp_redirect($url);
385 }
386 exit;
387 }
388
389 /**
390 * Determines whether the overlay should render.
391 *
392 * @return bool
393 */
394 public function should_render(): bool
395 {
396 if (defined('KING_ADDONS_IS_MAINTENANCE_PAGE') && KING_ADDONS_IS_MAINTENANCE_PAGE) {
397 return false;
398 }
399
400 if (is_admin() || wp_doing_ajax()) {
401 return false;
402 }
403
404 if (!$this->is_enabled()) {
405 return false;
406 }
407
408 if ($this->is_preview_mode()) {
409 return true;
410 }
411
412 if (!$this->passes_display_rules()) {
413 return false;
414 }
415
416 if ($this->is_excluded_page()) {
417 return false;
418 }
419
420 $status = $this->get_cookie_status();
421
422 if ($status === 'allowed') {
423 return false;
424 }
425
426 if ($status === 'denied' && $this->is_deny_action_redirect($this->options['behaviour']['deny_action'] ?? '') && $this->has_deny_redirect_target()) {
427 return false;
428 }
429
430 return true;
431 }
432
433 /**
434 * Indicates that a blocked state still needs markup (deny blocking).
435 *
436 * @return bool
437 */
438 protected function should_render_block_state(): bool
439 {
440 if (defined('KING_ADDONS_IS_MAINTENANCE_PAGE') && KING_ADDONS_IS_MAINTENANCE_PAGE) {
441 return false;
442 }
443
444 if (!$this->is_enabled()) {
445 return false;
446 }
447
448 $status = $this->get_cookie_status();
449 return $status === 'denied' && (($this->options['behaviour']['deny_action'] ?? '') === 'block');
450 }
451
452 /**
453 * Checks if general display rules allow the gate.
454 *
455 * @return bool
456 */
457 protected function passes_display_rules(): bool
458 {
459 $general = $this->options['general'];
460 $display = $this->options['display'];
461
462 if ($general['audience'] === 'guests' && is_user_logged_in()) {
463 return false;
464 }
465
466 $scope = $display['scope'];
467
468 if ($scope === 'posts') {
469 return is_singular('post');
470 }
471
472 if ($scope === 'pages') {
473 return $this->is_pages_scope_match();
474 }
475
476 return true;
477 }
478
479 /**
480 * Pages scope includes WordPress pages and the WooCommerce shop archive.
481 *
482 * WooCommerce shop is a product archive, so is_page() is false there even
483 * when the Shop page ID is selected as a page.
484 */
485 protected function is_pages_scope_match(): bool
486 {
487 if (is_page()) {
488 return true;
489 }
490
491 return function_exists('is_shop') && is_shop();
492 }
493
494 /**
495 * Checks if current page is excluded from gating.
496 *
497 * @return bool
498 */
499 protected function is_excluded_page(): bool
500 {
501 $exclude_ids = array_map('absint', $this->options['display']['exclude_ids']);
502
503 if (function_exists('is_shop') && is_shop() && function_exists('wc_get_page_id')) {
504 $shop_id = (int) wc_get_page_id('shop');
505 if ($shop_id && in_array($shop_id, $exclude_ids, true)) {
506 return true;
507 }
508 }
509
510 if (!is_singular()) {
511 return false;
512 }
513
514 $current_id = get_the_ID();
515
516 if (in_array($current_id, $exclude_ids, true)) {
517 return true;
518 }
519
520 $deny_redirect_id = isset($this->options['behaviour']['deny_redirect_page']) ? (int)$this->options['behaviour']['deny_redirect_page'] : 0;
521 if ($deny_redirect_id && $deny_redirect_id === $current_id) {
522 return true;
523 }
524
525 return false;
526 }
527
528 /**
529 * Builds the frontend payload localized to JS.
530 *
531 * @return array<string, mixed>
532 */
533 public function get_frontend_payload(): array
534 {
535 $design = $this->options['design'];
536 $behaviour = $this->options['behaviour'];
537 $general = $this->options['general'];
538 $dob = $this->options['dob'];
539
540 $deny_redirect_id = isset($behaviour['deny_redirect_page']) ? (int) $behaviour['deny_redirect_page'] : 0;
541 $deny_redirect_url = $this->resolve_deny_redirect_url();
542 $deny_action_raw = (string) ($behaviour['deny_action'] ?? 'block');
543 $deny_action = $this->is_deny_action_redirect($deny_action_raw) ? 'redirect' : 'block';
544 if ($deny_action === 'redirect' && !$deny_redirect_url) {
545 $deny_action = 'block';
546 }
547
548 return [
549 'enabled' => $this->is_enabled(),
550 'mode' => $general['mode'],
551 'minAge' => $this->resolve_minimum_age(),
552 'texts' => [
553 'title' => $design['title'],
554 'subtitle' => $design['subtitle'],
555 'yes' => $design['button_yes'],
556 'no' => $design['button_no'],
557 'block' => $behaviour['block_message'],
558 ],
559 'design' => [
560 'template' => $design['template'],
561 'overlayColor' => $design['overlay_color'],
562 'overlayOpacity' => $design['overlay_opacity'],
563 'cardBackground' => $design['card_background'],
564 'cardWidth' => $design['card_width'],
565 'cardAlign' => $design['card_align'],
566 'textColor' => $design['text_color'],
567 'titleSize' => $design['title_size'],
568 'bodySize' => $design['body_size'],
569 'titleWeight' => $design['title_weight'],
570 'bodyWeight' => $design['body_weight'],
571 'buttonYesColor' => $design['button_yes_color'],
572 'buttonYesBg' => $design['button_yes_bg'],
573 'buttonNoColor' => $design['button_no_color'],
574 'buttonNoBg' => $design['button_no_bg'],
575 'buttonYesHoverBg' => $design['button_yes_hover_bg'] ?? $design['button_yes_bg'],
576 'buttonYesHoverColor' => $design['button_yes_hover_color'] ?? $design['button_yes_color'],
577 'buttonNoHoverBg' => $design['button_no_hover_bg'] ?? $design['button_no_bg'],
578 'buttonNoHoverColor' => $design['button_no_hover_color'] ?? $design['button_no_color'],
579 'animation' => $design['animation'],
580 'logo' => $design['logo'],
581 'backgroundImage' => $design['background_image'],
582 ],
583 'behaviour' => [
584 'denyAction' => $deny_action,
585 'denyRedirect' => $deny_redirect_id,
586 'denyRedirectUrl' => $deny_redirect_url,
587 'blockMessage' => $behaviour['block_message'],
588 'consentCheckbox' => (bool) $behaviour['consent_checkbox'],
589 'consentLabel' => __('I agree to the policy.', 'king-addons'),
590 'repeatMode' => $behaviour['repeat_mode'] ?? 'days',
591 'repeatDays' => (int) ($behaviour['repeat_days'] ?? 30),
592 ],
593 'cookie' => [
594 'name' => $this->get_cookie_name(),
595 'days' => (int) $general['cookie_days'],
596 'revision' => $this->options['advanced']['revision'],
597 'respectRevision' => (bool) $behaviour['reset_on_rule_change'],
598 'domain' => $this->get_cookie_domain(),
599 ],
600 'dob' => [
601 'format' => $dob['format'],
602 'errors' => [
603 'invalid' => $dob['error_invalid'],
604 'denied' => $dob['error_denied'],
605 ],
606 ],
607 'status' => $this->get_cookie_status(),
608 'shouldRender' => $this->should_render() || $this->is_preview_mode(),
609 'isPreview' => $this->is_preview_mode(),
610 'ajax' => [
611 'url' => admin_url('admin-ajax.php'),
612 'nonce' => wp_create_nonce(self::NONCE_ACTION),
613 'action' => self::AJAX_ACTION_DOB,
614 ],
615 'isPremium' => $this->is_premium(),
616 'elementorTemplate' => '',
617 ];
618 }
619
620 /**
621 * Preview mode: force render Age Gate for admins via query param.
622 * Example: /?ka_age_gate_preview=1
623 */
624 protected function is_preview_mode(): bool
625 {
626 if (is_admin() || wp_doing_ajax()) {
627 return false;
628 }
629
630 if (!$this->is_enabled()) {
631 return false;
632 }
633
634 if (!is_user_logged_in() || !current_user_can('manage_options')) {
635 return false;
636 }
637
638 return isset($_GET['ka_age_gate_preview']) && (string) $_GET['ka_age_gate_preview'] === '1';
639 }
640
641 /**
642 * Indicates whether premium code is available.
643 *
644 * @return bool
645 */
646 protected function is_premium(): bool
647 {
648 if (!function_exists('king_addons_freemius')) {
649 return false;
650 }
651
652 $fs = king_addons_freemius();
653 if (!is_object($fs) || !method_exists($fs, 'can_use_premium_code')) {
654 return false;
655 }
656
657 return (bool) $fs->can_use_premium_code();
658 }
659
660 /**
661 * Returns the sanitized options merged with defaults.
662 *
663 * @return array<string, mixed>
664 */
665 public function get_options(): array
666 {
667 if (class_exists('King_Addons\\Text_Entities_Migration')) {
668 Text_Entities_Migration::maybe_run();
669 }
670
671 $saved = get_option(self::OPTION_NAME, []);
672 $defaults = $this->get_default_options();
673
674 return wp_parse_args($saved, $defaults);
675 }
676
677 /**
678 * Default options for the feature.
679 *
680 * @return array<string, mixed>
681 */
682 public function get_default_options(): array
683 {
684 return [
685 'general' => [
686 'enabled' => false,
687 'audience' => 'guests',
688 'mode' => 'confirm',
689 'min_age' => 18,
690 'cookie_days' => 30,
691 ],
692 'display' => [
693 'scope' => 'site',
694 'exclude_ids' => [],
695 'mode' => 'global',
696 'cpt_scope' => [],
697 'archives' => false,
698 'woo' => [
699 'enabled' => false,
700 'apply_to' => 'product',
701 'categories' => [],
702 ],
703 ],
704 'design' => [
705 'template' => 'center-card',
706 'overlay_color' => '#0d0d0d',
707 'overlay_opacity' => 0.7,
708 'card_background' => '#ffffff',
709 'card_width' => 520,
710 'card_align' => 'center',
711 'title' => __('Age verification required', 'king-addons'),
712 'subtitle' => __('This content is restricted to visitors over the specified age.', 'king-addons'),
713 'button_yes' => __('Yes, continue', 'king-addons'),
714 'button_no' => __('No, leave', 'king-addons'),
715 'text_color' => '#111827',
716 'title_size' => 24,
717 'body_size' => 16,
718 'title_weight' => 700,
719 'body_weight' => 400,
720 'button_yes_color' => '#ffffff',
721 'button_yes_bg' => '#10b981',
722 'button_no_color' => '#ffffff',
723 'button_no_bg' => '#ef4444',
724 'animation' => 'none',
725 'logo' => '',
726 'background_image' => '',
727 'button_yes_hover_bg' => '#0f9c75',
728 'button_no_hover_bg' => '#d92d20',
729 'button_yes_hover_color' => '#ffffff',
730 'button_no_hover_color' => '#ffffff',
731 'elementor_template' => 0,
732 ],
733 'behaviour' => [
734 'deny_action' => 'redirect_url',
735 'deny_redirect_page' => 0,
736 'deny_redirect_url' => 'https://google.com',
737 'block_message' => __('Access denied. You do not meet the minimum age requirement.', 'king-addons'),
738 'consent_checkbox' => false,
739 'reset_on_rule_change' => true,
740 'repeat_mode' => 'days',
741 'repeat_days' => 30,
742 ],
743 'advanced' => [
744 'revision' => time(),
745 ],
746 'geo' => [
747 'enabled' => false,
748 'default_age' => 18,
749 'map' => [],
750 ],
751 'dob' => [
752 'format' => 'dmy',
753 'max_age' => 120,
754 'error_invalid' => __('Enter a valid date of birth.', 'king-addons'),
755 'error_denied' => __('You do not meet the minimum age requirement.', 'king-addons'),
756 ],
757 ];
758 }
759
760 /**
761 * Sanitizes and normalizes saved options.
762 *
763 * @param mixed $raw Raw submitted options.
764 * @return array<string, mixed>
765 */
766 public function sanitize_options($raw): array
767 {
768 $raw = is_array($raw) ? $raw : [];
769 $current = $this->get_options();
770 $defaults = $this->get_default_options();
771
772 $general = $raw['general'] ?? [];
773 $display = $raw['display'] ?? [];
774 $design = $raw['design'] ?? [];
775 $behaviour = $raw['behaviour'] ?? [];
776
777 $allowed_templates = [
778 'center-card',
779 'bottom-card',
780 'top-card',
781 'side-left',
782 'side-right',
783 'fullscreen',
784 ];
785
786 $template = in_array($design['template'] ?? 'center-card', $allowed_templates, true)
787 ? $design['template']
788 : 'center-card';
789
790 // Back-compat: if card_align isn't explicitly set, infer it from template.
791 $inferred_align = 'center';
792 if ($template === 'bottom-card') {
793 $inferred_align = 'bottom';
794 } elseif ($template === 'top-card') {
795 $inferred_align = 'top';
796 }
797
798 $deny_action_raw = (string) ($behaviour['deny_action'] ?? ($defaults['behaviour']['deny_action'] ?? 'redirect_url'));
799 if ($deny_action_raw === 'redirect') {
800 // Back-compat: older saved values.
801 $deny_action_raw = 'redirect_page';
802 }
803 $allowed_deny_actions = ['redirect_page', 'redirect_url', 'block'];
804 $deny_action = in_array($deny_action_raw, $allowed_deny_actions, true) ? $deny_action_raw : ($defaults['behaviour']['deny_action'] ?? 'redirect_url');
805
806 $sanitized = [
807 'general' => [
808 'enabled' => !empty($general['enabled']),
809 'audience' => in_array($general['audience'] ?? 'guests', ['guests', 'all'], true) ? $general['audience'] : $defaults['general']['audience'],
810 'mode' => in_array($general['mode'] ?? 'confirm', ['confirm', 'minimum'], true) ? $general['mode'] : 'confirm',
811 'min_age' => max(0, absint($general['min_age'] ?? $defaults['general']['min_age'])),
812 'cookie_days' => max(0, absint($general['cookie_days'] ?? $defaults['general']['cookie_days'])),
813 ],
814 'display' => [
815 'scope' => in_array($display['scope'] ?? 'site', ['site', 'posts', 'pages'], true) ? $display['scope'] : $defaults['display']['scope'],
816 'exclude_ids' => array_values(array_filter(array_map('absint', $display['exclude_ids'] ?? []))),
817 ],
818 'design' => [
819 'template' => $template,
820 'overlay_color' => sanitize_hex_color($design['overlay_color'] ?? $defaults['design']['overlay_color']) ?: $defaults['design']['overlay_color'],
821 'overlay_opacity' => min(1, max(0, floatval($design['overlay_opacity'] ?? $defaults['design']['overlay_opacity']))),
822 'card_background' => sanitize_hex_color($design['card_background'] ?? $defaults['design']['card_background']) ?: $defaults['design']['card_background'],
823 'card_width' => max(280, absint($design['card_width'] ?? $defaults['design']['card_width'])),
824 'card_align' => in_array($design['card_align'] ?? $inferred_align, ['center', 'bottom', 'top'], true) ? ($design['card_align'] ?? $inferred_align) : 'center',
825 'title' => sanitize_text_field($design['title'] ?? $defaults['design']['title']),
826 'subtitle' => sanitize_text_field($design['subtitle'] ?? $defaults['design']['subtitle']),
827 'button_yes' => sanitize_text_field($design['button_yes'] ?? $defaults['design']['button_yes']),
828 'button_no' => sanitize_text_field($design['button_no'] ?? $defaults['design']['button_no']),
829 'text_color' => sanitize_hex_color($design['text_color'] ?? $defaults['design']['text_color']) ?: $defaults['design']['text_color'],
830 'title_size' => max(10, absint($design['title_size'] ?? $defaults['design']['title_size'])),
831 'body_size' => max(10, absint($design['body_size'] ?? $defaults['design']['body_size'])),
832 'title_weight' => max(100, absint($design['title_weight'] ?? $defaults['design']['title_weight'])),
833 'body_weight' => max(100, absint($design['body_weight'] ?? $defaults['design']['body_weight'])),
834 'button_yes_color' => sanitize_hex_color($design['button_yes_color'] ?? $defaults['design']['button_yes_color']) ?: $defaults['design']['button_yes_color'],
835 'button_yes_bg' => sanitize_hex_color($design['button_yes_bg'] ?? $defaults['design']['button_yes_bg']) ?: $defaults['design']['button_yes_bg'],
836 'button_no_color' => sanitize_hex_color($design['button_no_color'] ?? $defaults['design']['button_no_color']) ?: $defaults['design']['button_no_color'],
837 'button_no_bg' => sanitize_hex_color($design['button_no_bg'] ?? $defaults['design']['button_no_bg']) ?: $defaults['design']['button_no_bg'],
838 'animation' => in_array($design['animation'] ?? 'none', ['none', 'fade', 'slide-up', 'slide-down'], true) ? $design['animation'] : 'none',
839 'logo' => esc_url_raw($design['logo'] ?? ''),
840 'background_image' => esc_url_raw($design['background_image'] ?? ''),
841 ],
842 'behaviour' => [
843 'deny_action' => $deny_action,
844 'deny_redirect_page' => absint($behaviour['deny_redirect_page'] ?? 0),
845 'deny_redirect_url' => $this->sanitize_redirect_url($behaviour['deny_redirect_url'] ?? ($defaults['behaviour']['deny_redirect_url'] ?? '')),
846 'block_message' => sanitize_text_field($behaviour['block_message'] ?? $defaults['behaviour']['block_message']),
847 'consent_checkbox' => !empty($behaviour['consent_checkbox']),
848 'reset_on_rule_change' => isset($behaviour['reset_on_rule_change']) ? (bool) $behaviour['reset_on_rule_change'] : $defaults['behaviour']['reset_on_rule_change'],
849 'repeat_mode' => in_array($behaviour['repeat_mode'] ?? 'days', ['days', 'session', 'once'], true) ? $behaviour['repeat_mode'] : 'days',
850 'repeat_days' => max(0, absint($behaviour['repeat_days'] ?? $defaults['behaviour']['repeat_days'])),
851 ],
852 'advanced' => [
853 'revision' => $current['advanced']['revision'] ?? time(),
854 ],
855 'geo' => $this->sanitize_geo_options($raw),
856 'dob' => $this->sanitize_dob_options($raw),
857 ];
858
859 // Force revision bump when rule reset is enabled and settings are updated.
860 if (!empty($sanitized['behaviour']['reset_on_rule_change'])) {
861 $sanitized['advanced']['revision'] = time();
862 }
863
864 $this->options = wp_parse_args($sanitized, $this->get_default_options());
865
866 return $this->options;
867 }
868
869 /**
870 * Sanitizes geo options.
871 *
872 * @param array<string, mixed> $raw Raw input.
873 * @return array<string, mixed>
874 */
875 protected function sanitize_geo_options(array $raw): array
876 {
877 $defaults = $this->get_default_options()['geo'];
878 $geo = $raw['geo'] ?? [];
879
880 $enabled = !empty($geo['enabled']) && $this->is_premium();
881 $default_age = max(0, absint($geo['default_age'] ?? $defaults['default_age']));
882
883 // Parse geo map from textarea (format: "US=21\nUK=18")
884 $map = [];
885 if (!empty($geo['map']) && is_string($geo['map'])) {
886 $lines = explode("\n", $geo['map']);
887 foreach ($lines as $line) {
888 $line = trim($line);
889 if (empty($line) || strpos($line, '=') === false) {
890 continue;
891 }
892 [$code, $age] = explode('=', $line, 2);
893 $code = strtoupper(trim(sanitize_text_field($code)));
894 $age = absint(trim($age));
895 if (strlen($code) === 2 && $age > 0) {
896 $map[$code] = $age;
897 }
898 }
899 } elseif (is_array($geo['map'] ?? null)) {
900 // Already an array (from existing options)
901 foreach ($geo['map'] as $code => $age) {
902 $code = strtoupper(sanitize_text_field($code));
903 $map[$code] = absint($age);
904 }
905 }
906
907 return [
908 'enabled' => $enabled,
909 'default_age' => $default_age,
910 'map' => $map,
911 ];
912 }
913
914 /**
915 * Sanitizes DOB options.
916 *
917 * @param array<string, mixed> $raw Raw input.
918 * @return array<string, mixed>
919 */
920 protected function sanitize_dob_options(array $raw): array
921 {
922 $defaults = $this->get_default_options()['dob'];
923 $dob = $raw['dob'] ?? [];
924
925 return [
926 'format' => in_array($dob['format'] ?? 'dmy', ['dmy', 'mdy', 'ymd'], true) ? $dob['format'] : 'dmy',
927 'max_age' => max(10, absint($dob['max_age'] ?? $defaults['max_age'])),
928 'error_invalid' => sanitize_text_field($dob['error_invalid'] ?? $defaults['error_invalid']),
929 'error_denied' => sanitize_text_field($dob['error_denied'] ?? $defaults['error_denied']),
930 ];
931 }
932
933 /**
934 * Sanitizes a custom redirect URL.
935 *
936 * @param mixed $raw_url Raw URL.
937 * @return string Sanitized URL or empty string.
938 */
939 protected function sanitize_redirect_url($raw_url): string
940 {
941 $url = is_string($raw_url) ? trim($raw_url) : '';
942 if ($url === '') {
943 return '';
944 }
945
946 $url = esc_url_raw($url);
947 if ($url && wp_http_validate_url($url)) {
948 return $url;
949 }
950
951 return '';
952 }
953
954 /**
955 * Returns the effective redirect destination when denial action is redirect.
956 * Prefers an internal selected page, otherwise falls back to the custom URL.
957 */
958 protected function resolve_deny_redirect_url(): string
959 {
960 $behaviour = $this->options['behaviour'] ?? [];
961 $deny_action = (string) ($behaviour['deny_action'] ?? '');
962
963 // Legacy back-compat.
964 if ($deny_action === 'redirect') {
965 $deny_action = 'redirect_page';
966 }
967
968 $custom_url = isset($behaviour['deny_redirect_url']) ? $this->sanitize_redirect_url($behaviour['deny_redirect_url']) : '';
969
970 if ($deny_action === 'redirect_url') {
971 return $custom_url;
972 }
973
974 // redirect_page (default): prefer page ID, but fall back to URL if provided.
975 $redirect_id = isset($behaviour['deny_redirect_page']) ? (int) $behaviour['deny_redirect_page'] : 0;
976 if ($redirect_id) {
977 return get_permalink($redirect_id) ?: '';
978 }
979
980 return $custom_url;
981 }
982
983 /**
984 * True for redirect-based deny actions.
985 */
986 protected function is_deny_action_redirect(string $deny_action): bool
987 {
988 return in_array($deny_action, ['redirect', 'redirect_page', 'redirect_url'], true);
989 }
990
991 /**
992 * Whether a denial redirect destination is configured.
993 */
994 protected function has_deny_redirect_target(): bool
995 {
996 return $this->resolve_deny_redirect_url() !== '';
997 }
998
999 /**
1000 * Determines whether the feature is active.
1001 *
1002 * @return bool
1003 */
1004 protected function is_enabled(): bool
1005 {
1006 return !empty($this->options['general']['enabled']);
1007 }
1008
1009 /**
1010 * Returns the cookie name to use.
1011 *
1012 * @return string
1013 */
1014 protected function get_cookie_name(): string
1015 {
1016 return self::COOKIE_NAME;
1017 }
1018
1019 /**
1020 * Parses the stored cookie status.
1021 *
1022 * @return string allowed|denied|dob:{date}|''
1023 */
1024 protected function get_cookie_status(): string
1025 {
1026 $cookie_name = $this->get_cookie_name();
1027
1028 if (!isset($_COOKIE[$cookie_name])) {
1029 return '';
1030 }
1031
1032 $raw = sanitize_text_field(wp_unslash($_COOKIE[$cookie_name]));
1033 $parts = explode('|', $raw);
1034 $status = $parts[0] ?? '';
1035 $revision = $parts[1] ?? '';
1036
1037 if (!empty($this->options['behaviour']['reset_on_rule_change'])) {
1038 if ($revision && (string)$revision !== (string)$this->options['advanced']['revision']) {
1039 return '';
1040 }
1041 }
1042
1043 if ($status === 'allowed' || $status === 'denied' || strpos($status, 'dob:') === 0) {
1044 return $status;
1045 }
1046
1047 return '';
1048 }
1049
1050 /**
1051 * Writes the status cookie with revision marker.
1052 *
1053 * @param string $status allowed|denied|dob:{date}
1054 * @param int $days Cookie lifetime in days. Zero for session cookie.
1055 * @return void
1056 */
1057 protected function set_status_cookie(string $status, int $days): void
1058 {
1059 $value = $status . '|' . $this->options['advanced']['revision'];
1060 $expire = $days > 0 ? time() + (DAY_IN_SECONDS * $days) : 0;
1061 setcookie(
1062 $this->get_cookie_name(),
1063 $value,
1064 [
1065 'expires' => $expire,
1066 'path' => '/',
1067 'domain' => $this->get_cookie_domain(),
1068 'secure' => is_ssl(),
1069 'httponly' => false,
1070 'samesite' => 'Lax',
1071 ]
1072 );
1073 $_COOKIE[$this->get_cookie_name()] = $value;
1074 }
1075
1076 /**
1077 * Resolves cookie domain to current host.
1078 *
1079 * @return string
1080 */
1081 protected function get_cookie_domain(): string
1082 {
1083 $host = wp_parse_url(home_url(), PHP_URL_HOST);
1084 return $host ? $host : '';
1085 }
1086 }
1087
1088
1089
1090