consent = $consent; $this->log = $log; add_action( 'admin_menu', [ $this, 'register_menu' ] ); // Late: every plugin (incl. Pro) has registered by now, so the sidebar can be ordered to match the tabs. add_action( 'admin_menu', [ $this, 'sync_submenu_order' ], 99 ); add_action( 'admin_init', [ $this, 'register_settings' ] ); add_action( 'admin_init', [ $this, 'handle_notice_actions' ] ); add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts' ] ); add_action( 'all_admin_notices', [ $this, 'render_admin_chrome' ] ); add_filter( 'admin_body_class', [ $this, 'body_class' ] ); add_action( 'wp_ajax_cookroco_save_settings', [ $this, 'ajax_save_settings' ] ); add_action( 'wp_ajax_cookroco_save_design', [ $this, 'ajax_save_design' ] ); add_action( 'wp_ajax_cookroco_save_layout', [ $this, 'ajax_save_layout' ] ); add_action( 'wp_ajax_cookroco_save_content', [ $this, 'ajax_save_content' ] ); add_action( 'admin_init', [ $this, 'ensure_onboarding_decided' ] ); add_action( 'admin_post_cookroco_wizard', [ $this, 'handle_wizard' ] ); add_action( 'admin_post_cookroco_autopilot', [ $this, 'handle_autopilot' ] ); add_filter( 'plugin_action_links_' . plugin_basename(COOKROCO_PLUGIN_FILE), [ $this, 'plugin_links' ] ); // The banner, its rules and its texts are written into every page: a page cache would keep // serving the old ones (and the old policy version) until it expired on its own. foreach ( [ 'cookroco_settings', 'cookroco_banner_design', 'cookroco_cookie_categories', 'cookroco_custom_services', 'cookroco_policy_page_id' ] as $opt ) { add_action( 'update_option_' . $opt, [ __CLASS__, 'purge_page_caches' ] ); } } /** * Empties the page cache of the common caching plugins, through their own public functions * and actions. Each is called only if that plugin is present; once per request at most. */ public static function purge_page_caches(): void { static $done = false; if ( $done ) { return; } $done = true; if ( function_exists( 'rocket_clean_domain' ) ) { rocket_clean_domain(); } // WP Rocket. if ( function_exists( 'w3tc_flush_all' ) ) { w3tc_flush_all(); } // W3 Total Cache. if ( function_exists( 'wp_cache_clear_cache' ) ) { wp_cache_clear_cache(); } // WP Super Cache. if ( function_exists( 'sg_cachepress_purge_cache' ) ) { sg_cachepress_purge_cache(); } // SiteGround Optimizer. if ( function_exists( 'wpo_cache_flush' ) ) { wpo_cache_flush(); } // WP-Optimize. // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- LiteSpeed Cache's own purge action. do_action( 'litespeed_purge_all' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Cache Enabler's own purge action. do_action( 'cache_enabler_clear_complete_cache' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WP Fastest Cache's own purge action. do_action( 'wpfc_clear_all_cache' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Breeze's own purge action. do_action( 'breeze_clear_all_cache' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Hummingbird's own purge action. do_action( 'wphb_clear_page_cache' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Nginx Helper's own purge action. do_action( 'rt_nginx_helper_purge_all' ); } /** * Decide, exactly once, whether to show the first-run setup wizard. * - No flag at all → an existing install that upgraded into this version (a new install * would have had it seeded to '0' by COOKROCO_Core::activate). Mark it onboarded; never * surprise a live site with the wizard. * - Flag '0' → a genuinely new install on its first admin visit: open the wizard once, * then flip to '1' so it never auto-opens again (it stays available to re-run by hand). */ public function ensure_onboarding_decided(): void { if ( ! current_user_can( self::CAP ) || wp_doing_ajax() ) { return; } // An install that predates the onboarding flag is already configured — mark it done so the // first-run autopilot never shows to an existing site. A genuinely new install keeps its // seeded '0' so the Status screen leads with the one-click autopilot hero (no forced wizard). if ( false === get_option( 'cookroco_onboarded', false ) ) { update_option( 'cookroco_onboarded', '1' ); } } public function register_settings(): void { register_setting( 'cookroco_settings_group', 'cookroco_settings', [ 'type' => 'array', 'sanitize_callback' => [ $this, 'sanitize_settings_option' ], 'default' => [], ] ); register_setting( 'cookroco_design_group', 'cookroco_banner_design', [ 'type' => 'array', 'sanitize_callback' => [ $this, 'sanitize_design_option' ], 'default' => [], ] ); register_setting( 'cookroco_services_group', 'cookroco_custom_services', [ 'type' => 'array', 'sanitize_callback' => [ $this, 'sanitize_services_option' ], 'default' => [], ] ); register_setting( 'cookroco_categories_group', 'cookroco_cookie_categories', [ 'type' => 'array', 'sanitize_callback' => [ $this, 'sanitize_categories_option' ], 'default' => [], ] ); } public function sanitize_settings_option( $raw ): array { $r = is_array( $raw ) ? $raw : []; $c = (array) get_option( 'cookroco_settings', [] ); // Preserve fields set outside this form (layout/position via the visual picker). if ( ! is_array( $c ) ) { $c = []; } // Multi-form safe: a partial form (e.g. Blocking → Advanced) declares the checkbox keys // it owns via cookroco_settings[_fields][]. Checkbox keys NOT owned by the submitting // form are preserved from the stored value instead of being read as "absent = off" — // otherwise a second form that writes cookroco_settings would silently wipe the boxes it // doesn't render. No _fields marker = a full form (the legacy Settings page): read all. $scope = ( isset( $r['_fields'] ) && is_array( $r['_fields'] ) ) ? array_map( 'sanitize_key', $r['_fields'] ) : null; $cb = static function ( string $k, bool $default = false ) use ( $r, $c, $scope ): bool { $owned = ( null === $scope ) || in_array( $k, $scope, true ); if ( $owned ) { return ! empty( $r[ $k ] ); } return array_key_exists( $k, $c ) ? ! empty( $c[ $k ] ) : $default; }; // Off unless the owner turns it on. $show_floating = $cb( 'show_floating_button' ); $law_key = in_array( $r['default_law'] ?? $c['default_law'] ?? 'generic', [ 'gdpr', 'ccpa', 'lgpd', 'lfpdppp', 'generic' ], true ) ? ( $r['default_law'] ?? $c['default_law'] ?? 'generic' ) : 'generic'; $out = [ 'banner_variant' => in_array( $r['banner_variant'] ?? $c['banner_variant'] ?? 'bottom_bar', [ 'bottom_bar', 'top_bar', 'modal', 'floating', 'drawer' ], true ) ? ( $r['banner_variant'] ?? $c['banner_variant'] ?? 'bottom_bar' ) : 'bottom_bar', 'banner_position' => in_array( $r['banner_position'] ?? $c['banner_position'] ?? 'right', [ 'left', 'right', 'center' ], true ) ? ( $r['banner_position'] ?? $c['banner_position'] ?? 'right' ) : 'right', 'banner_font' => in_array( $r['banner_font'] ?? $c['banner_font'] ?? 'system', [ 'system', 'match', 'custom' ], true ) ? ( $r['banner_font'] ?? $c['banner_font'] ?? 'system' ) : 'system', 'banner_font_family' => sanitize_text_field( mb_substr( (string) ( $r['banner_font_family'] ?? $c['banner_font_family'] ?? '' ), 0, 120 ) ), 'bar_width' => in_array( $r['bar_width'] ?? $c['bar_width'] ?? 'contained', [ 'contained', 'full' ], true ) ? ( $r['bar_width'] ?? $c['bar_width'] ?? 'contained' ) : 'contained', 'show_backdrop' => array_key_exists( 'show_backdrop', $r ) ? ! empty( $r['show_backdrop'] ) : ! empty( $c['show_backdrop'] ), // Banner content/typography is managed by the Cookie Banner → Content panel (AJAX). // The Settings form does not carry these fields, so preserve them from the existing // option ($c) instead of resetting to defaults. 'show_reject_button' => array_key_exists( 'show_reject_button', $r ) ? ! empty( $r['show_reject_button'] ) : ! empty( $c['show_reject_button'] ), 'show_config_button' => array_key_exists( 'show_config_button', $r ) ? ! empty( $r['show_config_button'] ) : ( array_key_exists( 'show_config_button', $c ) ? ! empty( $c['show_config_button'] ) : true ), 'show_floating_button' => $show_floating, // Under the GDPR consent lasts 13 months at most (395 days, the CNIL's ceiling). 'consent_duration_days' => max( 1, min( 'gdpr' === $law_key ? 395 : 730, absint( $r['consent_duration_days'] ?? $c['consent_duration_days'] ?? 365 ) ) ), 'log_retention_days' => max( 30, min( 3650, absint( $r['log_retention_days'] ?? $c['log_retention_days'] ?? 365 ) ) ), 'policy_version' => mb_substr( sanitize_text_field( $r['policy_version'] ?? $c['policy_version'] ?? '1.0' ), 0, 20 ), // the consent log column holds 20 characters; a longer value would stop every record from being written 'banner_title' => sanitize_text_field( mb_substr( (string) ( $r['banner_title'] ?? $c['banner_title'] ?? '' ), 0, 80 ) ), 'banner_body' => sanitize_textarea_field( mb_substr( (string) ( $r['banner_body'] ?? $c['banner_body'] ?? '' ), 0, 1000 ) ), 'btn_accept_text' => sanitize_text_field( mb_substr( (string) ( $r['btn_accept_text'] ?? $c['btn_accept_text'] ?? '' ), 0, 32 ) ), 'btn_reject_text' => sanitize_text_field( mb_substr( (string) ( $r['btn_reject_text'] ?? $c['btn_reject_text'] ?? '' ), 0, 32 ) ), 'btn_config_text' => sanitize_text_field( mb_substr( (string) ( $r['btn_config_text'] ?? $c['btn_config_text'] ?? '' ), 0, 32 ) ), 'modal_title' => sanitize_text_field( mb_substr( (string) ( $r['modal_title'] ?? $c['modal_title'] ?? '' ), 0, 80 ) ), 'modal_desc' => sanitize_textarea_field( mb_substr( (string) ( $r['modal_desc'] ?? $c['modal_desc'] ?? '' ), 0, 1000 ) ), // Where the banner's privacy link goes when the notice lives elsewhere (a group site, // a legal portal). Empty = the Cookie Policy page, or else WordPress's privacy page. 'policy_url' => esc_url_raw( trim( (string) ( $r['policy_url'] ?? $c['policy_url'] ?? '' ) ), [ 'https', 'http' ] ), 'title_size' => max( 14, min( 22, intval( $r['title_size'] ?? $c['title_size'] ?? 18 ) ) ), 'body_size' => max( 11, min( 15, intval( $r['body_size'] ?? $c['body_size'] ?? 12 ) ) ), 'default_law' => $law_key, // Opt-out only where the law allows it: the GDPR, the LGPD and the LFPDPPP ask for // consent BEFORE tracking, so for them the model is always opt-in. 'default_state' => ( 'opt_out' === ( $r['default_state'] ?? $c['default_state'] ?? 'opt_in' ) && ! in_array( $law_key, COOKROCO_Consent::OPT_IN_LAWS, true ) ) ? 'opt_out' : 'opt_in', 'woocommerce_mode' => 'auto', 'gcm_enabled' => $cb( 'gcm_enabled', true ), 'block_scripts' => $cb( 'block_scripts', true ), 'block_raw_html' => $cb( 'block_raw_html', false ), 'block_embeds' => $cb( 'block_embeds', true ), // Off unless ticked: deleting the plugin keeps the consent records (proof of consent). 'delete_data_on_uninstall' => $cb( 'delete_data_on_uninstall' ), ]; // Keys this form does not know (Cookie Rocket Pro's, or any add-on's) are carried over from // the stored value. Without this, saving while Pro is not loaded (the free edition still // active next to it, or Pro switched off for a while) silently wiped Pro's settings. $out += $c; return apply_filters( 'cookroco_sanitize_settings', $out, $r, $c ); } public function sanitize_design_option( $raw ): array { $r = is_array( $raw ) ? $raw : []; $c = get_option( 'cookroco_banner_design', [] ); // Preserve fields not present in the submitted form. if ( ! is_array( $c ) ) { $c = []; } $out = [ 'primary_color' => sanitize_hex_color( $r['primary_color'] ?? $c['primary_color'] ?? '#111827' ), 'text_color' => sanitize_hex_color( $r['text_color'] ?? $c['text_color'] ?? '#1a1a1a' ), 'background_color' => sanitize_hex_color( $r['background_color'] ?? $c['background_color'] ?? '#ffffff' ), 'accept_button_bg' => sanitize_hex_color( $r['accept_button_bg'] ?? $c['accept_button_bg'] ?? '#111827' ), 'accept_text_color' => sanitize_hex_color( $r['accept_text_color'] ?? $c['accept_text_color'] ?? '#ffffff' ), 'reject_button_bg' => sanitize_hex_color( $r['reject_button_bg'] ?? $c['reject_button_bg'] ?? '#6b7280' ), // Secondary buttons (Reject / Customize): 'outline' (default, current ghost look) or // 'solid' (filled with the colors below). Default preserves every existing banner. 'secondary_btn_style' => in_array( $r['secondary_btn_style'] ?? $c['secondary_btn_style'] ?? 'outline', [ 'outline', 'solid' ], true ) ? ( $r['secondary_btn_style'] ?? $c['secondary_btn_style'] ?? 'outline' ) : 'outline', 'secondary_btn_bg' => sanitize_hex_color( $r['secondary_btn_bg'] ?? $c['secondary_btn_bg'] ?? '#6b7280' ), 'secondary_btn_text' => sanitize_hex_color( $r['secondary_btn_text'] ?? $c['secondary_btn_text'] ?? '#ffffff' ), 'border_radius' => self::sanitize_radius( $r['border_radius'] ?? null, $c['border_radius'] ?? '0px' ), // The line around the banner: on unless turned off, as every banner had it before. 'banner_border' => 'off' === ( $r['banner_border'] ?? $c['banner_border'] ?? 'on' ) ? 'off' : 'on', // The shadow under the banner: off unless turned on, as no banner had one before. 'banner_shadow' => 'on' === ( $r['banner_shadow'] ?? $c['banner_shadow'] ?? 'off' ) ? 'on' : 'off', 'dark_mode' => in_array( $r['dark_mode'] ?? $c['dark_mode'] ?? 'auto', [ 'auto', 'light', 'dark' ], true ) ? ( $r['dark_mode'] ?? $c['dark_mode'] ?? 'auto' ) : 'auto', 'theme' => sanitize_key( $r['theme'] ?? $c['theme'] ?? '' ), ]; /** * Filters the sanitized banner design, so an add-on can keep and clean its own fields. * * @param array $out Sanitized design. * @param array $r Submitted values. * @param array $c Stored design before this save. */ $out += $c; // Pro's design keys survive a save made while Pro is not loaded (see the settings above). return (array) apply_filters( 'cookroco_sanitize_design', $out, $r, $c ); } public function sanitize_categories_option( $raw ): array { $existing = get_option( 'cookroco_cookie_categories', [] ); if ( ! is_array( $existing ) ) $existing = []; $raw = is_array( $raw ) ? $raw : []; $out = []; // Edits the existing categories only (label/description/order) and preserves // required/enabled. Keys are never created here: the set of categories comes from // the stored option, so a crafted form cannot introduce new ones. foreach ( $existing as $key => $cat ) { if ( ! is_array( $cat ) ) { continue; } // a scalar element in a corrupt/imported categories option would fatal array_key_exists() below. $r = isset( $raw[ $key ] ) && is_array( $raw[ $key ] ) ? $raw[ $key ] : []; $out[ $key ] = [ 'label' => sanitize_text_field( mb_substr( (string) ( $r['label'] ?? ( $cat['label'] ?? $key ) ), 0, 60 ) ), 'description' => sanitize_textarea_field( mb_substr( (string) ( $r['description'] ?? ( $cat['description'] ?? '' ) ), 0, 300 ) ), 'required' => ! empty( $cat['required'] ), 'enabled' => array_key_exists( 'enabled', $cat ) ? ! empty( $cat['enabled'] ) : true, 'order' => max( 1, min( 99, absint( $r['order'] ?? ( $cat['order'] ?? 99 ) ) ) ), ]; } return $out; } /** * Gives a hidden admin page (registered with parent '') its screen title. * * WordPress cannot resolve a title for a page that has no parent menu, so admin-header.php * hands null to strip_tags() and PHP 8.1+ writes a deprecation to the site's debug log on * every visit. Setting the title on the page's load hook, which runs before the header is * built, fills the gap that get_admin_page_title() leaves. * * @param string|false $hook Hook suffix returned by add_submenu_page(). * @param string $page_title Title to show for the page. */ public static function title_hidden_page( $hook, string $page_title ): void { if ( ! $hook ) { return; } add_action( 'load-' . $hook, static function () use ( $page_title ) { global $title; if ( empty( $title ) ) { $title = $page_title; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Fills the screen title WordPress leaves null for pages without a parent menu. } } ); } public function register_menu(): void { add_menu_page( 'Cookie Rocket','Cookie Rocket',self::CAP,self::SLUG,[$this,'render_dashboard'],'dashicons-privacy',80 ); // Four task sections in the sidebar: Status, Banner, Blocking, Compliance. Everything // else (Settings, Consent Log, Scanner) stays registered and routable — so every old // deep link keeps working — but is reached from inside its owning section, not as its // own sidebar item. This is the "9 destinations → 4" collapse. add_submenu_page( self::SLUG,__('Status','cookie-rocket'),__('Status','cookie-rocket'),self::CAP,self::SLUG,[$this,'render_dashboard'] ); add_submenu_page( self::SLUG,__('Banner','cookie-rocket'),__('Banner','cookie-rocket'),self::CAP,self::SLUG.'-design',[$this,'render_design'] ); add_submenu_page( self::SLUG,__('Blocking','cookie-rocket'),__('Blocking','cookie-rocket'),self::CAP,self::SLUG.'-categories',[$this,'render_categories'] ); add_submenu_page( self::SLUG,__('Compliance','cookie-rocket'),__('Compliance','cookie-rocket'),self::CAP,self::SLUG.'-policy',[$this,'render_policy'] ); // Absorbed pages — hidden from the sidebar (parent ''), still routable so bookmarks live. self::title_hidden_page( add_submenu_page( '', __('Settings','cookie-rocket'),__('Settings','cookie-rocket'),self::CAP,self::SLUG.'-settings',[$this,'render_settings'] ), __('Settings','cookie-rocket') ); self::title_hidden_page( add_submenu_page( '', __('Scanner','cookie-rocket'),__('Scanner','cookie-rocket'),self::CAP,self::SLUG.'-scanner',[$this,'render_scanner'] ), __('Scanner','cookie-rocket') ); self::title_hidden_page( add_submenu_page( '', __('Consent Log','cookie-rocket'),__('Consent Log','cookie-rocket'),self::CAP,self::SLUG.'-log',[$this,'render_log'] ), __('Consent Log','cookie-rocket') ); // First-run setup wizard: hidden page, reached via auto-open on a new install or the // "Re-run setup" link on Status. Not a permanent destination. self::title_hidden_page( add_submenu_page( '', __('Set up Cookie Rocket','cookie-rocket'),__('Setup','cookie-rocket'),self::CAP,self::SLUG.'-setup',[$this,'render_wizard'] ), __('Set up Cookie Rocket','cookie-rocket') ); } /** * Unified top navigation (a single menu): a slim brand bar + horizontal * tabs rendered above every Cookie Rocket admin page, so all sections feel like one app. * Hooked on in_admin_header; bails on any non-plugin screen. */ /** * THE navigation: one list, one order. Both the tab bar and the sidebar read from here, * so they always show the same items in the same order. * * @return array slug => label, in the order the user should see them. */ public function nav_tabs(): array { $tabs = [ self::SLUG => __( 'Status', 'cookie-rocket' ), self::SLUG . '-design' => __( 'Banner', 'cookie-rocket' ), self::SLUG . '-categories' => __( 'Blocking', 'cookie-rocket' ), self::SLUG . '-policy' => __( 'Compliance', 'cookie-rocket' ), ]; return apply_filters( 'cookroco_admin_tabs', $tabs ); } /** * A section "owns" secondary pages that are reached from inside it, not from the tab bar. * Used to (a) keep the right tab highlighted on those pages and (b) redirect deep links to * those pages to the section that owns them. One source of truth so the two never drift. * * @return array owning section slug => slugs it absorbed. */ protected function owned_pages(): array { return [ self::SLUG => [ self::SLUG . '-license' ], self::SLUG . '-categories' => [ self::SLUG . '-scanner' ], self::SLUG . '-policy' => [ self::SLUG . '-log', self::SLUG . '-settings', self::SLUG . '-rights', self::SLUG . '-regions' ], ]; } /** * Order the sidebar submenu to match nav_tabs(). Registration order cannot do this on its * own: pages registered later (by the Pro, on a later hook) would always land at the end, * whatever the tab bar says. Runs late, once everyone has registered. */ public function sync_submenu_order(): void { global $submenu; if ( empty( $submenu[ self::SLUG ] ) || ! is_array( $submenu[ self::SLUG ] ) ) { return; } $order = array_keys( $this->nav_tabs() ); usort( $submenu[ self::SLUG ], static function ( $a, $b ) use ( $order ) { $ia = array_search( $a[2] ?? '', $order, true ); $ib = array_search( $b[2] ?? '', $order, true ); // Anything not in the canonical list keeps to the end, in its original order. $ia = ( false === $ia ) ? PHP_INT_MAX : $ia; $ib = ( false === $ib ) ? PHP_INT_MAX : $ib; return $ia <=> $ib; } ); } /** * Marks the Cookie Rocket screens, so the app layout (top bar + side navigation) applies to * them only and never to the rest of the dashboard. * * @param string $classes Space-separated body classes. * @return string */ public function body_class( $classes ): string { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only: picks the layout. $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; return ( 0 === strpos( $page, self::SLUG ) ) ? $classes . ' cr-app' : (string) $classes; } /** * The pages under Blocking, in order. * * @return array key => [ page slug, icon, label ]. */ public static function blocking_items(): array { return [ 'categories' => [ self::SLUG . '-categories', 'categories', __( 'Categories', 'cookie-rocket' ) ], 'scanner' => [ self::SLUG . '-scanner', 'scanner', __( 'Scanner', 'cookie-rocket' ) ], ]; } /** * The pages under Compliance, in order; extensions add theirs through the filter. * * @return array key => [ page slug, icon, label ]. */ public static function compliance_items(): array { $items = [ 'policy' => [ self::SLUG . '-policy', 'policy', __( 'Policy', 'cookie-rocket' ) ], 'log' => [ self::SLUG . '-log', 'log', __( 'Consent Log', 'cookie-rocket' ) ], ]; return (array) apply_filters( 'cookroco_compliance_subnav', $items ); } /** * A 20px line icon for the navigation; unknown names fall back to a neutral dot. * * @param string $name Icon name. * @return string Static SVG markup. */ private static function nav_icon( string $name ): string { $paths = [ 'status' => '', 'banner' => '', 'blocking' => '', 'categories' => '', 'scanner' => '', 'compliance' => '', 'policy' => '', 'log' => '', 'rights' => '', 'regions' => '', 'settings' => '', ]; $body = $paths[ $name ] ?? ''; return ''; } /** * The app chrome of every Cookie Rocket screen: a top bar with the product and its plan, and * the side navigation with every page one click away (Elementor's Editor One layout). */ public function render_admin_chrome(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only: highlights the current page. $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; if ( 0 !== strpos( $page, self::SLUG ) ) { return; } $pro = (bool) apply_filters( 'cookroco_pro_active', false ); // Other plugins' notices: WordPress moves every notice right after `.wp-header-end`, or after the // first heading of the page when there is none, which put them inside the editor panel and under // each title. The marker comes first, so they sit above the whole Cookie Rocket app, full width, // as on any other WordPress screen. echo '

'; echo '
'; echo ''; echo 'Cookie Rocket
'; if ( $pro ) { do_action( 'cookroco_appbar_pro_slot' ); } else { echo '' . self::crown() . '' . esc_html__( 'Upgrade to Pro', 'cookie-rocket' ) . ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- crown() is static SVG. } echo '
'; // Sections: a page of its own, or a group whose pages sit under it. $owned = $this->owned_pages(); $sections = []; foreach ( $this->nav_tabs() as $slug => $label ) { $key = [ self::SLUG => 'status', self::SLUG . '-design' => 'banner', self::SLUG . '-categories' => 'blocking', self::SLUG . '-policy' => 'compliance' ][ $slug ] ?? 'section'; $sections[ $slug ] = [ $label, $key, 'blocking' === $key ? self::blocking_items() : ( 'compliance' === $key ? self::compliance_items() : [] ) ]; } $sections[ self::SLUG . '-settings' ] = [ __( 'Settings', 'cookie-rocket' ), 'settings', [] ]; echo ''; // Notes about the site live on Status, as Elementor keeps its notices to its Home screen: // the working screens stay clean, and the editor is a full-height workspace. if ( self::SLUG === $page ) { echo '
'; $this->render_chrome_notices(); echo '
'; } } /** * The crown that marks what Cookie Rocket Pro adds, as Elementor marks its paid features. * * @return string Static SVG markup. */ public static function crown(): string { return ''; } /** * Contextual cards under the tab bar — shown on Cookie Rocket pages ONLY, never as * global admin notices (wp.org guideline 11): (1) one-click suggestion to apply the * compliance framework matching the site locale; (2) a dismissible review ask after * two weeks of use. */ /** * A notice driven by a FACT about the site, not by a timer. * * The banner already resolves and records each visitor's country. When a significant share * of incoming visitors falls under a legal framework other than the one the site applies to * everyone, that is a verifiable finding about the site itself, not a campaign. It appears only * when that finding exists, it can be dismissed, and it disappears if the distribution changes. * * It lives inside the Cookie Rocket screens (directory guideline 11: limited scope, moderate * use) and describes what the product does, without promising compliance (guideline 9). */ /** * The rejection rate, as returned by get_stats(). * * Every rejection is a visitor who is no longer measured in Analytics or attributed in Ads, * and Consent Mode advanced is what keeps that measurement when the visitor says no. A high * rate changes what the site is able to measure, so it is worth putting in front of the owner. * * Raised only when the rate is genuinely high and there is enough volume to say so. */ private function notice_reject_rate(): void { if ( get_option( 'cookroco_reject_notice_dismissed' ) ) { return; } $st = $this->log->get_stats(); $total = (int) ( $st['total'] ?? 0 ); if ( $total < 100 ) { return; } // below this volume a percentage says nothing $rejections = 0; foreach ( (array) ( $st['by_action'] ?? [] ) as $row ) { $a = is_array( $row ) ? ( $row['action'] ?? '' ) : ''; if ( 'reject_all' === $a || 'withdraw' === $a ) { $rejections += (int) ( $row['count'] ?? 0 ); } } $pct = (int) round( $rejections / $total * 100 ); if ( $pct < 25 ) { return; } // below this there is nothing worth raising $s = (array) get_option( 'cookroco_settings', [] ); $gcm = array_key_exists( 'gcm_enabled', $s ) ? ! empty( $s['gcm_enabled'] ) : true; // With advanced consent mode already running there is nothing to suggest, so the // notice reports the figure and stops there. $is_pro = (bool) apply_filters( 'cookroco_pro_active', false ); $already_has_it = $is_pro && 'advanced' === ( $s['consent_mode'] ?? '' ); // A Pro site on the basic mode already owns the fix: point it at the setting, never at // the sales page for the product it paid for. $owns_it = $is_pro && ! $already_has_it; $url = 'https://templatesrocket.com/cookie-rocket-pro?feature=reject-rate'; $dis = wp_nonce_url( add_query_arg( 'cookroco_reject_action', 'dismiss' ), 'cookroco_reject_action' ); echo '
'; echo '' . esc_html( sprintf( /* translators: 1: percentage of visitors who rejected, 2: number of consent decisions. */ __( '%1$d%% of your visitors rejected cookies, out of %2$d decisions.', 'cookie-rocket' ), $pct, $total ) ) . ' '; if ( $already_has_it ) { echo esc_html__( 'Consent Mode advanced is on, so your Google Ads and GA4 measurement keeps working for them. Nothing to do.', 'cookie-rocket' ); } elseif ( $gcm && $owns_it ) { echo esc_html__( 'Consent Mode v2 is on, but in its basic mode Google receives nothing from those visitors. Your Pro plan includes the advanced mode: Google\'s tags load without cookies, so Google can model the visits and conversions it would otherwise lose.', 'cookie-rocket' ); } elseif ( $gcm ) { echo esc_html__( 'Consent Mode v2 is on, but in its basic mode Google receives nothing from those visitors. Cookie Rocket Pro adds the advanced mode: Google\'s tags load without cookies, so Google can model the visits and conversions it would otherwise lose.', 'cookie-rocket' ); } else { echo esc_html__( 'Consent Mode v2 is off, so those visitors are invisible to Google Analytics and Ads. Turning it on costs nothing.', 'cookie-rocket' ); } echo '
'; if ( $already_has_it ) { // Nothing to act on here: the figure alone is the point. } elseif ( $gcm && $owns_it ) { echo '' . esc_html__( 'Turn on advanced mode', 'cookie-rocket' ) . ''; } elseif ( $gcm ) { echo '' . esc_html__( 'See how', 'cookie-rocket' ) . ''; } else { echo '' . esc_html__( 'Turn it on', 'cookie-rocket' ) . ''; } echo '' . esc_html__( 'Dismiss', 'cookie-rocket' ) . '
'; } private function notice_visitor_law_mismatch(): void { if ( get_option( 'cookroco_visitor_law_notice_dismissed' ) ) { return; } /** Filterable so a site that already routes the applicable law per visitor can turn * this notice off rather than be told about something it is already doing. */ if ( ! apply_filters( 'cookroco_show_visitor_law_notice', true ) ) { return; } $s = (array) get_option( 'cookroco_settings', [] ); $cur = is_string( $s['default_law'] ?? null ) ? $s['default_law'] : 'generic'; $r = $this->log->region_breakdown( 30 ); if ( $r['total'] < 50 ) { return; } // below this volume it is noise, not a finding $frameworks = [ 'gdpr' => __( 'the European Union', 'cookie-rocket' ), 'ccpa' => __( 'the United States', 'cookie-rocket' ), 'lgpd' => __( 'Brazil', 'cookie-rocket' ), 'lfpdppp' => __( 'Mexico', 'cookie-rocket' ), ]; // The framework with the most visitors that is NOT the one the site applies today. $top = ''; $top_count = 0; foreach ( $frameworks as $k => $_ ) { if ( $k === $cur ) { continue; } if ( $r[ $k ] > $top_count ) { $top = $k; $top_count = $r[ $k ]; } } if ( '' === $top ) { return; } $pct = (int) round( $top_count / $r['total'] * 100 ); if ( $pct < 15 ) { return; } // below this it is not worth interrupting anyone $url = 'https://templatesrocket.com/cookie-rocket-pro?feature=visitor-law'; echo '
'; echo '

' . esc_html( sprintf( /* translators: 1: percentage of visitors, 2: region name. */ __( '%1$d%% of your visitors in the last 30 days came from %2$s.', 'cookie-rocket' ), $pct, $frameworks[ $top ] ) ) . ' '; echo esc_html__( 'Your banner applies one framework to everyone. Cookie Rocket Pro applies the one that fits each visitor\'s country.', 'cookie-rocket' ) . ' '; echo '' . esc_html__( 'See how', 'cookie-rocket' ) . ' → '; echo '' . esc_html__( 'Dismiss', 'cookie-rocket' ) . '

'; echo '
'; } private function render_chrome_notices(): void { if ( ! current_user_can( 'manage_options' ) ) return; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the nonce is verified below if ( isset( $_GET['cr_dismiss_visitor_law'] ) && check_admin_referer( 'cr_dismiss_visitor_law' ) ) { update_option( 'cookroco_visitor_law_notice_dismissed', 1 ); } $this->notice_visitor_law_mismatch(); $this->notice_reject_rate(); $s = (array) get_option( 'cookroco_settings', [] ); $cur = is_array( $s ) ? ( $s['default_law'] ?? 'generic' ) : 'generic'; $sug = COOKROCO_Core::locale_default_law(); if ( 'generic' === $cur && 'generic' !== $sug && ! get_option( 'cookroco_law_notice_dismissed' ) ) { $labels = self::law_names(); $apply = wp_nonce_url( add_query_arg( 'cookroco_law_action', 'apply' ), 'cookroco_law_action' ); $dismiss = wp_nonce_url( add_query_arg( 'cookroco_law_action', 'dismiss' ), 'cookroco_law_action' ); echo '
'; /* translators: %s: legal framework name, e.g. "LFPDPPP (Mexico)". */ echo '' . esc_html( sprintf( __( 'Based on this site\'s language, %s applies to your visitors.', 'cookie-rocket' ), $labels[ $sug ] ?? $sug ) ) . ' '; echo esc_html__( 'Apply it to use the matching legal banner text by default.', 'cookie-rocket' ); echo '
' . esc_html__( 'Apply', 'cookie-rocket' ) . '' . esc_html__( 'Dismiss', 'cookie-rocket' ) . '
'; } // Asked on a MILESTONE, never on a timer: only once the plugin has done something // countable, and the wording names that thing. The counters already exist — // get_stats()['total'] and last_scan(). Asked once. if ( ! get_option( 'cookroco_review_dismissed' ) && ! get_transient( 'cookroco_review_snoozed' ) ) { $st = $this->log->get_stats(); $total = (int) ( $st['total'] ?? 0 ); // Only trackers this site really holds count: not those Consent Mode governs, none while // blocking is off, and a pasted one only with raw-HTML blocking on. $found = 0; $last = ( new COOKROCO_Scanner() )->last_scan(); $s_now = (array) get_option( 'cookroco_settings', [] ); if ( is_array( $last ) && ! empty( $last['found'] ) && ( ! array_key_exists( 'block_scripts', $s_now ) || ! empty( $s_now['block_scripts'] ) ) ) { foreach ( (array) $last['found'] as $f ) { if ( empty( $f['managed'] ) && ( 'inline' !== ( $f['via'] ?? 'src' ) || ! empty( $s_now['block_raw_html'] ) ) ) { $found++; } } } $hito = ''; if ( $total >= 100 && $found > 0 ) { /* translators: 1: number of trackers blocked, 2: number of consent decisions recorded. */ $hito = sprintf( __( 'Cookie Rocket is holding %1$d trackers on this site and has recorded %2$d consent decisions.', 'cookie-rocket' ), $found, $total ); } elseif ( $total >= 100 ) { /* translators: %d: number of consent decisions recorded. */ $hito = sprintf( __( 'Cookie Rocket has recorded %d consent decisions on this site.', 'cookie-rocket' ), $total ); } elseif ( $found >= 3 ) { /* translators: %d: number of trackers found and blocked. */ $hito = sprintf( __( 'Cookie Rocket found and is holding %d trackers on this site.', 'cookie-rocket' ), $found ); } if ( '' !== $hito ) { $never = wp_nonce_url( add_query_arg( 'cookroco_review_action', 'dismiss' ), 'cookroco_review_action' ); $later = wp_nonce_url( add_query_arg( 'cookroco_review_action', 'later' ), 'cookroco_review_action' ); echo '
'; echo '' . esc_html( $hito ) . ' '; echo esc_html__( 'If that has been useful, a review helps other site owners find it.', 'cookie-rocket' ); echo '
' . esc_html__( 'Leave a review', 'cookie-rocket' ) . '' . esc_html__( 'Later', 'cookie-rocket' ) . '' . esc_html__( 'Already did', 'cookie-rocket' ) . '
'; } } } /** Handles the Apply / Dismiss links of the chrome cards (nonce-checked, admin-only). */ public function handle_notice_actions(): void { if ( ! current_user_can( 'manage_options' ) ) return; if ( isset( $_GET['cookroco_law_action'] ) ) { check_admin_referer( 'cookroco_law_action' ); if ( 'apply' === sanitize_key( wp_unslash( $_GET['cookroco_law_action'] ) ) ) { $s = (array) get_option( 'cookroco_settings', [] ); if ( ! is_array( $s ) ) $s = []; $s['default_law'] = COOKROCO_Core::locale_default_law(); update_option( 'cookroco_settings', $s ); } else { update_option( 'cookroco_law_notice_dismissed', 1 ); } wp_safe_redirect( remove_query_arg( [ 'cookroco_law_action', '_wpnonce' ] ) ); exit; } if ( isset( $_GET['cookroco_reject_action'] ) ) { check_admin_referer( 'cookroco_reject_action' ); update_option( 'cookroco_reject_notice_dismissed', 1 ); wp_safe_redirect( remove_query_arg( [ 'cookroco_reject_action', '_wpnonce' ] ) ); exit; } if ( isset( $_GET['cookroco_review_action'] ) ) { check_admin_referer( 'cookroco_review_action' ); // "Later" only snoozes: the milestone is still there, so the question returns in 30 days. // "Already did" silences it for good. Asking twice is what earns one-star reviews. if ( 'later' === sanitize_key( wp_unslash( $_GET['cookroco_review_action'] ) ) ) { set_transient( 'cookroco_review_snoozed', 1, 30 * DAY_IN_SECONDS ); } else { update_option( 'cookroco_review_dismissed', 1 ); } wp_safe_redirect( remove_query_arg( [ 'cookroco_review_action', '_wpnonce' ] ) ); exit; } } public function enqueue_scripts( string $hook ): void { if ( false === strpos($hook,self::SLUG) ) return; wp_enqueue_style( 'cookroco-admin', COOKROCO_PLUGIN_URL.'admin/assets/admin.css', [],COOKROCO_VERSION ); wp_enqueue_script( 'cookroco-admin', COOKROCO_PLUGIN_URL.'admin/assets/admin.js', ['jquery'],COOKROCO_VERSION,true ); wp_localize_script( 'cookroco-admin','CookieRocketAdmin', [ 'ajaxUrl'=>admin_url('admin-ajax.php'),'nonce'=>wp_create_nonce('cookroco-admin'),'settings'=>get_option('cookroco_settings',[]),'design'=>get_option('cookroco_banner_design',[]),'categories'=>get_option('cookroco_cookie_categories',[]),'stats'=>$this->log->get_stats(),'scanningText'=>__('Scanning…','cookie-rocket'),'saveErrorText'=>__('Could not save — your changes were not stored. Please try again.','cookie-rocket'),'savedText'=>__('Saved.','cookie-rocket'),'adjustedText'=>__('Saved. Some values were changed to what is allowed — check:','cookie-rocket'),'activeText'=>__('Active','cookie-rocket') ] ); } /** * First-run setup wizard — a short linear guide that leaves blocking, the banner and the * cookie policy configured without the owner opening a single options screen. Server-rendered (one step per request, advanced by a * nonce-checked POST); it reuses the real scanner and policy creation, so it configures the * same state the manual screens do. Not a permanent menu item. */ public function render_wizard(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only step selector; each advancing action is nonce-checked in handle_wizard(). $step = isset( $_GET['step'] ) ? max( 1, min( 5, absint( wp_unslash( $_GET['step'] ) ) ) ) : 1; $s = (array) get_option( 'cookroco_settings', [] ); if ( ! is_array( $s ) ) { $s = []; } $post = admin_url( 'admin-post.php' ); $setup = admin_url( 'admin.php?page=' . self::SLUG . '-setup' ); $titles = [ 1 => __( 'Your region & law', 'cookie-rocket' ), 2 => __( 'Scan your site', 'cookie-rocket' ), 3 => __( 'Your banner', 'cookie-rocket' ), 4 => __( 'Your cookie policy', 'cookie-rocket' ), 5 => __( "You're all set", 'cookie-rocket' ), ]; echo '
'; echo '
Cookie Rocket'; /* translators: 1: current step number, 2: total steps. */ echo '' . esc_html( sprintf( __( 'Step %1$d of %2$d', 'cookie-rocket' ), $step, 5 ) ) . '
'; echo '
'; echo '

' . esc_html( $titles[ $step ] ) . '

'; if ( 1 === $step ) { $law = in_array( $s['default_law'] ?? '', [ 'gdpr', 'ccpa', 'lgpd', 'lfpdppp', 'generic' ], true ) ? $s['default_law'] : COOKROCO_Core::locale_default_law(); $opts = self::law_names(); echo '

' . esc_html__( 'We detected the framework that fits your site language. Confirm it, or pick another — you can change it later.', 'cookie-rocket' ) . '

'; echo '
'; wp_nonce_field( 'cookroco_wizard' ); echo '
'; echo '

'; } elseif ( 2 === $step ) { $scan = get_option( 'cookroco_last_scan', [] ); $found = ( is_array( $scan ) && ! empty( $scan['found'] ) ) ? count( $scan['found'] ) : 0; $done = is_array( $scan ) && ! empty( $scan['time'] ); echo '

' . esc_html__( 'We check your site for known trackers, list their cookies on your policy, and block each one until the visitor accepts its category.', 'cookie-rocket' ) . '

'; if ( $done ) { /* translators: %d: number of trackers found. */ echo '

' . esc_html( sprintf( _n( 'Scan complete — %d tracker found.', 'Scan complete — %d trackers found.', $found, 'cookie-rocket' ), $found ) ) . '

'; } echo '
'; wp_nonce_field( 'cookroco_wizard' ); echo '

' . esc_html__( 'Skip', 'cookie-rocket' ) . '

'; } elseif ( 3 === $step ) { $d = get_option( 'cookroco_banner_design', [] ); $active = is_array( $d ) ? (string) ( $d['theme'] ?? '' ) : ''; $pos = sanitize_key( $s['banner_position'] ?? 'right' ); echo '

' . esc_html__( 'Pick a neutral starting look and where it appears. You can fine-tune colors later in Banner.', 'cookie-rocket' ) . '

'; echo '
'; wp_nonce_field( 'cookroco_wizard' ); echo '
'; echo '
'; echo '

' . esc_html__( 'Skip', 'cookie-rocket' ) . '

'; } elseif ( 4 === $step ) { $pid = (int) get_option( 'cookroco_policy_page_id', 0 ); $exists = $pid && get_post_status( $pid ) && 'trash' !== get_post_status( $pid ); echo '

' . esc_html__( 'Publish a page that lists every cookie your site uses, grouped by purpose — a required disclosure, generated for you.', 'cookie-rocket' ) . '

'; echo '
'; wp_nonce_field( 'cookroco_wizard' ); if ( $exists ) { echo '

' . esc_html__( 'Your cookie policy page is already published.', 'cookie-rocket' ) . '

'; echo '

'; } else { echo ''; echo '

' . esc_html__( 'Skip', 'cookie-rocket' ) . '

'; } echo '
'; } else { // What is actually running, read from the settings: nothing here may promise more. $laws = self::law_names(); $law_key = (string) ( $s['default_law'] ?? 'generic' ); $cur_law = $laws[ $law_key ] ?? $laws['generic']; $opt_out = 'opt_out' === ( $s['default_state'] ?? 'opt_in' ) && ! in_array( $law_key, COOKROCO_Consent::OPT_IN_LAWS, true ); $on = static fn( string $k ): bool => array_key_exists( $k, $s ) ? ! empty( $s[ $k ] ) : true; echo '

' . esc_html__( 'Your banner is live. Here is what is running:', 'cookie-rocket' ) . '

'; echo '
    '; /* translators: %s: the active compliance framework, e.g. GDPR. */ echo '
  • ' . esc_html( sprintf( __( 'Compliance: %s', 'cookie-rocket' ), $cur_law ) ) . '
  • '; echo '
  • ' . ( $opt_out ? esc_html__( 'Optional cookies run until the visitor rejects them (opt-out)', 'cookie-rocket' ) : esc_html__( 'Block optional cookies until the visitor accepts (opt-in)', 'cookie-rocket' ) ) . '
  • '; if ( $on( 'block_scripts' ) && $on( 'gcm_enabled' ) ) { echo '
  • ' . esc_html__( 'Auto-blocking and Consent Mode v2 active', 'cookie-rocket' ) . '
  • '; } echo '
  • ' . esc_html__( 'Cookie banner live on your site', 'cookie-rocket' ) . '
  • '; echo '
'; echo '

' . esc_html__( 'Go to Status', 'cookie-rocket' ) . '

'; } echo '
'; } /** Process one wizard step (nonce + capability checked) and advance to the next. */ public function handle_wizard(): void { if ( ! current_user_can( self::CAP ) ) { wp_die( esc_html__( 'Insufficient permissions.', 'cookie-rocket' ) ); } check_admin_referer( 'cookroco_wizard' ); $step = isset( $_POST['step'] ) ? absint( wp_unslash( $_POST['step'] ) ) : 1; switch ( $step ) { case 1: $law = isset( $_POST['default_law'] ) ? sanitize_key( wp_unslash( $_POST['default_law'] ) ) : 'generic'; if ( in_array( $law, [ 'gdpr', 'ccpa', 'lgpd', 'lfpdppp', 'generic' ], true ) ) { $s = (array) get_option( 'cookroco_settings', [] ); if ( ! is_array( $s ) ) { $s = []; } $s['default_law'] = $law; update_option( 'cookroco_settings', $s ); } break; case 2: if ( class_exists( 'COOKROCO_Scanner' ) ) { ( new COOKROCO_Scanner() )->scan(); } break; case 3: $theme = isset( $_POST['theme'] ) ? sanitize_key( wp_unslash( $_POST['theme'] ) ) : ''; $themes = self::banner_themes(); if ( isset( $themes[ $theme ] ) && empty( $themes[ $theme ]['pro'] ) ) { $d = get_option( 'cookroco_banner_design', [] ); if ( ! is_array( $d ) ) { $d = []; } $d = array_merge( $d, $themes[ $theme ]['values'] ); $d['theme'] = $theme; update_option( 'cookroco_banner_design', $d ); } $pos = isset( $_POST['banner_position'] ) ? sanitize_key( wp_unslash( $_POST['banner_position'] ) ) : ''; if ( in_array( $pos, [ 'left', 'right' ], true ) ) { $s = (array) get_option( 'cookroco_settings', [] ); if ( ! is_array( $s ) ) { $s = []; } $s['banner_position'] = $pos; update_option( 'cookroco_settings', $s ); } break; case 4: $pid = (int) get_option( 'cookroco_policy_page_id', 0 ); $exists = $pid && get_post_status( $pid ) && 'trash' !== get_post_status( $pid ); if ( ! $exists && ! empty( $_POST['create_policy'] ) ) { $new = wp_insert_post( [ 'post_title' => __( 'Cookie Policy', 'cookie-rocket' ), 'post_content' => '[' . COOKROCO_Policy::SHORTCODE . ']', 'post_status' => 'publish', 'post_type' => 'page', ] ); if ( $new && ! is_wp_error( $new ) ) { update_option( 'cookroco_policy_page_id', (int) $new ); } } break; } update_option( 'cookroco_onboarded', '1' ); $next = $step + 1; $target = ( $next > 5 ) ? admin_url( 'admin.php?page=' . self::SLUG ) : admin_url( 'admin.php?page=' . self::SLUG . '-setup&step=' . $next ); wp_safe_redirect( $target ); exit; } /** Per-region consent-text presets: law => [ label, banner title, banner body ]. Shared by the * Banner content panel and the one-click autopilot so both write the same region-correct copy. */ /** * The compliance frameworks by name, in the one order every list uses. The CCPA / CPRA is * California's law, but Cookie Rocket applies it to the whole United States (Pro: Regions). * * @return array law key => name. */ /** * What each consent-log action means, in the site's language. * * @return array action key => label. */ public static function action_labels(): array { return [ 'accept_all' => __( 'Accepted all', 'cookie-rocket' ), 'reject_all' => __( 'Rejected', 'cookie-rocket' ), 'custom' => __( 'Customized', 'cookie-rocket' ), 'withdraw' => __( 'Withdrew', 'cookie-rocket' ), 'gpc_opt_out' => __( 'Opted out (GPC signal)', 'cookie-rocket' ), ]; } /** * The corner radius: a length in px from 0 to 40, so no corner can cut into the banner's * content (the inner spacing is never under 12 px). Without Pro only the offered corners * are taken; a value saved before is kept. * * @param mixed $raw Submitted value, or null. * @param string $current Value saved now. * @return string */ public static function sanitize_radius( $raw, $current ): string { $norm = static function ( $v ): string { return preg_match( '/^\s*(\d{1,3})\s*(px)?\s*$/', (string) $v, $m ) ? min( 40, (int) $m[1] ) . 'px' : ''; }; $cur = $norm( $current ); $cur = '' === $cur ? '0px' : $cur; if ( null === $raw ) { return $cur; } $new = $norm( $raw ); if ( '' === $new ) { return $cur; } // Any corner from 0 to 40 px ($norm clamps it) is stored as given: the editor offers four // presets, and nothing in this plugin is held back for another edition to unlock. return $new; } /** * Banner font modes offered in the editor, value => label. Filterable so an add-on can add its * own (Cookie Rocket Pro adds a self-hosted family) without this plugin carrying that code. * * @return array */ public static function font_choices(): array { $choices = [ 'match' => __( 'Match my site', 'cookie-rocket' ), 'system' => __( 'System default', 'cookie-rocket' ), ]; return (array) apply_filters( 'cookroco_font_choices', $choices ); } public static function law_names(): array { return [ 'gdpr' => __( 'GDPR (EU)', 'cookie-rocket' ), 'ccpa' => __( 'United States (CCPA / CPRA)', 'cookie-rocket' ), 'lfpdppp' => __( 'LFPDPPP (Mexico)', 'cookie-rocket' ), 'lgpd' => __( 'LGPD (Brazil)', 'cookie-rocket' ), 'generic' => __( 'Generic', 'cookie-rocket' ), ]; } /** * Banner text templates, one per framework: the same title and message the banner uses for that * law (COOKROCO_Consent::law_texts), so a template and the default can never drift apart. * * @return array law key => [ name, title, message ]. */ public static function law_templates(): array { $out = []; foreach ( self::law_names() as $key => $name ) { $t = COOKROCO_Consent::law_texts( $key ); $out[ $key ] = [ $name, $t['title'], $t['body'] ]; } return $out; } /** Friendly region name for a law key, for the autopilot result. */ private static function region_label( string $law ): string { $map = [ 'gdpr' => __( 'European Union (GDPR)', 'cookie-rocket' ), 'lgpd' => __( 'Brazil (LGPD)', 'cookie-rocket' ), 'lfpdppp' => __( 'Mexico (LFPDPPP)', 'cookie-rocket' ), 'ccpa' => __( 'United States (CCPA / CPRA)', 'cookie-rocket' ), 'generic' => __( 'Generic framework', 'cookie-rocket' ), ]; return $map[ $law ] ?? $map['generic']; } /** * One-click autopilot: the whole setup in a single action. Detects the region from the site * locale, writes the region-correct banner copy, scans for trackers (which auto-enables the * detected categories so they appear in the policy), and publishes the cookie-policy page. * The visitor-facing banner is already live from activation, so this only fills in the rest. */ public function handle_autopilot(): void { if ( ! current_user_can( self::CAP ) ) { wp_die( esc_html__( 'Insufficient permissions.', 'cookie-rocket' ) ); } check_admin_referer( 'cookroco_autopilot' ); $s = (array) get_option( 'cookroco_settings', [] ); if ( ! is_array( $s ) ) { $s = []; } $law = COOKROCO_Core::locale_default_law(); $s['default_law'] = $law; // The law's own text applies while the title and message are empty. Writing it in would turn it into // custom text, and Cookie Rocket Pro would then show it to visitors under every other law. unset( $s['banner_title'], $s['banner_body'] ); update_option( 'cookroco_settings', $s ); $found = []; if ( class_exists( 'COOKROCO_Scanner' ) ) { $res = ( new COOKROCO_Scanner() )->scan(); $found = ( is_array( $res ) && ! empty( $res['found'] ) ) ? (array) $res['found'] : []; } // A tracker pasted straight into the theme or header is only held by raw-HTML blocking. The // owner asked for an automatic setup, so it is switched on when the scan finds one: otherwise // the result would report as blocked a tracker that loads before consent. $has_inline = (bool) array_filter( $found, static fn( $f ) => empty( $f['managed'] ) && 'inline' === ( $f['via'] ?? 'src' ) ); if ( $has_inline && empty( $s['block_raw_html'] ) ) { $s['block_raw_html'] = true; update_option( 'cookroco_settings', $s ); } $blocking = ! array_key_exists( 'block_scripts', $s ) || ! empty( $s['block_scripts'] ); $managed = count( array_filter( $found, static fn( $f ) => ! empty( $f['managed'] ) ) ); $blocked = $blocking ? count( $found ) - $managed : 0; $pid = (int) get_option( 'cookroco_policy_page_id', 0 ); $exists = $pid && get_post_status( $pid ) && 'trash' !== get_post_status( $pid ); $policy = $exists ? 'existing' : 'failed'; if ( ! $exists ) { $new = wp_insert_post( [ 'post_title' => __( 'Cookie Policy', 'cookie-rocket' ), 'post_content' => '[' . COOKROCO_Policy::SHORTCODE . ']', 'post_status' => 'publish', 'post_type' => 'page', ] ); if ( $new && ! is_wp_error( $new ) ) { update_option( 'cookroco_policy_page_id', (int) $new ); $pid = (int) $new; $policy = 'created'; } } // The title visitors will see: the law's own, since the custom one was cleared above. $title = (string) ( COOKROCO_Consent::law_texts( $law )['title'] ?? '' ); set_transient( 'cookroco_autopilot_result', [ 'law' => $law, 'found' => count( $found ), 'blocked' => $blocked, 'managed' => $managed, 'raw_on' => $has_inline, 'policy' => (int) $pid, 'policy_state' => $policy, 'title' => $title ], 300 ); update_option( 'cookroco_onboarded', '1' ); wp_safe_redirect( admin_url( 'admin.php?page=' . self::SLUG . '&autopilot=done' ) ); exit; } /** First-run hero: the single "set up my whole site in one click" call to action. */ private function render_autopilot_hero(): void { $shield = ''; echo '
'; echo '' . $shield . ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static inline SVG. echo '

' . esc_html__( 'Set up your cookie banner in one click', 'cookie-rocket' ) . '

'; echo '

' . esc_html__( 'Cookie Rocket will pick the law for your site\'s language, scan for trackers, write your banner and publish your cookie policy — automatically. No screens to click through, no account, no cloud.', 'cookie-rocket' ) . '

'; echo '
'; echo ''; wp_nonce_field( 'cookroco_autopilot' ); echo ''; echo '
'; echo '' . esc_html__( 'I’ll configure it myself', 'cookie-rocket' ) . ''; echo '
'; } /** Post-autopilot celebration: exactly what was configured, so the owner sees the value at a glance. */ private function render_autopilot_result( array $r ): void { $check = ''; $preview = home_url( '/?cookroco_preview=1' ); echo '
'; echo ''; echo '

' . esc_html__( 'Your setup is complete.', 'cookie-rocket' ) . '

'; echo '
    '; echo '
  • ' . $check . esc_html( sprintf( /* translators: %s: region/law name. */ __( 'Region detected: %s', 'cookie-rocket' ), self::region_label( (string) $r['law'] ) ) ) . '
  • '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $check is a static icon. $found = (int) ( $r['found'] ?? 0 ); $blocked = (int) ( $r['blocked'] ?? $found ); $managed = (int) ( $r['managed'] ?? 0 ); echo '
  • ' . $check . esc_html( sprintf( /* translators: %d: number of trackers. */ _n( '%d tracker found and blocked until consent', '%d trackers found and blocked until consent', $blocked, 'cookie-rocket' ), $blocked ) ) . '
  • '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped echo '
  • ' . $check . esc_html( sprintf( /* translators: %s: banner title. */ __( 'Banner published: “%s”', 'cookie-rocket' ), (string) ( $r['title'] ?? '' ) ) ) . '
  • '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped if ( $managed > 0 ) { echo '
  • ' . $check . esc_html__( 'Google Tag Manager: its tags wait for consent through Consent Mode v2', 'cookie-rocket' ) . '
  • '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } if ( ! empty( $r['raw_on'] ) ) { echo '
  • ' . $check . esc_html__( 'Scripts pasted into your theme are held too (raw HTML blocking turned on)', 'cookie-rocket' ) . '
  • '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } $policy = (string) ( $r['policy_state'] ?? 'created' ); if ( 'failed' === $policy ) { echo '
  • ' . esc_html__( 'The cookie policy page could not be created. Add the [cookie_rocket_policy] shortcode to a page.', 'cookie-rocket' ) . '
  • '; } else { echo '
  • ' . $check . esc_html( 'existing' === $policy ? __( 'Cookie policy page: already in place, kept as it was', 'cookie-rocket' ) : __( 'Cookie policy page created', 'cookie-rocket' ) ) . '
  • '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } echo '
'; echo ''; echo '
'; } /** * Premium dashboard / welcome screen — the plugin landing page. * Status hero, key stats, quick actions and the Pro showcase. */ public function render_dashboard(): void { $s = (array) get_option( 'cookroco_settings', [] ); $stats = $this->log->get_stats(); $total = (int) ( $stats['total'] ?? 0 ); $last7 = (int) ( $stats['last7days'] ?? 0 ); $scan = get_option( 'cookroco_last_scan', [] ); $found = ( is_array( $scan ) && ! empty( $scan['found'] ) ) ? count( $scan['found'] ) : 0; $scan_t = ( is_array( $scan ) && ! empty( $scan['time'] ) ) ? mysql2date( (string) get_option( 'date_format', 'M j, Y' ), $scan['time'] ) : ''; $gcm = array_key_exists( 'gcm_enabled', $s ) ? ! empty( $s['gcm_enabled'] ) : true; $blk = array_key_exists( 'block_scripts', $s ) ? ! empty( $s['block_scripts'] ) : true; $policy = (int) get_option( 'cookroco_policy_page_id', 0 ); $base = 'admin.php?page=' . self::SLUG; $scanned = is_array( $scan ) && ! empty( $scan['time'] ); $policy_ok = $policy && get_post_status( $policy ) && 'trash' !== get_post_status( $policy ); // Title on the left, the two things people come here to do on the right (Elementor's admin header). $rescan_head = wp_nonce_url( admin_url( $base . '-scanner&cr_scan=1' ), 'cookroco_scan' ); echo '
'; // ─── One-click autopilot: the answer to "the other tools are complicated" ─── // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only view flag after our own redirect. $ap_done = isset( $_GET['autopilot'] ) && 'done' === sanitize_key( wp_unslash( $_GET['autopilot'] ) ); $ap_res = get_transient( 'cookroco_autopilot_result' ); if ( $ap_done && is_array( $ap_res ) ) { $this->render_autopilot_result( $ap_res ); delete_transient( 'cookroco_autopilot_result' ); } elseif ( '0' === (string) get_option( 'cookroco_onboarded', '1' ) ) { // Fresh install: the one-click setup leads, and the rest of the dashboard follows // below it, so anyone who would rather explore on their own still can. $this->render_autopilot_hero(); } // ─── Overview: the stat indicators (kept — Site-Kit style) ─── $log_url = admin_url( $base . '-log' ); $scan_pg = admin_url( $base . '-scanner' ); $rescan = wp_nonce_url( admin_url( $base . '-scanner&cr_scan=1' ), 'cookroco_scan' ); $grid = '
'; $grid .= $this->dash_stat( 'dashicons-yes-alt', number_format_i18n( $total ), __( 'Consents recorded', 'cookie-rocket' ), __( 'Every decision, stored as proof', 'cookie-rocket' ), $log_url, __( 'View the log', 'cookie-rocket' ) ); $grid .= $this->dash_stat( 'dashicons-chart-bar', number_format_i18n( $last7 ), __( 'Last 7 days', 'cookie-rocket' ), __( 'New consents this week', 'cookie-rocket' ), $log_url, __( 'See this week', 'cookie-rocket' ) ); // When the last scan could not read a SINGLE page, its zero does not mean "clean", // it means "unknown". Only a scan that actually read something may report a count. $cr_attempted = ( is_array( $scan ) && ! empty( $scan['scanned'] ) ) ? count( (array) $scan['scanned'] ) : 0; $cr_failed = ( is_array( $scan ) && ! empty( $scan['errors'] ) ) ? count( (array) $scan['errors'] ) : 0; $cr_scan_unreadable = $cr_attempted > 0 && $cr_failed >= $cr_attempted; if ( $cr_scan_unreadable ) { $grid .= $this->dash_stat( 'dashicons-warning', '—', __( 'Trackers detected', 'cookie-rocket' ), __( 'None of your pages could be read — the result below is not a clean bill of health', 'cookie-rocket' ), $scan_pg, __( 'See why', 'cookie-rocket' ) ); } else { $grid .= $this->dash_stat( 'dashicons-search', number_format_i18n( $found ), __( 'Trackers detected', 'cookie-rocket' ), __( 'Found in your latest scan', 'cookie-rocket' ), $scan_pg, __( 'Open the scanner', 'cookie-rocket' ) ); } $grid .= $this->dash_stat( 'dashicons-clock', $scan_t ? $scan_t : '—', __( 'Last scan', 'cookie-rocket' ), __( 'Re-scan when your site changes', 'cookie-rocket' ), $rescan, __( 'Re-scan now', 'cookie-rocket' ), true ); $grid .= '
'; echo wp_kses_post( $grid ); // ─── Slim, light header (brand + live status) — no heavy wall ─── $head = '
'; $head .= ''; // "Live" only when something actually holds trackers back: with both switches off the site // would be told it is protected while nothing is blocked. $live = $blk || $gcm; $head .= '
' . esc_html( $live ? __( 'Protection is live', 'cookie-rocket' ) : __( 'Protection is off', 'cookie-rocket' ) ) . '' . esc_html( $live ? __( 'Real blocking, Consent Mode v2 and cookie scanning — on your site, with no pageview limits and no account.', 'cookie-rocket' ) : __( 'Automatic blocking and Consent Mode v2 are both off, so trackers load before visitors decide. Turn them on in Settings → Blocking.', 'cookie-rocket' ) ) . '
'; $head .= '
'; $head .= $this->dash_pill( $blk, $blk ? __( 'Auto-blocking on', 'cookie-rocket' ) : __( 'Auto-blocking off', 'cookie-rocket' ) ); $head .= $this->dash_pill( $gcm, $gcm ? __( 'Consent Mode v2 on', 'cookie-rocket' ) : __( 'Consent Mode v2 off', 'cookie-rocket' ) ); $head .= '' . esc_html__( 'Re-run setup', 'cookie-rocket' ) . ''; $head .= '
'; echo '
'; echo wp_kses_post( $head ); // ─── What's left: the compliance verdict + the single next action ─── // No numbered wizard checklist. Each remaining task is a plain, actionable row; the // top-priority one carries the primary button. When nothing is left, it collapses to // an "all set" line with a quiet re-scan. Priority order: scan, then policy (the banner // is always live, so it's never a pending task — it's the resolved baseline). $todo = []; if ( ! $scanned ) { $todo[] = [ __( 'Scan your site', 'cookie-rocket' ), __( 'See which trackers you have and list their cookies.', 'cookie-rocket' ), $base . '-scanner', __( 'Scan now', 'cookie-rocket' ) ]; } if ( ! $policy_ok ) { $todo[] = [ __( 'Publish your cookie policy', 'cookie-rocket' ), __( 'A required privacy disclosure, in one click.', 'cookie-rocket' ), $base . '-policy', __( 'Create page', 'cookie-rocket' ) ]; } if ( empty( $todo ) ) { $rescan_all = wp_nonce_url( admin_url( $base . '-scanner&cr_scan=1' ), 'cookroco_scan' ); $nx = '
'; $nx .= ''; $nx .= '
' . esc_html__( 'You\'re all set', 'cookie-rocket' ) . '' . esc_html__( 'Protection is live and your setup is complete.', 'cookie-rocket' ) . '
'; $nx .= '' . esc_html__( 'Re-scan site', 'cookie-rocket' ) . ''; $nx .= '
'; } else { $left = count( $todo ); $nx = '

' . esc_html__( 'Finish setup', 'cookie-rocket' ) . '

'; /* translators: %d: number of setup steps still pending. */ $nx .= '' . esc_html( sprintf( _n( '%d step left', '%d steps left', $left, 'cookie-rocket' ), $left ) ) . '
'; $nx .= '
    '; foreach ( $todo as $i => $t ) { $btn = ( 0 === $i ) ? 'button button-primary' : 'button'; $nx .= '
  • '; $nx .= '' . esc_html( $t[0] ) . '' . esc_html( $t[1] ) . ''; $nx .= '' . esc_html( $t[3] ) . ''; $nx .= '
  • '; } $nx .= '
'; } echo wp_kses_post( $nx ); echo '
'; } private function dash_pill( bool $on, string $label ): string { $cls = $on ? 'cr-pill cr-pill--on' : 'cr-pill cr-pill--off'; $icon = $on ? 'dashicons-yes' : 'dashicons-minus'; return '' . esc_html( $label ) . ''; } private function dash_stat( string $icon, string $value, string $label, string $desc = '', string $link_url = '', string $link_text = '', bool $value_is_text = false ): string { $num_cls = 'cr-stat__num' . ( $value_is_text ? ' cr-stat__num--text' : '' ); $out = '
'; $out .= '
' . esc_html( $value ) . '
'; $out .= '
' . esc_html( $label ) . '
'; if ( '' !== $desc ) { $out .= '
' . esc_html( $desc ) . '
'; } if ( '' !== $link_url && '' !== $link_text ) { $out .= '' . esc_html( $link_text ) . ' →'; } return $out . '
'; } private function dash_action( string $page, string $icon, string $title, string $desc ): string { return '' . '' . '' . esc_html( $title ) . '' . esc_html( $desc ) . '' . ''; } public function render_settings(): void { $s = (array) get_option( 'cookroco_settings', [] ); $gcm = array_key_exists( 'gcm_enabled', $s ) ? ! empty( $s['gcm_enabled'] ) : true; $blk = array_key_exists( 'block_scripts', $s ) ? ! empty( $s['block_scripts'] ) : true; $buf = array_key_exists( 'block_raw_html', $s ) ? ! empty( $s['block_raw_html'] ) : false; $emb = array_key_exists( 'block_embeds', $s ) ? ! empty( $s['block_embeds'] ) : true; $pro = (bool) apply_filters( 'cookroco_pro_active', false ); $vendor_list = implode( ', ', wp_list_pluck( COOKROCO_Blocker::vendors(), 'label' ) ); /** * Filters the Settings tabs. An add-on adds a tab here and fills it on 'cookroco_settings_tab'. * * @param array $tabs key => label. */ $tabs = (array) apply_filters( 'cookroco_settings_tabs', [ 'general' => __( 'General', 'cookie-rocket' ), 'blocking' => __( 'Blocking', 'cookie-rocket' ), 'shortcodes' => __( 'Shortcodes', 'cookie-rocket' ), ] ); $row = static function ( string $label, string $control, string $desc = '', string $for = '' ): void { echo '' . ( $for ? '' : esc_html( $label ) ) . ''; echo $control; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from escaped parts below. if ( '' !== $desc ) { echo '

' . wp_kses_post( $desc ) . '

'; } echo ''; }; $check = static fn( string $name, bool $on, string $text ): string => ''; echo '

' . esc_html__( 'Settings', 'cookie-rocket' ) . '

'; echo '
'; settings_fields( 'cookroco_settings_group' ); // Folder tabs, as Elementor's Settings: one form, every tab saved together. echo '
'; $first = true; foreach ( $tabs as $key => $label ) { echo ''; $first = false; } echo '
'; foreach ( $tabs as $key => $label ) { echo '
'; if ( 'general' === $key ) { $law_now = in_array( $s['default_law'] ?? 'generic', [ 'gdpr', 'ccpa', 'lgpd', 'lfpdppp', 'generic' ], true ) ? ( $s['default_law'] ?? 'generic' ) : 'generic'; $law_opts = self::law_names(); $opts = ''; foreach ( $law_opts as $lk => $ll ) { $opts .= ''; } echo '

' . esc_html__( 'Consent', 'cookie-rocket' ) . '

' . esc_html__( 'The law, how long a decision lasts and when to ask again.', 'cookie-rocket' ) . '

'; $row( __( 'Compliance framework', 'cookie-rocket' ), '', __( 'The law whose text the banner uses by default. New installs pick it from the site language; the words themselves are in Banner → Content.', 'cookie-rocket' ), 'cookroco-default-law' ); $row( __( 'Consent duration', 'cookie-rocket' ), ' ' . esc_html__( 'days', 'cookie-rocket' ) . '', 'gdpr' === ( $s['default_law'] ?? '' ) ? __( 'After this many days the banner asks again. The CNIL recommends 6 months (180 days) and caps it at 13 months.', 'cookie-rocket' ) : __( 'After this many days the banner asks again.', 'cookie-rocket' ), 'cookroco-consent-duration' ); $row( __( 'Policy version', 'cookie-rocket' ), '', __( 'Raise it when you change your cookie policy: every visitor is asked again.', 'cookie-rocket' ), 'cookroco-policy-version' ); $row( __( 'Privacy notice link', 'cookie-rocket' ), '', __( 'Where the “Privacy policy” link on the banner goes. Leave it empty to use your Cookie Policy page or, without one, the privacy page set in Settings → Privacy. Use it when your notice lives on another site.', 'cookie-rocket' ), 'cookroco-policy-url' ); $row( __( 'Change of mind', 'cookie-rocket' ), $check( 'show_floating_button', ! empty( $s['show_floating_button'] ), __( 'Show a button on every page to change or withdraw consent', 'cookie-rocket' ) ), __( 'Off by default. Visitors must be able to change their decision as easily as they made it: without this button, put the [cookie-rocket-preferences] shortcode in your footer, which adds a “Manage cookies” button there.', 'cookie-rocket' ) ); do_action( 'cookroco_settings_rows_general', $s ); // Cookie Rocket Pro asks this in its own Data tab, next to how long records are kept. if ( ! apply_filters( 'cookroco_pro_active', false ) ) { $row( __( 'On uninstall', 'cookie-rocket' ), $check( 'delete_data_on_uninstall', ! empty( $s['delete_data_on_uninstall'] ), __( 'Delete all Cookie Rocket data when the plugin is deleted', 'cookie-rocket' ) ), __( 'Off by default: deleting the plugin keeps your settings and the consent log, which is your proof of consent.', 'cookie-rocket' ) ); } echo ''; if ( ! $pro ) { echo '

' . esc_html__( 'Each visitor, their own law', 'cookie-rocket' ) . ' ' . self::crown() . esc_html__( 'Pro', 'cookie-rocket' ) . '

'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- crown() is static SVG. echo '
' . self::crown() . '
' . esc_html__( 'GDPR for Europe, CCPA for the United States, LFPDPPP for Mexico', 'cookie-rocket' ) . '

' . esc_html__( 'Cookie Rocket Pro detects each visitor\'s country and shows them their law, also behind page caching.', 'cookie-rocket' ) . '

'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- crown() is static SVG. echo '' . self::crown() . esc_html__( 'Upgrade to Pro', 'cookie-rocket' ) . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- crown() is static SVG. } } elseif ( 'blocking' === $key ) { echo '

' . esc_html__( 'Script blocking', 'cookie-rocket' ) . '

' . esc_html__( 'What stays blocked until the visitor accepts.', 'cookie-rocket' ) . '

'; $row( __( 'Google Consent Mode v2', 'cookie-rocket' ), $check( 'gcm_enabled', $gcm, __( 'Send Consent Mode v2, denied by default, to Google Analytics, Ads and Tag Manager', 'cookie-rocket' ) ), __( 'Recommended. It gates Google cookies the way Google asks, even where a script is not blocked.', 'cookie-rocket' ) ); /* translators: %s: list of the providers blocked, such as "Google Analytics, Meta Pixel". */ $row( __( 'Automatic blocking', 'cookie-rocket' ), $check( 'block_scripts', $blk, __( 'Hold scripts from known providers until the visitor accepts their category', 'cookie-rocket' ) ), sprintf( __( 'Covers the scripts your plugins and theme load: %s.', 'cookie-rocket' ), esc_html( $vendor_list ) ) ); $row( __( 'Embed placeholders', 'cookie-rocket' ), $check( 'block_embeds', $emb, __( 'Hold YouTube, Vimeo, Maps and social embeds until consent, with a click-to-load placeholder', 'cookie-rocket' ) ), __( 'A tidy placeholder instead of an empty space; the content loads the moment the visitor accepts.', 'cookie-rocket' ) ); $row( __( 'Scripts in the page code', 'cookie-rocket' ), $check( 'block_raw_html', $buf, __( 'Also hold scripts pasted into the theme or the header, and embeds placed with page builders', 'cookie-rocket' ) ), __( 'Turn it on if a tracker is pasted in the <head> rather than added by a plugin, or if you place YouTube, Vimeo or Maps with a page builder. It reads the whole page before sending it, which some caching plugins do not expect: test it after turning it on. Off by default.', 'cookie-rocket' ) ); do_action( 'cookroco_settings_rows_blocking', $s ); echo ''; } elseif ( 'shortcodes' === $key ) { echo '

' . esc_html__( 'Shortcodes', 'cookie-rocket' ) . '

' . esc_html__( 'Paste them into any page, post or widget.', 'cookie-rocket' ) . '

'; foreach ( [ '[cookie-rocket-preferences]' => __( 'A button that opens the cookie preferences', 'cookie-rocket' ), '[cookie-rocket-withdraw]' => __( 'A link that withdraws consent', 'cookie-rocket' ), '[cookie_rocket_policy]' => __( 'The table of your cookie declaration', 'cookie-rocket' ) ] as $code => $what ) { $row( $what, '' . esc_html( $code ) . '' ); } echo ''; } else { do_action( 'cookroco_settings_tab', $key, $s ); } echo '
'; } // Save stays in reach at the bottom of the screen, and says when something is unsaved. echo '
'; submit_button( __( 'Save changes', 'cookie-rocket' ) ); echo '
'; } /** * Pro features showcase panel. * Standalone, additive — Pro features visible but disabled, with a single CTA. * Follows wp.org guideline 11 (no popups, no admin notices, single discreet CTA). * Outputs directly to satisfy Plugin Check's escape-on-output rule. */ private function render_pro_panel(): void { $features = [ [ 'dashicons-location-alt', __('Automatic geolocation','cookie-rocket'), __('Each visitor gets the law of their country, GDPR, LGPD or LFPDPPP, also behind page caching.','cookie-rocket') ], [ 'dashicons-search', __('Automatic monthly re-scan','cookie-rocket'), __('Re-scans your site every month and emails you when a new tracker appears, so your cookie inventory stays current without you thinking about it.','cookie-rocket') ], [ 'dashicons-shield', __('Tamper-evident consent ledger','cookie-rocket'), __('Every consent decision is sealed into a SHA-256 hash chain. One click verifies integrity and exports an audit evidence report you can hand to a client.','cookie-rocket') ], [ 'dashicons-privacy', __('ARCO / DSAR rights channel','cookie-rocket'), __('Data-subject requests arrive through a form on your own site and are handled from this dashboard. No external service involved.','cookie-rocket') ], [ 'dashicons-media-text', __('Generated privacy notice','cookie-rocket'), __('Built from your business details, with the elements the law that applies to each visitor requires.','cookie-rocket') ], [ 'dashicons-download', __('Audit log + CSV export','cookie-rocket'), __('Complete consent log (IP hash, date, categories, policy version) exportable to CSV.','cookie-rocket') ], [ 'dashicons-controls-repeat',__('Customizable floating button','cookie-rocket'), __('The button visitors use to change their choice, your way: text, icon or both, its icon, side, distance from the edge, colors, shape, size and shadow.','cookie-rocket') ], ]; $url = 'https://templatesrocket.com/cookie-rocket-pro?feature=panel'; echo '
'; // The crown sits in the column of the feature icons and the title lines up with their names. echo '
' . self::crown() . '

Cookie Rocket Pro

' . esc_html__( 'Premium features for professional sites', 'cookie-rocket' ) . '

'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- crown() is static SVG. echo '
    '; foreach ( $features as $f ) { echo '
  • '; echo ''; echo '
    ' . esc_html( $f[1] ) . '

    ' . esc_html( $f[2] ) . '

    '; echo '
  • '; } echo '
'; echo '
' . self::crown() . esc_html__( 'See Cookie Rocket Pro', 'cookie-rocket' ) . '' . esc_html__( 'Auto-updates + premium support included', 'cookie-rocket' ) . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- crown() is static SVG. echo '
'; } /** * One-click banner themes; Cookie Rocket Pro adds its own through the cookroco_banner_themes filter. * Each 'values' maps to cookroco_banner_design keys. */ public static function banner_themes(): array { $themes = [ // Neutral, non-imposing schemes (greys / black / white) — they fit any site; the user // then customises freely with the color fields. No brand colors are forced. 'light' => [ 'name' => __( 'Light', 'cookie-rocket' ), 'desc' => __( 'White with near-black buttons. The safe choice for most sites.', 'cookie-rocket' ), 'pro' => false, 'values' => [ 'banner_border' => 'on', 'banner_shadow' => 'off', 'background_color' => '#ffffff', 'text_color' => '#1a1a1a', 'accept_button_bg' => '#1a1a1a', 'accept_text_color' => '#ffffff', 'primary_color' => '#1a1a1a', 'border_radius' => '0', 'dark_mode' => 'light' ] ], 'dark' => [ 'name' => __( 'Dark', 'cookie-rocket' ), 'desc' => __( 'Near-black with light buttons, for sites with a dark design.', 'cookie-rocket' ), 'pro' => false, 'values' => [ 'banner_border' => 'on', 'banner_shadow' => 'off', 'background_color' => '#18181b', 'text_color' => '#f4f4f5', 'accept_button_bg' => '#f4f4f5', 'accept_text_color' => '#18181b', 'primary_color' => '#f4f4f5', 'border_radius' => '0', 'dark_mode' => 'dark' ] ], 'soft' => [ 'name' => __( 'Soft', 'cookie-rocket' ), 'desc' => __( 'Warm off-white and charcoal: quieter, for editorial and lifestyle sites.', 'cookie-rocket' ), 'pro' => false, 'values' => [ 'banner_border' => 'on', 'banner_shadow' => 'off', 'background_color' => '#f7f6f3', 'text_color' => '#3a3a3a', 'accept_button_bg' => '#3a3a3a', 'accept_text_color' => '#ffffff', 'primary_color' => '#3a3a3a', 'border_radius' => '0', 'dark_mode' => 'light' ] ], 'graphite' => [ 'name' => __( 'Graphite', 'cookie-rocket' ), 'desc' => __( 'Blue-black with pale buttons, for dark or technical sites.', 'cookie-rocket' ), 'pro' => false, 'values' => [ 'banner_border' => 'on', 'banner_shadow' => 'off', 'background_color' => '#1f2430', 'text_color' => '#e5e7eb', 'accept_button_bg' => '#e5e7eb', 'accept_text_color' => '#1f2430' ] ], ]; return apply_filters( 'cookroco_banner_themes', $themes ); } /** * What the Pro styles look like, shown in the free edition as pictures that lead to Cookie * Rocket Pro. Only the colors of the picture: the free edition cannot apply them (WordPress.org * guideline 5). Branded shows a sample brand color, painted as the Pro paints it. * * @return array */ private static function pro_style_previews(): array { return [ 'glass' => [ 'name' => __( 'Glass', 'cookie-rocket' ), 'desc' => __( 'A cool blue-grey tint that sits calmly on light pages.', 'cookie-rocket' ), 'pro' => true, 'values' => [ 'background_color' => '#eef1f5', 'text_color' => '#1f2937', 'accept_button_bg' => '#1f2937', 'accept_text_color' => '#ffffff' ] ], 'branded' => [ 'name' => __( 'Branded', 'cookie-rocket' ), 'desc' => __( 'Takes your brand color, from Elementor, your theme or your logo, and adjusts it so every text stays readable.', 'cookie-rocket' ), 'pro' => true, 'values' => [ 'background_color' => '#ffffff', 'text_color' => '#1a1a1a', 'accept_button_bg' => '#2563eb', 'accept_text_color' => '#ffffff' ] ], 'frosted' => [ 'name' => __( 'Frosted', 'cookie-rocket' ), 'desc' => __( 'Translucent: the page shows through, softly blurred, with twin light buttons.', 'cookie-rocket' ), 'pro' => true, 'values' => [ 'background_color' => '#f4f4f4', 'text_color' => '#171a20', 'accept_button_bg' => '#ffffff', 'accept_text_color' => '#393c41', 'border_radius' => '8' ] ], 'frosted_dark' => [ 'name' => __( 'Frosted Dark', 'cookie-rocket' ), 'desc' => __( 'Translucent and dark: the page shows through, softly blurred, with twin buttons in a lighter tint.', 'cookie-rocket' ), 'pro' => true, 'values' => [ 'background_color' => '#16171b', 'text_color' => '#f2f2f3', 'accept_button_bg' => '#34363d', 'accept_text_color' => '#f2f2f3', 'border_radius' => '8' ] ], ]; } /** * A Cookie Rocket Pro option, shown in the free edition as a picture of what it does, with a * lock: it opens the Cookie Rocket Pro page and applies nothing. The free edition carries no * setting and no code of the option (WordPress.org guideline 5); Pro fills the same place with * the real control. * * @param string $feature What the Pro page shows first (?feature=). * @param string $title The option. * @param string $desc What it gives, or empty. * @param string $pics The picture, from sample_tile() or fixed markup. */ private static function pro_sample( string $feature, string $title, string $desc, string $pics ): void { echo ''; echo '' . esc_html( $title ) . 'PRO'; if ( '' !== $desc ) { echo '' . esc_html( $desc ) . ''; } // Two, three or a single row of drawings; four go two by two. $n = substr_count( $pics, 'class="cr-pro-sample__tile' ); echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- sample_tile() escapes every part; the rest is fixed markup. echo ''; echo ''; } /** * One drawing of a Pro sample: a shape painted with fixed values, and its name. * * @param string $name Name under the shape. * @param string $style Inline style of the shape. * @param string $class Extra classes of the shape. * @param string $text Text drawn inside the shape. * @return string Escaped markup. */ private static function sample_tile( string $name, string $style = '', string $class = '', string $text = '' ): string { $shape = '' . esc_html( $text ) . ''; return '' . $shape . '' . esc_html( $name ) . ''; } public function render_design(): void { $d = get_option( 'cookroco_banner_design', [] ); $active = isset( $d['theme'] ) ? (string) $d['theme'] : ''; $s_now = (array) get_option( 'cookroco_settings', [] ); $variant = sanitize_key( is_array( $s_now ) ? ( $s_now['banner_variant'] ?? 'bottom_bar' ) : 'bottom_bar' ); $pos = sanitize_key( is_array( $s_now ) ? ( $s_now['banner_position'] ?? 'right' ) : 'right' ); $layouts = [ 'bottom_bar' => __( 'Bottom bar', 'cookie-rocket' ), 'top_bar' => __( 'Top bar', 'cookie-rocket' ), 'modal' => __( 'Center popup', 'cookie-rocket' ), 'floating' => __( 'Floating box', 'cookie-rocket' ), 'drawer' => __( 'Side panel', 'cookie-rocket' ) ]; $tab_icon = static fn( string $d ): string => ''; // Elementor's editor: the controls in a panel on the left, the live page on the right. echo '
'; echo '

' . esc_html__( 'Cookie Banner', 'cookie-rocket' ) . '

'; echo ''; echo '
'; // ─── Panel: Layout ─── echo '
'; echo '

' . esc_html__( 'Where & how the banner appears', 'cookie-rocket' ) . '

'; echo '
'; foreach ( $layouts as $key => $label ) { $sel = ( $key === $variant ) ? ' is-selected' : ''; echo '
'; echo '
'; echo '
' . esc_html( $label ) . '
'; echo '
'; } echo '
'; // Right under the cards it describes, not below the backdrop options. echo '

' . esc_html__( 'Click a layout to apply it instantly — all layouts are free.', 'cookie-rocket' ) . '

'; echo ''; // Bar width (only for bottom/top bars): contained card or full-width edge-to-edge. $bar_w = sanitize_key( $s_now['bar_width'] ?? 'contained' ); $is_bar = in_array( $variant, [ 'bottom_bar', 'top_bar' ], true ); echo ''; echo '
'; // Blurring the page behind the banner is a Pro refinement of the backdrop. The free plugin // carries none of its code and no disabled control: a picture shows what it does, and Pro // fills this slot with the real setting. if ( has_action( 'cookroco_layout_pro_slot' ) ) { do_action( 'cookroco_layout_pro_slot', $s_now ); } else { self::pro_sample( 'blur', __( 'Blur the page behind the banner', 'cookie-rocket' ), __( 'Frosts the page instead of only darkening it.', 'cookie-rocket' ), '' ); } echo '
'; // ─── Panel: Content (banner text — merge-safe AJAX save) ─── echo ''; // ─── Panel: Style & colors (accordions) ─── echo ''; // panel: style echo '
'; // editor__main echo '
'; // editor__panel // ─── Live preview: the real banner, updated instantly as you edit (WYSIWYG) ─── echo '
'; echo '
'; foreach ( [ 'desktop' => [ __( 'Desktop', 'cookie-rocket' ), '' ], 'tablet' => [ __( 'Tablet', 'cookie-rocket' ), '' ], 'mobile' => [ __( 'Mobile', 'cookie-rocket' ), '' ], ] as $device => [ $device_label, $device_icon ] ) { echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- static SVG. } echo '
' . esc_html__( 'Every change shows here at once.', 'cookie-rocket' ) . '
'; echo '
'; echo '
'; echo '
'; // editor__app, wrap } public function render_scanner(): void { $scanner = new COOKROCO_Scanner(); if ( isset( $_GET['cr_scan'] ) ) { check_admin_referer( 'cookroco_scan' ); // The result reports when nothing could be read. 'Scan complete' is shown only for a scan // that actually read something: announcing it over a total failure is false reassurance. $res_scan = $scanner->scan(); if ( empty( $res_scan['blind'] ) ) { echo '

' . esc_html__( 'Scan complete.', 'cookie-rocket' ) . '

'; } else { echo '

' . esc_html__( 'None of your pages could be read, so nothing was changed. Your previous result is still shown below.', 'cookie-rocket' ) . '

'; } } $s = (array) get_option( 'cookroco_settings', [] ); $blocking = array_key_exists( 'block_scripts', $s ) ? ! empty( $s['block_scripts'] ) : true; $raw_blocking = ! empty( $s['block_raw_html'] ); $raw_url = admin_url( 'admin.php?page=' . self::SLUG . '-categories' ); $last = $scanner->last_scan(); $found = ( is_array( $last ) && ! empty( $last['found'] ) ) ? $last['found'] : []; $age = $scanner->scan_age_days(); $pages = ( is_array( $last ) && ! empty( $last['scanned'] ) ) ? count( (array) $last['scanned'] ) : 0; $policy = (int) get_option( 'cookroco_policy_page_id', 0 ); $scan_url = wp_nonce_url( admin_url( 'admin.php?page=' . self::SLUG . '-scanner&cr_scan=1' ), 'cookroco_scan' ); $pro_url = 'https://templatesrocket.com/cookie-rocket-pro?feature=scanner'; $is_pro = apply_filters( 'cookroco_pro_active', false ); echo '

' . esc_html__( 'Scanner', 'cookie-rocket' ) . '

'; echo '

' . esc_html__( 'Scan your site to discover which third-party tracking services are present. Detected cookies are listed on your Cookie Policy automatically, and Cookie Rocket blocks each tracker until the visitor accepts its category.', 'cookie-rocket' ) . '

'; // ── Necessity card: a scan is a point-in-time snapshot that ages as the site changes. ── if ( null === $age ) { $card = '
'; $card .= '
' . esc_html__( 'Run your first scan', 'cookie-rocket' ) . ''; $card .= '

' . esc_html__( 'See exactly which trackers load on your site and which cookies they set.', 'cookie-rocket' ) . '

'; $card .= '' . esc_html__( 'Scan my site now', 'cookie-rocket' ) . '
'; echo wp_kses_post( $card ); } elseif ( $age >= COOKROCO_Scanner::STALE_DAYS ) { /* translators: %d: number of days since the last scan. */ $heading = esc_html( sprintf( _n( 'Your last scan was %d day ago', 'Your last scan was %d days ago', $age, 'cookie-rocket' ), $age ) ); $card = '
'; $card .= '
' . $heading . ''; $card .= '

' . esc_html__( 'Websites change constantly — a new plugin, embed or pixel can start tracking visitors without being disclosed. Your inventory is out of date.', 'cookie-rocket' ) . '

'; if ( ! $is_pro ) { $card .= '

' . esc_html__( 'Cookie Rocket Pro re-scans automatically every month and emails you when it finds a new tracker, so your cookie inventory stays current.', 'cookie-rocket' ) . '

'; } $card .= '
'; $card .= '' . esc_html__( 'Re-scan now', 'cookie-rocket' ) . '
'; echo wp_kses_post( $card ); } else { if ( 0 === $age ) { $heading = esc_html__( 'Scanned today', 'cookie-rocket' ); } else { /* translators: %d: number of days since the last scan. */ $heading = esc_html( sprintf( _n( 'Scanned %d day ago', 'Scanned %d days ago', $age, 'cookie-rocket' ), $age ) ); } $card = '
'; $card .= '
' . $heading . ''; $card .= '

' . esc_html__( 'This is a point-in-time snapshot. As your site changes, new trackers can appear.', 'cookie-rocket' ) . ( $is_pro ? '' : ' ' . esc_html__( 'Pro keeps it monitored automatically.', 'cookie-rocket' ) . '' ) . '

'; $card .= '' . esc_html__( 'Scan again', 'cookie-rocket' ) . '
'; echo wp_kses_post( $card ); } // ── Results ── if ( $found ) { echo '

' . esc_html__( 'Detected on your site', 'cookie-rocket' ) . '

'; $new_ids = ( is_array( $last ) && ! empty( $last['new'] ) ) ? (array) $last['new'] : []; if ( $new_ids && ! empty( $last['prev_found'] ) ) { $names = []; foreach ( $new_ids as $id ) { $names[] = $found[ $id ]['label'] ?? $id; } echo '

' . esc_html( sprintf( /* translators: %d: number of trackers that appeared since the previous scan. */ _n( '%d tracker is new since your last scan:', '%d trackers are new since your last scan:', count( $new_ids ), 'cookie-rocket' ), count( $new_ids ) ) ) . ' ' . esc_html( implode( ', ', $names ) ) . '

'; } // A table wider than a phone scrolls inside this card; tabindex lets a keyboard reach it (WCAG 2.1.1). echo '
'; echo ''; echo ''; echo ''; foreach ( $found as $f ) { echo ''; echo ''; echo ''; } echo '
' . esc_html__( 'Service', 'cookie-rocket' ) . '' . esc_html__( 'Category', 'cookie-rocket' ) . '' . esc_html__( 'Known cookies', 'cookie-rocket' ) . '' . esc_html__( 'Status', 'cookie-rocket' ) . '
' . esc_html( $f['label'] ?? '' ) . '' . esc_html( $f['cat'] ?? '' ) . '' . esc_html( implode( ', ', (array) ( $f['cookies'] ?? [] ) ) ) . ''; if ( ! empty( $f['managed'] ) ) { // The GTM container is deliberately not blocked: Consent Mode governs it. echo '' . esc_html__( 'Governed by Consent Mode v2', 'cookie-rocket' ) . ''; } elseif ( ! $blocking ) { echo '' . esc_html__( 'Detected · blocking off', 'cookie-rocket' ) . ''; } elseif ( 'inline' === ( $f['via'] ?? 'src' ) && ! $raw_blocking ) { // script_loader_tag only reaches enqueued scripts, so this one is reported as it // actually is, together with the switch that does handle it. // The link goes OUTSIDE the span: .cr-status is inline-flex, and an inside it // becomes another flex item, breaking the line and leaving the separator hanging. echo '' . esc_html__( 'Pasted directly into the HTML', 'cookie-rocket' ) . ''; echo '' . esc_html__( 'Turn on raw HTML blocking', 'cookie-rocket' ) . ''; } else { echo '' . esc_html__( 'Blocked until consent', 'cookie-rocket' ) . ''; } echo '
'; echo '
'; echo '

'; if ( $policy ) { echo esc_html__( 'These cookies are listed automatically on your Cookie Policy page.', 'cookie-rocket' ); } else { echo esc_html__( 'These cookies will be listed automatically once you create your Cookie Policy page.', 'cookie-rocket' ) . ' ' . esc_html__( 'Create it', 'cookie-rocket' ) . ' →'; } echo '

'; // Pages that could NOT be read are always reported, whether or not the scan found // anything, so a blocked URL never disappears from the report behind a finding. $unreadable = ( is_array( $last ) && ! empty( $last['errors'] ) ) ? (array) $last['errors'] : []; $read_ok = max( 0, $pages - count( $unreadable ) ); if ( $read_ok > 0 ) { /* translators: %d: number of pages scanned. */ echo '

' . esc_html( sprintf( _n( 'Scanned %d key page.', 'Scanned %d key pages.', $read_ok, 'cookie-rocket' ), $read_ok ) ) . ( $is_pro ? '' : ' ' . esc_html__( 'Pro re-scans automatically every month and emails you when something new appears.', 'cookie-rocket' ) ) . '

'; } if ( $unreadable ) { echo '

' . esc_html( sprintf( /* translators: %d: number of URLs that could not be read. */ _n( '%d page could not be read, so this result is incomplete.', '%d pages could not be read, so this result is incomplete.', count( $unreadable ), 'cookie-rocket' ), count( $unreadable ) ) ) . ' ' . esc_html__( 'Run the scan again when your site responds normally.', 'cookie-rocket' ) . '

'; echo '
    '; foreach ( array_slice( $unreadable, 0, 5 ) as $u ) { echo '
  • ' . esc_html( (string) $u ) . '
  • '; } echo '
'; } } elseif ( null !== $age ) { // If any URL could not be read, it is named, and nothing is claimed about what was // never looked at: zero findings from a failed request is not a clean site. $failures = ( is_array( $last ) && ! empty( $last['errors'] ) ) ? (array) $last['errors'] : []; if ( $failures ) { echo '

' . esc_html( sprintf( /* translators: %d: number of URLs that could not be read. */ _n( '%d page could not be read, so this result is incomplete.', '%d pages could not be read, so this result is incomplete.', count( $failures ), 'cookie-rocket' ), count( $failures ) ) ) . ' ' . esc_html__( 'Run the scan again when your site responds normally.', 'cookie-rocket' ) . '

'; echo '
    '; foreach ( array_slice( $failures, 0, 5 ) as $u ) { echo '
  • ' . esc_html( (string) $u ) . '
  • '; } echo '
'; } else { echo '

' . esc_html__( 'No known tracking services were detected on the scanned pages. As you add analytics, ads or embeds, run the scan again.', 'cookie-rocket' ) . '

'; } } // ── Pro slot: the Pro panel renders here; otherwise the Pro features are listed. ── if ( $is_pro ) { do_action( 'cookroco_scanner_pro_slot', $last ); } else { $hooks = [ __( 'Automatic monthly re-scan + email alert when a new tracker appears', 'cookie-rocket' ), __( 'Tamper-evident consent ledger — every decision sealed in a hash chain', 'cookie-rocket' ), __( 'Self-hosted ARCO / DSAR rights channel, handled from this dashboard', 'cookie-rocket' ), __( 'Privacy notice generated for the law that applies to each visitor', 'cookie-rocket' ), __( 'Exportable compliance report — proof for clients and audits', 'cookie-rocket' ), ]; echo '

' . esc_html__( 'Put your cookie monitoring on autopilot', 'cookie-rocket' ) . '

    '; foreach ( $hooks as $h ) { echo '
  • ' . esc_html( $h ) . '
  • '; } echo '
' . esc_html__( 'See Cookie Rocket Pro', 'cookie-rocket' ) . ' →
'; } echo '
'; } public function render_policy(): void { $page_id = (int) get_option( 'cookroco_policy_page_id', 0 ); $page = $page_id ? get_post( $page_id ) : null; $exists = ( $page && 'trash' !== $page->post_status ); if ( isset( $_GET['cr_create_policy'] ) && ! $exists ) { check_admin_referer( 'cookroco_create_policy' ); $new_id = wp_insert_post( [ 'post_title' => __( 'Cookie Policy', 'cookie-rocket' ), 'post_content' => '[' . COOKROCO_Policy::SHORTCODE . ']', 'post_status' => 'publish', 'post_type' => 'page', ] ); if ( $new_id && ! is_wp_error( $new_id ) ) { update_option( 'cookroco_policy_page_id', (int) $new_id ); $page_id = (int) $new_id; $page = get_post( $page_id ); $exists = true; echo '

' . esc_html__( 'Cookie Policy page created.', 'cookie-rocket' ) . '

'; } else { echo '

' . esc_html__( 'Could not create the page. Please add the shortcode to a page manually.', 'cookie-rocket' ) . '

'; } } $shortcode = '[' . COOKROCO_Policy::SHORTCODE . ']'; $scan = get_option( 'cookroco_last_scan', [] ); $has_scan = is_array( $scan ) && ! empty( $scan['found'] ); echo '

' . esc_html__( 'Cookie policy', 'cookie-rocket' ) . '

'; echo '

' . esc_html__( 'Generate a cookie declaration that lists every cookie your site uses, grouped by category, with its provider, purpose and duration. The list is built from your latest scan plus a built-in catalog of common cookies, and updates automatically.', 'cookie-rocket' ) . '

'; echo '
'; echo '

' . esc_html__( 'Your cookie declaration page', 'cookie-rocket' ) . '

'; if ( $exists ) { echo '

' . sprintf( /* translators: %s: the page title, already escaped and wrapped in . */ esc_html__( 'Published as %s', 'cookie-rocket' ), '' . esc_html( get_the_title( $page_id ) ) . '' ) . '

'; echo '

' . esc_html__( 'View page', 'cookie-rocket' ) . ' '; echo '' . esc_html__( 'Edit page', 'cookie-rocket' ) . '

'; } else { echo '

' . esc_html__( 'Create a ready-made page that displays your live cookie declaration — one click, no setup.', 'cookie-rocket' ) . '

'; $create_url = wp_nonce_url( admin_url( 'admin.php?page=' . self::SLUG . '-policy&cr_create_policy=1' ), 'cookroco_create_policy' ); echo '

' . esc_html__( 'Create Cookie Policy page', 'cookie-rocket' ) . '

'; } echo '
'; echo '
'; echo '
'; // card if ( ! $has_scan ) { echo '

' . wp_kses_post( sprintf( /* translators: %s: URL of the Scanner admin page. */ __( 'Tip: run the Scanner first so the third-party cookies detected on your site appear in the declaration. Strictly-necessary cookies are always listed.', 'cookie-rocket' ), esc_url( admin_url( 'admin.php?page=' . self::SLUG . '-scanner' ) ) ) ) . '

'; } // ─── Advanced: legal defaults (relocated from the former Settings page). These are // select/number/text fields, which the sanitizer already preserves from stored values; // the _none sentinel in _fields tells it this form owns no checkboxes, so it never wipes // the blocking boxes it doesn't render. ─── $s = (array) get_option( 'cookroco_settings', [] ); if ( ! is_array( $s ) ) { $s = []; } $law_now = in_array( $s['default_law'] ?? 'generic', [ 'gdpr', 'ccpa', 'lgpd', 'lfpdppp', 'generic' ], true ) ? ( $s['default_law'] ?? 'generic' ) : 'generic'; $law_opts = self::law_names(); echo ''; echo '
'; } public function render_log(): void { $per_page = 20; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only pagination of an admin view; no state change. $page_num = isset( $_GET['log_page'] ) ? max( 1, absint( wp_unslash( $_GET['log_page'] ) ) ) : 1; $offset = ( $page_num - 1 ) * $per_page; $stats = $this->log->get_stats(); $total = (int) ( $stats['total'] ?? 0 ); $last7 = (int) ( $stats['last7days'] ?? 0 ); $records = $this->log->get_records( $per_page, $offset ); $labels = self::action_labels(); // A person's records, searched by POST: an IP address typed here never lands in a URL, a // server log or the browser history. false = no search; null = neither an ID nor an IP. $query = ''; $found = false; if ( isset( $_POST['cr_log_q'] ) && check_admin_referer( 'cookroco_log_find', 'cr_log_nonce' ) && current_user_can( self::CAP ) ) { $query = sanitize_text_field( wp_unslash( $_POST['cr_log_q'] ) ); $found = $this->log->find_records( $query ); if ( is_array( $found ) ) { $records = $found; } } // What each column means, in the screen itself, for whoever reads the log for the first time. $help = [ 'date' => [ __( 'Date', 'cookie-rocket' ), __( 'When the visitor made the choice, in your site’s time zone.', 'cookie-rocket' ) ], 'action' => [ __( 'Action', 'cookie-rocket' ), __( 'What the visitor chose: Accepted all, Rejected (only strictly necessary cookies), Customized (categories picked one by one), Withdrew (took their consent back) or Opted out (their browser sent a Global Privacy Control signal).', 'cookie-rocket' ) ], 'cats' => [ __( 'Categories accepted', 'cookie-rocket' ), __( 'The cookie categories the visitor allowed. Strictly necessary cookies are always on and need no consent.', 'cookie-rocket' ) ], 'policy' => [ __( 'Policy', 'cookie-rocket' ), __( 'The version of your cookie policy in force when the visitor chose. Raise it whenever you change the policy, and every consent stays tied to the text the visitor saw.', 'cookie-rocket' ) ], 'consent' => [ __( 'Consent ID', 'cookie-rocket' ), __( 'The ID of the visitor’s browser. It stays the same while they keep their cookies, and the visitor can see it in their cookie preferences: ask them for it to find their records.', 'cookie-rocket' ) ], 'visitor' => [ __( 'Visitor', 'cookie-rocket' ), __( 'A code made from the visitor’s internet connection (IP address) and a secret key of your site. The IP address itself is never stored, and the code cannot be turned back into it. The same connection gives the same code; a phone that changes network gets a new one.', 'cookie-rocket' ) ], ]; echo '

' . esc_html__( 'Consent Log', 'cookie-rocket' ) . '

'; echo '

' . esc_html__( 'An auditable record of every consent decision on your site. Each row is one visitor choice; the visitor is identified only by a one-way hash — no raw IP is stored.', 'cookie-rocket' ) . '

'; $ret = COOKROCO_Log::retention_days(); /* translators: %d: number of days consent records are kept before automatic deletion. */ $ret_note = sprintf( _n( 'Records are kept for %d day, then deleted automatically.', 'Records are kept for %d days, then deleted automatically.', $ret, 'cookie-rocket' ), $ret ); /** * Filters the retention sentence on the Consent Log (Cookie Rocket Pro: sealed records are kept). * * @param string $ret_note Sentence. * @param int $ret Retention in days. */ $ret_note = (string) apply_filters( 'cookroco_retention_note', $ret_note, $ret ); echo '

' . esc_html( $ret_note ) . ' ' . esc_html__( 'Change retention', 'cookie-rocket' ) . '

'; // The same metric must look the same everywhere: this uses the canonical stat // component (.cr-stat). echo '
'; echo '
' . esc_html( number_format_i18n( $total ) ) . '' . esc_html__( 'Total consents', 'cookie-rocket' ) . '
'; echo '
' . esc_html( number_format_i18n( $last7 ) ) . '' . esc_html__( 'Last 7 days', 'cookie-rocket' ) . '
'; echo '
'; // The stat grid holds stats and nothing else. The actions slot renders AFTER it — // inside the grid, a button row became a grid cell and got stretched like a card. do_action( 'cookroco_log_actions_slot', $total ); echo '
'; echo '
'; foreach ( $help as $key => [ $term, $text ] ) { echo '
' . esc_html( $term ) . '
' . esc_html( $text ); if ( 'policy' === $key ) { echo ' ' . esc_html__( 'Change the policy version', 'cookie-rocket' ) . ''; } echo '
'; } echo '
'; echo '

' . esc_html__( 'This log identifies no one by name. To find a person’s records, ask them for their consent ID, or for the IP address they used, and search for it below.', 'cookie-rocket' ) . '

'; echo '
'; echo '
'; wp_nonce_field( 'cookroco_log_find', 'cr_log_nonce' ); echo ''; echo ''; echo '' . esc_html__( 'The IP address is only used for this search and is never stored.', 'cookie-rocket' ) . ''; echo '
'; if ( false !== $found ) { echo '

'; if ( null === $found ) { esc_html_e( 'Enter a consent ID (at least 6 characters) or an IP address.', 'cookie-rocket' ); } elseif ( ! $found ) { esc_html_e( 'No record matches this consent ID or IP address.', 'cookie-rocket' ); } else { /* translators: %d: number of consent records found. */ echo esc_html( sprintf( _n( '%d record matches.', '%d records match.', count( $found ), 'cookie-rocket' ), count( $found ) ) ); } echo ' ' . esc_html__( 'Show all records', 'cookie-rocket' ) . '

'; } if ( $records ) { // A table wider than a phone scrolls inside this card; tabindex lets a keyboard reach it (WCAG 2.1.1). echo '
'; echo ''; foreach ( $help as $key => [ $term ] ) { /* translators: %s: a column of the consent log, such as Date. */ echo ''; } echo ''; foreach ( $records as $r ) { $cats = json_decode( (string) ( $r['categories_accepted'] ?? '[]' ), true ); $cats = is_array( $cats ) ? implode( ', ', array_map( 'sanitize_text_field', $cats ) ) : ''; $action_key = (string) ( $r['action'] ?? '' ); $act = $labels[ $action_key ] ?? $action_key; $tone = ( 'accept_all' === $action_key ) ? 'ok' : 'muted'; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
' . esc_html( $term ) . ' ?
' . esc_html( $r['created_at'] ?? '' ) . '' . esc_html( $act ) . '' . ( '' !== $cats ? esc_html( $cats ) : '—' ) . '' . esc_html( $r['policy_version'] ?? '' ) . '' . esc_html( substr( (string) ( $r['session_id'] ?? '' ), 0, 8 ) ) . '' . esc_html( substr( (string) ( $r['ip_hash'] ?? '' ), 0, 12 ) ) . '
'; echo '
'; // card $total_pages = false === $found ? (int) ceil( $total / $per_page ) : 1; if ( $total_pages > 1 ) { $base = admin_url( 'admin.php?page=' . self::SLUG . '-log' ); echo '
'; if ( $page_num > 1 ) { echo '« ' . esc_html__( 'Previous', 'cookie-rocket' ) . ''; } /* translators: 1: current page number, 2: total number of pages */ echo '' . esc_html( sprintf( __( 'Page %1$d of %2$d', 'cookie-rocket' ), $page_num, $total_pages ) ) . ''; if ( $page_num < $total_pages ) { echo '' . esc_html__( 'Next', 'cookie-rocket' ) . ' »'; } echo '
'; } } elseif ( false === $found ) { echo '

' . esc_html__( 'No consents recorded yet. Decisions will appear here once visitors interact with your banner.', 'cookie-rocket' ) . '

'; } if ( ! apply_filters( 'cookroco_pro_active', false ) ) { echo '
'; echo '
' . esc_html__( 'Export and evidence report', 'cookie-rocket' ) . ''; echo '

' . esc_html__( 'Export the full consent log to CSV and issue an evidence report anchored to a tamper-evident chain — in Cookie Rocket Pro.', 'cookie-rocket' ) . ' ' . esc_html__( 'See Pro', 'cookie-rocket' ) . ' →

'; } echo '
'; // wrap } public function render_categories(): void { $cats = get_option( 'cookroco_cookie_categories', [] ); if ( ! is_array( $cats ) ) $cats = []; uasort( $cats, fn( $a, $b ) => ( $a['order'] ?? 99 ) <=> ( $b['order'] ?? 99 ) ); // Per-category dashicon for quick visual scanning; unknown keys fall back to a generic one. $icons = [ 'necessary' => 'shield-alt', 'functional' => 'admin-settings', 'analytics' => 'chart-bar', 'marketing' => 'megaphone', 'personalization' => 'admin-customizer', 'social' => 'share', ]; echo '

' . esc_html__( 'Categories', 'cookie-rocket' ) . '

'; echo '

' . esc_html__( 'Rename, describe and reorder the consent categories shown on your banner. The strictly-necessary category is always on and cannot be turned off.', 'cookie-rocket' ) . '

'; echo '
'; settings_fields( 'cookroco_categories_group' ); echo '
'; foreach ( $cats as $key => $cat ) { $key = (string) $key; $icon = $icons[ $key ] ?? 'category'; $title = ucwords( str_replace( [ '_', '-' ], ' ', $key ) ); $req = ! empty( $cat['required'] ); $field = 'cookroco_cookie_categories[' . $key . ']'; // raw base; escaped inline at each echo. echo '
'; echo '
'; echo ''; echo '' . esc_html( $title ) . ''; if ( $req ) { echo '' . esc_html__( 'Always on', 'cookie-rocket' ) . ''; } echo '
'; echo '
'; echo '
'; echo '
'; echo '
'; echo '
'; echo '
'; echo '
'; echo '
'; echo '
'; echo '
'; // body echo '
'; // card } echo '
'; // grid submit_button( __( 'Save changes', 'cookie-rocket' ) ); echo '
'; // ─── Advanced: script blocking (relocated here from the former Settings page, so the // controls that decide WHAT gets blocked live in the section named Blocking). Saved with // the "owned fields" pattern so this partial form never wipes the other cookroco_settings. ─── $s = (array) get_option( 'cookroco_settings', [] ); if ( ! is_array( $s ) ) { $s = []; } $gcm = array_key_exists( 'gcm_enabled', $s ) ? ! empty( $s['gcm_enabled'] ) : true; $blk = array_key_exists( 'block_scripts', $s ) ? ! empty( $s['block_scripts'] ) : true; $emb = array_key_exists( 'block_embeds', $s ) ? ! empty( $s['block_embeds'] ) : true; $buf = array_key_exists( 'block_raw_html', $s ) ? ! empty( $s['block_raw_html'] ) : false; $vendor_list = implode( ', ', wp_list_pluck( COOKROCO_Blocker::vendors(), 'label' ) ); echo '
'; echo '
'; settings_fields( 'cookroco_settings_group' ); foreach ( [ 'gcm_enabled', 'block_scripts', 'block_embeds', 'block_raw_html' ] as $fk ) { echo ''; } echo ''; echo ''; echo ''; echo ''; echo ''; echo '
' . esc_html__( 'Google Consent Mode v2', 'cookie-rocket' ) . '

' . esc_html__( 'Recommended. Gates Google cookies the official way, even without blocking the script.', 'cookie-rocket' ) . '

' . esc_html__( 'Auto-block tracking scripts', 'cookie-rocket' ) . '

' . wp_kses_post( __( 'Blocks enqueued scripts from known providers and re-activates them on consent. Covers:', 'cookie-rocket' ) ) . ' ' . esc_html( $vendor_list ) . '.

' . esc_html__( 'Smart embed placeholders', 'cookie-rocket' ) . '
' . esc_html__( 'Block scripts in raw HTML', 'cookie-rocket' ) . '

' . esc_html__( 'Enable only if you insert analytics/pixels directly in the head (not via a plugin). It buffers the page and can interact with caching — test it. Default: off.', 'cookie-rocket' ) . '

'; submit_button(); echo '
'; echo '
'; // ─── Your own services: whatever the plugin does not recognize, the owner declares // here. With a name, script signatures and cookies, it gets blocked and declared. ─── $services = COOKROCO_Blocker::custom_services(); $cats_svc = [ 'analytics' => __( 'Statistics & Analytics', 'cookie-rocket' ), 'marketing' => __( 'Marketing & Advertising', 'cookie-rocket' ), 'functional' => __( 'Functional & Preferences', 'cookie-rocket' ), 'personalization' => __( 'Personalization', 'cookie-rocket' ), 'social' => __( 'Social Media & Embeds', 'cookie-rocket' ) ]; echo '

' . esc_html__( 'Your own services', 'cookie-rocket' ) . '

'; $known = count( array_filter( array_keys( COOKROCO_Blocker::vendors() ), static fn( $k ) => 0 !== strpos( (string) $k, 'custom_' ) ) ); /* translators: %d: number of services Cookie Rocket recognizes on its own. */ echo '

' . esc_html( sprintf( _n( 'Cookie Rocket recognizes %d common service. Anything else on your site is neither blocked nor declared until you name it here. Add the script address so it can be blocked, and its cookies so they appear on your cookie policy.', 'Cookie Rocket recognizes %d common services. Anything else on your site is neither blocked nor declared until you name it here. Add the script address so it can be blocked, and its cookies so they appear on your cookie policy.', $known, 'cookie-rocket' ), $known ) ) . '

'; echo '
'; settings_fields( 'cookroco_services_group' ); // One card per service, the new one last: a title, then every field with its label above it. echo '
'; $i = 0; foreach ( array_merge( array_values( $services ), [ null ] ) as $svc ) { $n = 'cookroco_custom_services[' . $i . ']'; $is_new = ( null === $svc ); $title = $is_new ? __( 'Add a service', 'cookie-rocket' ) : (string) ( $svc['label'] ?? '' ); echo '
'; echo '

' . esc_html( '' !== $title ? $title : __( 'Service', 'cookie-rocket' ) ) . '

'; echo '
'; echo '

'; echo '

'; echo '

'; echo '

'; echo '
'; echo '

'; echo ''; echo '' . esc_html__( 'Part of the script URL, enough to recognize it. Separate several with commas.', 'cookie-rocket' ) . '

'; $lines = []; foreach ( (array) ( $svc['cookies'] ?? [] ) as $c ) { $lines[] = $c['name'] . ' | ' . $c['purpose'] . ' | ' . $c['duration']; } echo '

'; echo ''; echo '' . esc_html__( 'One per line: name | what it is for | how long it lasts. A trailing * covers a family of cookies.', 'cookie-rocket' ) . '

'; if ( ! $is_new ) { echo '

' . esc_html__( 'Clear the name to remove it.', 'cookie-rocket' ) . '

'; } echo '
'; $i++; } echo '
'; submit_button( __( 'Save services', 'cookie-rocket' ) ); echo '
'; if ( ! apply_filters( 'cookroco_pro_active', false ) ) { echo '
'; echo '
' . esc_html__( 'Regions', 'cookie-rocket' ) . ''; echo '

' . esc_html__( 'Different rules for the EU, the United States, Mexico and Brazil on the same site: consent model, Reject button, consent duration and banner text per region, with country exceptions — in Cookie Rocket Pro.', 'cookie-rocket' ) . ' ' . esc_html__( 'See Pro', 'cookie-rocket' ) . ' →

'; } echo '
'; // wrap } /** * Services declared by the site owner. * * The plugin recognizes a fixed list of providers and any real site uses more. Anything absent * from that list is neither blocked nor declared, which leaves the cookie policy * incomplete with nothing to signal it. Here the owner names the service, gives its * script signatures and describes its cookies, and both happen. * * @param mixed $raw Raw form input. * @return array */ public function sanitize_services_option( $raw ): array { $out = []; foreach ( (array) $raw as $svc ) { if ( ! is_array( $svc ) ) { continue; } $label = sanitize_text_field( (string) ( $svc['label'] ?? '' ) ); if ( '' === trim( $label ) ) { continue; } // a row with no name is an empty row $id = sanitize_key( substr( sanitize_title( $label ), 0, 40 ) ); if ( '' === $id ) { continue; } // WordPress runs this twice on the very first save (update_option → add_option), the // second time on what the first returned: lists instead of the form's text. Both shapes // are read, or the first service ever saved would keep "Array" as its script and cookie. // Signatures: one per line or comma-separated. Whatever identifies the script. $src = $svc['src'] ?? ''; $signatures = is_array( $src ) ? $src : preg_split( '/[\r\n,]+/', (string) $src ); $signatures = array_values( array_filter( array_map( fn( $f ) => sanitize_text_field( trim( is_scalar( $f ) ? (string) $f : '' ) ), (array) $signatures ) ) ); // Cookies: one per line, "name | purpose | duration" (or the list a first pass made of them). $cookies = []; $lines = $svc['cookies'] ?? ''; if ( is_array( $lines ) ) { $lines = array_map( static fn( $c ) => is_array( $c ) ? implode( ' | ', [ (string) ( $c['name'] ?? '' ), (string) ( $c['purpose'] ?? '' ), (string) ( $c['duration'] ?? '' ) ] ) : ( is_scalar( $c ) ? (string) $c : '' ), $lines ); } else { $lines = preg_split( '/[\r\n]+/', (string) $lines ); } foreach ( $lines as $line ) { $line = trim( (string) $line ); if ( '' === $line ) { continue; } $parts = array_map( 'trim', explode( '|', $line ) ); $name = sanitize_text_field( $parts[0] ?? '' ); if ( '' === $name ) { continue; } $cookies[] = [ 'name' => $name, 'purpose' => sanitize_text_field( $parts[1] ?? '' ), 'duration' => sanitize_text_field( $parts[2] ?? '' ), ]; } $out[ $id ] = [ 'label' => $label, 'cat' => in_array( $svc['cat'] ?? '', [ 'analytics', 'marketing', 'functional', 'personalization', 'social' ], true ) ? $svc['cat'] : 'analytics', 'src' => $signatures, 'inline' => [], 'cookies' => $cookies, ]; } return $out; } public function ajax_save_settings(): void { $this->verify_nonce(); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Nonce checked in verify_nonce(); array sanitized via sanitize_settings_option(). $raw = isset( $_POST['settings'] ) ? (array) wp_unslash( $_POST['settings'] ) : []; update_option( 'cookroco_settings', $this->sanitize_settings_option( $raw ) ); // A value the rules changed (an invalid Tag Manager ID, a duration over the GDPR's ceiling) // goes back to the screen, so the owner sees what was stored instead of a plain "Saved". $stored = (array) get_option( 'cookroco_settings', [] ); $adjusted = []; foreach ( $raw as $k => $v ) { if ( '_fields' === $k || ! is_scalar( $v ) || ! array_key_exists( $k, $stored ) || ! is_scalar( $stored[ $k ] ) || is_bool( $stored[ $k ] ) ) { continue; } if ( (string) $stored[ $k ] !== trim( (string) $v ) ) { $adjusted[ sanitize_key( (string) $k ) ] = (string) $stored[ $k ]; } } wp_send_json_success( [ 'message' => __( 'Saved.', 'cookie-rocket' ), 'adjusted' => $adjusted ] ); } public function ajax_save_design(): void { $this->verify_nonce(); // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Nonce checked in verify_nonce(); array sanitized via sanitize_design_option(). $raw = isset( $_POST['design'] ) ? (array) wp_unslash( $_POST['design'] ) : []; update_option( 'cookroco_banner_design', $this->sanitize_design_option( $raw ) ); wp_send_json_success( [ 'message' => __( 'Saved.', 'cookie-rocket' ) ] ); } /** Save only the banner layout + position (merged into settings, preserving everything else). */ public function ajax_save_layout(): void { $this->verify_nonce(); // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce checked in verify_nonce(); all inputs unslashed + sanitized via sanitize_key() and validated against allow-lists below. $variant = isset( $_POST['variant'] ) ? sanitize_key( wp_unslash( $_POST['variant'] ) ) : ''; $position = isset( $_POST['position'] ) ? sanitize_key( wp_unslash( $_POST['position'] ) ) : ''; $bar_width = isset( $_POST['bar_width'] ) ? sanitize_key( wp_unslash( $_POST['bar_width'] ) ) : ''; $has_bd = array_key_exists( 'show_backdrop', $_POST ); $backdrop = ! empty( $_POST['show_backdrop'] ); // phpcs:enable WordPress.Security.NonceVerification.Missing $s = (array) get_option( 'cookroco_settings', [] ); if ( ! is_array( $s ) ) { $s = []; } if ( in_array( $variant, [ 'bottom_bar', 'top_bar', 'modal', 'floating', 'drawer' ], true ) ) { $s['banner_variant'] = $variant; } if ( in_array( $position, [ 'left', 'right', 'center' ], true ) ) { $s['banner_position'] = $position; } if ( in_array( $bar_width, [ 'contained', 'full' ], true ) ) { $s['bar_width'] = $bar_width; } if ( $has_bd ) { $s['show_backdrop'] = $backdrop; } update_option( 'cookroco_settings', $s ); wp_send_json_success( [ 'variant' => $s['banner_variant'] ?? '', 'position' => $s['banner_position'] ?? 'right' ] ); } /** * Save the banner content fields (title, message, button labels, font sizes) from the * Cookie Banner → Content panel. Merge-safe: reads the existing settings and overwrites * ONLY the content keys, so unrelated settings (script blocking, duration, etc.) are kept. */ public function ajax_save_content(): void { $this->verify_nonce(); $s = (array) get_option( 'cookroco_settings', [] ); if ( ! is_array( $s ) ) { $s = []; } // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce checked in verify_nonce(); the array is unslashed here and every field is sanitized individually below. $r = isset( $_POST['content'] ) ? (array) wp_unslash( $_POST['content'] ) : []; // phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $s['banner_title'] = sanitize_text_field( mb_substr( (string) ( $r['banner_title'] ?? '' ), 0, 80 ) ); $s['banner_body'] = sanitize_textarea_field( mb_substr( (string) ( $r['banner_body'] ?? '' ), 0, 1000 ) ); $s['btn_accept_text'] = sanitize_text_field( mb_substr( (string) ( $r['btn_accept_text'] ?? '' ), 0, 32 ) ); $s['btn_reject_text'] = sanitize_text_field( mb_substr( (string) ( $r['btn_reject_text'] ?? '' ), 0, 32 ) ); $s['btn_config_text'] = sanitize_text_field( mb_substr( (string) ( $r['btn_config_text'] ?? '' ), 0, 32 ) ); $s['modal_title'] = sanitize_text_field( mb_substr( (string) ( $r['modal_title'] ?? '' ), 0, 80 ) ); $s['modal_desc'] = sanitize_textarea_field( mb_substr( (string) ( $r['modal_desc'] ?? '' ), 0, 1000 ) ); $s['show_reject_button'] = ! empty( $r['show_reject_button'] ); $s['show_config_button'] = ! empty( $r['show_config_button'] ); $s['title_size'] = max( 14, min( 22, intval( $r['title_size'] ?? 18 ) ) ); $s['body_size'] = max( 11, min( 15, intval( $r['body_size'] ?? 12 ) ) ); if ( array_key_exists( 'banner_font', $r ) ) { $fonts = array_keys( self::font_choices() ); $s['banner_font'] = in_array( (string) $r['banner_font'], $fonts, true ) ? (string) $r['banner_font'] : 'system'; } if ( array_key_exists( 'banner_font_family', $r ) ) { $s['banner_font_family'] = sanitize_text_field( mb_substr( (string) $r['banner_font_family'], 0, 120 ) ); } /** * Content fields an add-on adds to this panel (Cookie Rocket Pro: typography). * * @param array $s Settings about to be saved. * @param array $r Raw posted content, unslashed. */ $s = (array) apply_filters( 'cookroco_sanitize_content', $s, $r ); update_option( 'cookroco_settings', $s ); wp_send_json_success( [ 'message' => __( 'Saved.', 'cookie-rocket' ) ] ); } private function verify_nonce(): void { if (!current_user_can(self::CAP)) wp_send_json_error(['message'=>__('You do not have permission to change these settings.','cookie-rocket')],403); if (!wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_nonce']??'')),'cookroco-admin')) wp_send_json_error(['message'=>__('This page has expired. Reload it and try again.','cookie-rocket')],403); } public function plugin_links( array $links ): array { $extra = [''.esc_html__('Settings','cookie-rocket').'']; if ( ! apply_filters( 'cookroco_pro_active', false ) ) { $extra[] = ''.esc_html__('Get Pro','cookie-rocket').''; } return array_merge($extra,$links); } }