diff --git a/.distignore b/.distignore index c16c6d6..b6b674d 100644 --- a/.distignore +++ b/.distignore @@ -18,6 +18,7 @@ vendor/* # AI guidance and dev docs .claude/* +.vibe/* AGENTS.md CLAUDE.md README.md diff --git a/.gitattributes b/.gitattributes index 6aedfaa..88ef7df 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,6 +5,7 @@ .gitignore export-ignore .distignore export-ignore .claude export-ignore +.vibe export-ignore .DS_Store export-ignore .vscode export-ignore .idea export-ignore diff --git a/.vibe/config.toml b/.vibe/config.toml new file mode 100644 index 0000000..6d57383 --- /dev/null +++ b/.vibe/config.toml @@ -0,0 +1,186 @@ +active_model = "mistral-medium-3.5" +vim_keybindings = false +disable_welcome_banner_animation = false +displayed_workdir = "" +auto_compact_threshold = 200000 +context_warnings = false +textual_theme = "catppuccin-mocha" +instructions = "" +system_prompt_id = "vibe" +include_commit_signature = true +include_model_info = true +include_project_context = true +include_prompt_detail = true +enable_update_checks = true +api_timeout = 720.0 +tool_paths = [] +mcp_servers = [] +enabled_tools = [] +disabled_tools = [] + +[[providers]] +name = "mistral" +api_base = "https://api.mistral.ai/v1" +api_key_env_var = "MISTRAL_API_KEY" +api_style = "openai" +backend = "mistral" + +[[providers]] +name = "llamacpp" +api_base = "http://127.0.0.1:8080/v1" +api_key_env_var = "" +api_style = "openai" +backend = "generic" + +[[models]] +name = "mistral-vibe-cli-latest" +provider = "mistral" +alias = "mistral-medium-3.5" +temperature = 1.0 +input_price = 1.5 +output_price = 7.5 +thinking = "high" + +[[models]] +name = "devstral-small-latest" +provider = "mistral" +alias = "devstral-small" +temperature = 0.2 +input_price = 0.1 +output_price = 0.3 + +[[models]] +name = "devstral" +provider = "llamacpp" +alias = "local" +temperature = 0.2 +input_price = 0.0 +output_price = 0.0 + +[project_context] +max_chars = 40000 +default_commit_count = 5 +max_doc_bytes = 32768 +truncation_buffer = 1000 +max_depth = 3 +max_files = 1000 +max_dirs_per_level = 20 +timeout_seconds = 2.0 + +[session_logging] +save_dir = "/Users/jasperfrumau/.vibe/logs/session" +session_prefix = "session" +enabled = true + +[tools.search_replace] +permission = "ask" +allowlist = [] +denylist = [] +max_content_size = 100000 +create_backup = false +fuzzy_threshold = 0.9 + +[tools.bash] +permission = "ask" +allowlist = [ + "cat", + "echo", + "file", + "find", + "git diff", + "git log", + "git status", + "head", + "ls", + "pwd", + "stat", + "tail", + "tree", + "uname", + "wc", + "which", + "whoami", +] +denylist = [ + "gdb", + "pdb", + "passwd", + "nano", + "vim", + "vi", + "emacs", + "bash -i", + "sh -i", + "zsh -i", + "fish -i", + "dash -i", + "screen", + "tmux", +] +max_output_bytes = 16000 +default_timeout = 30 +denylist_standalone = [ + "ipython", + "bash", + "sh", + "nohup", + "vi", + "vim", + "emacs", + "nano", + "su", +] + +[tools.grep] +permission = "always" +allowlist = [] +denylist = [] +max_output_bytes = 64000 +default_max_matches = 100 +default_timeout = 60 +exclude_patterns = [ + ".venv/", + "venv/", + ".env/", + "env/", + "node_modules/", + ".git/", + "__pycache__/", + ".pytest_cache/", + ".mypy_cache/", + ".tox/", + ".nox/", + ".coverage/", + "htmlcov/", + "dist/", + "build/", + ".idea/", + ".vscode/", + "*.egg-info", + "*.pyc", + "*.pyo", + "*.pyd", + ".DS_Store", + "Thumbs.db", +] +codeignore_file = ".vibeignore" + +[tools.read_file] +permission = "always" +allowlist = [] +denylist = [] +max_read_bytes = 64000 +max_state_history = 10 + +[tools.todo] +permission = "always" +allowlist = [] +denylist = [] +max_todos = 100 + +[tools.write_file] +permission = "ask" +allowlist = [] +denylist = [] +max_write_bytes = 64000 +create_parent_dirs = true diff --git a/.vibe/prompts/vibe.md b/.vibe/prompts/vibe.md new file mode 100644 index 0000000..360d039 --- /dev/null +++ b/.vibe/prompts/vibe.md @@ -0,0 +1,63 @@ +# Custom Instructions for Vibe + +## Primary Directive +Always follow the rules and conventions in the project's `CLAUDE.md` file. This is your authoritative source for all project-specific guidance. + +## Project Structure + +**warder-cookie-consent** is a WordPress plugin wrapping the [vanilla-cookieconsent](https://github.com/orestbida/cookieconsent) v3 library. + +Key files: +- `warder-cookie-consent.php` — all PHP logic (~851 lines), single-file plugin +- `src/index.js` — JS entry point, maps settings to vanilla-cookieconsent config +- `dist/cookieconsent.bundle.js` — compiled output (do not edit directly) +- `assets/js/warder-admin.js` — admin page JS (AJAX save, UI interactions) + +## Before Responding +1. **Review CLAUDE.md** for: + - Architecture and data flow + - PHP function naming conventions (`warder_` prefix) + - Versioning rules (four files must be updated together) + - Build commands + +2. **Check Current Context**: + - Confirm the current branch and git status + - Review recent commits for relevant changes + +3. **Adhere to Key Principles**: + - All PHP functions use the `warder_` prefix; options stored under `warder_options` in `wp_options` + - Settings are deep-merged with defaults via `warder_get_merged_options()` — never read raw from DB + - JS is bundled via webpack: run `npx webpack` after editing `src/index.js` + - `.vibe/` directories are untracked and should remain so + - PHP requires 8.0+; no test suite exists + +## Build Commands + +```bash +npm install # Install JS dependencies +npx webpack # Build dist/cookieconsent.bundle.js +npx webpack --watch # Rebuild on file change +``` + +## Versioning (bump all four together) + +- `warder-cookie-consent.php` — `Version:` header + `WARDER_VERSION` constant +- `readme.txt` — `Stable tag:` + changelog entry +- `CHANGELOG.md` — new version heading +- `package.json` — `version` field + +Do **not** add a `version` field to `composer.json`. + +## Git Commits + +- Do not mention "Claude", "Claude Code", or AI tools in commit messages +- Do not include "Co-Authored-By: Mistral Vibe" or any AI attribution +- Use atomic commits; stage files individually or in logical groups with specific messages +- Follow standard conventional commit format (`feat:`, `fix:`, `docs:`, `refactor:`, etc.) + +## Response Guidelines +- Be concise and technical +- Reference specific files and line numbers +- Use markdown formatting for code and structure +- Prioritize verification over assumptions +- When unsure, ask for clarification before acting diff --git a/CHANGELOG.md b/CHANGELOG.md index 97118a9..5445266 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to Warder Cookie Consent are documented here. +## [1.5.2] - 2026-05-28 + +### Added +- AJAX save for the settings page via `wp_ajax_warder_save_settings` (`warder_ajax_save_settings()`). Submit is intercepted in `assets/js/admin.js`, the form serializes through `$.post( ajaxurl, ... )`, and the response is shown in a dismissible `.warder-ajax-notice` injected before the form — no `options.php` redirect, no scroll-to-top +- Admin JS extracted from the inline `wp_add_inline_script()` heredoc into `assets/js/admin.js`, enqueued via `wp_enqueue_script( 'warder-admin' )` with `wp_localize_script` providing `warderAdmin.ajaxurl`, `warderAdmin.save`, and `warderAdmin.saving` +- `?warder_notice=saved` redirect-after-POST in `warder_handle_admin_actions()` so add/delete-category and add/delete-cookie actions show a success notice on the resulting page + +### Fixed +- Setup welcome notice in `warder_admin_notices()` now self-suppresses once `warder_options_last_updated` is set, so it stops appearing on every admin page after configuration (WordPress.org guideline 11) +- Add Cookie form was nested inside `#warder-main-settings-form` and used the HTML5 `form="..."` attribute to target a hidden form elsewhere in the DOM. Browsers handle `display:none` target forms inconsistently, which dropped the `is_regex` checkbox state and caused the AJAX save handler to intercept Add Cookie submissions. Each Add Cookie form is now rendered as a self-contained `
` after `
` of the main settings form +- AJAX save notice is now scrolled into view via `$( 'html, body' ).animate( { scrollTop: $notice.offset().top - 50 }, 300 )` so it is visible regardless of the scroll position at submit time +- Show-add-cookie button scrolls the now-revealed container into view on open + +### Changed +- Dropped the `:not([form])` filters on the change-highlight selector and submit-button lookup in `assets/js/admin.js`, and removed the JS `.after()` reposition of add-cookie containers. These were workarounds for the old nested-form layout and are no longer needed now that PHP renders the containers outside the main form + ## [1.5.1] - 2026-05-28 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index c5db96d..ff4d467 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ There are no tests. PHP requires 8.0+. To install as a Composer dependency: ```bash -composer require imagewize/simple-cookie-consent +composer require imagewize/warder-cookie-consent ``` ## Architecture @@ -26,23 +26,23 @@ composer require imagewize/simple-cookie-consent ### Data Flow ``` -WordPress DB (scc_options key) - → scc_enqueue_scripts() fetches and localizes +WordPress DB (warder_options key) + → warder_enqueue_scripts() fetches and localizes → window.sccSettings in browser → createConfigFromSettings() in src/index.js → CookieConsent.run(config) ``` -### PHP Layer (`simple-cookie-consent.php`) +### PHP Layer (`warder-cookie-consent.php`) -All plugin logic is in this single file (~552 lines): +All plugin logic is in this single file (~851 lines): -- **`scc_get_default_options()`** — defines the canonical default settings structure -- **`scc_get_merged_options()`** — retrieves DB options and deep-merges with defaults; always returns a complete settings object -- **`scc_validate_options()`** — sanitizes/validates before saving to `scc_options` in `wp_options` -- **`scc_enqueue_scripts()`** — enqueues `dist/cookieconsent.bundle.js` and localizes it as `window.sccSettings` -- **`scc_settings_page()`** — renders the admin UI at Settings > Cookie Consent -- Settings are versioned via `scc_options_last_updated` timestamp for cache busting +- **`warder_get_default_options()`** — defines the canonical default settings structure +- **`warder_get_merged_options()`** — retrieves DB options and deep-merges with defaults; always returns a complete settings object +- **`warder_validate_options()`** — sanitizes/validates before saving to `warder_options` in `wp_options` +- **`warder_enqueue_scripts()`** — enqueues `dist/cookieconsent.bundle.js` and localizes it as `window.sccSettings` +- **`warder_render_options_page()`** — renders the admin UI at Settings > Cookie Consent +- Settings are versioned via `warder_options_last_updated` timestamp for cache busting ### JS Layer (`src/index.js` → `dist/cookieconsent.bundle.js`) @@ -59,12 +59,24 @@ Scripts are blocked until consent is given via `data-cookiecategory` HTML attrib ### Settings Structure ```php -scc_options = [ - 'general' => [ position, language, ... ], - 'texts' => [ banner title/description, accept/reject button labels, ... ], - 'categories' => [ - 'necessary' => [ enabled, readonly, cookies[] ], - 'analytics' => [ enabled, cookies[] ], +warder_options = [ + 'enabled' => bool, + 'current_lang' => string, + 'autoclear_cookies' => bool, + 'page_scripts' => bool, + 'show_preferences_toggle' => bool, + 'preferences_toggle_position'=> string, + 'title' => string, + 'description' => string, + 'primary_btn_text' => string, + 'primary_btn_role' => string, + 'secondary_btn_text' => string, + 'secondary_btn_role' => string, + 'privacy_policy_url' => string, + 'cookie_categories' => [ + 'necessary' => [ title, description, enabled, readonly, cookies[] ], + 'analytics' => [ title, description, enabled, cookies[] ], + ... // user-defined categories ], ] ``` @@ -75,7 +87,7 @@ Each `cookies` entry supports `name` (exact string or `/regex/` pattern) and `do The `Version:` header in `warder-cookie-consent.php` is the canonical version (this is what WordPress.org reads). When bumping the version, update all of these together: -- `warder-cookie-consent.php` — `Version:` header +- `warder-cookie-consent.php` — `Version:` header and `WARDER_VERSION` constant - `readme.txt` — `Stable tag:` plus new `== Changelog ==` and `== Upgrade Notice ==` entries - `CHANGELOG.md` — new version heading - `package.json` — `version` field (kept in sync even though this package is not published to npm) diff --git a/assets/js/admin.js b/assets/js/admin.js new file mode 100644 index 0000000..37ce4d1 --- /dev/null +++ b/assets/js/admin.js @@ -0,0 +1,46 @@ +/* global warderAdmin */ +jQuery( document ).ready( function( $ ) { + + $( '.show-add-cookie-form' ).on( 'click', function() { + var categoryId = $( this ).data( 'category' ); + var $container = $( '#warder-add-cookie-container-' + categoryId ); + $container.show(); + $( 'html, body' ).animate( { scrollTop: $container.offset().top - 50 }, 300 ); + } ); + + $( '.cancel-add-cookie' ).on( 'click', function( e ) { + e.preventDefault(); + $( this ).closest( '.warder-add-cookie-form-container' ).hide(); + } ); + + $( '#warder-main-settings-form input, #warder-main-settings-form textarea, #warder-main-settings-form select' ).on( 'change', function() { + $( this ).css( 'background-color', '#ffffdd' ); + } ); + + $( '#warder-main-settings-form' ).on( 'submit', function( e ) { + e.preventDefault(); + + var form = $( this ); + var submitBtn = form.find( 'input[type="submit"]' ); + + submitBtn.prop( 'disabled', true ).val( warderAdmin.saving ); + + $.post( + warderAdmin.ajaxurl, + form.serialize() + '&action=warder_save_settings', + function( response ) { + $( '.warder-ajax-notice' ).remove(); + var cls = response.success ? 'notice-success' : 'notice-error'; + var $notice = $( '

' + response.data.message + '

' ); + form.before( $notice ); + $( 'html, body' ).animate( { scrollTop: $notice.offset().top - 50 }, 300 ); + if ( response.success ) { + form.find( 'input, textarea, select' ).css( 'background-color', '' ); + } + } + ).always( function() { + submitBtn.prop( 'disabled', false ).val( warderAdmin.save ); + } ); + } ); + +} ); diff --git a/docs/admin-ajax-comparison.md b/docs/admin-ajax-comparison.md new file mode 100644 index 0000000..a954de4 --- /dev/null +++ b/docs/admin-ajax-comparison.md @@ -0,0 +1,563 @@ +# Admin AJAX Submission Patterns + +This document analyzes the admin page and AJAX submission implementations, identifying pain points in recent Warder commits and suggesting improvements aligned with WordPress best practices and modern plugin development standards. + +--- + +## Executive Summary + +Warder's recent AJAX implementation (commits `0a8f1f7`, `fb09e7c`, `a7e18db`) has been complicated by DOM nesting issues with form elements and event delegation. Modern WordPress plugin standards use a more modular, separated approach that avoids these problems. **Recommendation**: Adopt best practice patterns of separating AJAX handlers into dedicated classes and using consistent nonce/response patterns. + +--- + +## Comparison Matrix + +| Aspect | Warder Cookie Consent | Modern Plugin Standards | Recommendation | +|--------|----------------------|------------------------|----------------| +| **AJAX Handler Organization** | Single function `warder_ajax_save_settings()` + `warder_handle_admin_actions()` | Dedicated classes per module | ✅ **Adopt**: Create `class-warder-admin-ajax.php` with separate methods per action | +| **JavaScript File Structure** | Single `admin.js` with all handlers | Module-specific JS files | ✅ **Adopt**: Split into `admin-settings.js`, `admin-cookie-manager.js` | +| **Form Nesting** | Initially nested forms with `form=` attribute, later refactored out | Forms are always separate, never nested | ✅ **Current**: Warder now matches this pattern after refactor | +| **Event Delegation** | jQuery on `submit` with complex selectors | Uses `$(document).on()` for dynamic elements | ⚠️ **Improve**: Use `$(document).on('submit', '#warder-main-settings-form', ...)` | +| **Response Format** | `wp_send_json_success()` / `wp_send_json_error()` | WordPress-native JSON responses | ✅ **Keep**: Warder's pattern aligns with WP standards | +| **Nonce Verification** | `check_ajax_referer()` in AJAX, `check_admin_referer()` in POST | Consistent nonce verification | ✅ **Keep**: Warder follows WP best practices | +| **Error Handling** | Try/catch absent, direct response | Structured error responses | ⚠️ **Add**: Wrap validation in try/catch for consistency | + +--- + +## Warder's Recent Complications + +### Problem 1: Form Nesting and HTML5 `form` Attribute (Commit `0a8f1f7`) + +**Issue**: Add-cookie inputs were inside `#warder-main-settings-form` but used the HTML5 `form` attribute to target hidden forms elsewhere in the DOM. Browsers handle `display:none` target forms inconsistently, causing: +- Checkbox state loss (the `is_regex` field) +- AJAX intercept firing for add-cookie submissions + +**Fix Applied**: Added guard clause to skip AJAX intercept: +```javascript +if ( e.originalEvent && e.originalEvent.submitter && e.originalEvent.submitter.name === 'warder_add_cookie' ) { + return; +} +``` + +**Best Practice Approach**: +- Never nest forms +- Use separate `
` elements with their own submit handlers +- Keep JS event listeners scoped to specific forms + +### Problem 2: DOM Repositioning (Commit `fb09e7c`) + +**Issue**: Add-cookie containers were moved outside the main form to avoid nesting, but required complex DOM manipulation in JavaScript. + +**Fix Applied**: +- Moved add-cookie containers after main form in PHP +- JavaScript repositioning on open: + ```javascript + $( '#warder-main-settings-form' ).after( $container ); + $container.show(); + ``` + +**Best Practice Approach**: +- Render forms in their final position from PHP +- Use CSS `display: none` for initial hiding +- No JS DOM manipulation needed + +### Problem 3: Selector Complexity (Commit `a7e18db`) + +**Issue**: Selectors became overly complex to avoid interfering with add-cookie forms: +```javascript +#warder-main-settings-form input:not([form]), #warder-main-settings-form textarea:not([form]), #warder-main-settings-form select:not([form]) +``` + +**Best Practice Approach**: +- Use event delegation: `$(document).on('change', '#warder-main-settings-form input, #warder-main-settings-form textarea, ...')` +- Or scope selectors to specific containers without negation + +--- + +## Best Practices Worth Adopting + +### 1. Modular AJAX Handlers + +Modern WordPress plugins use dedicated classes for each AJAX action type: + +```php +// File pattern for module-specific AJAX handlers +class PluginName_Module_Ajax { + public function __construct() { + add_action( 'wp_ajax_module_action', array( $this, 'ajax_handler' ) ); + } + + public function ajax_handler() { + $out = array( + 'response' => false, + 'message' => __( 'Unable to handle your request.', 'text-domain' ), + ); + + if ( ! current_user_can( 'manage_options' ) ) { + echo wp_json_encode( $out ); + exit(); + } + + if ( isset( $_POST['module_action'] ) ) { + check_admin_referer( 'module_nonce', 'security' ); + $action = sanitize_text_field( wp_unslash( $_POST['module_action'] ) ); + $allowed = array( 'create', 'read', 'update', 'delete' ); + + if ( in_array( $action, $allowed, true ) && method_exists( $this, $action ) ) { + $out = $this->{$action}(); + } + } + + echo wp_json_encode( $out ); + exit(); + } +} +``` + +**Recommendation for Warder**: +```php +// File: includes/class-warder-admin-ajax.php +class Warder_Admin_Ajax { + public function __construct() { + add_action( 'wp_ajax_warder_save_settings', array( $this, 'save_settings' ) ); + add_action( 'wp_ajax_warder_add_category', array( $this, 'add_category' ) ); + add_action( 'wp_ajax_warder_add_cookie', array( $this, 'add_cookie' ) ); + add_action( 'wp_ajax_warder_delete_category', array( $this, 'delete_category' ) ); + add_action( 'wp_ajax_warder_delete_cookie', array( $this, 'delete_cookie' ) ); + } + + public function save_settings() { + check_ajax_referer( 'warder_save_settings', 'security' ); + // ... + wp_send_json_success( array( 'message' => __( 'Settings saved.', 'warder-cookie-consent' ) ) ); + } + + // ... other methods +} +new Warder_Admin_Ajax(); +``` + +### 2. Consistent Nonce Pattern + +Best practice uses a consistent nonce pattern: +- Same nonce name across related actions +- Passed via `$_POST['security']` parameter + +Warder currently uses: +- `_wpnonce` for AJAX (from Settings API) +- `warder_category_nonce` for category actions +- `warder_cookie_nonce` for cookie actions + +**Recommendation**: Standardize on one nonce field name, e.g., `warder_security`: +```php +// In PHP +wp_nonce_field( 'warder_admin_action', 'warder_security' ); + +// In JS +$.post( warderAdmin.ajaxurl, form.serialize() + '&action=warder_save_settings&security=' + $( '#warder_security' ).val(), ... ); +``` + +### 3. JavaScript Module Pattern + +Modern plugins organize JS by feature module: +``` +admin/js/ + plugin-admin.js # Core admin functionality + modules/ + module-cookie/ + assets/js/ + module-cookie.js # Cookie management + module-scanner/ + assets/js/ + module-scanner.js # Scanner functionality +``` + +**Recommendation for Warder**: +``` +assets/js/ + admin/ + admin-settings.js # Main settings page + admin-cookie-manager.js # Category/cookie CRUD operations + admin-common.js # Shared utilities +``` + +### 4. Response Handling Pattern + +Standard pattern uses a consistent response object structure: +```javascript +// In PHP +return array( + 'response' => true, + 'message' => __( 'Success message', 'text-domain' ), + 'content' => $html, // Optional +); + +// In JS +$.post( ajaxurl, data, function( response ) { + if ( response.response ) { + // Success + } else { + // Error: response.message + } +} ); +``` + +Warder uses `wp_send_json_success()` / `wp_send_json_error()` which is more WordPress-native. **Recommendation**: Keep Warder's current approach but consider adding: +- A `data` field for returning updated HTML or redirect URLs +- Standard error codes + +--- + +## Specific Improvements for Warder + +### 1. Create Dedicated AJAX Handler Class + +Move all AJAX-related logic from `warder-cookie-consent.php` to `includes/class-warder-admin-ajax.php`: + +```php + __( 'Unauthorized.', 'warder-cookie-consent' ) ) ); + } + + check_ajax_referer( $action, $nonce_field ); + } + + public function handle_save_settings() { + $this->validate_request( 'warder_save_settings' ); + + $input = isset( $_POST['warder_options'] ) ? wp_unslash( $_POST['warder_options'] ) : array(); + $valid = warder_validate_options( $input ); + + if ( update_option( 'warder_options', $valid ) ) { + delete_transient( 'warder_options_cache' ); + wp_send_json_success( array( + 'message' => __( 'Settings saved successfully.', 'warder-cookie-consent' ), + 'redirect' => add_query_arg( 'settings-updated', 'true', wp_get_referer() ) + ) ); + } else { + wp_send_json_success( array( 'message' => __( 'No changes detected.', 'warder-cookie-consent' ) ) ); + } + } + + public function handle_add_category() { + $this->validate_request( 'warder_add_category' ); + + $new_id = isset( $_POST['new_category_id'] ) ? sanitize_key( wp_unslash( $_POST['new_category_id'] ) ) : ''; + + if ( empty( $new_id ) ) { + wp_send_json_error( array( 'message' => __( 'Category ID is required.', 'warder-cookie-consent' ) ) ); + } + + $options = get_option( 'warder_options', array() ); + + if ( isset( $options['cookie_categories'][ $new_id ] ) ) { + wp_send_json_error( array( 'message' => __( 'Category already exists.', 'warder-cookie-consent' ) ) ); + } + + $options['cookie_categories'][ $new_id ] = array( + 'title' => ucfirst( $new_id ), + 'description' => '', + 'enabled' => false, + 'readonly' => false, + 'cookies' => array(), + ); + + update_option( 'warder_options', $options ); + delete_transient( 'warder_options_cache' ); + + wp_send_json_success( array( + 'message' => __( 'Category added.', 'warder-cookie-consent' ), + 'html' => $this->get_category_row_html( $new_id, $options['cookie_categories'][ $new_id ] ) + ) ); + } + + // ... other handlers +} +new Warder_Admin_Ajax(); +``` + +### 2. Split JavaScript Files + +Create separate files for different concerns: + +**`assets/js/admin-settings.js`**: +```javascript +/* global warderAdmin */ +jQuery( function( $ ) { + 'use strict'; + + var WarderAdminSettings = { + init: function() { + this.bindEvents(); + }, + + bindEvents: function() { + // Highlight changed fields + $( document ).on( 'change', '#warder-main-settings-form input, #warder-main-settings-form textarea, #warder-main-settings-form select', function() { + $( this ).css( 'background-color', '#ffffdd' ); + } ); + + // Form submission + $( document ).on( 'submit', '#warder-main-settings-form', this.handleSubmit ); + }, + + handleSubmit: function( e ) { + e.preventDefault(); + + var form = $( this ); + var submitBtn = form.find( 'input[type="submit"]' ); + + submitBtn.prop( 'disabled', true ).val( warderAdmin.saving ); + + $.post( { + url: warderAdmin.ajaxurl, + data: form.serialize() + '&action=warder_save_settings', + dataType: 'json' + } ).done( function( response ) { + $( '.warder-ajax-notice' ).remove(); + var cls = response.success ? 'notice-success' : 'notice-error'; + var $notice = $( '

' + response.data.message + '

' ); + form.before( $notice ); + + if ( response.success ) { + form.find( 'input, textarea, select' ).css( 'background-color', '' ); + + // Scroll to notice + $( 'html, body' ).animate( { scrollTop: $notice.offset().top - 50 }, 300 ); + + // Handle redirect if provided + if ( response.data.redirect ) { + window.location.href = response.data.redirect; + } + } + } ).fail( function() { + $( '.warder-ajax-notice' ).remove(); + var $notice = $( '

' + warderAdmin.error_message + '

' ); + form.before( $notice ); + } ).always( function() { + submitBtn.prop( 'disabled', false ).val( warderAdmin.save ); + } ); + } + }; + + WarderAdminSettings.init(); +} ); +``` + +**`assets/js/admin-cookie-manager.js`**: +```javascript +/* global warderAdmin */ +jQuery( function( $ ) { + 'use strict'; + + var WarderCookieManager = { + init: function() { + this.bindEvents(); + }, + + bindEvents: function() { + // Show add cookie form + $( document ).on( 'click', '.show-add-cookie-form', this.showAddCookieForm ); + + // Cancel add cookie + $( document ).on( 'click', '.cancel-add-cookie', this.cancelAddCookie ); + + // Add cookie form submission + $( document ).on( 'submit', 'form[id^="warder-add-cookie-form-"]', this.handleAddCookie ); + + // Delete cookie + $( document ).on( 'click', '.warder-delete-cookie', this.handleDeleteCookie ); + + // Delete category + $( document ).on( 'click', '.warder-delete-category', this.handleDeleteCategory ); + }, + + showAddCookieForm: function( e ) { + e.preventDefault(); + var categoryId = $( this ).data( 'category' ); + var $container = $( '#warder-add-cookie-container-' + categoryId ); + + // Container is already positioned after main form in PHP + $container.show(); + $( 'html, body' ).animate( { scrollTop: $container.offset().top - 50 }, 300 ); + }, + + cancelAddCookie: function( e ) { + e.preventDefault(); + $( this ).closest( '.warder-add-cookie-form-container' ).hide(); + }, + + handleAddCookie: function( e ) { + e.preventDefault(); + + var form = $( this ); + var submitBtn = form.find( 'input[type="submit"]' ); + + submitBtn.prop( 'disabled', true ); + + $.post( { + url: warderAdmin.ajaxurl, + data: form.serialize() + '&action=warder_add_cookie', + dataType: 'json' + } ).done( function( response ) { + if ( response.success ) { + form.closest( '.warder-add-cookie-form-container' ).hide(); + // Optionally update UI with new cookie row + if ( response.data.html ) { + $( '#warder-category-' + response.data.category_id + ' .warder-cookie-list' ).append( response.data.html ); + } + // Show success notice + var $notice = $( '

' + response.data.message + '

' ); + form.before( $notice ); + } else { + alert( response.data.message ); + } + } ).fail( function() { + alert( warderAdmin.error_message ); + } ).always( function() { + submitBtn.prop( 'disabled', false ); + } ); + }, + + // ... other handlers + }; + + WarderCookieManager.init(); +} ); +``` + +### 3. Simplify PHP Rendering + +In `warder_render_options_page()`, avoid the complex conditional rendering and instead: + +```php +// Render main settings form +?> + + + $category ) : + ?>
+ $message, + 'code' => $code, + ) ); +} + +private function validate_category_id( $category_id ) { + if ( empty( $category_id ) || ! is_string( $category_id ) ) { + $this->handle_error( __( 'Invalid category ID.', 'warder-cookie-consent' ), 'invalid_category' ); + } + + if ( 'necessary' === $category_id ) { + $this->handle_error( __( 'Cannot modify necessary category.', 'warder-cookie-consent' ), 'protected_category' ); + } + + return true; +} +``` + +--- + +## Security Considerations + +The plugin handles security well: + +1. **Capability Check**: Present and correct +2. **Nonce Verification**: Uses `check_ajax_referer()` correctly +3. **Input Sanitization**: Sanitizes with `sanitize_key()`, `sanitize_text_field()`, `absint()` +4. **Output Escaping**: Uses `esc_html__()`, `esc_attr__()`, `esc_js()` + +**Recommendation**: Consider adding: +- Rate limiting for AJAX endpoints +- `wp_verify_nonce()` as a fallback in addition to `check_ajax_referer()` + +--- + +## Migration Path + +To implement these improvements without breaking existing functionality: + +### Phase 1: Refactor AJAX Handlers (v1.6.0) +1. Create `includes/class-warder-admin-ajax.php` with all AJAX handlers +2. Keep existing hooks in `warder-cookie-consent.php` for backward compatibility +3. Gradually move logic from functions to class methods + +### Phase 2: Split JavaScript (v1.6.0 or v1.7.0) +1. Create `assets/js/admin-settings.js` and `assets/js/admin-cookie-manager.js` +2. Update `warder_enqueue_admin_scripts()` to enqueue both +3. Remove inline JS from PHP files + +### Phase 3: Update PHP Rendering (v1.7.0) +1. Simplify `warder_render_options_page()` to use separate form rendering +2. Move form HTML to template partials in `includes/templates/` +3. Extract category/cookie row rendering to separate functions + +### Phase 4: Enhanced Error Handling (v1.7.0) +1. Add standardized error codes and messages +2. Improve client-side error display +3. Add server-side validation helpers + +--- + +## Conclusion + +The main complications in Warder's recent commits stemmed from: +1. **Form nesting** with HTML5 `form` attribute (now resolved) +2. **Overly complex selectors** to work around nesting issues +3. **Event delegation issues** with dynamically shown forms + +Modern WordPress plugin development standards avoid these problems by: +1. **Modular architecture**: Separate classes for each feature +2. **Clean separation**: Forms are never nested, JS is module-specific +3. **Consistent patterns**: Same nonce, response format, and error handling throughout + +**Recommendation Priority**: +1. ✅ **High**: Create dedicated AJAX handler class (immediate benefit) +2. ✅ **High**: Split JavaScript files by concern (immediate benefit) +3. ⚠️ **Medium**: Standardize nonce and response patterns (consistency benefit) +4. 📋 **Low**: Migrate to template partials (long-term maintainability) + +These changes will make the codebase more maintainable and reduce the likelihood of the DOM/event-related bugs that have required recent fixes. diff --git a/docs/architecture-plan.md b/docs/architecture-plan.md new file mode 100644 index 0000000..3709f6f --- /dev/null +++ b/docs/architecture-plan.md @@ -0,0 +1,245 @@ +# Warder Cookie Consent - Architecture Plan + +## Current State (v1.5.1) + +### File Structure + +``` +warder-cookie-consent/ +├── warder-cookie-consent.php # All plugin logic (~851 lines) +├── src/index.js # JS entry point (webpack input) +├── dist/cookieconsent.bundle.js# Compiled bundle (webpack output) +├── webpack.config.js +├── languages/ +├── docs/ +└── ...config files +``` + +### What Is Already Implemented + +- Settings saved inline notice (`$_GET['settings-updated']`) inside `warder_render_options_page()` +- Yellow background highlight on changed fields (inline jQuery via `wp_add_inline_script`) +- Show/hide add-cookie form (inline jQuery) +- Activation-time merge of defaults (`warder_plugin_activate`) +- Cache busting via `warder_options_last_updated` timestamp on option update +- Welcome notice on other admin pages (`warder_admin_notices` via `admin_notices` hook) +- Cookie category/cookie add and delete via `warder_handle_admin_actions()` + +### Known Issues + +#### 1. Save UX — Page Jumps to Top +- **Cause**: form POSTs to `options.php`, which redirects back with `?settings-updated=true`, resetting scroll position +- **Impact**: disorienting on a long settings page; user loses context +- **Current partial fix**: inline success notice at top of page content — visible but requires scrolling back + +#### 2. Monolithic File +- **Issue**: all logic in one 851-line file +- **Impact**: harder to navigate and maintain as the plugin grows +- **Not urgent** at current size, but worth planning for v2.0.0 + +--- + +## Phase 1: Fix Save UX (v1.6.0) + +### Recommended Approach: AJAX Save + +Replace the standard `options.php` form submission with an AJAX handler. This eliminates the page reload entirely, keeps the user at their scroll position, and gives immediate visual feedback. + +**Why not the scroll-position-via-POST hack?** +Storing scroll offset in a hidden POST field and restoring it via JS after redirect is fragile, non-standard, and still flickers. AJAX solves the root cause. + +**Why not stay with WordPress Settings API redirect?** +The Settings API redirect to `options.php` is what causes the jump. Intercepting the submit and using `wp_ajax_` directly is cleaner and widely used in modern WP plugins. + +### PHP: AJAX Handler + +Add to `warder-cookie-consent.php`: + +```php +add_action( 'wp_ajax_warder_save_settings', 'warder_ajax_save_settings' ); + +function warder_ajax_save_settings() { + check_ajax_referer( 'warder_options_group-options', '_wpnonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'warder-cookie-consent' ) ) ); + } + + $input = isset( $_POST['warder_options'] ) ? $_POST['warder_options'] : array(); + $valid = warder_validate_options( $input ); + + if ( update_option( 'warder_options', $valid ) ) { + delete_transient( 'warder_options_cache' ); + wp_send_json_success( array( + 'message' => __( 'Settings saved successfully.', 'warder-cookie-consent' ), + ) ); + } else { + wp_send_json_success( array( + 'message' => __( 'No changes detected.', 'warder-cookie-consent' ), + ) ); + } +} +``` + +### JS: Intercept Submit + +Replace the current inline `$admin_js` string with an enqueued file (see Phase 2), or extend it inline for now: + +```javascript +jQuery( document ).ready( function( $ ) { + $( '#warder-main-settings-form' ).on( 'submit', function( e ) { + e.preventDefault(); + + var form = $( this ); + var submitBtn = form.find( 'input[type="submit"]' ); + var nonce = $( '#_wpnonce' ).val(); + + submitBtn.prop( 'disabled', true ).val( warderAdmin.saving ); + + $.post( ajaxurl, form.serialize() + '&action=warder_save_settings', function( response ) { + $( '.warder-ajax-notice' ).remove(); + var cls = response.success ? 'notice-success' : 'notice-error'; + form.before( + '
' + + '

' + response.data.message + '

' + ); + if ( response.success ) { + form.find( 'input, textarea, select' ).css( 'background-color', '' ); + } + } ).always( function() { + submitBtn.prop( 'disabled', false ).val( warderAdmin.save ); + } ); + } ); +} ); +``` + +Use `wp_localize_script` to pass translated strings (`warderAdmin.save`, `warderAdmin.saving`) rather than hardcoding them in JS. + +### Fallback (no-JS) +Keep the existing `options.php` form action and `settings_fields()` call intact. The AJAX handler intercepts via `e.preventDefault()` — if JS is unavailable, the form submits normally and the existing redirect notice still works. + +### Changes Required in `warder_enqueue_admin_scripts()` +- Add `wp_localize_script()` call for `warderAdmin` strings after enqueueing jQuery +- Extend `$admin_js` to include the submit intercept, or extract to a file (preferred) + +--- + +## Phase 2: Extract Admin JS to a File (v1.6.0 or v2.0.0) + +Currently all admin JS is a PHP heredoc string passed to `wp_add_inline_script`. This is hard to edit, lint, or test. + +### Change +1. Create `assets/js/admin.js` +2. In `warder_enqueue_admin_scripts()`, replace `wp_add_inline_script` with: + ```php + wp_enqueue_script( + 'warder-admin', + plugin_dir_url( __FILE__ ) . 'assets/js/admin.js', + array( 'jquery' ), + WARDER_VERSION, + true + ); + wp_localize_script( 'warder-admin', 'warderAdmin', array( + 'save' => __( 'Save Settings', 'warder-cookie-consent' ), + 'saving' => __( 'Saving…', 'warder-cookie-consent' ), + ) ); + ``` +3. Move all inline JS (show/hide form, field highlight, AJAX submit) into `assets/js/admin.js` + +No webpack needed — this is plain jQuery, not a module. + +--- + +## Phase 3: File Structure Refactoring (v2.0.0) + +Only warranted if the main PHP file grows significantly beyond its current 851 lines or if the codebase gains contributors. Premature splitting adds navigation overhead. + +### Proposed Structure (when needed) + +``` +warder-cookie-consent/ +├── warder-cookie-consent.php # Plugin header + require loader only +├── inc/ +│ ├── settings.php # warder_register_settings(), warder_validate_options() +│ ├── admin.php # warder_render_options_page(), warder_enqueue_admin_scripts() +│ ├── ajax.php # warder_ajax_save_settings(), warder_handle_admin_actions() +│ ├── frontend.php # warder_enqueue_scripts(), warder_add_preferences_button() +│ └── defaults.php # warder_get_default_options(), warder_get_merged_options() +├── assets/ +│ └── js/ +│ └── admin.js +├── src/ +│ └── index.js # Frontend bundle entry (webpack) +├── dist/ +│ └── cookieconsent.bundle.js +└── ... +``` + +### Migration Steps +1. Create `inc/` directory +2. Move function groups one file at a time, verifying nothing breaks after each move +3. Add `require_once` calls in the main file +4. No class-based refactor needed — procedural functions with `warder_` prefix work fine at this scale + +--- + +## WordPress.org Plugin Guidelines Compliance + +Relevant rules from the [Detailed Plugin Guidelines](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) and how they apply: + +### Guideline 4 — No Obfuscation +Webpack minification of `dist/cookieconsent.bundle.js` is fine. Variable name mangling (uglify `mangle` option) is **not** permitted. The webpack config must keep readable variable names in the bundle, and `src/index.js` must remain in the repo as the human-readable source. + +### Guideline 11 — Admin Notices Must Be Dismissible +- The AJAX save notice (Phase 1) must include the `is-dismissible` CSS class — already in the proposed markup. +- The existing welcome/setup notice in `warder_admin_notices()` must be dismissible. Currently it is (`is-dismissible`), but it shows on **every** admin page until settings are configured. It should self-suppress once the plugin has been configured (e.g. check `get_option('warder_options_last_updated')`). +- Upgrade prompts or upsell messaging must never appear site-wide — only on the plugin's own settings page. + +### Guideline 13 — Use WordPress Bundled Libraries +- jQuery is bundled with WordPress; always enqueue via `wp_enqueue_script( 'jquery' )`, never bundle or load a separate copy. +- `vanilla-cookieconsent` is not bundled with WordPress, so including it via webpack is correct. It uses the MIT licence, which is GPL-compatible (Guideline 1). +- Do not load any assets from third-party CDNs (Guideline 8). All JS and CSS must be self-hosted in the plugin. + +### Guideline 7 — No User Tracking Without Consent +The AJAX save handler (`warder_ajax_save_settings`) must not transmit any data externally. Settings are saved only to `wp_options` — this is compliant. If any future feature sends data off-site (e.g. telemetry), it requires an explicit opt-in checkbox. + +### Guideline 8 — No Third-Party Code Execution +The plugin must not make external HTTP requests during normal operation. The AJAX handler calls only `update_option()` and `wp_send_json_*()` — fully local. If external requests are ever added (e.g. licence checks), they require disclosure and must be genuinely functional (not solely for licence enforcement). + +--- + +## Security Checklist (All Changes) + +- Verify nonce on every AJAX handler: `check_ajax_referer()` +- Check capabilities: `current_user_can( 'manage_options' )` +- Sanitize all `$_POST` input via `warder_validate_options()` before saving +- Escape all output with `esc_html()`, `esc_attr()`, `esc_url()`, `esc_js()` +- Never trust `$_GET['settings-updated']` for security decisions (it is UI-only) +- No external HTTP requests in AJAX handlers (Guideline 8) + +--- + +## Testing Checklist + +- [ ] Settings save without page jump (AJAX path) +- [ ] Success/error notice appears next to form, includes `is-dismissible` class (Guideline 11) +- [ ] Changed-field highlight resets on successful save +- [ ] Fallback: settings save normally when JS is disabled +- [ ] Welcome notice suppresses itself after plugin has been configured (Guideline 11) +- [ ] Add/delete category and cookie still work (not affected by AJAX save change) +- [ ] All existing options survive a round-trip through `warder_validate_options()` +- [ ] No external HTTP requests fired during save (Guideline 8) +- [ ] Works on WordPress multisite +- [ ] Compatible with PHP 8.0+ + +--- + +## WordPress Coding Standards + +All changes must follow: +- [WordPress PHP Coding Standards](https://developer.wordpress.org/coding-standards/wordpress-coding-standards/php/) +- [WordPress JavaScript Coding Standards](https://developer.wordpress.org/coding-standards/wordpress-coding-standards/js/) +- [WordPress CSS Coding Standards](https://developer.wordpress.org/coding-standards/wordpress-coding-standards/css/) +- [WordPress.org Detailed Plugin Guidelines](https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/) + +Lint PHP with `vendor/bin/phpcs --standard=phpcs.xml warder-cookie-consent.php` before committing. diff --git a/package.json b/package.json index b18e98a..f44c03f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwz-cookie-consent", - "version": "1.5.1", + "version": "1.5.2", "main": "webpack.config.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" diff --git a/readme.txt b/readme.txt index 8b51234..6d80f04 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Donate link: https://imagewize.com Tags: cookie, consent, gdpr, privacy, compliance Requires at least: 5.0 Tested up to: 7.0 -Stable tag: 1.5.1 +Stable tag: 1.5.2 Requires PHP: 8.0 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -72,6 +72,15 @@ Yes. Settings are versioned via a timestamp that is appended to the script URL, == Changelog == += 1.5.2 = +*2026-05-28* + +* Added: AJAX save for the Cookie Consent settings page — Save All Settings no longer reloads the page or scrolls back to the top; the success notice appears next to the form and the page scrolls it into view +* Fixed: setup welcome notice now self-suppresses once the plugin has been configured, instead of appearing on every admin page +* Fixed: Add Cookie forms are now rendered after the main settings form rather than nested inside it, so submitting an Add Cookie form no longer accidentally triggers the main settings save and no longer loses the regex checkbox state +* Fixed: success notice after adding/deleting categories or cookies (`?warder_notice=saved` after the redirect) +* Changed: removed leftover `:not([form])` selectors and JS DOM repositioning that were workarounds for the old nested-form layout + = 1.5.1 = *2026-05-28* @@ -165,6 +174,9 @@ Yes. Settings are versioned via a timestamp that is appended to the script URL, == Upgrade Notice == += 1.5.2 = +Save All Settings now uses AJAX, so the page no longer jumps back to the top after saving. Add Cookie forms are no longer nested inside the main settings form, so the regex checkbox and the rest of the cookie inputs submit reliably. + = 1.5.1 = Fixes Add Cookie form positioning so it appears directly below the category button, and fixes a regression where submitting that form silently failed (nested form was being discarded by the browser). diff --git a/warder-cookie-consent.php b/warder-cookie-consent.php index 7af0245..147859c 100644 --- a/warder-cookie-consent.php +++ b/warder-cookie-consent.php @@ -2,7 +2,7 @@ /** * Plugin Name: Warder Cookie Consent * Description: GDPR-compliant cookie consent banner with category management and floating preferences toggle. - * Version: 1.5.1 + * Version: 1.5.2 * Author: Jasper Frumau * Author URI: https://imagewize.com * Requires at least: 5.0 @@ -16,6 +16,8 @@ defined( 'ABSPATH' ) || exit; +define( 'WARDER_VERSION', '1.5.2' ); + /** * Registers plugin settings and adds default options on first activation. */ @@ -182,27 +184,48 @@ function warder_enqueue_admin_scripts( $hook ) { return; } - wp_enqueue_script( 'jquery' ); - - $admin_js = ' -jQuery(document).ready(function($) { - $(".show-add-cookie-form").on("click", function() { - var categoryId = $(this).data("category"); - $("#warder-add-cookie-container-" + categoryId).show(); - }); - $(".cancel-add-cookie").on("click", function(e) { - e.preventDefault(); - $(this).closest(".warder-add-cookie-form-container").hide(); - }); - $("#warder-main-settings-form input, #warder-main-settings-form textarea, #warder-main-settings-form select").on("change", function() { - $(this).css("background-color", "#ffffdd"); - }); -});'; - - wp_add_inline_script( 'jquery', $admin_js ); + wp_enqueue_script( + 'warder-admin', + plugin_dir_url( __FILE__ ) . 'assets/js/admin.js', + array( 'jquery' ), + WARDER_VERSION, + true + ); + + wp_localize_script( + 'warder-admin', + 'warderAdmin', + array( + 'ajaxurl' => admin_url( 'admin-ajax.php' ), + 'save' => __( 'Save Settings', 'warder-cookie-consent' ), + 'saving' => __( 'Saving…', 'warder-cookie-consent' ), + ) + ); } add_action( 'admin_enqueue_scripts', 'warder_enqueue_admin_scripts' ); +/** + * Handles AJAX save of plugin settings from the admin settings page. + */ +function warder_ajax_save_settings() { + check_ajax_referer( 'warder_options_group-options', '_wpnonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'warder-cookie-consent' ) ) ); + } + + $input = isset( $_POST['warder_options'] ) ? wp_unslash( $_POST['warder_options'] ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $valid = warder_validate_options( $input ); + + if ( update_option( 'warder_options', $valid ) ) { + delete_transient( 'warder_options_cache' ); + wp_send_json_success( array( 'message' => __( 'Settings saved successfully.', 'warder-cookie-consent' ) ) ); + } else { + wp_send_json_success( array( 'message' => __( 'No changes detected.', 'warder-cookie-consent' ) ) ); + } +} +add_action( 'wp_ajax_warder_save_settings', 'warder_ajax_save_settings' ); + /** * Processes add/delete actions for cookie categories and cookies on the settings page. * @@ -277,6 +300,16 @@ function warder_handle_admin_actions( $options ) { if ( $changed ) { update_option( 'warder_options', $options ); delete_transient( 'warder_options_cache' ); + wp_safe_redirect( + add_query_arg( + array( + 'page' => 'warder-cookie-consent', + 'warder_notice' => 'saved', + ), + admin_url( 'options-general.php' ) + ) + ); + exit; } return $options; @@ -297,6 +330,9 @@ function warder_render_options_page() { delete_transient( 'warder_options_cache' ); } + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $warder_notice = isset( $_GET['warder_notice'] ) ? sanitize_key( wp_unslash( $_GET['warder_notice'] ) ) : ''; + $options = get_option( 'warder_options', array() ); $default_options = warder_get_default_options(); $options = wp_parse_args( $options, $default_options ); @@ -311,7 +347,7 @@ function warder_render_options_page() {

- +

@@ -573,21 +609,40 @@ class="regular-text warder-category-title-field"
- - - ' . esc_html__( 'No cookie categories found. Default categories will be created when you save settings.', 'warder-cookie-consent' ) . '

'; - } - ?> - - - - - - " to submit here, avoiding nested forms. - if ( isset( $options['cookie_categories'] ) && is_array( $options['cookie_categories'] ) ) : - foreach ( array_keys( $options['cookie_categories'] ) as $form_category_id ) : - ?> -