*/ private static $services = []; /** * Cached, request-scoped copy of the module's options. * * @var array */ private static $runtime_options = []; /** * Start the engine running. * * @return void */ public function __construct() { $this->options = wp_parse_args( get_option( self::OPTION_NAME, [] ), self::get_default_settings() ); self::$runtime_options = $this->options; add_action( 'init', [ $this, 'prepare_translated_options' ], 0 ); add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_frontend_assets' ], 100 ); add_action( 'wp_footer', [ $this, 'render_frontend' ], 5 ); add_shortcode( 'adminoptim_consent_preferences', [ $this, 'render_preferences_shortcode' ] ); } /** * Get the consent defaults without translated strings. * * @return array */ public static function get_default_settings(): array { return [ 'enable_banner' => 1, 'expiry_days' => 180, 'banner_title' => 'Your privacy choices', 'banner_message' => 'We use optional services only when you allow their category. You can change your choices at any time.', 'accept_label' => 'Accept All', 'reject_label' => 'Reject Optional', 'manage_label' => 'Manage Preferences', 'preferences_title' => 'Consent preferences', 'preferences_text' => 'Choose which optional categories you allow. Necessary services are always active.', 'save_label' => 'Save Preferences', 'necessary_description' => 'Required for the site to function and cannot be switched off.', 'functional_enabled' => 1, 'functional_description' => 'Supports optional site features and preferences.', 'analytics_enabled' => 1, 'analytics_description' => 'Helps measure how visitors use the site.', 'marketing_enabled' => 1, 'marketing_description' => 'Supports advertising and campaign measurement.', ]; } /** * Get defaults after translations have loaded. * * @return array */ public static function get_translated_default_settings(): array { $defaults = self::get_default_settings(); $defaults['banner_title'] = __( 'Your privacy choices', 'admin-optimizer' ); $defaults['banner_message'] = __( 'We use optional services only when you allow their category. You can change your choices at any time.', 'admin-optimizer' ); $defaults['accept_label'] = __( 'Accept All', 'admin-optimizer' ); $defaults['reject_label'] = __( 'Reject Optional', 'admin-optimizer' ); $defaults['manage_label'] = __( 'Manage Preferences', 'admin-optimizer' ); $defaults['preferences_title'] = __( 'Consent preferences', 'admin-optimizer' ); $defaults['preferences_text'] = __( 'Choose which optional categories you allow. Necessary services are always active.', 'admin-optimizer' ); $defaults['save_label'] = __( 'Save Preferences', 'admin-optimizer' ); $defaults['necessary_description'] = __( 'Required for the site to function and cannot be switched off.', 'admin-optimizer' ); $defaults['functional_description'] = __( 'Supports optional site features and preferences.', 'admin-optimizer' ); $defaults['analytics_description'] = __( 'Helps measure how visitors use the site.', 'admin-optimizer' ); $defaults['marketing_description'] = __( 'Supports advertising and campaign measurement.', 'admin-optimizer' ); return $defaults; } /** * Normalize raw consent settings into stable stored values. * * @param mixed $input Raw settings input. * @return array */ public static function normalize_settings( $input ): array { $defaults = self::get_default_settings(); $input = is_array( $input ) ? $input : []; $sanitized = [ 'enable_banner' => empty( $input['enable_banner'] ) ? 0 : 1, 'functional_enabled' => empty( $input['functional_enabled'] ) ? 0 : 1, 'analytics_enabled' => empty( $input['analytics_enabled'] ) ? 0 : 1, 'marketing_enabled' => empty( $input['marketing_enabled'] ) ? 0 : 1, 'expiry_days' => isset( $input['expiry_days'] ) ? max( 1, min( 365, absint( $input['expiry_days'] ) ) ) : $defaults['expiry_days'], ]; foreach ( [ 'banner_title', 'accept_label', 'reject_label', 'manage_label', 'preferences_title', 'save_label', 'necessary_description', 'functional_description', 'analytics_description', 'marketing_description' ] as $field ) { $sanitized[ $field ] = isset( $input[ $field ] ) ? sanitize_text_field( $input[ $field ] ) : $defaults[ $field ]; } foreach ( [ 'banner_message', 'preferences_text' ] as $field ) { $sanitized[ $field ] = isset( $input[ $field ] ) ? sanitize_textarea_field( $input[ $field ] ) : $defaults[ $field ]; } return $sanitized; } /** * Refresh translated defaults on init. * * @return void */ public function prepare_translated_options(): void { $this->options = wp_parse_args( get_option( self::OPTION_NAME, [] ), self::get_translated_default_settings() ); self::$runtime_options = $this->options; } /** * Normalize an arbitrary value into a known consent category slug. * * @param mixed $category Category value to normalize. * @return string Normalized category slug, or an empty string if invalid. */ public static function normalize_category( $category ): string { $category = is_string( $category ) ? sanitize_key( $category ) : ''; return in_array( $category, [ 'necessary', 'functional', 'analytics', 'marketing' ], true ) ? $category : ''; } /** * Check whether a consent category is enabled in settings. * * @param mixed $category Category to check. * @param array|null $options Options to check against. Defaults to the current module options. * @return bool */ public static function is_category_enabled( $category, ?array $options = null ): bool { $category = self::normalize_category( $category ); if ( 'necessary' === $category ) { return true; } $options = null === $options ? self::options() : $options; return '' !== $category && ! empty( $options[ $category . '_enabled' ] ); } /** * Decode and validate a raw consent cookie value. * * @param mixed $raw Raw (still URL-encoded) cookie value. * @param array|null $options Options to validate against. Defaults to the current module options. * @param int|null $now Current timestamp to validate against. Defaults to time(). * @return array|null Normalized consent data, or null if the cookie is missing, malformed, or expired. */ public static function normalize_cookie( $raw, ?array $options = null, ?int $now = null ): ?array { $options = null === $options ? self::options() : wp_parse_args( $options, self::get_default_settings() ); $now = null === $now ? time() : $now; if ( ! is_string( $raw ) || '' === $raw ) { return null; } $data = json_decode( rawurldecode( $raw ), true ); if ( ! is_array( $data ) || self::SCHEMA_VERSION !== ( $data['version'] ?? null ) || ! isset( $data['timestamp'], $data['consent'] ) || ! is_int( $data['timestamp'] ) || ! is_array( $data['consent'] ) ) { return null; } $expiry = max( 1, (int) $options['expiry_days'] ) * DAY_IN_SECONDS; if ( $data['timestamp'] > $now + 300 || $data['timestamp'] + $expiry < $now ) { return null; } $state = [ 'necessary' => true ]; foreach ( [ 'functional', 'analytics', 'marketing' ] as $category ) { $state[ $category ] = self::is_category_enabled( $category, $options ) && true === ( $data['consent'][ $category ] ?? false ); } return [ 'version' => self::SCHEMA_VERSION, 'consent' => $state, 'timestamp' => $data['timestamp'], ]; } /** * Get the current visitor's normalized consent state from the request cookie. * * @return array|null */ public static function get_consent(): ?array { $raw = isset( $_COOKIE[ self::COOKIE_NAME ] ) ? sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE_NAME ] ) ) : ''; return self::normalize_cookie( $raw ); } /** * Check whether the current visitor has consented to a category. * * @param mixed $category Category to check. * @return bool */ public static function has_consent( $category ): bool { $category = self::normalize_category( $category ); if ( 'necessary' === $category ) { return true; } if ( ! self::is_category_enabled( $category ) ) { return false; } $state = self::get_consent(); return null !== $state && ! empty( $state['consent'][ $category ] ); } /** * Register an optional service for consent gating. * * @param array $service { * Service definition. * * @type string $id Unique service ID. * @type string $name Service display name. Defaults to the ID. * @type string $category Consent category the service belongs to. * @type string $description Service description. * @type string $src Service script URL. Must use the http or https scheme. * } * @return bool True on successful registration, false if the service definition is invalid. */ public static function register_service( array $service ) { $id = isset( $service['id'] ) ? sanitize_key( $service['id'] ) : ''; $category = self::normalize_category( $service['category'] ?? '' ); $src = isset( $service['src'] ) ? esc_url_raw( $service['src'], [ 'http', 'https' ] ) : ''; $scheme = is_string( $src ) ? strtolower( (string) wp_parse_url( $src, PHP_URL_SCHEME ) ) : ''; if ( '' === $id || ! in_array( $category, [ 'functional', 'analytics', 'marketing' ], true ) || ! in_array( $scheme, [ 'http', 'https' ], true ) ) { return false; } self::$services[ $id ] = [ 'id' => $id, 'name' => sanitize_text_field( $service['name'] ?? $id ), 'category' => $category, 'description' => sanitize_text_field( $service['description'] ?? '' ), 'src' => $src, ]; return true; } /** * Get all services registered for consent gating. * * @return array */ public static function get_registered_services(): array { return array_values( self::$services ); } /** * Enqueue the frontend banner assets and localize the consent config. * * @return void */ public function enqueue_frontend_assets(): void { if ( empty( $this->options['enable_banner'] ) ) { return; } do_action( 'adminoptim_consent_register_services' ); wp_enqueue_style( 'adminoptim-consent-manager', self::MODULE_URL . 'assets/css/consent-manager.min.css', [], ADMINOPTIMIZER_VERSION ); wp_enqueue_script( 'adminoptim-consent-manager', self::MODULE_URL . 'assets/js/consent-manager.min.js', [], ADMINOPTIMIZER_VERSION, true ); wp_localize_script( 'adminoptim-consent-manager', 'AdminOptimizerConsentConfig', $this->frontend_config() ); } /** * Build the frontend config localized to the consent manager script. * * @return array */ private function frontend_config(): array { $categories = []; foreach ( [ 'necessary', 'functional', 'analytics', 'marketing' ] as $category ) { if ( self::is_category_enabled( $category, $this->options ) ) { $categories[] = $category; } } $services = array_values( array_filter( self::get_registered_services(), function ( $service ) { return self::is_category_enabled( $service['category'], $this->options ); } ) ); return [ 'cookieName' => self::COOKIE_NAME, 'version' => self::SCHEMA_VERSION, 'expiryDays' => (int) $this->options['expiry_days'], 'categories' => $categories, 'services' => $services, 'existingConsent' => self::get_consent(), ]; } /** * Render the consent banner and preferences dialog markup in the footer. * * @return void */ public function render_frontend(): void { if ( empty( $this->options['enable_banner'] ) ) { return; } $category_labels = [ 'necessary' => __( 'Necessary', 'admin-optimizer' ), 'functional' => __( 'Functional', 'admin-optimizer' ), 'analytics' => __( 'Analytics', 'admin-optimizer' ), 'marketing' => __( 'Marketing', 'admin-optimizer' ), ]; ?>
' . esc_html__( 'Cookie preferences', 'admin-optimizer' ) . ''; } /** * Get the module's current options, falling back to stored or default settings. * * @return array */ private static function options(): array { if ( ! empty( self::$runtime_options ) ) { return self::$runtime_options; } return wp_parse_args( get_option( self::OPTION_NAME, [] ), self::get_default_settings() ); } }