PluginProbe
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder / 51.1.44
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder v51.1.44
51.1.86 51.1.84 51.1.85 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 All 40 releases
king-addons / includes / extensions / Age_Gate / Age_Gate.php

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

887 lines 30.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 (is_admin()) {
254 return;
255 }
256
257 $should_render = $this->should_render() || $this->should_render_block_state();
258
259 if (!$should_render) {
260 return;
261 }
262
263 wp_register_style(
264 'king-addons-age-gate',
265 KING_ADDONS_URL . 'includes/widgets/Age_Gate/style.css',
266 [],
267 KING_ADDONS_VERSION
268 );
269
270 wp_register_script(
271 'king-addons-age-gate',
272 KING_ADDONS_URL . 'includes/widgets/Age_Gate/script.js',
273 [],
274 KING_ADDONS_VERSION,
275 true
276 );
277
278 wp_localize_script('king-addons-age-gate', 'kingAddonsAgeGate', $this->get_frontend_payload());
279
280 wp_enqueue_style('king-addons-age-gate');
281 wp_enqueue_script('king-addons-age-gate');
282 }
283
284 /**
285 * Outputs the overlay container markup.
286 *
287 * @return void
288 */
289 public function render_frontend_markup(): void
290 {
291 if (!$this->should_render() && !$this->should_render_block_state()) {
292 return;
293 }
294 ?>
295 <div id="king-addons-age-gate" class="king-addons-age-gate" aria-hidden="true">
296 <div class="king-addons-age-gate__overlay"></div>
297 <div class="king-addons-age-gate__card" role="dialog" aria-modal="true" aria-label="<?php echo esc_attr($this->options['design']['title']); ?>">
298 <div class="king-addons-age-gate__content"></div>
299 </div>
300 </div>
301 <?php
302 }
303
304 /**
305 * Adds an early portal container to the body for fixed overlays.
306 *
307 * @return void
308 */
309 public function render_portal_container(): void
310 {
311 if (!$this->should_render() && !$this->should_render_block_state()) {
312 return;
313 }
314
315 echo '<div id="king-addons-age-gate-portal" class="king-addons-age-gate__portal" aria-hidden="true"></div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
316 }
317
318 /**
319 * Performs redirect on denied state when configured.
320 *
321 * @return void
322 */
323 public function maybe_handle_denied_redirect(): void
324 {
325 if (is_admin() || wp_doing_ajax()) {
326 return;
327 }
328
329 if (!$this->is_enabled()) {
330 return;
331 }
332
333 $status = $this->get_cookie_status();
334 $behaviour = $this->options['behaviour'];
335 $redirect_id = isset($behaviour['deny_redirect_page']) ? (int)$behaviour['deny_redirect_page'] : 0;
336
337 if ($status !== 'denied' || $behaviour['deny_action'] !== 'redirect' || !$redirect_id) {
338 return;
339 }
340
341 if (is_page($redirect_id)) {
342 return;
343 }
344
345 $url = get_permalink($redirect_id);
346 if ($url) {
347 wp_safe_redirect($url);
348 exit;
349 }
350 }
351
352 /**
353 * Determines whether the overlay should render.
354 *
355 * @return bool
356 */
357 public function should_render(): bool
358 {
359 if (is_admin() || wp_doing_ajax()) {
360 return false;
361 }
362
363 if (!$this->is_enabled()) {
364 return false;
365 }
366
367 if (!$this->passes_display_rules()) {
368 return false;
369 }
370
371 if ($this->is_excluded_page()) {
372 return false;
373 }
374
375 $status = $this->get_cookie_status();
376
377 if ($status === 'allowed') {
378 return false;
379 }
380
381 if ($status === 'denied' && $this->options['behaviour']['deny_action'] === 'redirect') {
382 return false;
383 }
384
385 return true;
386 }
387
388 /**
389 * Indicates that a blocked state still needs markup (deny blocking).
390 *
391 * @return bool
392 */
393 protected function should_render_block_state(): bool
394 {
395 if (!$this->is_enabled()) {
396 return false;
397 }
398
399 $status = $this->get_cookie_status();
400 return $status === 'denied' && $this->options['behaviour']['deny_action'] === 'block';
401 }
402
403 /**
404 * Checks if general display rules allow the gate.
405 *
406 * @return bool
407 */
408 protected function passes_display_rules(): bool
409 {
410 $general = $this->options['general'];
411 $display = $this->options['display'];
412
413 if ($general['audience'] === 'guests' && is_user_logged_in()) {
414 return false;
415 }
416
417 $scope = $display['scope'];
418
419 if ($scope === 'posts') {
420 return is_singular('post');
421 }
422
423 if ($scope === 'pages') {
424 return is_page();
425 }
426
427 return true;
428 }
429
430 /**
431 * Checks if current page is excluded from gating.
432 *
433 * @return bool
434 */
435 protected function is_excluded_page(): bool
436 {
437 if (!is_singular()) {
438 return false;
439 }
440
441 $exclude_ids = array_map('absint', $this->options['display']['exclude_ids']);
442 $current_id = get_the_ID();
443
444 if (in_array($current_id, $exclude_ids, true)) {
445 return true;
446 }
447
448 $deny_redirect_id = isset($this->options['behaviour']['deny_redirect_page']) ? (int)$this->options['behaviour']['deny_redirect_page'] : 0;
449 if ($deny_redirect_id && $deny_redirect_id === $current_id) {
450 return true;
451 }
452
453 return false;
454 }
455
456 /**
457 * Builds the frontend payload localized to JS.
458 *
459 * @return array<string, mixed>
460 */
461 public function get_frontend_payload(): array
462 {
463 $design = $this->options['design'];
464 $behaviour = $this->options['behaviour'];
465 $general = $this->options['general'];
466 $dob = $this->options['dob'];
467
468 return [
469 'enabled' => $this->is_enabled(),
470 'mode' => $general['mode'],
471 'minAge' => $this->resolve_minimum_age(),
472 'texts' => [
473 'title' => $design['title'],
474 'subtitle' => $design['subtitle'],
475 'yes' => $design['button_yes'],
476 'no' => $design['button_no'],
477 'block' => $behaviour['block_message'],
478 ],
479 'design' => [
480 'template' => $design['template'],
481 'overlayColor' => $design['overlay_color'],
482 'overlayOpacity' => $design['overlay_opacity'],
483 'cardBackground' => $design['card_background'],
484 'cardWidth' => $design['card_width'],
485 'cardAlign' => $design['card_align'],
486 'textColor' => $design['text_color'],
487 'titleSize' => $design['title_size'],
488 'bodySize' => $design['body_size'],
489 'titleWeight' => $design['title_weight'],
490 'bodyWeight' => $design['body_weight'],
491 'buttonYesColor' => $design['button_yes_color'],
492 'buttonYesBg' => $design['button_yes_bg'],
493 'buttonNoColor' => $design['button_no_color'],
494 'buttonNoBg' => $design['button_no_bg'],
495 'buttonYesHoverBg' => $design['button_yes_hover_bg'] ?? $design['button_yes_bg'],
496 'buttonYesHoverColor' => $design['button_yes_hover_color'] ?? $design['button_yes_color'],
497 'buttonNoHoverBg' => $design['button_no_hover_bg'] ?? $design['button_no_bg'],
498 'buttonNoHoverColor' => $design['button_no_hover_color'] ?? $design['button_no_color'],
499 'animation' => $design['animation'],
500 'logo' => $design['logo'],
501 'backgroundImage' => $design['background_image'],
502 ],
503 'behaviour' => [
504 'denyAction' => $behaviour['deny_action'],
505 'denyRedirect' => (int) $behaviour['deny_redirect_page'],
506 'denyRedirectUrl' => $behaviour['deny_redirect_page'] ? get_permalink((int) $behaviour['deny_redirect_page']) : '',
507 'blockMessage' => $behaviour['block_message'],
508 'consentCheckbox' => (bool) $behaviour['consent_checkbox'],
509 'consentLabel' => esc_html__('I agree to the policy.', 'king-addons'),
510 'repeatMode' => $behaviour['repeat_mode'] ?? 'days',
511 'repeatDays' => (int) ($behaviour['repeat_days'] ?? 30),
512 ],
513 'cookie' => [
514 'name' => $this->get_cookie_name(),
515 'days' => (int) $general['cookie_days'],
516 'revision' => $this->options['advanced']['revision'],
517 'respectRevision' => (bool) $behaviour['reset_on_rule_change'],
518 'domain' => $this->get_cookie_domain(),
519 ],
520 'dob' => [
521 'format' => $dob['format'],
522 'errors' => [
523 'invalid' => $dob['error_invalid'],
524 'denied' => $dob['error_denied'],
525 ],
526 ],
527 'status' => $this->get_cookie_status(),
528 'shouldRender' => $this->should_render(),
529 'ajax' => [
530 'url' => admin_url('admin-ajax.php'),
531 'nonce' => wp_create_nonce(self::NONCE_ACTION),
532 'action' => self::AJAX_ACTION_DOB,
533 ],
534 'isPremium' => $this->is_premium(),
535 'elementorTemplate' => '',
536 ];
537 }
538
539 /**
540 * Indicates whether premium code is available.
541 *
542 * @return bool
543 */
544 protected function is_premium(): bool
545 {
546 if (!function_exists('king_addons_freemius')) {
547 return false;
548 }
549
550 $fs = king_addons_freemius();
551 if (!is_object($fs) || !method_exists($fs, 'can_use_premium_code')) {
552 return false;
553 }
554
555 return (bool) $fs->can_use_premium_code();
556 }
557
558 /**
559 * Returns the sanitized options merged with defaults.
560 *
561 * @return array<string, mixed>
562 */
563 public function get_options(): array
564 {
565 $saved = get_option(self::OPTION_NAME, []);
566 $defaults = $this->get_default_options();
567
568 return wp_parse_args($saved, $defaults);
569 }
570
571 /**
572 * Default options for the feature.
573 *
574 * @return array<string, mixed>
575 */
576 public function get_default_options(): array
577 {
578 return [
579 'general' => [
580 'enabled' => false,
581 'audience' => 'guests',
582 'mode' => 'confirm',
583 'min_age' => 18,
584 'cookie_days' => 30,
585 ],
586 'display' => [
587 'scope' => 'site',
588 'exclude_ids' => [],
589 'mode' => 'global',
590 'cpt_scope' => [],
591 'archives' => false,
592 'woo' => [
593 'enabled' => false,
594 'apply_to' => 'product',
595 'categories' => [],
596 ],
597 ],
598 'design' => [
599 'template' => 'center-card',
600 'overlay_color' => '#0d0d0d',
601 'overlay_opacity' => 0.7,
602 'card_background' => '#ffffff',
603 'card_width' => 520,
604 'card_align' => 'center',
605 'title' => esc_html__('Age verification required', 'king-addons'),
606 'subtitle' => esc_html__('This content is restricted to visitors over the specified age.', 'king-addons'),
607 'button_yes' => esc_html__('Yes, continue', 'king-addons'),
608 'button_no' => esc_html__('No, leave', 'king-addons'),
609 'text_color' => '#111827',
610 'title_size' => 24,
611 'body_size' => 16,
612 'title_weight' => 700,
613 'body_weight' => 400,
614 'button_yes_color' => '#ffffff',
615 'button_yes_bg' => '#10b981',
616 'button_no_color' => '#ffffff',
617 'button_no_bg' => '#ef4444',
618 'animation' => 'none',
619 'logo' => '',
620 'background_image' => '',
621 'button_yes_hover_bg' => '#0f9c75',
622 'button_no_hover_bg' => '#d92d20',
623 'button_yes_hover_color' => '#ffffff',
624 'button_no_hover_color' => '#ffffff',
625 'elementor_template' => 0,
626 ],
627 'behaviour' => [
628 'deny_action' => 'redirect',
629 'deny_redirect_page' => 0,
630 'block_message' => esc_html__('Access denied. You do not meet the minimum age requirement.', 'king-addons'),
631 'consent_checkbox' => false,
632 'reset_on_rule_change' => true,
633 'repeat_mode' => 'days',
634 'repeat_days' => 30,
635 ],
636 'advanced' => [
637 'revision' => time(),
638 ],
639 'geo' => [
640 'enabled' => false,
641 'default_age' => 18,
642 'map' => [],
643 ],
644 'dob' => [
645 'format' => 'dmy',
646 'max_age' => 120,
647 'error_invalid' => esc_html__('Enter a valid date of birth.', 'king-addons'),
648 'error_denied' => esc_html__('You do not meet the minimum age requirement.', 'king-addons'),
649 ],
650 ];
651 }
652
653 /**
654 * Sanitizes and normalizes saved options.
655 *
656 * @param mixed $raw Raw submitted options.
657 * @return array<string, mixed>
658 */
659 public function sanitize_options($raw): array
660 {
661 $raw = is_array($raw) ? $raw : [];
662 $current = $this->get_options();
663 $defaults = $this->get_default_options();
664
665 $general = $raw['general'] ?? [];
666 $display = $raw['display'] ?? [];
667 $design = $raw['design'] ?? [];
668 $behaviour = $raw['behaviour'] ?? [];
669
670 $sanitized = [
671 'general' => [
672 'enabled' => !empty($general['enabled']),
673 'audience' => in_array($general['audience'] ?? 'guests', ['guests', 'all'], true) ? $general['audience'] : $defaults['general']['audience'],
674 'mode' => in_array($general['mode'] ?? 'confirm', ['confirm', 'minimum'], true) ? $general['mode'] : 'confirm',
675 'min_age' => max(0, absint($general['min_age'] ?? $defaults['general']['min_age'])),
676 'cookie_days' => max(0, absint($general['cookie_days'] ?? $defaults['general']['cookie_days'])),
677 ],
678 'display' => [
679 'scope' => in_array($display['scope'] ?? 'site', ['site', 'posts', 'pages'], true) ? $display['scope'] : $defaults['display']['scope'],
680 'exclude_ids' => array_values(array_filter(array_map('absint', $display['exclude_ids'] ?? []))),
681 ],
682 'design' => [
683 'template' => in_array($design['template'] ?? 'center-card', ['center-card', 'bottom-card'], true) ? $design['template'] : 'center-card',
684 'overlay_color' => sanitize_hex_color($design['overlay_color'] ?? $defaults['design']['overlay_color']) ?: $defaults['design']['overlay_color'],
685 'overlay_opacity' => min(1, max(0, floatval($design['overlay_opacity'] ?? $defaults['design']['overlay_opacity']))),
686 'card_background' => sanitize_hex_color($design['card_background'] ?? $defaults['design']['card_background']) ?: $defaults['design']['card_background'],
687 'card_width' => max(280, absint($design['card_width'] ?? $defaults['design']['card_width'])),
688 'card_align' => in_array($design['card_align'] ?? 'center', ['center', 'bottom'], true) ? $design['card_align'] : 'center',
689 'title' => sanitize_text_field($design['title'] ?? $defaults['design']['title']),
690 'subtitle' => sanitize_text_field($design['subtitle'] ?? $defaults['design']['subtitle']),
691 'button_yes' => sanitize_text_field($design['button_yes'] ?? $defaults['design']['button_yes']),
692 'button_no' => sanitize_text_field($design['button_no'] ?? $defaults['design']['button_no']),
693 'text_color' => sanitize_hex_color($design['text_color'] ?? $defaults['design']['text_color']) ?: $defaults['design']['text_color'],
694 'title_size' => max(10, absint($design['title_size'] ?? $defaults['design']['title_size'])),
695 'body_size' => max(10, absint($design['body_size'] ?? $defaults['design']['body_size'])),
696 'title_weight' => max(100, absint($design['title_weight'] ?? $defaults['design']['title_weight'])),
697 'body_weight' => max(100, absint($design['body_weight'] ?? $defaults['design']['body_weight'])),
698 'button_yes_color' => sanitize_hex_color($design['button_yes_color'] ?? $defaults['design']['button_yes_color']) ?: $defaults['design']['button_yes_color'],
699 'button_yes_bg' => sanitize_hex_color($design['button_yes_bg'] ?? $defaults['design']['button_yes_bg']) ?: $defaults['design']['button_yes_bg'],
700 'button_no_color' => sanitize_hex_color($design['button_no_color'] ?? $defaults['design']['button_no_color']) ?: $defaults['design']['button_no_color'],
701 'button_no_bg' => sanitize_hex_color($design['button_no_bg'] ?? $defaults['design']['button_no_bg']) ?: $defaults['design']['button_no_bg'],
702 'animation' => in_array($design['animation'] ?? 'none', ['none', 'fade', 'slide-up', 'slide-down'], true) ? $design['animation'] : 'none',
703 'logo' => esc_url_raw($design['logo'] ?? ''),
704 'background_image' => esc_url_raw($design['background_image'] ?? ''),
705 ],
706 'behaviour' => [
707 'deny_action' => in_array($behaviour['deny_action'] ?? 'redirect', ['redirect', 'block'], true) ? $behaviour['deny_action'] : 'redirect',
708 'deny_redirect_page' => absint($behaviour['deny_redirect_page'] ?? 0),
709 'block_message' => sanitize_text_field($behaviour['block_message'] ?? $defaults['behaviour']['block_message']),
710 'consent_checkbox' => !empty($behaviour['consent_checkbox']),
711 'reset_on_rule_change' => isset($behaviour['reset_on_rule_change']) ? (bool) $behaviour['reset_on_rule_change'] : $defaults['behaviour']['reset_on_rule_change'],
712 'repeat_mode' => in_array($behaviour['repeat_mode'] ?? 'days', ['days', 'session', 'once'], true) ? $behaviour['repeat_mode'] : 'days',
713 'repeat_days' => max(0, absint($behaviour['repeat_days'] ?? $defaults['behaviour']['repeat_days'])),
714 ],
715 'advanced' => [
716 'revision' => $current['advanced']['revision'] ?? time(),
717 ],
718 'geo' => $this->sanitize_geo_options($raw),
719 'dob' => $this->sanitize_dob_options($raw),
720 ];
721
722 // Force revision bump when rule reset is enabled and settings are updated.
723 if (!empty($sanitized['behaviour']['reset_on_rule_change'])) {
724 $sanitized['advanced']['revision'] = time();
725 }
726
727 $this->options = wp_parse_args($sanitized, $this->get_default_options());
728
729 return $this->options;
730 }
731
732 /**
733 * Sanitizes geo options.
734 *
735 * @param array<string, mixed> $raw Raw input.
736 * @return array<string, mixed>
737 */
738 protected function sanitize_geo_options(array $raw): array
739 {
740 $defaults = $this->get_default_options()['geo'];
741 $geo = $raw['geo'] ?? [];
742
743 $enabled = !empty($geo['enabled']) && $this->is_premium();
744 $default_age = max(0, absint($geo['default_age'] ?? $defaults['default_age']));
745
746 // Parse geo map from textarea (format: "US=21\nUK=18")
747 $map = [];
748 if (!empty($geo['map']) && is_string($geo['map'])) {
749 $lines = explode("\n", $geo['map']);
750 foreach ($lines as $line) {
751 $line = trim($line);
752 if (empty($line) || strpos($line, '=') === false) {
753 continue;
754 }
755 [$code, $age] = explode('=', $line, 2);
756 $code = strtoupper(trim(sanitize_text_field($code)));
757 $age = absint(trim($age));
758 if (strlen($code) === 2 && $age > 0) {
759 $map[$code] = $age;
760 }
761 }
762 } elseif (is_array($geo['map'] ?? null)) {
763 // Already an array (from existing options)
764 foreach ($geo['map'] as $code => $age) {
765 $code = strtoupper(sanitize_text_field($code));
766 $map[$code] = absint($age);
767 }
768 }
769
770 return [
771 'enabled' => $enabled,
772 'default_age' => $default_age,
773 'map' => $map,
774 ];
775 }
776
777 /**
778 * Sanitizes DOB options.
779 *
780 * @param array<string, mixed> $raw Raw input.
781 * @return array<string, mixed>
782 */
783 protected function sanitize_dob_options(array $raw): array
784 {
785 $defaults = $this->get_default_options()['dob'];
786 $dob = $raw['dob'] ?? [];
787
788 return [
789 'format' => in_array($dob['format'] ?? 'dmy', ['dmy', 'mdy', 'ymd'], true) ? $dob['format'] : 'dmy',
790 'max_age' => max(10, absint($dob['max_age'] ?? $defaults['max_age'])),
791 'error_invalid' => sanitize_text_field($dob['error_invalid'] ?? $defaults['error_invalid']),
792 'error_denied' => sanitize_text_field($dob['error_denied'] ?? $defaults['error_denied']),
793 ];
794 }
795
796 /**
797 * Determines whether the feature is active.
798 *
799 * @return bool
800 */
801 protected function is_enabled(): bool
802 {
803 return !empty($this->options['general']['enabled']);
804 }
805
806 /**
807 * Returns the cookie name to use.
808 *
809 * @return string
810 */
811 protected function get_cookie_name(): string
812 {
813 return self::COOKIE_NAME;
814 }
815
816 /**
817 * Parses the stored cookie status.
818 *
819 * @return string allowed|denied|dob:{date}|''
820 */
821 protected function get_cookie_status(): string
822 {
823 $cookie_name = $this->get_cookie_name();
824
825 if (!isset($_COOKIE[$cookie_name])) {
826 return '';
827 }
828
829 $raw = sanitize_text_field(wp_unslash($_COOKIE[$cookie_name]));
830 $parts = explode('|', $raw);
831 $status = $parts[0] ?? '';
832 $revision = $parts[1] ?? '';
833
834 if (!empty($this->options['behaviour']['reset_on_rule_change'])) {
835 if ($revision && (string)$revision !== (string)$this->options['advanced']['revision']) {
836 return '';
837 }
838 }
839
840 if ($status === 'allowed' || $status === 'denied' || strpos($status, 'dob:') === 0) {
841 return $status;
842 }
843
844 return '';
845 }
846
847 /**
848 * Writes the status cookie with revision marker.
849 *
850 * @param string $status allowed|denied|dob:{date}
851 * @param int $days Cookie lifetime in days. Zero for session cookie.
852 * @return void
853 */
854 protected function set_status_cookie(string $status, int $days): void
855 {
856 $value = $status . '|' . $this->options['advanced']['revision'];
857 $expire = $days > 0 ? time() + (DAY_IN_SECONDS * $days) : 0;
858 setcookie(
859 $this->get_cookie_name(),
860 $value,
861 [
862 'expires' => $expire,
863 'path' => '/',
864 'domain' => $this->get_cookie_domain(),
865 'secure' => is_ssl(),
866 'httponly' => false,
867 'samesite' => 'Lax',
868 ]
869 );
870 $_COOKIE[$this->get_cookie_name()] = $value;
871 }
872
873 /**
874 * Resolves cookie domain to current host.
875 *
876 * @return string
877 */
878 protected function get_cookie_domain(): string
879 {
880 $host = wp_parse_url(home_url(), PHP_URL_HOST);
881 return $host ? $host : '';
882 }
883 }
884
885
886
887