From 7c0b8d34821fb697715af3c63e7c766e02bcc003 Mon Sep 17 00:00:00 2001 From: castellon Date: Wed, 30 Sep 2026 13:12:13 +0200 Subject: [PATCH 1/3] Add "Customize cookie settings" button and preferences panel (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a real + get_readable_text_color( $color ); + $accent_link = $this->get_readable_on_white_color( $color, $bg_color ); + $panel_text = $this->get_readable_text_color( $bg_color ); + $style = sprintf( + '--frcn-cookie-accent: %1$s; --frcn-cookie-accent-contrast: %2$s; --frcn-cookie-accent-on-light: %3$s; --frcn-cookie-bg: %4$s; --frcn-cookie-text: %5$s; --frcn-cookie-radius: %6$s; --frcn-cookie-icon-url: url(%7$s);', + esc_attr( $color ), + esc_attr( $accent_text ), + esc_attr( $accent_link ), + esc_attr( $bg_color ), + esc_attr( $panel_text ), + esc_attr( $this->get_radius_value( $radius ) ), + esc_attr( FRCN_PLUGIN_URL . 'assets/cookie-notice/cookie-icon.svg' ) + ); + ?> + + fallback above. + $noscript_css .= ' .frcn-cookie-notice__button--customize { display: none; }'; + $this->print_noscript_style( $noscript_css ); /** @@ -1292,7 +1505,55 @@ public function log_consent_callback() { $this->maybe_increment_stat( $decision ); - wp_send_json_success(); + wp_send_json_success( array( 'categories' => $this->get_consent_categories_payload( $decision ) ) ); + } + + /** + * Build the (optional, additive) per-category consent map for the current + * request, applying the frcn_cookie_consent_categories filter around it. + * + * The Free tier never populates this itself — the stored binary + * accepted/rejected cookie stays the single source of truth for Free, and + * this method only exists so FrontConsent PRO's per-category consent + * (Analytics, Marketing, etc.) has a single, well-defined place to read + * the raw category selection submitted by the preferences panel (see + * render_preferences_panel()'s frcn_cookie_preferences_categories action) + * and to persist/return its own per-category decision alongside the + * binary one. Never required for the binary flow to keep working. + * + * @param string $decision 'accepted' or 'rejected'. + * @return array Category slug => state, empty unless something extends it. + */ + private function get_consent_categories_payload( $decision ) { + $categories = array(); + + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- the surrounding log_consent_callback() already verified the nonce above. + if ( isset( $_POST['categories'] ) ) { + // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- raw JSON can't be run through a string sanitizer without corrupting it; every decoded key/value is sanitized individually below (sanitize_key()/rest_sanitize_boolean()) before it's ever used. + $decoded = json_decode( (string) wp_unslash( $_POST['categories'] ), true ); + + if ( is_array( $decoded ) ) { + foreach ( $decoded as $key => $value ) { + $categories[ sanitize_key( (string) $key ) ] = rest_sanitize_boolean( $value ); + } + } + } + + /** + * Filters the per-category consent map recorded alongside a binary + * accept/reject decision. + * + * FrontConsent PRO hooks here to persist (e.g. in its own cookie/option) + * and/or normalize the category selection a visitor made in the + * preferences panel (see frcn_cookie_preferences_categories), and to + * read back any category state it needs when this fires again on a + * later request. The Free tier's own stored consent format never + * depends on this value — it is optional and purely additive. + * + * @param array $categories Category slug => state, decoded from the request. + * @param string $decision The binary decision being recorded ('accepted' or 'rejected'). + */ + return apply_filters( 'frcn_cookie_consent_categories', $categories, $decision ); } /** diff --git a/readme.md b/readme.md index 00b2d69..b64d40e 100644 --- a/readme.md +++ b/readme.md @@ -5,6 +5,7 @@ Repository for FrontConsent, a WordPress plugin providing a GDPR/ePrivacy-compli ## Functionalities - **Cookie Notice** — configurable cookie consent banner (bar/box/popup) that gates Google Tag Manager, GA4, and supported tracking integrations (Clientify, Brevo, ChatGPT Ads) behind consent, with Google Consent Mode v2 support for compatibility with Google Site Kit and other analytics plugins. +- **Cookie preferences panel** — a "Customize cookie settings" button (left of Reject/Accept) and the persistent reopen trigger both open a dialog with Accept all / Reject all / Save changes and a static "Strictly necessary" section, all recording the exact same consent cookie/Consent Mode update/logging as the binary Accept/Reject buttons. Extensible via the `frcn_cookie_preferences_categories` action and `frcn_cookie_consent_categories` filter so FrontConsent PRO can add per-category consent (Analytics, Marketing, etc.) without forking markup — see [docs/preferences-panel-hooks.md](docs/preferences-panel-hooks.md). - **Acceptance stats** — aggregate accepted/rejected counter on the settings page. - **Migration from FrontBlocks** — automatically imports settings and stats from FrontBlocks Site Tools' bundled Cookie Notice module, which FrontConsent replaces. - **Extensible settings tabs** — the settings page renders as a tab shell (Settings, plus a PRO upsell tab by default). A companion plugin such as FrontConsent PRO can add its own tab via the `frontconsent_settings_tabs` filter and render its panel on the `frontconsent_settings_form_tab_panels` action (fields saved through the main form) or `frontconsent_settings_tab_panels` action (a panel with its own form, e.g. a License tab). diff --git a/readme.txt b/readme.txt index bb36a33..712df5c 100644 --- a/readme.txt +++ b/readme.txt @@ -68,6 +68,10 @@ Service provided by OpenAI: [Terms of Use](https://openai.com/policies/terms-of- == Changelog == += Unreleased = +* Added a "Customize cookie settings" button (left of Reject/Accept, showing the cookie icon) that opens a cookie preferences dialog with Accept all / Reject all / Save changes actions and a static "Strictly necessary" section, also reachable from the persistent "Cookie preferences" reopen trigger. Every action records the exact same consent cookie, Consent Mode update and logging as the existing Accept/Reject buttons. +* Added PRO-ready extension points so a future FrontConsent PRO add-on can render per-category consent (Analytics, Marketing, etc.) into the same panel without forking markup: the `frcn_cookie_preferences_categories` action, the `frcn_cookie_consent_categories` filter, and the `frcn_cookie_customize_button_label`/`frcn_cookie_customize_button_enabled` filters. Documented in `docs/preferences-panel-hooks.md`. + = 1.0.1 = * Settings page now uses tabs, so a companion plugin can add its own tab via the new `frontconsent_settings_tabs` filter, rendering its panel on either `frontconsent_settings_form_tab_panels` (fields saved through the main settings form) or `frontconsent_settings_tab_panels` (a panel with its own form, e.g. FrontConsent PRO's License tab). * Added a FrontConsent PRO upsell tab and an inline promo link on the Cookie Notice tab, shown only when FrontConsent PRO isn't installed. diff --git a/tests/Unit/CookieNoticeAccessibilityTest.php b/tests/Unit/CookieNoticeAccessibilityTest.php index 48f3d47..0b510de 100644 --- a/tests/Unit/CookieNoticeAccessibilityTest.php +++ b/tests/Unit/CookieNoticeAccessibilityTest.php @@ -59,9 +59,17 @@ public function test_bar_layout_uses_region_role_not_dialog() { $html = $this->render_banner_html(); - $this->assertStringContainsString( 'role="region"', $html ); - $this->assertStringNotContainsString( 'role="dialog"', $html ); - $this->assertStringNotContainsString( 'aria-modal', $html ); + // Scoped to the banner element itself: the cookie preferences panel + // (a separate, always-present dialog — see + // CookieNotice::render_preferences_panel()) legitimately carries + // role="dialog"/aria-modal regardless of the banner's own layout, so + // asserting over the whole wp_footer output would wrongly fail here. + $banner_end = strpos( $html, 'id="frcn-cookie-reopen"' ); + $banner_html = substr( $html, 0, $banner_end ); + + $this->assertStringContainsString( 'role="region"', $banner_html ); + $this->assertStringNotContainsString( 'role="dialog"', $banner_html ); + $this->assertStringNotContainsString( 'aria-modal', $banner_html ); } /** diff --git a/tests/Unit/CookieNoticeConsentCategoriesTest.php b/tests/Unit/CookieNoticeConsentCategoriesTest.php new file mode 100644 index 0000000..7fe5ecd --- /dev/null +++ b/tests/Unit/CookieNoticeConsentCategoriesTest.php @@ -0,0 +1,156 @@ +cookie_notice = new CookieNotice(); + + update_option( 'frontconsent_settings', array( 'enable_cookie_notice' => true ) ); + + // wp_send_json_success() only routes through the interceptable + // wp_die() below when the request is treated as an Ajax one. + add_filter( 'wp_doing_ajax', '__return_true' ); + add_filter( 'wp_die_ajax_handler', array( $this, 'get_die_handler' ) ); + + $_POST['nonce'] = wp_create_nonce( CookieNotice::NONCE_ACTION ); + } + + public function tear_down() { + remove_filter( 'wp_die_ajax_handler', array( $this, 'get_die_handler' ) ); + remove_filter( 'wp_doing_ajax', '__return_true' ); + unset( $_POST['nonce'], $_POST['decision'], $_POST['categories'] ); + delete_option( 'frontconsent_settings' ); + parent::tear_down(); + } + + /** + * @return callable + */ + public function get_die_handler() { + return static function ( $message ) { + throw new Exception( is_scalar( $message ) ? (string) $message : 'die' ); + }; + } + + /** + * Invoke log_consent_callback() and decode its JSON response. + * + * @return array + */ + private function log_consent() { + ob_start(); + + try { + $this->cookie_notice->log_consent_callback(); + } catch ( Exception $e ) { + unset( $e ); + } + + $output = ob_get_clean(); + + return json_decode( $output, true ); + } + + /** + * With nothing hooked in, the response still carries an (empty) array — + * the binary decision itself never depends on this. + */ + public function test_categories_default_to_an_empty_array() { + $_POST['decision'] = 'accepted'; + + $response = $this->log_consent(); + + $this->assertTrue( $response['success'] ); + $this->assertSame( array(), $response['data']['categories'] ); + } + + /** + * A submitted categories JSON payload is decoded, sanitized to booleans + * keyed by a sanitized slug, and handed to the filter. + */ + public function test_submitted_categories_are_decoded_and_sanitized() { + $_POST['decision'] = 'accepted'; + $_POST['categories'] = wp_json_encode( + array( + 'Analytics!' => true, + 'marketing' => false, + ) + ); + + $response = $this->log_consent(); + + $this->assertSame( + array( + 'analytics' => true, + 'marketing' => false, + ), + $response['data']['categories'] + ); + } + + /** + * The frcn_cookie_consent_categories filter must actually run around the + * categories payload, receiving the decision alongside it, and its + * return value must be what reaches the response — this is the concrete + * extension point a PRO-like add-on uses to persist/read its own + * per-category state. + */ + public function test_consent_categories_filter_is_applied_and_observable_in_the_response() { + $_POST['decision'] = 'rejected'; + + $captured_decision = null; + + add_filter( + 'frcn_cookie_consent_categories', + static function ( $categories, $decision ) use ( &$captured_decision ) { + $captured_decision = $decision; + $categories['marketing'] = false; + $categories['analytics'] = true; + return $categories; + }, + 10, + 2 + ); + + $response = $this->log_consent(); + + $this->assertSame( 'rejected', $captured_decision ); + $this->assertSame( + array( + 'marketing' => false, + 'analytics' => true, + ), + $response['data']['categories'] + ); + } + + /** + * The binary accepted/rejected cookie/logging flow must keep working + * completely unmodified when no categories are submitted at all — the + * category map is purely additive. + */ + public function test_binary_decision_is_recorded_without_any_categories_payload() { + $_POST['decision'] = 'accepted'; + + $response = $this->log_consent(); + + $this->assertTrue( $response['success'] ); + } +} diff --git a/tests/Unit/CookieNoticePreferencesPanelTest.php b/tests/Unit/CookieNoticePreferencesPanelTest.php new file mode 100644 index 0000000..4444253 --- /dev/null +++ b/tests/Unit/CookieNoticePreferencesPanelTest.php @@ -0,0 +1,296 @@ +cookie_notice = new CookieNotice(); + } + + public function tear_down() { + delete_option( 'frontconsent_settings' ); + remove_all_actions( 'frcn_cookie_preferences_categories' ); + remove_all_filters( 'frcn_cookie_consent_categories' ); + remove_all_filters( 'frcn_cookie_customize_button_label' ); + remove_all_filters( 'frcn_cookie_customize_button_enabled' ); + parent::tear_down(); + } + + /** + * Render the full wp_footer output (banner markup + reopen trigger + + * preferences panel + status announcer), the same way WordPress itself + * would for a request. + * + * @return string + */ + private function render_banner_html() { + ob_start(); + $this->cookie_notice->render_banner(); + return ob_get_clean(); + } + + /** + * The button must render as a real, keyboard-focusable