From 6a6e8f39605b5bf9c2b56f1640b6079db38e8095 Mon Sep 17 00:00:00 2001 From: Cooper Dalrymple Date: Tue, 9 Jun 2026 12:53:19 -0500 Subject: [PATCH] Add floating mode support --- assets/floating.js | 21 +++ inc/abstract-location.php | 54 +++++- inc/class-settings.php | 63 ++++++- inc/class-widget.php | 164 +++++++++++++++++- inc/gravityforms/class-addon.php | 4 +- inc/location/class-comment-form.php | 9 + inc/location/class-login-form.php | 6 + inc/location/class-login-page.php | 8 +- inc/location/class-lost-password-form.php | 7 +- inc/location/class-post-password-form.php | 6 + inc/location/class-registration-page.php | 6 +- inc/location/woocommerce/class-checkout.php | 9 + inc/location/woocommerce/class-login.php | 2 + .../woocommerce/class-lost-password.php | 2 + inc/location/woocommerce/class-register.php | 2 + 15 files changed, 341 insertions(+), 22 deletions(-) create mode 100644 assets/floating.js diff --git a/assets/floating.js b/assets/floating.js new file mode 100644 index 0000000..72371cc --- /dev/null +++ b/assets/floating.js @@ -0,0 +1,21 @@ +/** + * @package ogre-captcha + * @author cleverogre + * @copyright 2026 CleverOgre + * @license GLP-3.0-or-later + * @version 1.1.2 + * @since 1.1.2 + */ + +(() => { + document.addEventListener('DOMContentLoaded', () => { + if (typeof window['CAP_FLOATING_DYNAMIC'] === 'undefined') return; + + const element = document.body.querySelector(CAP_FLOATING_DYNAMIC['selector']); + if (element === null) return; + + for (const [name, value] of Object.entries(CAP_FLOATING_DYNAMIC['attributes'])) { + element.setAttribute(name, value); + } + }); +})(); diff --git a/inc/abstract-location.php b/inc/abstract-location.php index 4a2eded..8fe5dde 100644 --- a/inc/abstract-location.php +++ b/inc/abstract-location.php @@ -11,8 +11,8 @@ namespace Ogre\Captcha; use Ogre\Singleton; -use Ogre\Captcha as Plugin; use Ogre\Captcha\Settings; +use Override; use WP_Error; defined('ABSPATH') || exit; @@ -70,4 +70,56 @@ abstract class Location { wp_die(ob_get_clean()); } + public function header():void { + if (!Settings::is_widget_floating()) return; + ob_start(); + } + + public function footer():void { + if (!Settings::is_widget_floating()) return; + $html = ob_get_clean(); + $html = Widget::set_button_floating($html); + echo $html; + } + +} + +abstract class LoginLocation extends Location { + + protected string $action; + + protected function __construct(string $name, string $action) { + $this->action = $action; + parent::__construct($name); + } + + #[Override] + public function activate(): void + { + add_action('lostpassword_form', [$this, 'render']); + add_action('login_head', [$this, 'header']); + add_action('login_footer', [$this, 'footer']); + add_action('lostpassword_post', [$this, 'process_form']); + } + + private function get_action():string { + return isset($_REQUEST['action']) && is_string($_REQUEST['action']) ? $_REQUEST['action'] : 'login'; + } + + protected function is_action():bool { + return $this->get_action() == $this->action; + } + + #[Override] + public function header():void { + if (!$this->is_action()) return; + parent::header(); + } + + #[Override] + public function footer():void { + if (!$this->is_action()) return; + parent::footer(); + } + } diff --git a/inc/class-settings.php b/inc/class-settings.php index 68e99a7..56a65bf 100644 --- a/inc/class-settings.php +++ b/inc/class-settings.php @@ -33,6 +33,13 @@ final class Settings { public const LOCATION_WC_REGISTER = 'wc_register'; public const LOCATION_WC_CHECKOUT = 'wc_checkout'; + public const WIDGET_TYPE_STANDARD = 'standard'; + public const WIDGET_TYPE_FLOATING = 'floating'; + public const WIDGET_TYPES = [ + self::WIDGET_TYPE_STANDARD, + self::WIDGET_TYPE_FLOATING, + ]; + protected function __construct() { add_action('admin_menu', [$this, 'menu']); add_action('admin_init', [$this, 'init']); @@ -44,6 +51,10 @@ final class Settings { return $data; } + private static function has(string $key):bool { + return array_key_exists($key, self::get()); + } + private static function set(string $key, $value = null):void { $data = self::get(); if (is_null($value) && isset($data[$key])) { @@ -54,7 +65,8 @@ final class Settings { update_option(Plugin::get_id(), $data); } - private static function is_checked(string $name, string $value):bool { + private static function is_checked(string $name, string $value, string|null $default = null):bool { + if (!is_null($default) && !self::has($name)) return $value == $default; return in_array($value, (array) self::get($name)); } @@ -139,6 +151,16 @@ final class Settings { return self::is_form_location_enabled(self::LOCATION_WC_CHECKOUT); } + public static function get_widget_type():string { + $value = (array) self::get('widget_type'); + $value = !empty($value) ? array_values($value)[0] : ''; + return in_array($value, self::WIDGET_TYPES) ? $value : self::WIDGET_TYPE_STANDARD; + } + + public static function is_widget_floating():bool { + return self::get_widget_type() === self::WIDGET_TYPE_FLOATING; + } + public function menu() { if (!current_user_can(self::CAPABILITY)) return; add_options_page( @@ -204,11 +226,11 @@ final class Settings { add_settings_section( 'forms', - Plugin::__('CAPTCHA Forms'), + Plugin::__('Forms'), [$this, 'section'], Plugin::get_id(), [ - 'description' => Plugin::__('Define which forms you would like to secure with CAPTCHA.'), + 'description' => Plugin::__('Define which forms you would like to secure with Cap.'), ] ); @@ -250,6 +272,36 @@ final class Settings { ] ); } + + add_settings_section( + 'widget', + Plugin::__('Widget'), + [$this, 'section'], + Plugin::get_id(), + [ + 'description' => Plugin::__('Change how the Cap widget is displayed on the frontend.'), + ] + ); + + add_settings_field( + Plugin::get_id() . '_widget_type', + Plugin::__('Widget type'), + [$this, 'checkbox_field'], + Plugin::get_id(), + 'widget', + [ + 'type' => 'radio', + 'name' => 'widget_type', + 'options' => [ + self::WIDGET_TYPE_STANDARD => Plugin::__('Standard (default)'), + self::WIDGET_TYPE_FLOATING => Plugin::__('Floating'), + ], + 'default' => self::WIDGET_TYPE_STANDARD, + 'description' => Plugin::__('Not sure which type you want to use? View the demo to try them out.'), + ] + ); + + } public function page() { @@ -334,11 +386,12 @@ final class Settings { echo implode('
', array_map( fn (string $name, string $label):string => sprintf( - '', + '', + esc_attr($args['type'] ?? 'checkbox'), esc_attr(Plugin::get_id()), esc_attr($args['name']), esc_attr($name), - checked(self::is_checked($args['name'], $name), display: false), + checked(self::is_checked($args['name'], $name, $args['default'] ?? null), display: false), esc_html($label) ), array_keys($args['options']), diff --git a/inc/class-widget.php b/inc/class-widget.php index a0cc760..e4dd8e2 100644 --- a/inc/class-widget.php +++ b/inc/class-widget.php @@ -12,12 +12,15 @@ namespace Ogre\Captcha; use Ogre\Captcha as Plugin; use Ogre\Captcha\Settings; +use WP_HTML_Tag_Processor; defined('ABSPATH') || exit; final class Widget { private const HANDLE = 'cap'; + private const FLOATING_HANDLE = 'cap-floating'; + private const DYNAMIC_FLOATING_HANDLE = 'cap-floating-dynamic'; private const TOKEN_NAME = 'cap-token'; private static function get_script_url():string { @@ -25,28 +28,177 @@ final class Widget { return Settings::get_instance_url('assets/widget.js'); } - public static function enqueue_assets():void { + private static function get_floating_script_url():string { + if (!Settings::is_connected()) return ''; + return Settings::get_instance_url('assets/floating.js'); + } + + public static function enqueue_assets(bool $floating = false):void { if (!Settings::is_connected()) return; if (!wp_script_is(self::HANDLE)) { - wp_enqueue_script(self::HANDLE, self::get_script_url()); + wp_enqueue_script( + self::HANDLE, + self::get_script_url() + ); + } + + if ($floating && !wp_script_is(self::FLOATING_HANDLE)) { + wp_enqueue_script( + self::FLOATING_HANDLE, + self::get_floating_script_url(), + [ + self::HANDLE + ] + ); } if (!wp_style_is(self::HANDLE)) { - wp_enqueue_style(self::HANDLE, Plugin::get_url('assets/widget.css')); + wp_enqueue_style( + self::HANDLE, + Plugin::get_url('assets/widget.css') + ); } } - public static function render(string $id = 'cap'):void { + public static function render(array $args = []):void { if (!Settings::is_connected()) return; - self::enqueue_assets(); + + $args = wp_parse_args( + $args, + [ + 'id' => 'cap', + 'floating' => Settings::is_widget_floating(), + ] + ); + + self::enqueue_assets(!!$args['floating']); printf( '', - esc_attr($id), + esc_attr(!empty($args['id']) ? strval($args['id']) : 'cap'), esc_url(Settings::get_instance_url(Settings::get_site_key() . '/')) ); } + public static function get_button_floating_attributes(array $args = []):array { + $args = wp_parse_args( + $args, + [ + 'id' => 'cap', + 'position' => 'bottom', + 'offset' => null, + ] + ); + + $attributes = [ + 'data-cap-floating' => "#{$args['id']}", + 'data-cap-floating-position' => $args['position'], + ]; + + if (!is_null($args['offset'])) { + $attributes['data-cap-floating-offset'] = $args['offset']; + } + + return $attributes; + } + + private static function parse_button_floating_args(array $args):array { + return wp_parse_args( + $args, + [ + 'id' => 'cap', + 'position' => 'bottom', + 'offset' => null, + 'tag_name' => null, + 'class_name' => null, + 'type_name' => 'submit', + ] + ); + } + + private static function get_button_floating_selector(array $args):string { + $selector = ''; + + if (!is_null($args['tag_name']) && !empty($args['tag_name'])) { + $selector = $args['tag_name']; + } else { + $selector = ':where(input, button)'; + } + + if (!is_null($args['class_name']) && !empty($args['class_name'])) { + $selector = sprintf( + '.', + sanitize_html_class($args['class_name']) + ); + } + + if (!is_null($args['type_name']) && !empty($args['type_name'])) { + $selector .= sprintf( + '[type="%s"]', + esc_attr($args['type_name']) + ); + } + + return $selector; + } + + public static function set_button_floating(string $html, array $args = []):string { + if (empty($html)) return $html; + + $args = self::parse_button_floating_args($args); + $attributes = self::get_button_floating_attributes($args); + + $queries = ['input', 'button']; + if (!is_null($args['tag_name']) || !is_null($args['class_name'])) { + $queries = [[ + 'tag_name' => $args['tag_name'], + 'class_name' => $args['class_name'], + ]]; + } + + $tags = new WP_HTML_Tag_Processor($html); + while (true) { + $found = false; + foreach ($queries as $query) { + if ($tags->next_tag($query)) { + $found = true; + break; + } + } + if (!$found) break; + + if (!is_null($args['type_name']) && !empty($args['type_name']) && $tags->get_attribute('type') !== $args['type_name']) continue; + + foreach ($attributes as $name => $value) { + $tags->set_attribute($name, $value); + } + } + return $tags->get_updated_html(); + } + + public static function add_dynamic_floating_attributes(array $args = []):void { + if (wp_script_is(self::DYNAMIC_FLOATING_HANDLE)) return; + + $args = self::parse_button_floating_args($args); + + wp_enqueue_script( + self::DYNAMIC_FLOATING_HANDLE, + Plugin::get_url('assets/floating.js'), + [ + self::FLOATING_HANDLE + ] + ); + + wp_localize_script( + self::DYNAMIC_FLOATING_HANDLE, + 'CAP_FLOATING_DYNAMIC', + [ + 'selector' => self::get_button_floating_selector($args), + 'attributes' => self::get_button_floating_attributes($args), + ] + ); + } + public static function get_token():string { return isset($_POST[self::TOKEN_NAME]) ? (string) $_POST[self::TOKEN_NAME] : ''; } diff --git a/inc/gravityforms/class-addon.php b/inc/gravityforms/class-addon.php index 161ea35..9554436 100644 --- a/inc/gravityforms/class-addon.php +++ b/inc/gravityforms/class-addon.php @@ -130,7 +130,9 @@ final class Addon extends GFAddOn { ob_start(); Widget::render(); - return ob_get_clean() . $button_input; + $button_input = ob_get_clean() . $button_input; + if (Settings::is_widget_floating()) $button_input = Widget::set_button_floating($button_input); + return $button_input; } public function validation(array $validation_result, string $context): array { diff --git a/inc/location/class-comment-form.php b/inc/location/class-comment-form.php index db6a7c2..cf507e9 100644 --- a/inc/location/class-comment-form.php +++ b/inc/location/class-comment-form.php @@ -13,6 +13,7 @@ namespace Ogre\Captcha\Location; use Override; use Ogre\Captcha\Location; use Ogre\Captcha\Settings; +use Ogre\Captcha\Widget; use WP_Error; defined('ABSPATH') || exit; @@ -27,8 +28,16 @@ final class CommentForm extends Location { public function activate(): void { add_action('comment_form_after_fields', [$this, 'render'], 1); + add_filter('comment_form_submit_button', [$this, 'submit_button'], 10, 2); add_filter('preprocess_comment', [$this, 'process_form']); } + + public function submit_button(string $submit_button, array $args):string { + if (Settings::is_widget_floating()) { + $submit_button = Widget::set_button_floating($submit_button); + } + return $submit_button; + } public function process_form(array $commentdata):array { // Don't process CAPTCHA for comment replies inside admin menu diff --git a/inc/location/class-login-form.php b/inc/location/class-login-form.php index 16f8eaa..7d7d57e 100644 --- a/inc/location/class-login-form.php +++ b/inc/location/class-login-form.php @@ -13,6 +13,7 @@ namespace Ogre\Captcha\Location; use Override; use Ogre\Captcha\Location; use Ogre\Captcha\Settings; +use Ogre\Captcha\Widget; defined('ABSPATH') || exit; @@ -39,6 +40,11 @@ final class LoginForm extends Location { ob_start(); $this->render(); $content .= ob_get_clean(); + + if (Settings::is_widget_floating()) { + Widget::add_dynamic_floating_attributes(); + } + return $content; } diff --git a/inc/location/class-login-page.php b/inc/location/class-login-page.php index 6be5c9f..147f3d5 100644 --- a/inc/location/class-login-page.php +++ b/inc/location/class-login-page.php @@ -12,17 +12,18 @@ namespace Ogre\Captcha\Location; use Ogre\Captcha\API; use Override; -use Ogre\Captcha\Location; +use Ogre\Captcha\LoginLocation; use Ogre\Captcha\Settings; +use Ogre\Captcha\Widget; use WP_Error; use WP_User; defined('ABSPATH') || exit; -final class LoginPage extends Location { +final class LoginPage extends LoginLocation { protected function __construct() { - parent::__construct(Settings::LOCATION_LOGIN_PAGE); + parent::__construct(Settings::LOCATION_LOGIN_PAGE, 'login'); } #[Override] @@ -34,6 +35,7 @@ final class LoginPage extends Location { #[Override] public function activate(): void { + parent::activate(); add_action('login_form', [$this, 'render']); add_filter('shake_error_codes', [$this, 'shake_error_codes']); add_filter('authenticate', [$this, 'process_form'], 20, 1); diff --git a/inc/location/class-lost-password-form.php b/inc/location/class-lost-password-form.php index 74ae73d..70f082a 100644 --- a/inc/location/class-lost-password-form.php +++ b/inc/location/class-lost-password-form.php @@ -11,17 +11,17 @@ namespace Ogre\Captcha\Location; use Override; -use Ogre\Captcha\Location; +use Ogre\Captcha\LoginLocation; use Ogre\Captcha\Settings; use WP_Error; use WP_User; defined('ABSPATH') || exit; -final class LostPasswordForm extends Location { +final class LostPasswordForm extends LoginLocation { protected function __construct() { - parent::__construct(Settings::LOCATION_LOST_PASSWORD_FORM); + parent::__construct(Settings::LOCATION_LOST_PASSWORD_FORM, 'lostpassword'); } #[Override] @@ -33,6 +33,7 @@ final class LostPasswordForm extends Location { #[Override] public function activate(): void { + parent::activate(); add_action('lostpassword_form', [$this, 'render']); add_action('lostpassword_post', [$this, 'process_form']); } diff --git a/inc/location/class-post-password-form.php b/inc/location/class-post-password-form.php index a959b64..bbd2691 100644 --- a/inc/location/class-post-password-form.php +++ b/inc/location/class-post-password-form.php @@ -13,6 +13,7 @@ namespace Ogre\Captcha\Location; use Override; use Ogre\Captcha\Location; use Ogre\Captcha\Settings; +use Ogre\Captcha\Widget; use WP_Error; use WP_Post; @@ -35,6 +36,11 @@ final class PostPasswordForm extends Location { ob_start(); $this->render(); $output .= ob_get_clean(); + + if (Settings::is_widget_floating()) { + $output = Widget::set_button_floating($output); + } + return $output; } diff --git a/inc/location/class-registration-page.php b/inc/location/class-registration-page.php index 92c1a2b..2f01b5d 100644 --- a/inc/location/class-registration-page.php +++ b/inc/location/class-registration-page.php @@ -11,16 +11,16 @@ namespace Ogre\Captcha\Location; use Override; -use Ogre\Captcha\Location; +use Ogre\Captcha\LoginLocation; use Ogre\Captcha\Settings; use WP_Error; defined('ABSPATH') || exit; -final class RegistrationPage extends Location { +final class RegistrationPage extends LoginLocation { protected function __construct() { - parent::__construct(Settings::LOCATION_REGISTRATION_PAGE); + parent::__construct(Settings::LOCATION_REGISTRATION_PAGE, 'register'); } #[Override] diff --git a/inc/location/woocommerce/class-checkout.php b/inc/location/woocommerce/class-checkout.php index a3d50f2..ae6ed2e 100644 --- a/inc/location/woocommerce/class-checkout.php +++ b/inc/location/woocommerce/class-checkout.php @@ -13,6 +13,7 @@ namespace Ogre\Captcha\Location\WooCommerce; use Override; use Ogre\Captcha\Location; use Ogre\Captcha\Settings; +use Ogre\Captcha\Widget; use WP_Error; defined('ABSPATH') || exit; @@ -33,9 +34,17 @@ final class Checkout extends Location { public function activate(): void { add_action('woocommerce_after_checkout_billing_form', [$this, 'render']); + add_filter('woocommerce_order_button_html', [$this, 'submit_button'], 10, 1); add_action('woocommerce_after_checkout_validation', [$this, 'process_form'], 10, 2); } + public function submit_button(string $html):string { + if (Settings::is_widget_floating()) { + $html = Widget::set_button_floating($html); + } + return $html; + } + public function process_form(array $data, WP_Error $errors) { $this->process($errors); } diff --git a/inc/location/woocommerce/class-login.php b/inc/location/woocommerce/class-login.php index 66fa069..46b0c5c 100644 --- a/inc/location/woocommerce/class-login.php +++ b/inc/location/woocommerce/class-login.php @@ -32,7 +32,9 @@ final class Login extends Location { #[Override] public function activate(): void { + add_action('woocommerce_login_form_start', [$this, 'header']); add_action('woocommerce_login_form', [$this, 'render']); + add_action('woocommerce_login_form_end', [$this, 'footer']); if (isset($_POST['woocommerce-login-nonce'])) { add_filter('woocommerce_process_login_errors', [$this, 'process_form'], 10, 3); } diff --git a/inc/location/woocommerce/class-lost-password.php b/inc/location/woocommerce/class-lost-password.php index e366c4e..52d844f 100644 --- a/inc/location/woocommerce/class-lost-password.php +++ b/inc/location/woocommerce/class-lost-password.php @@ -33,7 +33,9 @@ final class LostPassword extends Location { #[Override] public function activate(): void { + add_action('woocommerce_before_lost_password_form', [$this, 'header']); add_action('woocommerce_lostpassword_form', [$this, 'render']); + add_action('woocommerce_after_lost_password_form', [$this, 'footer']); if (isset($_POST['woocommerce-lost-password-nonce'])) { add_action('lostpassword_post', [$this, 'process_form']); } diff --git a/inc/location/woocommerce/class-register.php b/inc/location/woocommerce/class-register.php index b42cedd..3b6f89c 100644 --- a/inc/location/woocommerce/class-register.php +++ b/inc/location/woocommerce/class-register.php @@ -32,7 +32,9 @@ final class Register extends Location { #[Override] public function activate(): void { + add_action('woocommerce_register_form_start', [$this, 'header']); add_action('woocommerce_register_form', [$this, 'render']); + add_action('woocommerce_register_form_end', [$this, 'footer']); if (isset($_POST['woocommerce-register-nonce'])) { add_filter('woocommerce_process_registration_errors', [$this, 'process_form'], 10, 4); }