PluginProbe
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder / 51.1.65
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder v51.1.65
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.65, at includes/extensions/Age_Gate/Age_Gate.php

1,063 lines 36.2 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 is_page();
474 }
475
476 return true;
477 }
478
479 /**
480 * Checks if current page is excluded from gating.
481 *
482 * @return bool
483 */
484 protected function is_excluded_page(): bool
485 {
486 if (!is_singular()) {
487 return false;
488 }
489
490 $exclude_ids = array_map('absint', $this->options['display']['exclude_ids']);
491 $current_id = get_the_ID();
492
493 if (in_array($current_id, $exclude_ids, true)) {
494 return true;
495 }
496
497 $deny_redirect_id = isset($this->options['behaviour']['deny_redirect_page']) ? (int)$this->options['behaviour']['deny_redirect_page'] : 0;
498 if ($deny_redirect_id && $deny_redirect_id === $current_id) {
499 return true;
500 }
501
502 return false;
503 }
504
505 /**
506 * Builds the frontend payload localized to JS.
507 *
508 * @return array<string, mixed>
509 */
510 public function get_frontend_payload(): array
511 {
512 $design = $this->options['design'];
513 $behaviour = $this->options['behaviour'];
514 $general = $this->options['general'];
515 $dob = $this->options['dob'];
516
517 $deny_redirect_id = isset($behaviour['deny_redirect_page']) ? (int) $behaviour['deny_redirect_page'] : 0;
518 $deny_redirect_url = $this->resolve_deny_redirect_url();
519 $deny_action_raw = (string) ($behaviour['deny_action'] ?? 'block');
520 $deny_action = $this->is_deny_action_redirect($deny_action_raw) ? 'redirect' : 'block';
521 if ($deny_action === 'redirect' && !$deny_redirect_url) {
522 $deny_action = 'block';
523 }
524
525 return [
526 'enabled' => $this->is_enabled(),
527 'mode' => $general['mode'],
528 'minAge' => $this->resolve_minimum_age(),
529 'texts' => [
530 'title' => $design['title'],
531 'subtitle' => $design['subtitle'],
532 'yes' => $design['button_yes'],
533 'no' => $design['button_no'],
534 'block' => $behaviour['block_message'],
535 ],
536 'design' => [
537 'template' => $design['template'],
538 'overlayColor' => $design['overlay_color'],
539 'overlayOpacity' => $design['overlay_opacity'],
540 'cardBackground' => $design['card_background'],
541 'cardWidth' => $design['card_width'],
542 'cardAlign' => $design['card_align'],
543 'textColor' => $design['text_color'],
544 'titleSize' => $design['title_size'],
545 'bodySize' => $design['body_size'],
546 'titleWeight' => $design['title_weight'],
547 'bodyWeight' => $design['body_weight'],
548 'buttonYesColor' => $design['button_yes_color'],
549 'buttonYesBg' => $design['button_yes_bg'],
550 'buttonNoColor' => $design['button_no_color'],
551 'buttonNoBg' => $design['button_no_bg'],
552 'buttonYesHoverBg' => $design['button_yes_hover_bg'] ?? $design['button_yes_bg'],
553 'buttonYesHoverColor' => $design['button_yes_hover_color'] ?? $design['button_yes_color'],
554 'buttonNoHoverBg' => $design['button_no_hover_bg'] ?? $design['button_no_bg'],
555 'buttonNoHoverColor' => $design['button_no_hover_color'] ?? $design['button_no_color'],
556 'animation' => $design['animation'],
557 'logo' => $design['logo'],
558 'backgroundImage' => $design['background_image'],
559 ],
560 'behaviour' => [
561 'denyAction' => $deny_action,
562 'denyRedirect' => $deny_redirect_id,
563 'denyRedirectUrl' => $deny_redirect_url,
564 'blockMessage' => $behaviour['block_message'],
565 'consentCheckbox' => (bool) $behaviour['consent_checkbox'],
566 'consentLabel' => esc_html__('I agree to the policy.', 'king-addons'),
567 'repeatMode' => $behaviour['repeat_mode'] ?? 'days',
568 'repeatDays' => (int) ($behaviour['repeat_days'] ?? 30),
569 ],
570 'cookie' => [
571 'name' => $this->get_cookie_name(),
572 'days' => (int) $general['cookie_days'],
573 'revision' => $this->options['advanced']['revision'],
574 'respectRevision' => (bool) $behaviour['reset_on_rule_change'],
575 'domain' => $this->get_cookie_domain(),
576 ],
577 'dob' => [
578 'format' => $dob['format'],
579 'errors' => [
580 'invalid' => $dob['error_invalid'],
581 'denied' => $dob['error_denied'],
582 ],
583 ],
584 'status' => $this->get_cookie_status(),
585 'shouldRender' => $this->should_render() || $this->is_preview_mode(),
586 'isPreview' => $this->is_preview_mode(),
587 'ajax' => [
588 'url' => admin_url('admin-ajax.php'),
589 'nonce' => wp_create_nonce(self::NONCE_ACTION),
590 'action' => self::AJAX_ACTION_DOB,
591 ],
592 'isPremium' => $this->is_premium(),
593 'elementorTemplate' => '',
594 ];
595 }
596
597 /**
598 * Preview mode: force render Age Gate for admins via query param.
599 * Example: /?ka_age_gate_preview=1
600 */
601 protected function is_preview_mode(): bool
602 {
603 if (is_admin() || wp_doing_ajax()) {
604 return false;
605 }
606
607 if (!$this->is_enabled()) {
608 return false;
609 }
610
611 if (!is_user_logged_in() || !current_user_can('manage_options')) {
612 return false;
613 }
614
615 return isset($_GET['ka_age_gate_preview']) && (string) $_GET['ka_age_gate_preview'] === '1';
616 }
617
618 /**
619 * Indicates whether premium code is available.
620 *
621 * @return bool
622 */
623 protected function is_premium(): bool
624 {
625 if (!function_exists('king_addons_freemius')) {
626 return false;
627 }
628
629 $fs = king_addons_freemius();
630 if (!is_object($fs) || !method_exists($fs, 'can_use_premium_code')) {
631 return false;
632 }
633
634 return (bool) $fs->can_use_premium_code();
635 }
636
637 /**
638 * Returns the sanitized options merged with defaults.
639 *
640 * @return array<string, mixed>
641 */
642 public function get_options(): array
643 {
644 $saved = get_option(self::OPTION_NAME, []);
645 $defaults = $this->get_default_options();
646
647 return wp_parse_args($saved, $defaults);
648 }
649
650 /**
651 * Default options for the feature.
652 *
653 * @return array<string, mixed>
654 */
655 public function get_default_options(): array
656 {
657 return [
658 'general' => [
659 'enabled' => false,
660 'audience' => 'guests',
661 'mode' => 'confirm',
662 'min_age' => 18,
663 'cookie_days' => 30,
664 ],
665 'display' => [
666 'scope' => 'site',
667 'exclude_ids' => [],
668 'mode' => 'global',
669 'cpt_scope' => [],
670 'archives' => false,
671 'woo' => [
672 'enabled' => false,
673 'apply_to' => 'product',
674 'categories' => [],
675 ],
676 ],
677 'design' => [
678 'template' => 'center-card',
679 'overlay_color' => '#0d0d0d',
680 'overlay_opacity' => 0.7,
681 'card_background' => '#ffffff',
682 'card_width' => 520,
683 'card_align' => 'center',
684 'title' => esc_html__('Age verification required', 'king-addons'),
685 'subtitle' => esc_html__('This content is restricted to visitors over the specified age.', 'king-addons'),
686 'button_yes' => esc_html__('Yes, continue', 'king-addons'),
687 'button_no' => esc_html__('No, leave', 'king-addons'),
688 'text_color' => '#111827',
689 'title_size' => 24,
690 'body_size' => 16,
691 'title_weight' => 700,
692 'body_weight' => 400,
693 'button_yes_color' => '#ffffff',
694 'button_yes_bg' => '#10b981',
695 'button_no_color' => '#ffffff',
696 'button_no_bg' => '#ef4444',
697 'animation' => 'none',
698 'logo' => '',
699 'background_image' => '',
700 'button_yes_hover_bg' => '#0f9c75',
701 'button_no_hover_bg' => '#d92d20',
702 'button_yes_hover_color' => '#ffffff',
703 'button_no_hover_color' => '#ffffff',
704 'elementor_template' => 0,
705 ],
706 'behaviour' => [
707 'deny_action' => 'redirect_url',
708 'deny_redirect_page' => 0,
709 'deny_redirect_url' => 'https://google.com',
710 'block_message' => esc_html__('Access denied. You do not meet the minimum age requirement.', 'king-addons'),
711 'consent_checkbox' => false,
712 'reset_on_rule_change' => true,
713 'repeat_mode' => 'days',
714 'repeat_days' => 30,
715 ],
716 'advanced' => [
717 'revision' => time(),
718 ],
719 'geo' => [
720 'enabled' => false,
721 'default_age' => 18,
722 'map' => [],
723 ],
724 'dob' => [
725 'format' => 'dmy',
726 'max_age' => 120,
727 'error_invalid' => esc_html__('Enter a valid date of birth.', 'king-addons'),
728 'error_denied' => esc_html__('You do not meet the minimum age requirement.', 'king-addons'),
729 ],
730 ];
731 }
732
733 /**
734 * Sanitizes and normalizes saved options.
735 *
736 * @param mixed $raw Raw submitted options.
737 * @return array<string, mixed>
738 */
739 public function sanitize_options($raw): array
740 {
741 $raw = is_array($raw) ? $raw : [];
742 $current = $this->get_options();
743 $defaults = $this->get_default_options();
744
745 $general = $raw['general'] ?? [];
746 $display = $raw['display'] ?? [];
747 $design = $raw['design'] ?? [];
748 $behaviour = $raw['behaviour'] ?? [];
749
750 $allowed_templates = [
751 'center-card',
752 'bottom-card',
753 'top-card',
754 'side-left',
755 'side-right',
756 'fullscreen',
757 ];
758
759 $template = in_array($design['template'] ?? 'center-card', $allowed_templates, true)
760 ? $design['template']
761 : 'center-card';
762
763 // Back-compat: if card_align isn't explicitly set, infer it from template.
764 $inferred_align = 'center';
765 if ($template === 'bottom-card') {
766 $inferred_align = 'bottom';
767 } elseif ($template === 'top-card') {
768 $inferred_align = 'top';
769 }
770
771 $deny_action_raw = (string) ($behaviour['deny_action'] ?? ($defaults['behaviour']['deny_action'] ?? 'redirect_url'));
772 if ($deny_action_raw === 'redirect') {
773 // Back-compat: older saved values.
774 $deny_action_raw = 'redirect_page';
775 }
776 $allowed_deny_actions = ['redirect_page', 'redirect_url', 'block'];
777 $deny_action = in_array($deny_action_raw, $allowed_deny_actions, true) ? $deny_action_raw : ($defaults['behaviour']['deny_action'] ?? 'redirect_url');
778
779 $sanitized = [
780 'general' => [
781 'enabled' => !empty($general['enabled']),
782 'audience' => in_array($general['audience'] ?? 'guests', ['guests', 'all'], true) ? $general['audience'] : $defaults['general']['audience'],
783 'mode' => in_array($general['mode'] ?? 'confirm', ['confirm', 'minimum'], true) ? $general['mode'] : 'confirm',
784 'min_age' => max(0, absint($general['min_age'] ?? $defaults['general']['min_age'])),
785 'cookie_days' => max(0, absint($general['cookie_days'] ?? $defaults['general']['cookie_days'])),
786 ],
787 'display' => [
788 'scope' => in_array($display['scope'] ?? 'site', ['site', 'posts', 'pages'], true) ? $display['scope'] : $defaults['display']['scope'],
789 'exclude_ids' => array_values(array_filter(array_map('absint', $display['exclude_ids'] ?? []))),
790 ],
791 'design' => [
792 'template' => $template,
793 'overlay_color' => sanitize_hex_color($design['overlay_color'] ?? $defaults['design']['overlay_color']) ?: $defaults['design']['overlay_color'],
794 'overlay_opacity' => min(1, max(0, floatval($design['overlay_opacity'] ?? $defaults['design']['overlay_opacity']))),
795 'card_background' => sanitize_hex_color($design['card_background'] ?? $defaults['design']['card_background']) ?: $defaults['design']['card_background'],
796 'card_width' => max(280, absint($design['card_width'] ?? $defaults['design']['card_width'])),
797 'card_align' => in_array($design['card_align'] ?? $inferred_align, ['center', 'bottom', 'top'], true) ? ($design['card_align'] ?? $inferred_align) : 'center',
798 'title' => sanitize_text_field($design['title'] ?? $defaults['design']['title']),
799 'subtitle' => sanitize_text_field($design['subtitle'] ?? $defaults['design']['subtitle']),
800 'button_yes' => sanitize_text_field($design['button_yes'] ?? $defaults['design']['button_yes']),
801 'button_no' => sanitize_text_field($design['button_no'] ?? $defaults['design']['button_no']),
802 'text_color' => sanitize_hex_color($design['text_color'] ?? $defaults['design']['text_color']) ?: $defaults['design']['text_color'],
803 'title_size' => max(10, absint($design['title_size'] ?? $defaults['design']['title_size'])),
804 'body_size' => max(10, absint($design['body_size'] ?? $defaults['design']['body_size'])),
805 'title_weight' => max(100, absint($design['title_weight'] ?? $defaults['design']['title_weight'])),
806 'body_weight' => max(100, absint($design['body_weight'] ?? $defaults['design']['body_weight'])),
807 'button_yes_color' => sanitize_hex_color($design['button_yes_color'] ?? $defaults['design']['button_yes_color']) ?: $defaults['design']['button_yes_color'],
808 'button_yes_bg' => sanitize_hex_color($design['button_yes_bg'] ?? $defaults['design']['button_yes_bg']) ?: $defaults['design']['button_yes_bg'],
809 'button_no_color' => sanitize_hex_color($design['button_no_color'] ?? $defaults['design']['button_no_color']) ?: $defaults['design']['button_no_color'],
810 'button_no_bg' => sanitize_hex_color($design['button_no_bg'] ?? $defaults['design']['button_no_bg']) ?: $defaults['design']['button_no_bg'],
811 'animation' => in_array($design['animation'] ?? 'none', ['none', 'fade', 'slide-up', 'slide-down'], true) ? $design['animation'] : 'none',
812 'logo' => esc_url_raw($design['logo'] ?? ''),
813 'background_image' => esc_url_raw($design['background_image'] ?? ''),
814 ],
815 'behaviour' => [
816 'deny_action' => $deny_action,
817 'deny_redirect_page' => absint($behaviour['deny_redirect_page'] ?? 0),
818 'deny_redirect_url' => $this->sanitize_redirect_url($behaviour['deny_redirect_url'] ?? ($defaults['behaviour']['deny_redirect_url'] ?? '')),
819 'block_message' => sanitize_text_field($behaviour['block_message'] ?? $defaults['behaviour']['block_message']),
820 'consent_checkbox' => !empty($behaviour['consent_checkbox']),
821 'reset_on_rule_change' => isset($behaviour['reset_on_rule_change']) ? (bool) $behaviour['reset_on_rule_change'] : $defaults['behaviour']['reset_on_rule_change'],
822 'repeat_mode' => in_array($behaviour['repeat_mode'] ?? 'days', ['days', 'session', 'once'], true) ? $behaviour['repeat_mode'] : 'days',
823 'repeat_days' => max(0, absint($behaviour['repeat_days'] ?? $defaults['behaviour']['repeat_days'])),
824 ],
825 'advanced' => [
826 'revision' => $current['advanced']['revision'] ?? time(),
827 ],
828 'geo' => $this->sanitize_geo_options($raw),
829 'dob' => $this->sanitize_dob_options($raw),
830 ];
831
832 // Force revision bump when rule reset is enabled and settings are updated.
833 if (!empty($sanitized['behaviour']['reset_on_rule_change'])) {
834 $sanitized['advanced']['revision'] = time();
835 }
836
837 $this->options = wp_parse_args($sanitized, $this->get_default_options());
838
839 return $this->options;
840 }
841
842 /**
843 * Sanitizes geo options.
844 *
845 * @param array<string, mixed> $raw Raw input.
846 * @return array<string, mixed>
847 */
848 protected function sanitize_geo_options(array $raw): array
849 {
850 $defaults = $this->get_default_options()['geo'];
851 $geo = $raw['geo'] ?? [];
852
853 $enabled = !empty($geo['enabled']) && $this->is_premium();
854 $default_age = max(0, absint($geo['default_age'] ?? $defaults['default_age']));
855
856 // Parse geo map from textarea (format: "US=21\nUK=18")
857 $map = [];
858 if (!empty($geo['map']) && is_string($geo['map'])) {
859 $lines = explode("\n", $geo['map']);
860 foreach ($lines as $line) {
861 $line = trim($line);
862 if (empty($line) || strpos($line, '=') === false) {
863 continue;
864 }
865 [$code, $age] = explode('=', $line, 2);
866 $code = strtoupper(trim(sanitize_text_field($code)));
867 $age = absint(trim($age));
868 if (strlen($code) === 2 && $age > 0) {
869 $map[$code] = $age;
870 }
871 }
872 } elseif (is_array($geo['map'] ?? null)) {
873 // Already an array (from existing options)
874 foreach ($geo['map'] as $code => $age) {
875 $code = strtoupper(sanitize_text_field($code));
876 $map[$code] = absint($age);
877 }
878 }
879
880 return [
881 'enabled' => $enabled,
882 'default_age' => $default_age,
883 'map' => $map,
884 ];
885 }
886
887 /**
888 * Sanitizes DOB options.
889 *
890 * @param array<string, mixed> $raw Raw input.
891 * @return array<string, mixed>
892 */
893 protected function sanitize_dob_options(array $raw): array
894 {
895 $defaults = $this->get_default_options()['dob'];
896 $dob = $raw['dob'] ?? [];
897
898 return [
899 'format' => in_array($dob['format'] ?? 'dmy', ['dmy', 'mdy', 'ymd'], true) ? $dob['format'] : 'dmy',
900 'max_age' => max(10, absint($dob['max_age'] ?? $defaults['max_age'])),
901 'error_invalid' => sanitize_text_field($dob['error_invalid'] ?? $defaults['error_invalid']),
902 'error_denied' => sanitize_text_field($dob['error_denied'] ?? $defaults['error_denied']),
903 ];
904 }
905
906 /**
907 * Sanitizes a custom redirect URL.
908 *
909 * @param mixed $raw_url Raw URL.
910 * @return string Sanitized URL or empty string.
911 */
912 protected function sanitize_redirect_url($raw_url): string
913 {
914 $url = is_string($raw_url) ? trim($raw_url) : '';
915 if ($url === '') {
916 return '';
917 }
918
919 $url = esc_url_raw($url);
920 if ($url && wp_http_validate_url($url)) {
921 return $url;
922 }
923
924 return '';
925 }
926
927 /**
928 * Returns the effective redirect destination when denial action is redirect.
929 * Prefers an internal selected page, otherwise falls back to the custom URL.
930 */
931 protected function resolve_deny_redirect_url(): string
932 {
933 $behaviour = $this->options['behaviour'] ?? [];
934 $deny_action = (string) ($behaviour['deny_action'] ?? '');
935
936 // Legacy back-compat.
937 if ($deny_action === 'redirect') {
938 $deny_action = 'redirect_page';
939 }
940
941 $custom_url = isset($behaviour['deny_redirect_url']) ? $this->sanitize_redirect_url($behaviour['deny_redirect_url']) : '';
942
943 if ($deny_action === 'redirect_url') {
944 return $custom_url;
945 }
946
947 // redirect_page (default): prefer page ID, but fall back to URL if provided.
948 $redirect_id = isset($behaviour['deny_redirect_page']) ? (int) $behaviour['deny_redirect_page'] : 0;
949 if ($redirect_id) {
950 return get_permalink($redirect_id) ?: '';
951 }
952
953 return $custom_url;
954 }
955
956 /**
957 * True for redirect-based deny actions.
958 */
959 protected function is_deny_action_redirect(string $deny_action): bool
960 {
961 return in_array($deny_action, ['redirect', 'redirect_page', 'redirect_url'], true);
962 }
963
964 /**
965 * Whether a denial redirect destination is configured.
966 */
967 protected function has_deny_redirect_target(): bool
968 {
969 return $this->resolve_deny_redirect_url() !== '';
970 }
971
972 /**
973 * Determines whether the feature is active.
974 *
975 * @return bool
976 */
977 protected function is_enabled(): bool
978 {
979 return !empty($this->options['general']['enabled']);
980 }
981
982 /**
983 * Returns the cookie name to use.
984 *
985 * @return string
986 */
987 protected function get_cookie_name(): string
988 {
989 return self::COOKIE_NAME;
990 }
991
992 /**
993 * Parses the stored cookie status.
994 *
995 * @return string allowed|denied|dob:{date}|''
996 */
997 protected function get_cookie_status(): string
998 {
999 $cookie_name = $this->get_cookie_name();
1000
1001 if (!isset($_COOKIE[$cookie_name])) {
1002 return '';
1003 }
1004
1005 $raw = sanitize_text_field(wp_unslash($_COOKIE[$cookie_name]));
1006 $parts = explode('|', $raw);
1007 $status = $parts[0] ?? '';
1008 $revision = $parts[1] ?? '';
1009
1010 if (!empty($this->options['behaviour']['reset_on_rule_change'])) {
1011 if ($revision && (string)$revision !== (string)$this->options['advanced']['revision']) {
1012 return '';
1013 }
1014 }
1015
1016 if ($status === 'allowed' || $status === 'denied' || strpos($status, 'dob:') === 0) {
1017 return $status;
1018 }
1019
1020 return '';
1021 }
1022
1023 /**
1024 * Writes the status cookie with revision marker.
1025 *
1026 * @param string $status allowed|denied|dob:{date}
1027 * @param int $days Cookie lifetime in days. Zero for session cookie.
1028 * @return void
1029 */
1030 protected function set_status_cookie(string $status, int $days): void
1031 {
1032 $value = $status . '|' . $this->options['advanced']['revision'];
1033 $expire = $days > 0 ? time() + (DAY_IN_SECONDS * $days) : 0;
1034 setcookie(
1035 $this->get_cookie_name(),
1036 $value,
1037 [
1038 'expires' => $expire,
1039 'path' => '/',
1040 'domain' => $this->get_cookie_domain(),
1041 'secure' => is_ssl(),
1042 'httponly' => false,
1043 'samesite' => 'Lax',
1044 ]
1045 );
1046 $_COOKIE[$this->get_cookie_name()] = $value;
1047 }
1048
1049 /**
1050 * Resolves cookie domain to current host.
1051 *
1052 * @return string
1053 */
1054 protected function get_cookie_domain(): string
1055 {
1056 $host = wp_parse_url(home_url(), PHP_URL_HOST);
1057 return $host ? $host : '';
1058 }
1059 }
1060
1061
1062
1063