diff --git a/Block/Turnstile.php b/Block/Turnstile.php index 60fa167..f9abb15 100644 --- a/Block/Turnstile.php +++ b/Block/Turnstile.php @@ -11,6 +11,7 @@ namespace PixelOpen\CloudflareTurnstile\Block; use PixelOpen\CloudflareTurnstile\Helper\Config; +use PixelOpen\CloudflareTurnstile\Model\Config\Source\RenderingMode; use Magento\Framework\View\Element\Template; use Magento\Framework\View\Element\Template\Context; use Magento\Framework\Filter\FilterManager; @@ -85,4 +86,85 @@ public function getId(): string { return 'cloudflare-turnstile-' . $this->filter->translitUrl($this->getAction()); } + + /** + * Check if Turnstile is enabled on frontend + * + * @return bool + */ + public function isEnabled(): bool + { + return $this->config->isEnabledOnFront(); + } + + /** + * Retrieve sitekey + * + * @return string + */ + public function getSiteKey(): string + { + return $this->config->getSiteKey(); + } + + /** + * Check if the current action/form is enabled + * + * @return bool + */ + public function isFormEnabled(): bool + { + $forms = $this->config->getFrontendForms(); + return in_array($this->getAction(), $forms); + } + + /** + * Retrieve theme from config if not overridden + * + * @return string + */ + public function getThemeFromConfig(): string + { + return $this->getTheme() ?: $this->config->getFrontendTheme(); + } + + /** + * Retrieve size from config if not overridden + * + * @return string + */ + public function getSizeFromConfig(): string + { + return $this->getSize() ?: $this->config->getFrontendSize(); + } + + /** + * Retrieve configured rendering mode + * + * @return string + */ + public function getRenderingMode(): string + { + return $this->config->getFrontendRenderingMode(); + } + + /** + * Use Knockout rendering (Luma/Blank themes) + * + * @return bool + */ + public function isKnockoutRendering(): bool + { + return $this->getRenderingMode() === RenderingMode::MODE_KNOCKOUT; + } + + /** + * Use Hyvä/Alpine fallback rendering + * + * @return bool + */ + public function isFallbackRendering(): bool + { + return $this->getRenderingMode() === RenderingMode::MODE_FALLBACK; + } } diff --git a/CHANGELOG.md b/CHANGELOG.md index b48b861..ea6e046 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## 100.4.0 + +- Added storefront setting to choose the widget rendering mode (Knockout vs Hyvä/Alpine fallback) +- Ensured only one renderer executes at a time to prevent Cloudflare "sitekey object" errors +- Added dedicated fallback container class (`cf-turnstile-manual`) so Turnstile auto-render does not attach to Hyvä widgets +- Improved frontend/admin CSS and JS to respect the selected rendering mode + ## 100.3.0 - Allow adding a captcha to the native newsletter subscription block ([2](https://github.com/Pixel-Open/magento-cloudflare-turnstile/issues/2)) diff --git a/Helper/Config.php b/Helper/Config.php index 2c31277..d97563e 100644 --- a/Helper/Config.php +++ b/Helper/Config.php @@ -12,6 +12,7 @@ use Magento\Framework\App\Helper\AbstractHelper; use Magento\Store\Model\ScopeInterface; +use PixelOpen\CloudflareTurnstile\Model\Config\Source\RenderingMode; class Config extends AbstractHelper { @@ -22,6 +23,7 @@ class Config extends AbstractHelper public const TURNSTILE_CONFIG_PATH_FRONTEND_THEME = 'pixel_open_cloudflare_turnstile/frontend/theme'; public const TURNSTILE_CONFIG_PATH_FRONTEND_SIZE = 'pixel_open_cloudflare_turnstile/frontend/size'; public const TURNSTILE_CONFIG_PATH_FRONTEND_FORMS = 'pixel_open_cloudflare_turnstile/frontend/forms'; + public const TURNSTILE_CONFIG_PATH_FRONTEND_RENDERING_MODE = 'pixel_open_cloudflare_turnstile/frontend/rendering_mode'; public const TURNSTILE_CONFIG_PATH_ADMINHTML_ENABLED = 'pixel_open_cloudflare_turnstile/adminhtml/enabled'; public const TURNSTILE_CONFIG_PATH_ADMINHTML_THEME = 'pixel_open_cloudflare_turnstile/adminhtml/theme'; @@ -119,6 +121,21 @@ public function getFrontendSize(): string ); } + /** + * Retrieve frontend rendering mode + * + * @return string + */ + public function getFrontendRenderingMode(): string + { + $mode = $this->scopeConfig->getValue( + self::TURNSTILE_CONFIG_PATH_FRONTEND_RENDERING_MODE, + ScopeInterface::SCOPE_STORE + ); + + return $mode ?: RenderingMode::MODE_KNOCKOUT; + } + /** * Retrieve admin size * diff --git a/Model/Config/Source/RenderingMode.php b/Model/Config/Source/RenderingMode.php new file mode 100644 index 0000000..8257adc --- /dev/null +++ b/Model/Config/Source/RenderingMode.php @@ -0,0 +1,40 @@ + self::MODE_KNOCKOUT, + 'label' => __('Knockout (Luma/Blank themes)'), + ], + [ + 'value' => self::MODE_FALLBACK, + 'label' => __('Hyvä / Alpine fallback'), + ], + ]; + } +} + diff --git a/Model/ConfigProvider/Frontend.php b/Model/ConfigProvider/Frontend.php index 9679314..f57c7f6 100644 --- a/Model/ConfigProvider/Frontend.php +++ b/Model/ConfigProvider/Frontend.php @@ -35,9 +35,10 @@ public function getConfig(): array 'config' => [ 'enabled' => $this->config->isEnabledOnFront(), 'sitekey' => $this->config->getSiteKey(), - 'theme' => $this->config->getFrontendTheme(), - 'size' => $this->config->getFrontendSize(), - 'forms' => $this->config->getFrontendForms(), + 'theme' => $this->config->getFrontendTheme(), + 'size' => $this->config->getFrontendSize(), + 'forms' => $this->config->getFrontendForms(), + 'renderingMode' => $this->config->getFrontendRenderingMode(), ] ]; } diff --git a/README.md b/README.md index 70fd21e..1afbe33 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ ## Installation ``` -composer require pixelopen/magento-cloudflare-turnstile +composer require shinesoftware/magento-cloudflare-turnstile ``` ## Configuration diff --git a/composer.json b/composer.json index c0b6e24..75d3706 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,7 @@ "magento/framework": "*" }, "type": "magento2-module", - "version": "100.3.0", + "version": "100.3.1", "autoload": { "files": [ "registration.php" @@ -23,4 +23,4 @@ "role": "Developer" } ] -} +} \ No newline at end of file diff --git a/etc/adminhtml/system.xml b/etc/adminhtml/system.xml index 1e0da47..db05a77 100644 --- a/etc/adminhtml/system.xml +++ b/etc/adminhtml/system.xml @@ -30,6 +30,14 @@ Magento\Config\Model\Config\Source\Yesno + + + PixelOpen\CloudflareTurnstile\Model\Config\Source\RenderingMode + Knockout for Luma/Blank based themes, or the Hyvä / Alpine fallback when Knockout is not available.]]> + + 1 + + PixelOpen\CloudflareTurnstile\Model\Config\Source\Theme diff --git a/etc/config.xml b/etc/config.xml index aea3bc3..40a5d71 100644 --- a/etc/config.xml +++ b/etc/config.xml @@ -14,6 +14,7 @@ 0 light normal + knockout 0 diff --git a/view/adminhtml/web/css/turnstile.css b/view/adminhtml/web/css/turnstile.css index e0bce46..4f9f078 100644 --- a/view/adminhtml/web/css/turnstile.css +++ b/view/adminhtml/web/css/turnstile.css @@ -5,4 +5,14 @@ .cloudflare-turnstile { font-weight: bold; color: #c00; + margin-top: 1rem; +} + +.cf-turnstile-manual { + margin-top: 0; + min-height: 65px; +} + +.cloudflare-turnstile .cf-turnstile { + margin-top: 0; } diff --git a/view/base/templates/turnstile.phtml b/view/base/templates/turnstile.phtml index 9d58505..52eeb10 100644 --- a/view/base/templates/turnstile.phtml +++ b/view/base/templates/turnstile.phtml @@ -1,22 +1,68 @@ - - -
- +isEnabled(); +$sitekey = $block->getSiteKey(); +$action = $block->getAction(); +$isFormEnabled = $block->isFormEnabled(); +$theme = $block->getThemeFromConfig(); +$size = $block->getSizeFromConfig(); +$elementId = $block->getId(); +$renderingMode = $block->getRenderingMode(); +$isKnockout = $block->isKnockoutRendering(); +$isFallback = $block->isFallbackRendering(); + +// Early return if not enabled +if (!$isEnabled || !$isFormEnabled || !$sitekey) { + return; +} +?> + +
+ data-bind="scope:'cloudflare-turnstile-escapeHtmlAttr($elementId) ?>'" + +> + + + + + + +
- + + + diff --git a/view/base/web/js/fallback.js b/view/base/web/js/fallback.js new file mode 100644 index 0000000..dcef0cc --- /dev/null +++ b/view/base/web/js/fallback.js @@ -0,0 +1,218 @@ +/** + * Copyright (C) 2023 Pixel Développement + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +/** + * Fallback for themes without Knockout.js (e.g., Hyvä Theme) + * This script only activates if Knockout.js is not available or fails to render + */ +(function() { + 'use strict'; + + /** + * Initialize fallback for a specific container + * + * @param {string} elementId - The ID of the container element + */ + function initFallback(elementId) { + const container = document.getElementById(elementId); + const fallbackContainer = document.getElementById('cf-turnstile-fallback-' + elementId); + + if (!container || !fallbackContainer) { + return; + } + + // Only initialize when the render mode explicitly requires the fallback + const renderMode = container.getAttribute('data-render-mode') || 'knockout'; + if (renderMode !== 'fallback') { + return; + } + + // Check if Knockout.js is available + function isKnockoutAvailable() { + // Check if ko is defined globally or if Magento UI components are available + return typeof window.ko !== 'undefined' || + (typeof window.require !== 'undefined' && window.require.specified && window.require.specified('ko')); + } + + // Check if Knockout has rendered the widget + function hasKnockoutRendered() { + // Look for the widget rendered by Knockout (it should have a cf-turnstile class but not our fallback ID) + const koWidget = container.querySelector('.cf-turnstile:not([id*="cf-turnstile-fallback-"])'); + // Also check if there's a cf-turnstile-response input (which means the widget was rendered) + const form = container.closest('form'); + const hasResponseInput = form && form.querySelector('input[name="cf-turnstile-response"]'); + return koWidget !== null || hasResponseInput !== null; + } + + // Initialize fallback after checking if Knockout is working + function init() { + // Only use fallback if Knockout is not available or didn't render + if (isKnockoutAvailable()) { + // Knockout is available, wait a bit to see if it renders + setTimeout(function() { + if (!hasKnockoutRendered()) { + // Knockout is available but didn't render, use fallback + activateFallback(); + } + }, 1000); // Wait 1 second for Knockout to initialize and render + } else { + // Knockout is not available, use fallback immediately + activateFallback(); + } + } + + // Activate the fallback widget + function activateFallback() { + // Make sure we don't activate if Knockout already rendered + if (hasKnockoutRendered()) { + return; + } + + fallbackContainer.style.display = 'block'; + + // Configuration from data attributes + // getAttribute returns null if attribute doesn't exist, so we need to handle that + const getDataAttribute = function(element, attr) { + const value = element.getAttribute(attr); + return (value !== null && value !== undefined) ? String(value) : null; + }; + + const config = { + sitekey: getDataAttribute(container, 'data-sitekey'), + theme: getDataAttribute(container, 'data-theme') || 'auto', + size: getDataAttribute(container, 'data-size') || 'normal', + action: getDataAttribute(container, 'data-action') || 'default' + }; + + // Validate sitekey before proceeding + if (!config.sitekey || typeof config.sitekey !== 'string' || config.sitekey.trim() === '') { + fallbackContainer.innerText = 'Unable to secure the form. The site key is missing.'; + return; + } + + // Load Cloudflare Turnstile script if not already loaded + function loadTurnstileScript() { + if (window.turnstile) { + renderWidget(); + return; + } + + // Check if script is already being loaded + if (document.querySelector('script[src*="challenges.cloudflare.com/turnstile"]')) { + // Wait for script to load + const checkInterval = setInterval(function() { + if (window.turnstile) { + clearInterval(checkInterval); + renderWidget(); + } + }, 100); + + // Timeout after 10 seconds + setTimeout(function() { + clearInterval(checkInterval); + if (!window.turnstile) { + fallbackContainer.innerText = 'Unable to load security verification. Please refresh the page.'; + } + }, 10000); + + return; + } + + // Load the script + const script = document.createElement('script'); + script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; + script.async = true; + script.defer = true; + script.onload = function() { + renderWidget(); + }; + script.onerror = function() { + fallbackContainer.innerText = 'Unable to load security verification. Please refresh the page.'; + }; + document.head.appendChild(script); + } + + // Render the widget + function renderWidget() { + if (!window.turnstile || !window.turnstile.render) { + fallbackContainer.innerText = 'Unable to initialize security verification.'; + return; + } + + // Double check that Knockout didn't render in the meantime + if (hasKnockoutRendered()) { + fallbackContainer.style.display = 'none'; + return; + } + + try { + // Validate and ensure all values are strings (sitekey already validated above) + const renderConfig = { + sitekey: String(config.sitekey).trim(), + theme: String(config.theme || 'auto').trim(), + size: String(config.size || 'normal').trim(), + action: String(config.action || 'default').trim() + }; + + // Final validation - ensure sitekey is not empty + if (!renderConfig.sitekey || renderConfig.sitekey === '') { + throw new Error('Sitekey is empty or invalid'); + } + + const widgetId = window.turnstile.render(fallbackContainer, renderConfig); + + if (typeof widgetId === 'undefined') { + fallbackContainer.innerText = 'Unable to secure the form.'; + } else { + // Store widget ID for potential reset + fallbackContainer.setAttribute('data-widget-id', widgetId); + } + } catch (error) { + fallbackContainer.innerText = 'Unable to secure the form.'; + } + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', loadTurnstileScript); + } else { + // DOM is already ready + loadTurnstileScript(); + } + } + + // Start checking after DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + } + + // Auto-initialize all containers with class 'cloudflare-turnstile' that have data attributes + function autoInit() { + const containers = document.querySelectorAll('.cloudflare-turnstile[data-render-mode="fallback"]'); + containers.forEach(function(container) { + if (container.id) { + initFallback(container.id); + } + }); + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', autoInit); + } else { + autoInit(); + } + + // Export function for manual initialization if needed + window.CloudflareTurnstileFallback = { + init: initFallback + }; +})(); + diff --git a/view/base/web/js/view/component.js b/view/base/web/js/view/component.js index 32a1c94..05523a3 100644 --- a/view/base/web/js/view/component.js +++ b/view/base/web/js/view/component.js @@ -31,7 +31,8 @@ define( 'sitekey': '', 'forms': [], 'size': 'normal', - 'theme': 'auto' + 'theme': 'auto', + 'renderingMode': 'knockout' }, action: 'default', size: '', // Override config value if not empty @@ -39,6 +40,7 @@ define( widgetId: null, autoRendering: true, element: null, + renderingMode: 'knockout', /** * Initialize @@ -49,6 +51,8 @@ define( if (typeof window[this.configSource] !== 'undefined' && window[this.configSource].config) { this.config = window[this.configSource].config; } + + this.renderingMode = this.config.renderingMode || 'knockout'; }, /** @@ -68,14 +72,31 @@ define( load: function (element) { this.element = element; - if (!this.config.sitekey) { - this.element.innerText = $.mage.__('Unable to secure the form. The site key is missing.'); - } else { + // Extract sitekey value from observable if needed + const sitekey = this.getValue(this.config.sitekey); + + if (sitekey) { this.beforeRender(); if (this.autoRendering) { this.render(); } + return; } + + this.element.innerText = $.mage.__('Unable to secure the form. The site key is missing.'); + }, + + /** + * Get value from observable or return value directly + * + * @param {*} value + * @returns {*} + */ + getValue: function (value) { + if (ko?.isObservable(value)) { + return ko.unwrap(value); + } + return value; }, /** @@ -83,12 +104,33 @@ define( */ render: function () { if (this.element) { - const widgetId = turnstile.render(this.element, { - sitekey: this.config.sitekey, - theme: this.theme || this.config.theme, - size: this.size || this.config.size, - action: this.action - }); + // Extract values from observables if needed + let sitekey = this.getValue(this.config.sitekey); + const theme = String(this.getValue(this.theme || this.config.theme) || 'auto').trim(); + const size = String(this.getValue(this.size || this.config.size) || 'normal').trim(); + const action = String(this.getValue(this.action) || 'default').trim(); + + // Validate and convert sitekey to string + if (!sitekey) { + this.element.innerText = $.mage.__('Unable to secure the form. The site key is missing.'); + return; + } + + sitekey = String(sitekey).trim(); + + if (sitekey === '' || sitekey === 'null' || sitekey === 'undefined') { + this.element.innerText = $.mage.__('Unable to secure the form. The site key is invalid.'); + return; + } + + const renderConfig = { + sitekey: sitekey, + theme: theme, + size: size, + action: action + }; + + const widgetId = turnstile.render(this.element, renderConfig); if (typeof widgetId === 'undefined') { this.element.innerText = $.mage.__('Unable to secure the form'); } else { diff --git a/view/frontend/layout/default.xml b/view/frontend/layout/default.xml index a5e8862..ced4c0d 100644 --- a/view/frontend/layout/default.xml +++ b/view/frontend/layout/default.xml @@ -10,6 +10,7 @@ +