diff --git a/config/install/official_facebook_pixel.settings.yml b/config/install/official_facebook_pixel.settings.yml index f494806..5964870 100644 --- a/config/install/official_facebook_pixel.settings.yml +++ b/config/install/official_facebook_pixel.settings.yml @@ -1,4 +1,16 @@ # Copyright (C) 2018 Facebook Pixel # This file is distributed under the same license as the Facebook Pixel package. -pixel_id: '{pixel_id}' -pii_id: 1 +pixel_id: '' +pii_id: 0 +visibility: + request_path_mode: 0 + request_path_pages: "/admin\n/admin/*\n/batch\n/node/add*\n/node/*/*\n/user/*/*\n/user/login" + user_role_mode: 1 + user_role_roles: + administrator: administrator +privacy: + donottrack: true + fb_optout: true + eu_cookie_compliance: false + disable_noscript_img: false + fb_optout_key: 'fb-disable' diff --git a/config/schema/official_facebook_pixel.schema.yml b/config/schema/official_facebook_pixel.schema.yml new file mode 100644 index 0000000..16712ed --- /dev/null +++ b/config/schema/official_facebook_pixel.schema.yml @@ -0,0 +1,49 @@ +# Schema for the configuration files of the facebook_pixel module. +official_facebook_pixel.settings: + type: config_object + label: 'Official Facebook Pixel settings' + mapping: + pixel_id: + type: string + label: 'Facebook pixel ID' + pii_id: + type: integer + label: 'Use Advanced Matching on pixel?' + visibility: + type: mapping + label: 'Visibility' + mapping: + request_path_mode: + type: integer + label: 'Add tracking to specific pages' + request_path_pages: + type: string + label: 'Pages by their paths' + user_role_mode: + type: integer + label: 'Add tracking for specific roles' + user_role_roles: + type: sequence + label: 'Roles' + sequence: + type: string + label: 'Role' + privacy: + type: mapping + label: 'Privacy' + mapping: + donottrack: + type: boolean + label: 'Universal web tracking opt-out' + fb_optout: + type: boolean + label: 'Advanced fb-disable user opt-out' + eu_cookie_compliance: + type: boolean + label: 'EU Cookie Compliance integration' + disable_noscript_img: + type: boolean + label: 'Disable noscript img fallback' + fb_optout_key: + type: string + label: 'The technical opt-out key' diff --git a/js/official_facebook_pixel.admin.js b/js/official_facebook_pixel.admin.js new file mode 100644 index 0000000..1dcee8a --- /dev/null +++ b/js/official_facebook_pixel.admin.js @@ -0,0 +1,75 @@ +/** + * @file + * Official Facebook Pixel admin behaviors. + */ + +(function ($) { + + 'use strict'; + + /** + * Provide the summary information for the tracking settings vertical tabs. + */ + Drupal.behaviors.trackingSettingsSummary = { + attach: function () { + // Make sure this behavior is processed only if drupalSetSummary is defined. + if (typeof jQuery.fn.drupalSetSummary === 'undefined') { + return; + } + $('#edit-page-visibility-settings').drupalSetSummary(function (context) { + var $radio = $('input[name="official_facebook_pixel_visibility_request_path_mode"]:checked', context); + if ($radio.val() === '0') { + if (!$('textarea[name="official_facebook_pixel_visibility_request_path_pages"]', context).val()) { + return Drupal.t('Not restricted'); + } + else { + return Drupal.t('All pages with exceptions'); + } + } + else { + return Drupal.t('Restricted to certain pages'); + } + }); + + $('#edit-role-visibility-settings').drupalSetSummary(function (context) { + var vals = []; + $('input[type="checkbox"]:checked', context).each(function () { + vals.push($.trim($(this).next('label').text())); + }); + if (!vals.length) { + return Drupal.t('Not restricted'); + } + else if ($('input[name="official_facebook_pixel_visibility_user_role_mode"]:checked', context).val() === '1') { + return Drupal.t('Excepted: @roles', {'@roles': vals.join(', ')}); + } + else { + return vals.join(', '); + } + }); + + $('#edit-privacy').drupalSetSummary(function (context) { + var vals = []; + if ($('input#edit-official-acebook-pixel-privacy-donottrack', context).is(':checked')) { + vals.push(Drupal.t('Use Advanced Matching on pixel')); + } + if ($('input#edit-official-facebook-pixel-privacy-donottrack', context).is(':checked')) { + vals.push(Drupal.t('Universal web tracking opt-out')); + } + if ($('input#edit-official-facebook-pixel-fb-optout', context).is(':checked')) { + vals.push(Drupal.t('Advanced fb-disable user opt-out')); + } + if ($('input#edit-official-facebook-pixel-eu-cookie-compliance', context).is(':checked')) { + vals.push(Drupal.t('EU Cookie Compliance integration')); + } + if ($('input#edit-official-facebook-pixel-disable-noscript-img', context).is(':checked')) { + vals.push(Drupal.t('Disable noscript fallback tracking pixel')); + } + if (!vals.length) { + return Drupal.t('No privacy'); + } + return Drupal.t('@items enabled', {'@items': vals.join(', ')}); + }); + } + }; + +})(jQuery); diff --git a/js/official_facebook_pixel.js b/js/official_facebook_pixel.js new file mode 100755 index 0000000..ef41aef --- /dev/null +++ b/js/official_facebook_pixel.js @@ -0,0 +1,122 @@ +/** + * @file + * official_facebook_pixel.js + * + * Defines the behavior of the official_facebook_pixel. + */ + +(function ($, Drupal, drupalSettings) { + 'use strict'; + + Drupal.official_facebook_pixel = (typeof Drupal.official_facebook_pixel !== "undefined") ? Drupal.official_facebook_pixel : {}; + + Drupal.official_facebook_pixel.init = function () { + !function (f, b, e, v, n, t, s) { + if (f.fbq) return; n = f.fbq = function () { + n.callMethod ? + n.callMethod.apply(n, arguments) : n.queue.push(arguments) + }; if (!f._fbq) f._fbq = n; + n.push = n; n.loaded = !0; n.version = '2.0'; n.queue = []; t = b.createElement(e); t.async = !0; + t.src = v; s = b.getElementsByTagName(e)[0]; s.parentNode.insertBefore(t, s) + }(window, + document, 'script', 'https://connect.facebook.net/en_US/fbevents.js'); + }; + + Drupal.official_facebook_pixel.executeEvent = function (eventArray) { + var cmd = eventArray[0]; + var event = eventArray[1]; + var paramsObj = eventArray[2]; + if (typeof paramsObj === "undefined") { + fbq(cmd, event); + } else { + fbq(cmd, event, JSON.stringify(paramsObj)); + } + }; + + Drupal.behaviors.official_facebook_pixel = { + attach: function (context) { + $('body').once('official-facebook-pixel').each(function () { + + // Drupal.official_facebook_pixel.disabled allows other modules to set the functionality disabled. + Drupal.official_facebook_pixel.disabled = Drupal.official_facebook_pixel.disabled || false; + + // If configured, check JSON callback to determine if in EU. + if (drupalSettings.official_facebook_pixel.pixel_id > 0 && !Drupal.official_facebook_pixel.disabled) { + var pixel_id = drupalSettings.official_facebook_pixel.pixel_id; + var privacy_donottrack = drupalSettings.official_facebook_pixel.privacy_donottrack; + var privacy_fb_optout = drupalSettings.official_facebook_pixel.privacy_fb_optout; + var privacy_fb_optout_key = drupalSettings.official_facebook_pixel.privacy_fb_optout_key; + var privacy_eu_cookie_compliance = drupalSettings.official_facebook_pixel.privacy_eu_cookie_compliance; + var events = drupalSettings.official_facebook_pixel.events; + var debug = drupalSettings.official_facebook_pixel.debug; + + // Define Drupal.official_facebook_pixel.disabled as a dynamic condition to + // disable FB Pixel at runtime. + // This is helpful for GDPR compliance module integration + // and works even with static caching mechanisms like boost module. + + // Define Opt-out conditions check + if (privacy_fb_optout) { + // Facebook Pixel Opt-Out + // Define global fbOptout() function to set opt-out. + window.fbOptout = function(reload) { + reload = (typeof reload !== 'undefined') ? reload : false; + document.cookie = privacy_fb_optout_key + '=true; expires=Thu, 31 Dec 2999 23:59:59 UTC; path=/'; + window[privacy_fb_optout_key] = true; + if(debug){ + console.debug('official_facebook_pixel: ' + 'Tracking disabled: Opted-out from offical_facebook_pixel by fbOptout()'); + } + if (reload) { + location.reload(); + } + } + // Check if opt-out cookie was already set: + if (document.cookie.indexOf(privacy_fb_optout_key + '=true') !== -1) { + window[privacy_fb_optout_key] = true; + if(debug){ + console.debug('official_facebook_pixel: ' + 'Tracking disabled: Opted-out from offical_facebook_pixel by cookie: "' + privacy_fb_optout_key + '"'); + } + } + Drupal.official_facebook_pixel.disabled = Drupal.official_facebook_pixel.disabled || window[privacy_fb_optout_key] == true; + } + + // Define eu_cookie_compliance conditions check (https://www.drupal.org/project/eu_cookie_compliance) + if (privacy_eu_cookie_compliance) { + if (typeof Drupal.eu_cookie_compliance === "undefined") { console.warn("official_facebook_pixel: official_facebook_pixel eu_cookie_compliance integration option is enabled, but eu_cookie_compliance javascripts seem to be loaded after official_facebook_pixel, which may break functionality."); } + var eccHasAgreed = (typeof Drupal.eu_cookie_compliance !== "undefined" && Drupal.eu_cookie_compliance.hasAgreed()); + if(debug && !eccHasAgreed){ + console.debug('official_facebook_pixel: ' + 'Tracking disabled: eu_cookie_compliance integration enabled, Drupal.eu_cookie_compliance.hasAgreed() returned FALSE.'); + } + Drupal.official_facebook_pixel.disabled = Drupal.official_facebook_pixel.disabled || !eccHasAgreed; + } + + // Define Do-not-track conditions check (see https://www.w3.org/TR/tracking-dnt/) + if (privacy_donottrack) { + var DNT = (typeof navigator.doNotTrack !== "undefined" && (navigator.doNotTrack === "yes" || navigator.doNotTrack == 1)) || (typeof navigator.msDoNotTrack !== "undefined" && navigator.msDoNotTrack == 1) || (typeof window.doNotTrack !== "undefined" && window.doNotTrack == 1); + // If eccHasAgreed is true, it overrides DNT because eu_cookie_compliance contains a setting for opt-in with DNT: + // "Automatic. Respect the DNT (Do not track) setting in the browser, if present. Uses opt-in when DNT is 1 or not set, and consent by default when DNT is 0." + if(debug){ + console.debug('official_facebook_pixel: ' + 'Tracking disabled: DNT was sent by your client.'); + } + Drupal.official_facebook_pixel.disabled = Drupal.official_facebook_pixel.disabled || (DNT && (typeof eccHasAgreed == "undefined" || !eccHasAgreed)); + } + + if (!Drupal.official_facebook_pixel.disabled) { + // Initialize: + Drupal.official_facebook_pixel.init(); + if(debug){ + console.debug('official_facebook_pixel: ' + 'Initialized.'); + } + // Run events: + events.forEach(function (item, index) { + Drupal.official_facebook_pixel.executeEvent(item); + if(debug){ + console.debug('official_facebook_pixel: ' + 'Executed event: ', item); + } + }); + } + } + }); + }, + }; +})(jQuery, Drupal, drupalSettings); diff --git a/official_facebook_pixel.install b/official_facebook_pixel.install new file mode 100755 index 0000000..82c0218 --- /dev/null +++ b/official_facebook_pixel.install @@ -0,0 +1,21 @@ +getEditable(OfficialFacebookPixelConfig::CONFIG_NAME); + $config->set('visibilit.request_path_mode', 0) + ->set('visibility.request_path_pages', "/admin\n/admin/*\n/batch\n/node/add*\n/node/*/*\n/user/*/*\n/user/login") + ->set('visibility.user_role_mode', 1) + ->set('visibility.user_role_roles', ['administrator' => 'administrator']) + ->set('privacy.donottrack', true) + ->set('privacy.fb_optout', true) + ->set('privacy.fb_optout_key', 'fb-disable') + ->set('privacy.eu_cookie_compliance', false) + ->set('privacy.disable_noscript_img', false) + ->save(TRUE); +} diff --git a/official_facebook_pixel.libraries.yml b/official_facebook_pixel.libraries.yml new file mode 100644 index 0000000..316654a --- /dev/null +++ b/official_facebook_pixel.libraries.yml @@ -0,0 +1,15 @@ +admin: + version: VERSION + js: + js/official_facebook_pixel.admin.js: {} + dependencies: + - core/jquery + - core/drupal +official_facebook_pixel: + version: VERSION + js: + js/official_facebook_pixel.js: {} + dependencies: + - core/drupalSettings + - core/jquery + - core/drupal diff --git a/official_facebook_pixel.links.menu.yml b/official_facebook_pixel.links.menu.yml index 385bce9..3c19f26 100644 --- a/official_facebook_pixel.links.menu.yml +++ b/official_facebook_pixel.links.menu.yml @@ -4,5 +4,5 @@ official_facebook_pixel.admin: title: 'Official Facebook Pixel' route_name: official_facebook_pixel.settings_form description: 'Official Facebook Pixel Settings' - parent: system.admin_config_system - weight: 100 \ No newline at end of file + parent: system.admin_config_search + weight: 100 diff --git a/official_facebook_pixel.module b/official_facebook_pixel.module index 906e77d..212bafb 100644 --- a/official_facebook_pixel.module +++ b/official_facebook_pixel.module @@ -20,23 +20,121 @@ use Drupal\official_facebook_pixel\OfficialFacebookPixelInjection; use Drupal\official_facebook_pixel\OfficialFacebookPixelOptions; use Drupal\official_facebook_pixel\OfficialFacebookPixelUtils; -use Drupal\official_facebook_pixel\PixelScriptBuilder; +use Drupal\official_facebook_pixel\OfficialFacebookPixelConfig; /** * Implements hook_page_attachments(). */ function official_facebook_pixel_page_attachments(array &$page) { - // Return if user is admin - $roles = \Drupal::currentUser()->getRoles(); - if (is_array($roles) && in_array("administrator", $roles)) { + $account = \Drupal::currentUser(); + if(!_official_facebook_pixel_visibility_pages() || !_official_facebook_pixel_visibility_roles($account)){ + // No tracking if page or user role should not be tracked. return; } - $options = OfficialFacebookPixelOptions::getInstance(); // Return if pixel_id is not positive integer if (!OfficialFacebookPixelUtils::isPositiveInteger($options->getPixelId())) { return; } - OfficialFacebookPixelInjection::injectPixelCode($page); } + +/** + * Implements hook_page_bottom(). + */ +function official_facebook_pixel_page_bottom(array &$page_bottom) { + $account = \Drupal::currentUser(); + if(!_official_facebook_pixel_visibility_pages() || !_official_facebook_pixel_visibility_roles($account)){ + // No tracking if page or user role should not be tracked. + return; + } + $config = \Drupal::config(OfficialFacebookPixelConfig::CONFIG_NAME); + if ($config->get('privacy.disable_noscript_img')) { + // Do no add the script img fallback if it is disabled due to privacy settings. + return; + } + OfficialFacebookPixelInjection::injectNoScriptCode($page_bottom); +} + + +/** + * Tracking visibility check for user roles. + * + * Based on visibility setting this function returns TRUE if JS code should + * be added for the current role and otherwise FALSE. + * + * @param object $account + * A user object containing an array of roles to check. + * + * @return bool + * TRUE if JS code should be added for the current role and otherwise FALSE. + */ +function _official_facebook_pixel_visibility_roles($account) { + $config = \Drupal::config(OfficialFacebookPixelConfig::CONFIG_NAME); + $enabled = $visibility_user_role_mode = $config->get('visibility.user_role_mode'); + $visibility_user_role_roles = $config->get('visibility.user_role_roles') ? $config->get('visibility.user_role_roles') : []; + + if (count($visibility_user_role_roles) > 0) { + // One or more roles are selected. + foreach (array_values($account->getRoles()) as $user_role) { + // Is the current user a member of one of these roles? + if (in_array($user_role, $visibility_user_role_roles)) { + // Current user is a member of a role that should be tracked/excluded + // from tracking. + $enabled = !$visibility_user_role_mode; + break; + } + } + } + else { + // No role is selected for tracking, therefore all roles should be tracked. + $enabled = TRUE; + } + + return $enabled; +} + +/** + * Tracking visibility check for pages. + * + * Based on visibility setting this function returns TRUE if JS code should + * be added to the current page and otherwise FALSE. + */ +function _official_facebook_pixel_visibility_pages() { + static $page_match; + + // Cache visibility result if function is called more than once. + if (!isset($page_match)) { + $config = \Drupal::config(OfficialFacebookPixelConfig::CONFIG_NAME); + $visibility_request_path_mode = $config->get('visibility.request_path_mode'); + $visibility_request_path_pages = $config->get('visibility.request_path_pages'); + + // Match path if necessary. + if (!empty($visibility_request_path_pages)) { + // Convert path to lowercase. This allows comparison of the same path + // with different case. Ex: /Page, /page, /PAGE. + $pages = mb_strtolower($visibility_request_path_pages); + if ($visibility_request_path_mode < 2) { + // Compare the lowercase path alias (if any) and internal path. + $path = \Drupal::service('path.current')->getPath(); + $path_alias = mb_strtolower(\Drupal::service('path.alias_manager')->getAliasByPath($path)); + $page_match = \Drupal::service('path.matcher')->matchPath($path_alias, $pages) || (($path != $path_alias) && \Drupal::service('path.matcher')->matchPath($path, $pages)); + // When $visibility_request_path_mode has a value of 0, the tracking + // code is displayed on all pages except those listed in $pages. When + // set to 1, it is displayed only on those pages listed in $pages. + $page_match = !($visibility_request_path_mode xor $page_match); + } + elseif (\Drupal::moduleHandler()->moduleExists('php')) { + $page_match = php_eval($visibility_request_path_pages); + } + else { + $page_match = FALSE; + } + } + else { + $page_match = TRUE; + } + + } + return $page_match; +} diff --git a/official_facebook_pixel.routing.yml b/official_facebook_pixel.routing.yml index 9bbe393..ccb6b4b 100644 --- a/official_facebook_pixel.routing.yml +++ b/official_facebook_pixel.routing.yml @@ -6,6 +6,6 @@ official_facebook_pixel.settings_form: _form: '\Drupal\official_facebook_pixel\Form\OfficialFacebookPixelSettingsForm' _title: 'Official Facebook Pixel Settings' requirements: - _permission: 'administer site configuration' + _permission: 'administer official_facebook_pixel' options: _admin_route: TRUE diff --git a/src/Component/Render/JavaScriptSnippet.php b/src/Component/Render/JavaScriptSnippet.php new file mode 100644 index 0000000..96d05bd --- /dev/null +++ b/src/Component/Render/JavaScriptSnippet.php @@ -0,0 +1,43 @@ +string = (string) $string; + } + + /** + * {@inheritdoc} + */ + public function __toString() { + return $this->string; + } + + /** + * {@inheritdoc} + */ + public function jsonSerialize() { + return $this->__toString(); + } + +} diff --git a/src/Form/OfficialFacebookPixelSettingsForm.php b/src/Form/OfficialFacebookPixelSettingsForm.php index 593d914..9b6e0ba 100644 --- a/src/Form/OfficialFacebookPixelSettingsForm.php +++ b/src/Form/OfficialFacebookPixelSettingsForm.php @@ -20,8 +20,12 @@ namespace Drupal\official_facebook_pixel\Form; +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\Form\ConfigFormBase; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Session\AccountInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; use Drupal\official_facebook_pixel\OfficialFacebookPixelConfig; use Drupal\official_facebook_pixel\OfficialFacebookPixelOptions; @@ -32,6 +36,42 @@ use Drupal\official_facebook_pixel\OfficialFacebookPixelOptions; * @package Drupal\official_facebook_pixel\Form */ class OfficialFacebookPixelSettingsForm extends ConfigFormBase { + + /** + * The module handler. + * + * @var Drupal\Core\Extension\ModuleHandlerInterface + */ + protected $moduleHandler; + + /** + * The current user. + * + * @var Drupal\Core\Session\AccountInterface + */ + protected $currentUser; + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + // Load the service required to construct this class. + $container->get('config.factory'), + $container->get('current_user'), + $container->get('module_handler') + ); + } + + /** + * {@inheritdoc} + */ + public function __construct(ConfigFactoryInterface $config_factory, AccountInterface $currentUser, ModuleHandlerInterface $moduleHandler) { + parent::__construct($config_factory); + $this->currentUser = $currentUser; + $this->moduleHandler = $moduleHandler; + } + /** * {@inheritdoc} */ @@ -52,29 +92,169 @@ class OfficialFacebookPixelSettingsForm extends ConfigFormBase { * {@inheritdoc} */ public function buildForm(array $form, FormStateInterface $form_state) { - $options = OfficialFacebookPixelOptions::getInstance(); + $optionsObj = OfficialFacebookPixelOptions::getInstance(); + $config = $this->config(OfficialFacebookPixelConfig::CONFIG_NAME); $form[OfficialFacebookPixelConfig::FORM_PIXEL_KEY] = [ '#type' => 'textfield', '#title' => $this->t(OfficialFacebookPixelConfig::FORM_PIXEL_TITLE), '#description' => $this->t(OfficialFacebookPixelConfig::FORM_PIXEL_DESCRIPTION), - '#default_value' => $options->getPixelId(), + '#default_value' => $optionsObj->getPixelId(), '#maxlength' => 64, '#size' => 64 ]; - $form[OfficialFacebookPixelConfig::FORM_PII_KEY] = [ + // Visibility settings. + $form['tracking_scope'] = [ + '#type' => 'vertical_tabs', + '#title' => $this->t('Tracking scope'), + '#attached' => [ + 'library' => [ + 'official_facebook_pixel/admin', + ], + ], + ]; + + // Page specific visibility configurations. + $account = $this->currentUser; + $php_access = $account->hasPermission('use PHP for official_facebook_pixel tracking visibility'); + $visibility_request_path_pages = $config->get('visibility.request_path_pages'); + + $form['tracking']['page_visibility_settings'] = [ + '#type' => 'details', + '#title' => $this->t('Pages'), + '#group' => 'tracking_scope', + ]; + + if ($config->get('visibility.request_path_mode') == 2 && !$php_access) { + $form['tracking']['page_visibility_settings'] = []; + $form['tracking']['page_visibility_settings']['official_facebook_pixel_visibility_request_path_mode'] = ['#type' => 'value', '#value' => 2]; + $form['tracking']['page_visibility_settings']['official_facebook_pixel_visibility_request_path_pages'] = ['#type' => 'value', '#value' => $visibility_request_path_pages]; + } + else { + $page_options = [ + 0 => $this->t('Every page except the listed pages'), + 1 => $this->t('The listed pages only'), + ]; + $description = $this->t("Specify pages by using their paths. Enter one path per line. The '*' character is a wildcard. Example paths are %blog for the blog page and %blog-wildcard for every personal blog. %front is the front page.", ['%blog' => '/blog', '%blog-wildcard' => '/blog/*', '%front' => '']); + + if ($this->moduleHandler->moduleExists('php') && $php_access) { + $page_options[2] = $this->t('Pages on which this PHP code returns TRUE (experts only)'); + $title = $this->t('Pages or PHP code'); + $description .= ' ' . $this->t('If the PHP option is chosen, enter PHP code between %php. Note that executing incorrect PHP code can break your Drupal site.', ['%php' => '']); + } + else { + $title = $this->t('Pages'); + } + $form['tracking']['page_visibility_settings']['official_facebook_pixel_visibility_request_path_mode'] = [ + '#type' => 'radios', + '#title' => $this->t('Add tracking to specific pages'), + '#options' => $page_options, + '#default_value' => $config->get('visibility.request_path_mode'), + ]; + $form['tracking']['page_visibility_settings']['official_facebook_pixel_visibility_request_path_pages'] = [ + '#type' => 'textarea', + '#title' => $title, + '#title_display' => 'invisible', + '#default_value' => !empty($visibility_request_path_pages) ? $visibility_request_path_pages : '', + '#description' => $description, + '#rows' => 10, + ]; + } + + // Render the role overview. + $visibility_user_role_roles = $config->get('visibility.user_role_roles'); + + $form['tracking']['role_visibility_settings'] = [ + '#type' => 'details', + '#title' => $this->t('Roles'), + '#group' => 'tracking_scope', + ]; + + $form['tracking']['role_visibility_settings']['official_facebook_pixel_visibility_user_role_mode'] = [ + '#type' => 'radios', + '#title' => $this->t('Add tracking for specific roles'), + '#options' => [ + 0 => $this->t('Add to the selected roles only'), + 1 => $this->t('Add to every role except the selected ones'), + ], + '#default_value' => $config->get('visibility.user_role_mode'), + ]; + $form['tracking']['role_visibility_settings']['official_facebook_pixel_visibility_user_role_roles'] = [ + '#type' => 'checkboxes', + '#title' => $this->t('Roles'), + '#default_value' => !empty($visibility_user_role_roles) ? $visibility_user_role_roles : [], + '#options' => array_map('\Drupal\Component\Utility\Html::escape', user_role_names()), + '#description' => $this->t('If none of the roles are selected, all users will be tracked. If a user has any of the roles checked, that user will be tracked (or excluded, depending on the setting above).'), + ]; + + // Privacy specific configurations. + $form['tracking']['privacy'] = [ + '#type' => 'details', + '#title' => $this->t('Privacy'), + '#group' => 'tracking_scope', + ]; + $form['tracking']['privacy'][OfficialFacebookPixelConfig::FORM_PII_KEY] = [ '#type' => 'checkbox', '#title' => $this->t(OfficialFacebookPixelConfig::FORM_PII_TITLE), '#description' => $this->t(sprintf( OfficialFacebookPixelConfig::FORM_PII_DESCRIPTION, OfficialFacebookPixelConfig::FORM_PII_DESCRIPTION_LINK)), - '#default_value' => $options->getUsePii(), + '#default_value' => $optionsObj->getUsePii(), + ]; + $form['tracking']['privacy']['official_facebook_pixel_privacy_donottrack'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Universal web tracking opt-out'), + '#description' => $this->t('If enabled, if a user has Do-Not-Track enabled in the browser, the Facebook Pixel module will not execute the tracking code on your site. Compliance with Do Not Track could be purely voluntary, enforced by industry self-regulation, or mandated by state or federal law. Please accept your visitors privacy. If they have opt-out from tracking and advertising, you should accept their personal decision.', ['@donottrack' => 'https://www.eff.org/issues/do-not-track']), + '#default_value' => $config->get('privacy.donottrack'), + ]; + $form['tracking']['privacy']['official_facebook_pixel_fb_optout'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Enable \'fb-disable\' JavaScript Opt-Out (fbOptout())'), + '#description' => $this->t('If enabled, for enhanced privacy if Facebook Pixel user opt-out code "window[\'fb-disable\']" is true, the Facebook pixel module will not execute the Facebook Pixel tracking code on your site. Furthermore provides the global JavaScript function "fbOptout()" to set an opt-out cookie if called.'), + '#default_value' => $config->get('privacy.fb_optout'), + ]; + $form['tracking']['privacy']['official_facebook_pixel_eu_cookie_compliance'] = [ + '#type' => 'checkbox', + '#title' => $this->t('EU Cookie Compliance integration'), + '#description' => $this->t('If enabled, the Facebook Pixel module will not track users as long as we do not have their consent. This option is designed to work with the module Eu Cookie Compliance. Technically it checks for Drupal.eu_cookie_compliance.hasAgreed(). Important: Set "Script scope" to "Header" in the EU Cookie Compliance settings for this to work.', ['@eu_cookie_compliance' => 'https://www.drupal.org/project/eu_cookie_compliance']), + '#default_value' => $this->moduleHandler->moduleExists('eu_cookie_compliance') ? $config->get('privacy.eu_cookie_compliance') : 0, + '#disabled' => !$this->moduleHandler->moduleExists('eu_cookie_compliance'), + ]; + $form['tracking']['privacy']['official_facebook_pixel_disable_noscript_img'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Disable noscript fallback tracking pixel'), + '#description' => $this->t('Disable the <noscript> tracking pixel image, which does not respect any of these privacy settings.'), + '#default_value' => $config->get('privacy.disable_noscript_img'), ]; return parent::buildForm($form, $form_state); } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + parent::validateForm($form, $form_state); + + $form_state->setValue('official_facebook_pixel_visibility_request_path_pages', trim($form_state->getValue('official_facebook_pixel_visibility_request_path_pages'))); + $form_state->setValue('official_facebook_pixel_visibility_user_role_roles', array_filter($form_state->getValue('official_facebook_pixel_visibility_user_role_roles'))); + + // Verify that every path is prefixed with a slash, but don't check PHP + // code snippets and do not check for slashes if no paths configured. + if ($form_state->getValue('official_facebook_pixel_visibility_request_path_mode') != 2 && !empty($form_state->getValue('official_facebook_pixel_visibility_request_path_pages'))) { + $pages = preg_split('/(\r\n?|\n)/', $form_state->getValue('official_facebook_pixel_visibility_request_path_pages')); + foreach ($pages as $page) { + if (strpos($page, '/') !== 0 && $page !== '') { + $form_state->setErrorByName('official_facebook_pixel_visibility_request_path_pages', $this->t('Path "@page" not prefixed with slash.', ['@page' => $page])); + // Drupal forms show one error only. + break; + } + } + } + } + /** * {@inheritdoc} */ @@ -84,6 +264,15 @@ class OfficialFacebookPixelSettingsForm extends ConfigFormBase { // Set the submitted pixel_id setting ->set(OfficialFacebookPixelConfig::FORM_PIXEL_KEY, $form_state->getValue(OfficialFacebookPixelConfig::FORM_PIXEL_KEY)) ->set(OfficialFacebookPixelConfig::FORM_PII_KEY, $form_state->getValue(OfficialFacebookPixelConfig::FORM_PII_KEY)) + ->set('visibility.request_path_mode', $form_state->getValue('official_facebook_pixel_visibility_request_path_mode')) + ->set('visibility.request_path_pages', $form_state->getValue('official_facebook_pixel_visibility_request_path_pages')) + ->set('visibility.user_role_mode', $form_state->getValue('official_facebook_pixel_visibility_user_role_mode')) + ->set('visibility.user_role_roles', $form_state->getValue('official_facebook_pixel_visibility_user_role_roles')) + ->set('visibility.user_role_roles', $form_state->getValue('official_facebook_pixel_visibility_user_role_roles')) + ->set('privacy.donottrack', $form_state->getValue('official_facebook_pixel_privacy_donottrack')) + ->set('privacy.fb_optout', $form_state->getValue('official_facebook_pixel_fb_optout')) + ->set('privacy.eu_cookie_compliance', $form_state->getValue('official_facebook_pixel_eu_cookie_compliance')) + ->set('privacy.disable_noscript_img', $form_state->getValue('official_facebook_pixel_disable_noscript_img')) ->save(); parent::submitForm($form, $form_state); diff --git a/src/OfficialFacebookPixelConfig.php b/src/OfficialFacebookPixelConfig.php index 47c03a2..aff922a 100644 --- a/src/OfficialFacebookPixelConfig.php +++ b/src/OfficialFacebookPixelConfig.php @@ -29,8 +29,8 @@ class OfficialFacebookPixelConfig { const CONFIG_NAME = 'official_facebook_pixel.settings'; const FORM_ID = 'official_facebook_pixel_settings'; const FORM_PII_KEY = 'pii_id'; - const FORM_PII_TITLE = 'Use Advanced Matching on pixel?'; - const FORM_PII_DESCRIPTION = 'Enabling Advanced Matching improves audience building.
For businesses that operate in the European Union, you may need to take additional action. Read the Cookie Consent Guide for Sites and Apps for suggestions on complying with EU privacy requirements. the Facebook Pixel ID'; + const FORM_PII_TITLE = 'Use Advanced Matching'; + const FORM_PII_DESCRIPTION = 'Enabling Advanced Matching improves audience building. For example transfers the logged-in users E-Mail address.
For businesses that operate in the European Union, you may need to take additional action. Read the Cookie Consent Guide for Sites and Apps for suggestions on complying with EU privacy requirements. the Facebook Pixel ID'; const FORM_PII_DESCRIPTION_LINK = 'https://developers.facebook.com/docs/privacy/'; const FORM_PIXEL_KEY = 'pixel_id'; const FORM_PIXEL_TITLE = 'Pixel ID'; diff --git a/src/OfficialFacebookPixelInjection.php b/src/OfficialFacebookPixelInjection.php index 61d3730..790fe5e 100644 --- a/src/OfficialFacebookPixelInjection.php +++ b/src/OfficialFacebookPixelInjection.php @@ -22,19 +22,22 @@ namespace Drupal\official_facebook_pixel; use Drupal\official_facebook_pixel\OfficialFacebookPixelConfig; use Drupal\official_facebook_pixel\OfficialFacebookPixelOptions; +use Drupal\Component\Serialization\Json; +use Drupal\official_facebook_pixel\Component\Render\JavaScriptSnippet; /** * Class OfficialFacebookPixelInjection. * * @package Drupal\official_facebook_pixel */ -class OfficialFacebookPixelInjection { - public static function injectPixelCode(array &$page) { +class OfficialFacebookPixelInjection +{ + public static function injectPixelCode(array &$page) + { $options = OfficialFacebookPixelOptions::getInstance(); PixelScriptBuilder::initialize($options->getPixelId()); self::injectScriptCode($page, $options); - self::injectNoScriptCode($page); foreach (OfficialFacebookPixelConfig::integrationConfigFor8() as $key => $value) { $class_name = 'Drupal\\official_facebook_pixel\\integration\\'.$value; @@ -42,31 +45,45 @@ class OfficialFacebookPixelInjection { } } - public static function injectScriptCode(array &$page, $options) { - // Inject inline script code to head - $pixel_script_code = PixelScriptBuilder::getPixelBaseCode(); - $pixel_script_code .= PixelScriptBuilder::getPixelInitCode( - $options->getAgentString(), - $options->getUserInfo()); - $pixel_script_code .= PixelScriptBuilder::getPixelPageViewCode(); - $page['#attached']['html_head'][] = [ - [ - '#tag' => 'script', - '#value' => $pixel_script_code - ], - 'facebook_pixel_script_code' + public static function injectScriptCode(array &$page, $options) + { + $options = OfficialFacebookPixelOptions::getInstance(); + $config = \Drupal::config(OfficialFacebookPixelConfig::CONFIG_NAME); + + // Provide a list of events to run + $eventsArray = [ + ['init', $options->getPixelId(), $options->getUserInfo(), ['agent' => $options->getAgentString()]] + ]; + + // Add page view by default: + $eventsArray[] = PixelScriptBuilder::getPixelPageViewEventArray(); + $jsSettings = [ + 'pixel_id' => $options->getPixelId(), + 'privacy_donottrack' => $config->get('privacy.donottrack'), + 'privacy_fb_optout' => $config->get('privacy.fb_optout'), + 'privacy_fb_optout_key' => $config->get('privacy.fb_optout_key'), + 'privacy_eu_cookie_compliance' => $config->get('privacy.eu_cookie_compliance') && \Drupal::service('module_handler')->moduleExists('eu_cookie_compliance'), + 'events' => $eventsArray, ]; + + $page['#attached']['drupalSettings']['official_facebook_pixel'] = $jsSettings; + $page['#attached']['library'][] = 'official_facebook_pixel/official_facebook_pixel'; } - public static function injectNoScriptCode(array &$page) { + public static function injectNoScriptCode(array &$page) + { + $config = \Drupal::config(OfficialFacebookPixelConfig::CONFIG_NAME); + if ($config->get('privacy.disable_noscript_img')) { + // Do no add the script img fallback if it is disabled due to privacy settings. + return; + } + // Inject inline noscript code to head $pixel_noscript_code = PixelScriptBuilder::getPixelNoscriptCode(); - $page['#attached']['html_head'][] = [ - [ - '#tag' => 'noscript', - '#value' => $pixel_noscript_code - ], - 'facebook_pixel_noscript_code' + $page['official_facebook_pixel_noscript_code'] = [ + '#type' => 'html_tag', + '#tag' => 'noscript', + '#value' => new JavaScriptSnippet($pixel_noscript_code) ]; } } diff --git a/src/OfficialFacebookPixelOptions.php b/src/OfficialFacebookPixelOptions.php index eddda19..348168f 100644 --- a/src/OfficialFacebookPixelOptions.php +++ b/src/OfficialFacebookPixelOptions.php @@ -77,7 +77,7 @@ class OfficialFacebookPixelOptions { public function setUserInfo() { $user = \Drupal::currentUser(); $use_pii = $this->getUsePii(); - if (0 === $user->id() || $use_pii !== 1) { + if (0 === $user->id() || $use_pii != 1) { // User not logged in or admin chose not to send PII. $this->userInfo = array(); } else { diff --git a/src/PixelScriptBuilder.php b/src/PixelScriptBuilder.php index 1d3ccf6..1d4c682 100644 --- a/src/PixelScriptBuilder.php +++ b/src/PixelScriptBuilder.php @@ -19,10 +19,13 @@ namespace Drupal\official_facebook_pixel; +use Drupal\Component\Serialization\Json; + /** * Pixel object */ -class PixelScriptBuilder { +class PixelScriptBuilder +{ const ADDPAYMENTINFO = 'AddPaymentInfo'; const ADDTOCART = 'AddToCart'; const ADDTOWISHLIST = 'AddToWishlist'; @@ -46,30 +49,7 @@ class PixelScriptBuilder { private static $pixelId = ''; - private static $pixelBaseCode = " - - - -"; - - private static $pixelFbqCodeWithoutScript = " - fbq('%s', '%s'%s%s); -"; - - private static $pixelNoscriptCode = " - - - -"; + private static $pixelNoscriptCode = "\"fbpx\""; public static function initialize($pixel_id = '') { self::$pixelId = $pixel_id; @@ -90,152 +70,118 @@ src=\"https://www.facebook.com/tr?id=%s&ev=%s%s&noscript=1\" /> } /** - * Gets FB pixel base code - */ - public static function getPixelBaseCode() { - return self::$pixelBaseCode; - } - - /** - * Gets FB pixel init code - */ - public static function getPixelInitCode($agent_string, $param = array(), $with_script_tag = true) { - if (empty(self::$pixelId)) { - return; - } - - $code = $with_script_tag - ? "" - : self::$pixelFbqCodeWithoutScript; - $param_str = $param; - if (is_array($param)) { - $param_str = json_encode($param, JSON_PRETTY_PRINT); - } - $agent_param = array('agent' => $agent_string); - return sprintf( - $code, - 'init', - self::$pixelId, - ', ' . $param_str, - ', ' . json_encode($agent_param, JSON_PRETTY_PRINT)); - } - - /** - * Gets FB pixel track code - * $param is the parameter for the pixel event. - * If it is an array, FB_INTEGRATION_TRACKING_KEY parameter with $tracking_name value will automatically - * be added into the $param. If it is a string, please append the FB_INTEGRATION_TRACKING_KEY parameter - * with its tracking name into the JS Parameter block + * Returns the prepared pixel event array from the given parameters + * ready to be processed by JS. + * + * @param [type] $event + * @param array $params + * @param string $tracking_name + * @return void */ - public static function getPixelTrackCode($event, $param = array(), $tracking_name = '', $with_script_tag = true) { + public static function getPixelEventArray($event, array $params = [], $tracking_name = '') { if (empty(self::$pixelId)) { return; } - - $code = $with_script_tag - ? "" - : self::$pixelFbqCodeWithoutScript; - $param_str = $param; - if (is_array($param)) { - if (!empty($tracking_name)) { - $param[self::FB_INTEGRATION_TRACKING_KEY] = $tracking_name; - } - $param_str = json_encode($param, JSON_PRETTY_PRINT); + if (!empty($tracking_name)) { + $params[self::FB_INTEGRATION_TRACKING_KEY] = $tracking_name; } $class = new \ReflectionClass(__CLASS__); - return sprintf( - $code, + $eventArray = [ $class->getConstant(strtoupper($event)) !== false ? 'track' : 'trackCustom', - $event, - ', ' . $param_str, - ''); - } - - /** - * Gets FB pixel noscript code - */ - public static function getPixelNoscriptCode($event = 'PageView', $cd = array(), $tracking_name = '') { - if (empty(self::$pixelId)) { - return; + $event + ]; + if (!empty($params)) { + $eventArray[] = Json::encode($params); } - - $data = ''; - foreach ($cd as $k => $v) { - $data .= '&cd[' . $k . ']=' . $v; - } - if (!empty($tracking_name)) { - $data .= '&cd[' . self::FB_INTEGRATION_TRACKING_KEY . ']=' . $tracking_name; - } - return sprintf( - self::$pixelNoscriptCode, - self::$pixelId, - $event, - $data); + return $eventArray; } /** - * Gets FB pixel AddToCart code + * Gets FB pixel AddToCart EventArray */ - public static function getPixelAddToCartCode($param = array(), $tracking_name = '', $with_script_tag = true) { - return self::getPixelTrackCode( + public static function getPixelAddToCartEventArray($params = array(), $tracking_name = '') { + return self::getPixelEventArray( self::ADDTOCART, - $param, - $tracking_name, - $with_script_tag); + $params, + $tracking_name + ); } /** - * Gets FB pixel InitiateCheckout code + * Gets FB pixel InitiateCheckout EventArray */ - public static function getPixelInitiateCheckoutCode($param = array(), $tracking_name = '', $with_script_tag = true) { - return self::getPixelTrackCode( + public static function getPixelInitiateCheckoutEventArray($params = array(), $tracking_name = '') { + return self::getPixelEventArray( self::INITIATECHECKOUT, - $param, - $tracking_name, - $with_script_tag); + $params, + $tracking_name + ); } /** - * Gets FB pixel Lead code + * Gets FB pixel Lead EventArray */ - public static function getPixelLeadCode($param = array(), $tracking_name = '', $with_script_tag = true) { - return self::getPixelTrackCode( + public static function getPixelLeadEventArray($params = array(), $tracking_name = '') { + return self::getPixelEventArray( self::LEAD, - $param, - $tracking_name, - $with_script_tag); + $params, + $tracking_name + ); } /** - * Gets FB pixel PageView code + * Gets FB pixel PageView EventArray */ - public static function getPixelPageViewCode($param = array(), $tracking_name = '', $with_script_tag = true) { - return self::getPixelTrackCode( + public static function getPixelPageViewEventArray($params = array(), $tracking_name = '') { + return self::getPixelEventArray( self::PAGEVIEW, - $param, - $tracking_name, - $with_script_tag); + $params, + $tracking_name + ); } /** - * Gets FB pixel Purchase code + * Gets FB pixel Purchase EventArray */ - public static function getPixelPurchaseCode($param = array(), $tracking_name = '', $with_script_tag = true) { - return self::getPixelTrackCode( + public static function getPixelPurchaseEventArray($params = array(), $tracking_name = '') { + return self::getPixelEventArray( self::PURCHASE, - $param, - $tracking_name, - $with_script_tag); + $params, + $tracking_name + ); } /** - * Gets FB pixel ViewContent code + * Gets FB pixel ViewContent EventArray */ - public static function getPixelViewContentCode($param = array(), $tracking_name = '', $with_script_tag = true) { - return self::getPixelTrackCode( + public static function getPixelViewContentEventArray($params = array(), $tracking_name = '') { + return self::getPixelEventArray( self::VIEWCONTENT, - $param, - $tracking_name, - $with_script_tag); + $params, + $tracking_name + ); + } + + /** + * Gets FB pixel noscript EventArray + */ + public static function getPixelNoscriptCode($event = 'PageView', $cd = array(), $tracking_name = '') { + if (empty(self::$pixelId)) { + return; + } + + $data = ''; + foreach ($cd as $k => $v) { + $data .= '&cd[' . $k . ']=' . $v; + } + if (!empty($tracking_name)) { + $data .= '&cd[' . self::FB_INTEGRATION_TRACKING_KEY . ']=' . $tracking_name; + } + return sprintf( + self::$pixelNoscriptCode, + self::$pixelId, + $event, + $data + ); } }