commit 6bf9f553ded32d6e2035b2da9788d1d51292a1c1 Author: Steve Hanlon Date: Mon Feb 16 05:45:42 2026 +0000 Initial commit: ATT Consent plugin v1.0.0 Google Consent Mode v2 cookie consent plugin with session attribution preservation, custom script management, and gtag.js/GTM support. Co-Authored-By: Claude Opus 4.6 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1a225d5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,82 @@ +# ATT Consent - Developer Context + +## What This Is + +A WordPress plugin implementing Google Consent Mode v2 with session attribution preservation. It replaces paid CMv2 plugins (e.g., CookieYes) with a self-contained, distributable alternative. + +## Architecture + +### Core Flow + +1. **Inline `` script (priority 1)** - Sets consent defaults and captures attribution to sessionStorage. Must run before any Google tags. +2. **Tracking script (priority 2)** - Loads gtag.js or GTM. In Advanced mode, loads immediately. In Basic mode, deferred until consent. +3. **Footer** - Banner HTML + enqueued `banner.js` and `banner.css`. + +### Key Files + +- `includes/class-frontend.php` - Orchestrates the output order. The `output_consent_defaults()` method generates the critical inline script. +- `public/js/consent-manager.js` - Core logic: `AttConsent.update()` replays attribution, sends consent signals, injects scripts, sets cookie. This is the public API. +- `public/js/banner.js` - UI only. Calls `AttConsent.update()` on button clicks. +- `includes/class-admin.php` - Admin settings with 5 tabs. Handles save via `admin_post`, scripts CRUD via AJAX. +- `includes/class-scripts-manager.php` - CRUD for custom JS snippets stored in `{prefix}att_cc_scripts` table. + +### Settings + +Single serialized option: `att_consent_settings`. Access via `ATT_Consent::get_settings()` which merges with defaults. + +### Custom Scripts Table + +```sql +{prefix}att_cc_scripts (id, name, snippet, category, placement, status, priority, created_at, updated_at) +``` + +Scripts are passed to frontend JS via the inline config object, NOT rendered in PHP (they'd execute before consent). Injected into DOM by `consent-manager.js` after consent. + +### Consent Cookie + +Name: `att_cc_consent`. Value: JSON `{"functional":bool,"analytics":bool,"marketing":bool}`. First-party, SameSite=Lax. + +### Attribution Storage + +sessionStorage key `att_cc_attr` stores referrer, UTMs, GCLID/DCLID on first page of session. Key `att_cc_consent_given` tracks whether attribution has been replayed. Attribution is replayed via `gtag('set')` BEFORE `gtag('consent','update')` so GA4 session_start gets correct source. + +## Category-to-Signal Mapping + +| Category | Google Signals | +|----------|---------------| +| Necessary | `security_storage` (always granted) | +| Functional | `functionality_storage`, `personalization_storage` | +| Analytics | `analytics_storage` | +| Marketing | `ad_storage`, `ad_user_data`, `ad_personalization` | + +## WordPress Hooks + +**Filters:** `att_consent_settings_save`, `att_consent_banner_html` +**Actions:** `att_consent_before_banner`, `att_consent_after_banner` +**JS Event:** `att_consent_update` (CustomEvent with `detail: {functional, analytics, marketing}`) + +## Coding Standards + +- WordPress PHPCS (WordPress-Extra) +- All admin inputs sanitized: `sanitize_text_field`, `sanitize_hex_color`, `absint`, `wp_kses_post` +- Nonce verification on all forms and AJAX +- Capability check: `manage_options` for all admin operations +- Script snippets stored raw (admin-only input, intentionally executable) +- Frontend JS is vanilla - no jQuery dependency +- Text domain: `att-consent` + +## Testing + +Activate plugin, enter a GA4 Measurement ID in General settings, visit frontend. Verify: +- `window.dataLayer` contains consent default with `wait_for_update` +- Clicking Accept All fires `gtag('consent','update')` with all granted +- Cookie `att_cc_consent` is set +- Return visit: no banner shown, consent applied from cookie +- Attribution test: visit with `?utm_source=test`, navigate without consenting, consent on page 2, check GA4 DebugView for correct source + +## Environment + +- WordPress 6.8.3, Astra child theme +- Local Sites development environment +- WooCommerce + LearnPress site +- Previously used CookieYes (`cookie-law-info` plugin) diff --git a/admin/css/admin.css b/admin/css/admin.css new file mode 100644 index 0000000..ff608e6 --- /dev/null +++ b/admin/css/admin.css @@ -0,0 +1,127 @@ +/* ATT Consent Admin Styles */ + +.att-cc-admin-wrap .nav-tab-wrapper { + margin-bottom: 20px; +} + +.att-cc-tab-content { + background: #fff; + border: 1px solid #ccd0d4; + border-top: none; + padding: 20px; +} + +/* Scripts table */ +#att-cc-scripts-table .column-name { width: 25%; } +#att-cc-scripts-table .column-category { width: 15%; } +#att-cc-scripts-table .column-placement { width: 12%; } +#att-cc-scripts-table .column-priority { width: 10%; } +#att-cc-scripts-table .column-status { width: 12%; } +#att-cc-scripts-table .column-actions { width: 20%; } + +.att-cc-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-size: 12px; + font-weight: 600; + color: #fff; +} + +.att-cc-badge--functional { background: #2196F3; } +.att-cc-badge--analytics { background: #FF9800; } +.att-cc-badge--marketing { background: #9C27B0; } + +.att-cc-status { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-size: 12px; +} + +.att-cc-status--active { + background: #e7f5e7; + color: #2e7d32; +} + +.att-cc-status--inactive { + background: #f5e7e7; + color: #c62828; +} + +/* Admin modal */ +.att-cc-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 100100; + display: flex; + align-items: center; + justify-content: center; +} + +.att-cc-modal__overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.6); +} + +.att-cc-modal__content { + position: relative; + background: #fff; + border-radius: 4px; + padding: 24px; + max-width: 700px; + width: 90%; + max-height: 85vh; + overflow-y: auto; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); +} + +.att-cc-modal__content h2 { + margin-top: 0; +} + +.att-cc-modal__actions { + padding-top: 16px; + border-top: 1px solid #ddd; + display: flex; + gap: 8px; +} + +/* Code textarea */ +textarea.code { + font-family: Consolas, Monaco, 'Courier New', monospace; + font-size: 13px; + tab-size: 4; +} + +/* Color picker spacing */ +.att-cc-admin-wrap .wp-picker-container { + display: inline-block; +} + +/* Feedback messages */ +.att-cc-feedback { + padding: 8px 12px; + margin: 10px 0; + border-radius: 3px; + display: none; +} + +.att-cc-feedback--success { + background: #e7f5e7; + border: 1px solid #2e7d32; + color: #2e7d32; +} + +.att-cc-feedback--error { + background: #f5e7e7; + border: 1px solid #c62828; + color: #c62828; +} diff --git a/admin/js/admin.js b/admin/js/admin.js new file mode 100644 index 0000000..f6e2315 --- /dev/null +++ b/admin/js/admin.js @@ -0,0 +1,166 @@ +/* global jQuery, attCCAdmin */ +(function($) { + 'use strict'; + + $(function() { + // Initialize color pickers. + $('.att-cc-color-picker').wpColorPicker(); + + // Toggle gtag/GTM fields based on tracking mode. + var $trackingMode = $('#tracking_mode'); + function toggleTrackingFields() { + var mode = $trackingMode.val(); + $('.att-cc-gtag-row').toggle(mode === 'gtag'); + $('.att-cc-gtm-row').toggle(mode === 'gtm'); + } + if ($trackingMode.length) { + toggleTrackingFields(); + $trackingMode.on('change', toggleTrackingFields); + } + + // --- Custom Scripts CRUD --- + + var $modal = $('#att-cc-script-modal'); + var $form = $('#att-cc-script-form'); + var $title = $('#att-cc-script-modal-title'); + var $scriptId = $('#att-cc-script-id'); + + // Open modal for new script. + $('#att-cc-add-script').on('click', function() { + resetForm(); + $title.text('Add Script'); + $modal.show(); + }); + + // Close modal. + $('.att-cc-close-modal, .att-cc-modal__overlay').on('click', function() { + $modal.hide(); + }); + + // Escape key closes modal. + $(document).on('keydown', function(e) { + if (e.key === 'Escape' && $modal.is(':visible')) { + $modal.hide(); + } + }); + + // Edit script. + $(document).on('click', '.att-cc-edit-script', function() { + var id = $(this).data('id'); + $.post(attCCAdmin.ajaxUrl, { + action: 'att_cc_get_script', + nonce: attCCAdmin.nonce, + script_id: id, + }, function(response) { + if (response.success) { + var s = response.data; + $scriptId.val(s.id); + $('#att-cc-script-name').val(s.name); + $('#att-cc-script-category').val(s.category); + $('#att-cc-script-placement').val(s.placement); + $('#att-cc-script-priority').val(s.priority); + $('#att-cc-script-status').val(s.status); + $('#att-cc-script-snippet').val(s.snippet); + $title.text('Edit Script'); + $modal.show(); + } + }); + }); + + // Delete script. + $(document).on('click', '.att-cc-delete-script', function() { + if (!confirm(attCCAdmin.strings.confirmDelete)) { + return; + } + var id = $(this).data('id'); + $.post(attCCAdmin.ajaxUrl, { + action: 'att_cc_delete_script', + nonce: attCCAdmin.nonce, + script_id: id, + }, function(response) { + if (response.success) { + refreshScriptsTable(response.data.scripts); + } else { + alert(attCCAdmin.strings.error); + } + }); + }); + + // Save script (form submit). + $form.on('submit', function(e) { + e.preventDefault(); + + var data = { + action: 'att_cc_save_script', + nonce: attCCAdmin.nonce, + script_id: $scriptId.val(), + name: $('#att-cc-script-name').val(), + snippet: $('#att-cc-script-snippet').val(), + category: $('#att-cc-script-category').val(), + placement: $('#att-cc-script-placement').val(), + status: $('#att-cc-script-status').val(), + priority: $('#att-cc-script-priority').val(), + }; + + $.post(attCCAdmin.ajaxUrl, data, function(response) { + if (response.success) { + $modal.hide(); + refreshScriptsTable(response.data.scripts); + } else { + alert(attCCAdmin.strings.error); + } + }); + }); + + function resetForm() { + $scriptId.val(''); + $('#att-cc-script-name').val(''); + $('#att-cc-script-category').val('analytics'); + $('#att-cc-script-placement').val('head'); + $('#att-cc-script-priority').val('10'); + $('#att-cc-script-status').val('active'); + $('#att-cc-script-snippet').val(''); + } + + function refreshScriptsTable(scripts) { + var $tbody = $('#att-cc-scripts-list'); + $tbody.empty(); + + if (!scripts || scripts.length === 0) { + $tbody.append('No custom scripts added yet.'); + return; + } + + var categoryColors = { + functional: 'functional', + analytics: 'analytics', + marketing: 'marketing', + }; + + scripts.forEach(function(s) { + var row = '' + + '' + escapeHtml(s.name) + '' + + '' + capitalize(s.category) + '' + + '' + capitalize(s.placement) + '' + + '' + s.priority + '' + + '' + capitalize(s.status) + '' + + '' + + ' ' + + '' + + '' + + ''; + $tbody.append(row); + }); + } + + function escapeHtml(text) { + var div = document.createElement('div'); + div.appendChild(document.createTextNode(text)); + return div.innerHTML; + } + + function capitalize(str) { + return str.charAt(0).toUpperCase() + str.slice(1); + } + }); +})(jQuery); diff --git a/admin/views/settings-advanced.php b/admin/views/settings-advanced.php new file mode 100644 index 0000000..1ced04f --- /dev/null +++ b/admin/views/settings-advanced.php @@ -0,0 +1,89 @@ + +
+ + + + + + + + + + + + + + + + + + + +
+ +

+ +

+
+ +

+ +

+
+ + + +

+ +

+
+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + +
security_storage
functionality_storage, personalization_storage
analytics_storage
ad_storage, ad_user_data, ad_personalization
+ + +
diff --git a/admin/views/settings-appearance.php b/admin/views/settings-appearance.php new file mode 100644 index 0000000..60cd02d --- /dev/null +++ b/admin/views/settings-appearance.php @@ -0,0 +1,118 @@ + +
+ + + + +

+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ + + +

+
+ + + +
+ + + +
+ + + +
+ + + +
+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +

+
+ + +
diff --git a/admin/views/settings-categories.php b/admin/views/settings-categories.php new file mode 100644 index 0000000..811b232 --- /dev/null +++ b/admin/views/settings-categories.php @@ -0,0 +1,64 @@ + +
+ + + + +

+ + + + + + + + + + + + + + + + + + +
+ + + +

+
+ + + +

+
+ + + +

+
+ + + +

+
+ + +
diff --git a/admin/views/settings-general.php b/admin/views/settings-general.php new file mode 100644 index 0000000..04a1cb2 --- /dev/null +++ b/admin/views/settings-general.php @@ -0,0 +1,120 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +

+ +

+
+ + + +

+ +

+
+ + + +

+ +

+
+ + + +

+ +

+
+ + + +
+ + + +

+ +

+
+ + +
diff --git a/admin/views/settings-scripts.php b/admin/views/settings-scripts.php new file mode 100644 index 0000000..9691b20 --- /dev/null +++ b/admin/views/settings-scripts.php @@ -0,0 +1,133 @@ + +
+

+ + +

+ +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + +
diff --git a/att-consent.php b/att-consent.php new file mode 100644 index 0000000..b21701b --- /dev/null +++ b/att-consent.php @@ -0,0 +1,31 @@ +%s', + esc_url( admin_url( 'admin.php?page=att-consent' ) ), + esc_html__( 'Settings', 'att-consent' ) + ); + array_unshift( $links, $settings_link ); + return $links; + } + + /** + * Register the admin menu page. + */ + public function add_menu_page() { + add_menu_page( + __( 'ATT Consent', 'att-consent' ), + __( 'Cookie Consent', 'att-consent' ), + 'manage_options', + 'att-consent', + array( $this, 'render_settings_page' ), + 'dashicons-shield', + 81 + ); + } + + /** + * Enqueue admin assets. + * + * @param string $hook_suffix The current admin page hook. + */ + public function enqueue_assets( $hook_suffix ) { + if ( 'toplevel_page_att-consent' !== $hook_suffix ) { + return; + } + + wp_enqueue_style( 'wp-color-picker' ); + wp_enqueue_style( + 'att-consent-admin', + ATT_CC_PLUGIN_URL . 'admin/css/admin.css', + array(), + ATT_CC_VERSION + ); + + wp_enqueue_script( 'wp-color-picker' ); + wp_enqueue_script( + 'att-consent-admin', + ATT_CC_PLUGIN_URL . 'admin/js/admin.js', + array( 'jquery', 'wp-color-picker' ), + ATT_CC_VERSION, + true + ); + + wp_localize_script( 'att-consent-admin', 'attCCAdmin', array( + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'att_cc_admin' ), + 'strings' => array( + 'confirmDelete' => __( 'Are you sure you want to delete this script?', 'att-consent' ), + 'saved' => __( 'Script saved.', 'att-consent' ), + 'deleted' => __( 'Script deleted.', 'att-consent' ), + 'error' => __( 'An error occurred. Please try again.', 'att-consent' ), + ), + ) ); + } + + /** + * Render the settings page. + */ + public function render_settings_page() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + $active_tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'general'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $tabs = array( + 'general' => __( 'General', 'att-consent' ), + 'appearance' => __( 'Appearance', 'att-consent' ), + 'categories' => __( 'Categories', 'att-consent' ), + 'scripts' => __( 'Custom Scripts', 'att-consent' ), + 'advanced' => __( 'Advanced', 'att-consent' ), + ); + + $settings = ATT_Consent::get_settings(); + ?> +
+

+ +

' . esc_html__( 'Settings saved.', 'att-consent' ) . '

'; + } + ?> + + + +
+ +
+ + 10000 ) { + $settings['wait_for_update'] = 10000; + } + break; + } + + $settings = apply_filters( 'att_consent_settings_save', $settings, $tab ); + update_option( 'att_consent_settings', $settings ); + + wp_safe_redirect( admin_url( 'admin.php?page=att-consent&tab=' . $tab . '&saved=1' ) ); + exit; + } + + /** + * AJAX: Save a custom script. + */ + public function ajax_save_script() { + check_ajax_referer( 'att_cc_admin', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( 'Unauthorized' ); + } + + $data = array( + 'id' => absint( $_POST['script_id'] ?? 0 ) ?: null, + 'name' => $_POST['name'] ?? '', + 'snippet' => $_POST['snippet'] ?? '', + 'category' => $_POST['category'] ?? 'analytics', + 'placement' => $_POST['placement'] ?? 'head', + 'status' => $_POST['status'] ?? 'active', + 'priority' => $_POST['priority'] ?? 10, + ); + + $result = ATT_Consent_Scripts_Manager::save_script( $data ); + + if ( false !== $result ) { + wp_send_json_success( array( + 'id' => $result, + 'scripts' => ATT_Consent_Scripts_Manager::get_all_scripts(), + ) ); + } else { + wp_send_json_error( 'Failed to save script.' ); + } + } + + /** + * AJAX: Delete a custom script. + */ + public function ajax_delete_script() { + check_ajax_referer( 'att_cc_admin', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( 'Unauthorized' ); + } + + $id = absint( $_POST['script_id'] ?? 0 ); + if ( ! $id ) { + wp_send_json_error( 'Invalid ID.' ); + } + + if ( ATT_Consent_Scripts_Manager::delete_script( $id ) ) { + wp_send_json_success( array( + 'scripts' => ATT_Consent_Scripts_Manager::get_all_scripts(), + ) ); + } else { + wp_send_json_error( 'Failed to delete script.' ); + } + } + + /** + * AJAX: Get a single script for editing. + */ + public function ajax_get_script() { + check_ajax_referer( 'att_cc_admin', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( 'Unauthorized' ); + } + + $id = absint( $_POST['script_id'] ?? 0 ); + $script = ATT_Consent_Scripts_Manager::get_script( $id ); + + if ( $script ) { + wp_send_json_success( $script ); + } else { + wp_send_json_error( 'Script not found.' ); + } + } +} diff --git a/includes/class-att-consent.php b/includes/class-att-consent.php new file mode 100644 index 0000000..d4da47d --- /dev/null +++ b/includes/class-att-consent.php @@ -0,0 +1,179 @@ + 'gtag', + 'ga4_measurement_id' => '', + 'gtm_container_id' => '', + 'consent_mode' => 'advanced', + 'banner_position' => 'bottom', + 'consent_expiry' => 365, + + // Appearance. + 'banner_bg_color' => '#1a1a2e', + 'banner_text_color' => '#ffffff', + 'btn_accept_bg' => '#4CAF50', + 'btn_accept_text' => '#ffffff', + 'btn_reject_bg' => '#555555', + 'btn_reject_text' => '#ffffff', + 'btn_preferences_bg' => 'transparent', + 'btn_preferences_text' => '#ffffff', + + // Text / Labels. + 'banner_heading' => 'We value your privacy', + 'banner_message' => 'We use cookies to enhance your browsing experience, serve personalised content, and analyse our traffic. By clicking "Accept All", you consent to our use of cookies.', + 'btn_accept_label' => 'Accept All', + 'btn_reject_label' => 'Reject All', + 'btn_preferences_label' => 'Manage Preferences', + 'btn_save_label' => 'Save Preferences', + + // Category descriptions. + 'cat_necessary_desc' => 'These cookies are essential for the website to function and cannot be switched off.', + 'cat_functional_desc' => 'These cookies enable personalised features and functionality.', + 'cat_analytics_desc' => 'These cookies help us understand how visitors interact with our website.', + 'cat_marketing_desc' => 'These cookies are used to deliver advertisements relevant to you.', + + // Advanced. + 'url_passthrough' => true, + 'ads_data_redaction' => true, + 'wait_for_update' => 500, + ); + + /** + * Get singleton instance. + * + * @return ATT_Consent + */ + public static function get_instance() { + if ( null === self::$instance ) { + self::$instance = new self(); + } + return self::$instance; + } + + /** + * Constructor. + */ + private function __construct() { + add_action( 'init', array( $this, 'load_textdomain' ) ); + $this->load_dependencies(); + } + + /** + * Load plugin text domain. + */ + public function load_textdomain() { + load_plugin_textdomain( 'att-consent', false, dirname( ATT_CC_PLUGIN_BASENAME ) . '/languages' ); + } + + /** + * Load required files. + */ + private function load_dependencies() { + require_once ATT_CC_PLUGIN_DIR . 'includes/class-scripts-manager.php'; + require_once ATT_CC_PLUGIN_DIR . 'includes/class-consent-api.php'; + + if ( is_admin() ) { + require_once ATT_CC_PLUGIN_DIR . 'includes/class-admin.php'; + new ATT_Consent_Admin(); + } + + if ( ! is_admin() || wp_doing_ajax() ) { + require_once ATT_CC_PLUGIN_DIR . 'includes/class-frontend.php'; + new ATT_Consent_Frontend(); + } + + new ATT_Consent_API(); + } + + /** + * Plugin activation. + */ + public static function activate() { + self::create_scripts_table(); + + // Set defaults if no settings exist yet. + if ( false === get_option( 'att_consent_settings' ) ) { + update_option( 'att_consent_settings', self::$defaults ); + } + } + + /** + * Plugin deactivation. + */ + public static function deactivate() { + // No-op. Cleanup happens in uninstall.php. + } + + /** + * Create the custom scripts table. + */ + private static function create_scripts_table() { + global $wpdb; + + $table_name = $wpdb->prefix . 'att_cc_scripts'; + $charset_collate = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE $table_name ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + name varchar(200) NOT NULL DEFAULT '', + snippet longtext NOT NULL, + category varchar(50) NOT NULL DEFAULT 'analytics', + placement varchar(20) NOT NULL DEFAULT 'head', + status varchar(20) NOT NULL DEFAULT 'active', + priority int(11) NOT NULL DEFAULT 10, + created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY category (category), + KEY status (status) + ) $charset_collate;"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta( $sql ); + + update_option( 'att_cc_db_version', ATT_CC_VERSION ); + } + + /** + * Get plugin settings merged with defaults. + * + * @return array + */ + public static function get_settings() { + $settings = get_option( 'att_consent_settings', array() ); + return wp_parse_args( $settings, self::$defaults ); + } + + /** + * Get default settings. + * + * @return array + */ + public static function get_defaults() { + return self::$defaults; + } +} diff --git a/includes/class-consent-api.php b/includes/class-consent-api.php new file mode 100644 index 0000000..8e63a5c --- /dev/null +++ b/includes/class-consent-api.php @@ -0,0 +1,51 @@ +settings = ATT_Consent::get_settings(); + + // Priority 1: consent defaults + attribution capture (must be first in ). + add_action( 'wp_head', array( $this, 'output_consent_defaults' ), 1 ); + + // Priority 2: gtag.js or GTM (Advanced mode only). + add_action( 'wp_head', array( $this, 'output_tracking_script' ), 2 ); + + // Footer: banner HTML. + add_action( 'wp_footer', array( $this, 'output_banner_html' ), 5 ); + + // Enqueue banner assets. + add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + } + + /** + * Output the inline consent defaults script. + * This MUST run before any gtag/GTM scripts. + */ + public function output_consent_defaults() { + $s = $this->settings; + + $config = array( + 'consent_mode' => $s['consent_mode'], + 'consent_expiry' => (int) $s['consent_expiry'], + 'wait_for_update' => (int) $s['wait_for_update'], + 'url_passthrough' => (bool) $s['url_passthrough'], + 'ads_data_redaction' => (bool) $s['ads_data_redaction'], + 'tracking_mode' => $s['tracking_mode'], + ); + + // In basic mode, pass the tracking snippet for deferred injection. + if ( 'basic' === $s['consent_mode'] ) { + $config['tracking_snippet'] = $this->get_tracking_snippet(); + } + + // Pass custom scripts for conditional injection. + $config['scripts'] = ATT_Consent_Scripts_Manager::get_scripts_for_frontend(); + + $config_json = wp_json_encode( $config ); + + ?> + + settings; + + // In basic mode, tracking is deferred until consent. + if ( 'basic' === $s['consent_mode'] ) { + return; + } + + echo $this->get_tracking_snippet(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- contains script tags. + } + + /** + * Get the tracking script snippet HTML. + * + * @return string + */ + private function get_tracking_snippet() { + $s = $this->settings; + + if ( 'gtm' === $s['tracking_mode'] && ! empty( $s['gtm_container_id'] ) ) { + $id = esc_attr( $s['gtm_container_id'] ); + return " + +"; + } + + if ( 'gtag' === $s['tracking_mode'] && ! empty( $s['ga4_measurement_id'] ) ) { + $id = esc_attr( $s['ga4_measurement_id'] ); + return " +"; + } + + return ''; + } + + /** + * Enqueue frontend assets. + */ + public function enqueue_assets() { + wp_enqueue_style( + 'att-consent-banner', + ATT_CC_PLUGIN_URL . 'public/css/banner.css', + array(), + ATT_CC_VERSION + ); + + wp_enqueue_script( + 'att-consent-manager', + ATT_CC_PLUGIN_URL . 'public/js/consent-manager.js', + array(), + ATT_CC_VERSION, + true + ); + + wp_enqueue_script( + 'att-consent-banner', + ATT_CC_PLUGIN_URL . 'public/js/banner.js', + array( 'att-consent-manager' ), + ATT_CC_VERSION, + true + ); + } + + /** + * Output the banner and preferences modal HTML. + */ + public function output_banner_html() { + $s = $this->settings; + + do_action( 'att_consent_before_banner' ); + + $position_class = 'att-cc-position-' . esc_attr( $s['banner_position'] ); + + $style_vars = sprintf( + '--att-cc-bg:%s;--att-cc-text:%s;--att-cc-accept-bg:%s;--att-cc-accept-text:%s;--att-cc-reject-bg:%s;--att-cc-reject-text:%s;--att-cc-pref-bg:%s;--att-cc-pref-text:%s;', + esc_attr( $s['banner_bg_color'] ), + esc_attr( $s['banner_text_color'] ), + esc_attr( $s['btn_accept_bg'] ), + esc_attr( $s['btn_accept_text'] ), + esc_attr( $s['btn_reject_bg'] ), + esc_attr( $s['btn_reject_text'] ), + esc_attr( $s['btn_preferences_bg'] ), + esc_attr( $s['btn_preferences_text'] ) + ); + + $banner_html = ''; + + $modal_html = ''; + + $html = apply_filters( 'att_consent_banner_html', $banner_html . $modal_html, $s ); + echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped above. + + do_action( 'att_consent_after_banner' ); + } +} diff --git a/includes/class-scripts-manager.php b/includes/class-scripts-manager.php new file mode 100644 index 0000000..276adac --- /dev/null +++ b/includes/class-scripts-manager.php @@ -0,0 +1,168 @@ +prefix . 'att_cc_scripts'; + } + + /** + * Get all scripts, optionally filtered. + * + * @param string|null $category Filter by category. + * @param string|null $placement Filter by placement. + * @return array + */ + public static function get_scripts( $category = null, $placement = null ) { + global $wpdb; + $table = self::table_name(); + + $where = array( 'status = %s' ); + $params = array( 'active' ); + + if ( null !== $category ) { + $where[] = 'category = %s'; + $params[] = $category; + } + + if ( null !== $placement ) { + $where[] = 'placement = %s'; + $params[] = $placement; + } + + $where_sql = implode( ' AND ', $where ); + + return $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$table} WHERE {$where_sql} ORDER BY priority ASC, id ASC", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + ...$params + ), + ARRAY_A + ); + } + + /** + * Get all scripts (including inactive) for the admin list. + * + * @return array + */ + public static function get_all_scripts() { + global $wpdb; + $table = self::table_name(); + + return $wpdb->get_results( + "SELECT * FROM {$table} ORDER BY priority ASC, id ASC", // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + ARRAY_A + ); + } + + /** + * Get a single script by ID. + * + * @param int $id Script ID. + * @return array|null + */ + public static function get_script( $id ) { + global $wpdb; + $table = self::table_name(); + + return $wpdb->get_row( + $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + ARRAY_A + ); + } + + /** + * Save (insert or update) a script. + * + * @param array $data Script data. + * @return int|false The script ID on success, false on failure. + */ + public static function save_script( $data ) { + global $wpdb; + $table = self::table_name(); + + $allowed_categories = array( 'functional', 'analytics', 'marketing' ); + $allowed_placements = array( 'head', 'footer' ); + $allowed_statuses = array( 'active', 'inactive' ); + + $fields = array( + 'name' => sanitize_text_field( $data['name'] ?? '' ), + 'snippet' => wp_unslash( $data['snippet'] ?? '' ), + 'category' => in_array( $data['category'] ?? '', $allowed_categories, true ) ? $data['category'] : 'analytics', + 'placement' => in_array( $data['placement'] ?? '', $allowed_placements, true ) ? $data['placement'] : 'head', + 'status' => in_array( $data['status'] ?? '', $allowed_statuses, true ) ? $data['status'] : 'active', + 'priority' => absint( $data['priority'] ?? 10 ), + ); + + if ( ! empty( $data['id'] ) ) { + $fields['updated_at'] = current_time( 'mysql' ); + $result = $wpdb->update( + $table, + $fields, + array( 'id' => absint( $data['id'] ) ), + array( '%s', '%s', '%s', '%s', '%s', '%d', '%s' ), + array( '%d' ) + ); + return false !== $result ? absint( $data['id'] ) : false; + } + + $fields['created_at'] = current_time( 'mysql' ); + $fields['updated_at'] = current_time( 'mysql' ); + $result = $wpdb->insert( $table, $fields ); + + return false !== $result ? $wpdb->insert_id : false; + } + + /** + * Delete a script by ID. + * + * @param int $id Script ID. + * @return bool + */ + public static function delete_script( $id ) { + global $wpdb; + $table = self::table_name(); + + return false !== $wpdb->delete( $table, array( 'id' => absint( $id ) ), array( '%d' ) ); + } + + /** + * Get scripts grouped by category and placement for frontend output. + * + * @return array + */ + public static function get_scripts_for_frontend() { + $scripts = self::get_scripts(); + $grouped = array( + 'functional' => array( 'head' => array(), 'footer' => array() ), + 'analytics' => array( 'head' => array(), 'footer' => array() ), + 'marketing' => array( 'head' => array(), 'footer' => array() ), + ); + + foreach ( $scripts as $script ) { + $cat = $script['category']; + $placement = $script['placement']; + if ( isset( $grouped[ $cat ][ $placement ] ) ) { + $grouped[ $cat ][ $placement ][] = $script['snippet']; + } + } + + return $grouped; + } +} diff --git a/public/css/banner.css b/public/css/banner.css new file mode 100644 index 0000000..a5e0f11 --- /dev/null +++ b/public/css/banner.css @@ -0,0 +1,272 @@ +/* ATT Consent - Banner & Modal Styles */ + +/* Reset box-sizing for our elements */ +#att-cc-banner, +#att-cc-banner *, +#att-cc-modal, +#att-cc-modal * { + box-sizing: border-box; +} + +/* --- Banner --- */ + +#att-cc-banner { + position: fixed; + left: 0; + right: 0; + z-index: 999999; + background: var(--att-cc-bg, #1a1a2e); + color: var(--att-cc-text, #ffffff); + padding: 20px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif; + font-size: 14px; + line-height: 1.5; + transition: transform 0.3s ease, opacity 0.3s ease; +} + +#att-cc-banner.att-cc-hidden { + display: none; +} + +/* Position variants */ +#att-cc-banner.att-cc-position-bottom { + bottom: 0; +} + +#att-cc-banner.att-cc-position-top { + top: 0; +} + +#att-cc-banner.att-cc-position-center { + top: 50%; + left: 50%; + right: auto; + transform: translate(-50%, -50%); + max-width: 600px; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +.att-cc-banner__inner { + max-width: 1200px; + margin: 0 auto; + display: flex; + align-items: center; + gap: 24px; + flex-wrap: wrap; +} + +.att-cc-position-center .att-cc-banner__inner { + flex-direction: column; + align-items: stretch; +} + +.att-cc-banner__content { + flex: 1; + min-width: 280px; +} + +.att-cc-banner__heading { + font-size: 16px; + font-weight: 700; + margin: 0 0 6px 0; + color: inherit; +} + +.att-cc-banner__message { + margin: 0; + opacity: 0.9; + font-size: 13px; +} + +.att-cc-banner__message a { + color: inherit; + text-decoration: underline; +} + +.att-cc-banner__actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + flex-shrink: 0; +} + +.att-cc-position-center .att-cc-banner__actions { + justify-content: stretch; +} + +.att-cc-position-center .att-cc-btn { + flex: 1; +} + +/* --- Buttons --- */ + +.att-cc-btn { + padding: 10px 20px; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + transition: opacity 0.2s, box-shadow 0.2s; + line-height: 1.4; + font-family: inherit; +} + +.att-cc-btn:hover { + opacity: 0.9; +} + +.att-cc-btn:focus-visible { + outline: 2px solid #fff; + outline-offset: 2px; +} + +.att-cc-btn--accept { + background: var(--att-cc-accept-bg, #4CAF50); + color: var(--att-cc-accept-text, #ffffff); +} + +.att-cc-btn--reject { + background: var(--att-cc-reject-bg, #555555); + color: var(--att-cc-reject-text, #ffffff); +} + +.att-cc-btn--preferences { + background: var(--att-cc-pref-bg, transparent); + color: var(--att-cc-pref-text, #ffffff); + border: 1px solid currentColor; +} + +/* --- Preferences Modal --- */ + +#att-cc-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1000000; + display: none; + align-items: center; + justify-content: center; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif; + font-size: 14px; + line-height: 1.5; +} + +#att-cc-modal.att-cc-modal--open { + display: flex; +} + +.att-cc-modal__overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.6); +} + +.att-cc-modal__dialog { + position: relative; + background: var(--att-cc-bg, #1a1a2e); + color: var(--att-cc-text, #ffffff); + border-radius: 12px; + padding: 28px; + max-width: 540px; + width: 90%; + max-height: 85vh; + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); +} + +.att-cc-modal__heading { + font-size: 18px; + font-weight: 700; + margin: 0 0 20px 0; + color: inherit; +} + +.att-cc-modal__category { + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + padding: 14px 0; +} + +.att-cc-modal__category:last-of-type { + border-bottom: none; +} + +.att-cc-modal__cat-header { + display: flex; + align-items: center; +} + +.att-cc-modal__cat-header label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 14px; + color: inherit; + width: 100%; +} + +.att-cc-modal__cat-header input[type="checkbox"] { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: var(--att-cc-accept-bg, #4CAF50); +} + +.att-cc-always-on { + margin-left: auto; + font-size: 11px; + opacity: 0.6; + font-weight: 400; +} + +.att-cc-modal__cat-desc { + margin: 6px 0 0 26px; + font-size: 12px; + opacity: 0.75; +} + +.att-cc-modal__actions { + margin-top: 20px; + display: flex; + gap: 8px; +} + +.att-cc-modal__actions .att-cc-btn { + flex: 1; +} + +/* Prevent body scroll when modal is open */ +body.att-cc-modal-active { + overflow: hidden; +} + +/* --- Responsive --- */ + +@media (max-width: 600px) { + .att-cc-banner__inner { + flex-direction: column; + align-items: stretch; + } + + .att-cc-banner__actions { + flex-direction: column; + } + + .att-cc-btn { + width: 100%; + text-align: center; + } + + .att-cc-modal__dialog { + width: 95%; + padding: 20px; + } +} diff --git a/public/js/banner.js b/public/js/banner.js new file mode 100644 index 0000000..38e797f --- /dev/null +++ b/public/js/banner.js @@ -0,0 +1,174 @@ +/** + * ATT Consent Banner UI + * + * Handles banner display, preferences modal, focus trapping, and user interactions. + * Depends on AttConsent (consent-manager.js) for consent state management. + */ +(function() { + 'use strict'; + + var banner = document.getElementById('att-cc-banner'); + var modal = document.getElementById('att-cc-modal'); + + if (!banner || !modal) { + return; + } + + // If consent already exists, hide the banner immediately. + if (AttConsent.hasConsent()) { + hideBanner(); + return; + } + + // --- Banner buttons --- + + var acceptBtn = banner.querySelector('[data-att-cc="accept-all"]'); + var rejectBtn = banner.querySelector('[data-att-cc="reject-all"]'); + var prefsBtn = banner.querySelector('[data-att-cc="preferences"]'); + + if (acceptBtn) { + acceptBtn.addEventListener('click', function() { + AttConsent.update({ functional: true, analytics: true, marketing: true }); + hideBanner(); + }); + } + + if (rejectBtn) { + rejectBtn.addEventListener('click', function() { + AttConsent.update({ functional: false, analytics: false, marketing: false }); + hideBanner(); + }); + } + + if (prefsBtn) { + prefsBtn.addEventListener('click', function() { + openModal(); + }); + } + + // --- Modal buttons --- + + var saveBtn = modal.querySelector('[data-att-cc="save-preferences"]'); + var closeBtn = modal.querySelector('[data-att-cc="close-modal"]'); + var overlay = modal.querySelector('.att-cc-modal__overlay'); + + if (saveBtn) { + saveBtn.addEventListener('click', function() { + var categories = { + functional: document.getElementById('att-cc-functional').checked, + analytics: document.getElementById('att-cc-analytics').checked, + marketing: document.getElementById('att-cc-marketing').checked + }; + AttConsent.update(categories); + closeModal(); + hideBanner(); + }); + } + + if (closeBtn) { + closeBtn.addEventListener('click', function() { + closeModal(); + }); + } + + if (overlay) { + overlay.addEventListener('click', function() { + closeModal(); + }); + } + + // --- Helper functions --- + + function hideBanner() { + banner.setAttribute('aria-hidden', 'true'); + banner.classList.add('att-cc-hidden'); + } + + function openModal() { + // Pre-fill checkboxes with existing consent if available. + var existing = AttConsent.getConsent(); + if (existing) { + setCheckbox('att-cc-functional', existing.functional); + setCheckbox('att-cc-analytics', existing.analytics); + setCheckbox('att-cc-marketing', existing.marketing); + } + + modal.setAttribute('aria-hidden', 'false'); + modal.classList.add('att-cc-modal--open'); + document.body.classList.add('att-cc-modal-active'); + trapFocus(modal); + } + + function closeModal() { + modal.setAttribute('aria-hidden', 'true'); + modal.classList.remove('att-cc-modal--open'); + document.body.classList.remove('att-cc-modal-active'); + + // Return focus to the preferences button. + if (prefsBtn) { + prefsBtn.focus(); + } + } + + function setCheckbox(id, value) { + var el = document.getElementById(id); + if (el) { + el.checked = !!value; + } + } + + /** + * Trap keyboard focus within an element. + * + * @param {HTMLElement} element The container to trap focus in. + */ + function trapFocus(element) { + var focusable = element.querySelectorAll( + 'button, [href], input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])' + ); + + if (focusable.length === 0) { + return; + } + + var first = focusable[0]; + var last = focusable[focusable.length - 1]; + + first.focus(); + + function handleKeydown(e) { + if (e.key === 'Escape') { + closeModal(); + element.removeEventListener('keydown', handleKeydown); + return; + } + + if (e.key !== 'Tab') { + return; + } + + if (e.shiftKey) { + if (document.activeElement === first) { + last.focus(); + e.preventDefault(); + } + } else { + if (document.activeElement === last) { + first.focus(); + e.preventDefault(); + } + } + } + + element.addEventListener('keydown', handleKeydown); + } + + // Listen for programmatic consent revocation to re-show banner. + document.addEventListener('att_consent_update', function(e) { + var cats = e.detail; + if (!cats.functional && !cats.analytics && !cats.marketing) { + // All denied - consent was effectively revoked, but don't re-show + // banner immediately (user just clicked reject). + } + }); +})(); diff --git a/public/js/consent-manager.js b/public/js/consent-manager.js new file mode 100644 index 0000000..4997e97 --- /dev/null +++ b/public/js/consent-manager.js @@ -0,0 +1,259 @@ +/** + * ATT Consent Manager + * + * Handles Google Consent Mode v2 updates, session attribution replay, + * custom script injection, and consent state persistence. + * + * Phase 1 (consent defaults + attribution capture) runs inline in . + * This file handles Phase 2 (consent updates) and Phase 3 (basic mode tag loading). + */ +var AttConsent = (function() { + 'use strict'; + + var gtag = window.attCCGtag; + var config = window.attCCConfig || {}; + + /** + * Update consent state. + * + * @param {Object} categories - { functional: bool, analytics: bool, marketing: bool } + */ + function update(categories) { + // Step 1: Replay stored attribution BEFORE consent update. + // This ensures GA4 session_start gets correct source/medium. + if (!sessionStorage.getItem('att_cc_consent_given')) { + replayAttribution(); + sessionStorage.setItem('att_cc_consent_given', '1'); + } + + // Step 2: Send consent update to Google. + gtag('consent', 'update', { + ad_storage: categories.marketing ? 'granted' : 'denied', + analytics_storage: categories.analytics ? 'granted' : 'denied', + ad_user_data: categories.marketing ? 'granted' : 'denied', + ad_personalization: categories.marketing ? 'granted' : 'denied', + functionality_storage: categories.functional ? 'granted' : 'denied', + personalization_storage: categories.functional ? 'granted' : 'denied' + }); + + // Step 3: Store consent in first-party cookie. + setConsentCookie(categories); + + // Step 4: In basic mode, inject tracking script if analytics or marketing consented. + if (config.consent_mode === 'basic' && (categories.analytics || categories.marketing)) { + injectTrackingScript(); + } + + // Step 5: Execute custom scripts for consented categories. + executeConsentedScripts(categories); + + // Step 6: Fire custom event for theme/plugin integration. + try { + document.dispatchEvent(new CustomEvent('att_consent_update', { + detail: categories + })); + } catch (e) { + // IE11 fallback (if ever needed). + var evt = document.createEvent('CustomEvent'); + evt.initCustomEvent('att_consent_update', true, true, categories); + document.dispatchEvent(evt); + } + + // Step 7: WP Consent API integration. + if (typeof wp_set_consent === 'function') { + wp_set_consent('functional', categories.functional ? 'allow' : 'deny'); + wp_set_consent('statistics', categories.analytics ? 'allow' : 'deny'); + wp_set_consent('marketing', categories.marketing ? 'allow' : 'deny'); + } + } + + /** + * Replay stored attribution data via gtag('set'). + * Called BEFORE the consent update so GA4 picks up the original + * campaign parameters on the session_start event. + */ + function replayAttribution() { + var stored = sessionStorage.getItem('att_cc_attr'); + if (!stored) { + return; + } + + try { + var a = JSON.parse(stored); + var params = {}; + + if (a.us) { params.campaign_source = a.us; } + if (a.um) { params.campaign_medium = a.um; } + if (a.uc) { params.campaign_name = a.uc; } + if (a.ut) { params.campaign_term = a.ut; } + if (a.uo) { params.campaign_content = a.uo; } + + if (Object.keys(params).length > 0) { + gtag('set', params); + } + } catch (e) { + // Silent failure - don't block consent. + } + } + + /** + * Store consent choices in a first-party cookie. + * + * @param {Object} categories Consent categories. + */ + function setConsentCookie(categories) { + var days = config.consent_expiry || 365; + var expires = new Date(Date.now() + days * 864e5).toUTCString(); + var value = encodeURIComponent(JSON.stringify(categories)); + var cookie = 'att_cc_consent=' + value + + '; expires=' + expires + + '; path=/; SameSite=Lax'; + + if (location.protocol === 'https:') { + cookie += '; Secure'; + } + + document.cookie = cookie; + } + + /** + * Inject the tracking script in basic mode (deferred until consent). + */ + function injectTrackingScript() { + if (!config.tracking_snippet || window.attCCTrackingLoaded) { + return; + } + window.attCCTrackingLoaded = true; + injectHTML(config.tracking_snippet, document.head); + } + + /** + * Execute custom scripts for the consented categories. + * + * @param {Object} categories Consent categories. + */ + function executeConsentedScripts(categories) { + var scripts = config.scripts || {}; + + Object.keys(categories).forEach(function(cat) { + if (!categories[cat] || !scripts[cat]) { + return; + } + + ['head', 'footer'].forEach(function(placement) { + if (!scripts[cat][placement] || !scripts[cat][placement].length) { + return; + } + + var container = placement === 'head' ? document.head : document.body; + + scripts[cat][placement].forEach(function(snippet) { + injectHTML(snippet, container); + }); + + // Clear to prevent double execution. + scripts[cat][placement] = []; + }); + }); + } + + /** + * Inject an HTML string (potentially containing